컨텐츠로 건너뛰기

LSP 소스 코드 분석

모듈 개요

LSP(Language Server Protocol) 모듈은 OpenCode에 IDE 수준의 코드 이해 기능을 제공합니다. 표준화된 LSP 프로토콜을 통해 OpenCode는 정확한 진단 정보(컴파일 에러, 타입 에러, 린트 경고), 정의로 이동, 참조 검색, 호버 정보, 심볼 계층을 획득할 수 있습니다. 이를 통해 AI 어시스턴트는 정확한 코드 의미론에 기반하여 의사결정을 내릴 수 있습니다.

이 모듈은 packages/opencode/src/lsp/에 위치하며, 총 5개 파일 약 2900줄의 코드로 구성되어 있습니다. 핵심 진입점인 LSP 네임스페이스는 Effect Service 아키텍처를 채택하여 Instance.state()를 통해 전역 싱글톤 상태를 관리합니다. 이 모듈은 30개 이상의 Language Server(TypeScript, Gopls, Rust Analyzer, Pyright, Clangd, Lua LS 등)의 내장 정의를 포함하고 있으며, 각각 고유한 프로젝트 루트 디렉토리 감지 로직과 spawn 구현을 가지고 있습니다.

핵심 설계 선택:

  • Effect Service 패턴: LSP 네임스페이스가 ServiceMap.Service를 통해 전역 서비스로 등록되며, Instance.state()가 수명 주기를 관리합니다
  • 스마트 클라이언트 재사용: getClients(file)(root, serverID)로 중복을 제거하여 프로젝트당 하나의 서버 인스턴스만 시작합니다
  • 디바운스된 진단 수집: waitForDiagnostics()가 150ms 디바운스 + 3초 타임아웃을 사용하여 Agent 차단을 방지합니다
  • 자동 다운로드와 격리: Language Server 바이너리가 Global.Path.bin에 자동 다운로드되며, Flag.OPENCODE_DISABLE_LSP_DOWNLOAD로 제어됩니다

주요 파일

파일 경로줄 수책임
src/lsp/index.ts~559모듈 메인 진입점: Effect Service 정의, InstanceState 상태 관리, getClients 스마트 매칭, 모든 공개 API
src/lsp/client.ts~253LSP 클라이언트 구현: vscode-jsonrpc 연결 관리, 진단 수집, 파일 버전 관리, 연결 종료
src/lsp/server.ts~196830개 이상의 Language Server 정의: LSPServer.Info 인터페이스, NearestRoot 고차 함수, spawn 구현과 자동 다운로드
src/lsp/launch.ts~22spawn 헬퍼 함수: Process.spawn을 래핑하여 서버 시작 코드 간소화
src/lsp/language.ts~120LANGUAGE_EXTENSIONS 매핑 테이블: 파일 확장자(예: .tsx) → LSP languageId(예: typescriptreact)

타입 시스템

Range와 Diagnostic — 위치와 진단

// 텍스트 범위 (줄 번호 + 문자 오프셋)
export const Range = z.object({
  start: z.object({ line: z.number(), character: z.number() }),
  end: z.object({ line: z.number(), character: z.number() }),
})

// LSP 진단 정보
export const Diagnostic = z.object({
  range: Range,
  severity: z.enum(["error", "warning", "information", "hint"]).optional(),
  message: z.string(),
  source: z.string().optional(),     // 진단 소스 (예: "typescript", "eslint")
  code: z.union([z.string(), z.number()]).optional(),
})

LSPServer.Info — Language Server 정의

interface Info {
  id: string                          // 고유 식별자 (예: "typescript", "gopls")
  languageId: string | string[]       // 지원하는 languageId
  extensions: string[]                // 파일 확장자 (예: [".ts", ".tsx"])
  nearesRoot: NearestRoot             // 프로젝트 루트 감지 함수
  spawn: (args) => Effect.Effect<...> // 서버 시작 로직
  disabled?: boolean                  // 비활성화 여부
}

NearestRoot — 프로젝트 루트 감지

NearestRoot는 고차 함수로, 파일 경로를 받아 해당 Language Server의 프로젝트 루트 디렉토리를 반환합니다:

// 파일 시스템 마커를 기반으로 루트 감지
const NearestRoot = {
  // tsconfig.json, package.json, jsconfig.json 감지
  typescript: (file) => findUp(file, ["tsconfig.json", "package.json"]),
  // go.mod 감지
  gopls: (file) => findUp(file, ["go.mod"]),
  // Cargo.toml 감지
  rustAnalyzer: (file) => findUp(file, ["Cargo.toml"]),
  // pyproject.toml, setup.py 감지
  pyright: (file) => findUp(file, ["pyproject.toml", "setup.py"]),
}

