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 | ~253 | LSP 클라이언트 구현: vscode-jsonrpc 연결 관리, 진단 수집, 파일 버전 관리, 연결 종료 |
src/lsp/server.ts | ~1968 | 30개 이상의 Language Server 정의: LSPServer.Info 인터페이스, NearestRoot 고차 함수, spawn 구현과 자동 다운로드 |
src/lsp/launch.ts | ~22 | spawn 헬퍼 함수: Process.spawn을 래핑하여 서버 시작 코드 간소화 |
src/lsp/language.ts | ~120 | LANGUAGE_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에 저장됩니다