이 메커니즘은 모노레포에서 올바른 프로젝트 루트를 감지하는 데 중요합니다. 서로 다른 하위 프로젝트가 서로 다른 Language Server 인스턴스를 필요로 할 수 있기 때문입니다.

코어 플로우

클라이언트 관리: getClients()

getClients(file)는 LSP 모듈의 핵심 로직으로, 파일 경로를 기반으로 적절한 Language Server 클라이언트를 찾거나 생성합니다:

getClients(file)
  ├─ 파일 확장자로 languageId 감지 (language.ts 매핑 사용)
  ├─ languageId에 해당하는 모든 LSPServer.Info 조회
  ├─ 각 서버에 대해 NearestRoot(file)로 프로젝트 루트 감지
  ├─ (root, serverID)로 기존 클라이언트가 있는지 확인
  │   ├─ 있음 → 기존 클라이언트 재사용
  │   └─ 없음 → 새 클라이언트 생성:
  │       ├─ spawn()으로 Language Server 프로세스 시작
  │       ├─ JSON-RPC 연결 확립
  │       ├─ initialize 요청 전송
  │       └─ 클라이언트 캐시에 저장
  └─ 활성 클라이언트 목록 반환

스마트 재사용 메커니즘:

  • (root, serverID) 쌍으로 중복 제거
  • 동일한 프로젝트 루트 내에서는 하나의 서버 인스턴스만 실행
  • 서로 다른 프로젝트 루트는 별도의 서버 인스턴스 생성

진단 수집: waitForDiagnostics()

waitForDiagnostics()는 Agent가 정확한 코드 분석 결과를 얻기 위해 사용합니다:

async function waitForDiagnostics(filePath: string): Promise<Diagnostic[]> {
  // 1. 파일에 대한 클라이언트 획득
  const clients = await getClients(filePath)
  
  // 2. 디바운스 대기 (150ms) — Language Server가 진단을 완료할 시간 제공
  await sleep(150)
  
  // 3. 타임아웃 (3초) — Agent 차단 방지
  const diagnostics = await Promise.race([
    collectDiagnostics(clients, filePath),
    timeout(3000, []),
  ])
  
  return diagnostics
}

디바운스 + 타임아웃의 2계층 보호:

  • 150ms 디바운스: Language Server가 파일 변경 후 진단을 완료할 시간을 줍니다
  • 3초 타임아웃: Language Server가 응답하지 않아도 Agent가 무한 대기하지 않습니다

LSP 도구 — Agent가 LSP 기능에 접근

LSP 모듈은 Agent가 사용할 수 있는 여러 도구를 등록합니다:

도구기능매개변수
diagnostics파일 진단 정보 획득filePath
goto_definition정의로 이동filePath, line, character
references참조 검색filePath, line, character
hover호버 정보filePath, line, character
document_symbols문서 심볼filePath
workspace_symbols작업공간 심볼 검색query

이러한 도구를 통해 Agent는 IDE와 동등한 코드 이해 능력을 갖추게 됩니다.

30개 이상의 Language Server 정의 (server.ts)

server.ts (~1968줄)는 모듈에서 가장 큰 파일로, 30개 이상의 Language Server 정의를 포함합니다. 각 정의는 다음을 포함합니다:

  • id: 고유 식별자
  • languageId: LSP 프로토콜에서 사용하는 언어 식별자
  • extensions: 연관된 파일 확장자
  • nearestRoot: 프로젝트 루트 감지 로직
  • spawn: 서버 프로세스 시작 구현

일부 Language Server는 자동 다운로드 로직을 포함합니다:

// 자동 다운로드 예시 (간소화)
spawn: Effect.fn("lsp.spawn.pyright")(function* () {
  const binPath = path.join(Global.Path.bin, "pyright")
  if (!existsSync(binPath)) {
    yield* downloadPyright(binPath)  // 자동 다운로드
  }
  return yield* Process.spawn(binPath, args)
})

Flag.OPENCODE_DISABLE_LSP_DOWNLOAD 환경 변수를 설정하면 자동 다운로드를 비활성화할 수 있습니다.

호출 체인 예제

체인 1: Agent가 파일 진단 요청

1. Agent가 diagnostics 도구 호출: { filePath: "src/app.ts" }


2. LSP.waitForDiagnostics("src/app.ts")
   ├─ getClients("src/app.ts")
   │   ├─ 확장자 ".ts" → languageId "typescript"
   │   ├─ LSPServer["typescript"] 조회
   │   ├─ NearestRoot("src/app.ts") → 프로젝트 루트 감지
   │   │   └─ findUp → tsconfig.json 발견 → "/project/root"
   │   ├─ 클라이언트 캐시 확인: ("/project/root", "typescript")
   │   │   ├─ 존재 → 기존 클라이언트 재사용
   │   │   └─ 없음 → 새 클라이언트 생성:
   │   │       ├─ spawn("npx", ["typescript-language-server", "--stdio"])
   │   │       ├─ JSON-RPC 연결 확립
   │   │       └─ initialize 요청 전송
   │   └─ 클라이언트 반환

   ├─ 디바운스 대기 (150ms)
   ├─ 진단 수집:
   │   └─ client.diagnostics("src/app.ts")
   │       └─ LSP textDocument/diagnostic 요청

   └─ 결과: [{ range, severity: "error", message: "Type 'string' is not assignable..." }, ...]

체인 2: 모노레포에서 다중 프로젝트 감지

프로젝트 구조:
  /workspace/
    ├── services/
    │   ├── api/
    │   │   └── tsconfig.json  ← 프로젝트 A
    │   └── web/
    │       └── tsconfig.json  ← 프로젝트 B
    └── shared/
        └── utils.ts

1. Agent가 "services/api/handler.ts" 분석:
   ├─ NearestRoot("services/api/handler.ts") → "/workspace/services/api"
   └─ TypeScript 서버 인스턴스 A 시작 (루트: "/workspace/services/api")

2. Agent가 "services/web/app.ts" 분석:
   ├─ NearestRoot("services/web/app.ts") → "/workspace/services/web"
   └─ TypeScript 서버 인스턴스 B 시작 (루트: "/workspace/services/web")
   (서버 A와는 다른 인스턴스 — 다른 tsconfig.json 설정)

3. Agent가 "shared/utils.ts" 분석:
   ├─ NearestRoot("shared/utils.ts") → "/workspace"
   └─ 이 경로에 tsconfig.json이 없을 수 있음
   └─ 가장 가까운 tsconfig.json까지 상향 검색

설계 트레이드오프

결정근거
Effect Service 패턴전역 싱글톤 서비스로 등록하여, 여러 모듈이 동일한 LSP 클라이언트 풀을 공유합니다. Instance.state()가 수명 주기를 관리하여 인스턴스 파기 시 자동 정리
(root, serverID)로 클라이언트 중복 제거모노레포에서 서로 다른 하위 프로젝트가 독립적인 Language Server 인스턴스를 필요로 합니다. (root, serverID)로 그룹화하여 정확한 격리를 보장
디바운스 + 타임아웃 진단 수집Language Server의 응답 시간은 예측 불가능합니다. 디바운스는 진단 완전성을 보장하고, 타임아웃은 Agent 차단을 방지합니다
자동 다운로드사용자가 Language Server를 수동으로 설치하지 않아도 되며, 첫 사용 시 자동으로 다운로드됩니다. DISABLE_LSP_DOWNLOAD 플래그로 제어 가능
findUp 상향 검색cwd에서 상향으로 설정 파일을 검색하여, 모노레포의 하위 디렉토리에서도 올바른 프로젝트 루트를 자동으로 찾습니다
30개 이상의 내장 서버 정의가장 인기 있는 Language Server를 내장하여 사용자가 추가 설정 없이 사용할 수 있습니다

다른 모듈과의 관계

  • Agent: Agent는 LSP 도구(diagnostics, goto_definition, references 등)를 통해 코드 의미론 정보에 접근합니다. 이를 통해 정확한 에러 분석과 코드 탐색이 가능합니다
  • Instance: LSP 상태는 Instance.state()를 통해 관리되며, 프로젝트 인스턴스의 수명 주기를 따릅니다. 인스턴스 파기 시 모든 Language Server 프로세스가 종료됩니다
  • Config: Language Server 설정은 Config에서 로딩됩니다. 사용자는 특정 Language Server를 비활성화하거나 커스텀 서버를 추가할 수 있습니다
  • ToolRegistry: LSP 도구는 ToolRegistry에 등록되어 Agent의 도구 해결 메커니즘에 통합됩니다
  • Bus: 진단 업데이트 이벤트가 Bus를 통해 브로드캐스트되어 UI 레이어가 실시간으로 에러 표시를 업데이트할 수 있습니다
  • Global: Language Server 바이너리는 Global.Path.bin에 다운로드되며, 설정 파일은 Global.Path.data에 저장됩니다