diff --git a/.gitmodules b/.gitmodules index d88d75f..543c06d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,6 @@ [submodule "ref/opencode"] path = ref/opencode url = https://github.com/sst/opencode.git +[submodule "ref/multispy"] + path = ref/multispy + url = https://github.com/microsoft/multilspy.git diff --git a/CLAUDE.md b/CLAUDE.md index f8f6681..de86154 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,6 +124,7 @@ Claude/MCP Client <-> StdioTransport <-> McpServer <-> Providers | Python | pyright-langserver | pyproject.toml, setup.py, requirements.txt | | Go | gopls | go.mod, go.work | | Rust | rust-analyzer | Cargo.toml | +| Kotlin | JetBrains Kotlin LSP (auto-download) | build.gradle.kts, build.gradle, pom.xml | ### Built-in Formatters diff --git a/README.md b/README.md index 95b2378..4aa5398 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ Create `dora.json` or `opencode.json` in your project root: | Python | pyright | pyproject.toml, requirements.txt | | Go | gopls | go.mod | | Rust | rust-analyzer | Cargo.toml | +| Kotlin | JetBrains Kotlin LSP | build.gradle.kts, pom.xml | ### Formatters diff --git a/packages/lsp/CLAUDE.md b/packages/lsp/CLAUDE.md new file mode 100644 index 0000000..5a46dd0 --- /dev/null +++ b/packages/lsp/CLAUDE.md @@ -0,0 +1,117 @@ +# @pleaseai/code-lsp + +LSP (Language Server Protocol) client implementation for AI coding tools. + +## Overview + +This package provides a unified interface for interacting with multiple language servers, enabling real-time diagnostics, hover information, and symbol navigation. + +## Architecture + +``` +src/ +├── index.ts # Public API, LSPManager class +├── client.ts # LSP client implementation (JSON-RPC) +├── server.ts # LSP server definitions +└── language.ts # Language ID mapping +``` + +## Supported Language Servers + +| Server | ID | Extensions | Root Detection | +|--------|-----|------------|----------------| +| TypeScript | `typescript` | .ts, .tsx, .js, .jsx, .mjs, .cjs, .mts, .cts | package-lock.json, bun.lockb, bun.lock, yarn.lock, pnpm-lock.yaml | +| Deno | `deno` | .ts, .tsx, .js, .jsx, .mjs | deno.json, deno.jsonc | +| Oxlint | `oxlint` | .ts, .tsx, .js, .jsx, .mjs, .cjs, .mts, .cts, .vue, .astro, .svelte | .oxlintrc.json, package-lock.json, bun.lockb, bun.lock, pnpm-lock.yaml, yarn.lock, package.json | +| Pyright | `pyright` | .py, .pyi | pyproject.toml, setup.py, requirements.txt, pyrightconfig.json | +| Gopls | `gopls` | .go | go.work, go.mod, go.sum | +| Rust Analyzer | `rust-analyzer` | .rs | Cargo.toml, Cargo.lock | +| Kotlin | `kotlin` | .kt, .kts | build.gradle.kts, build.gradle, settings.gradle.kts, settings.gradle, pom.xml | + +## Adding a New Server + +1. Define the server in `server.ts`: +```typescript +export const MyServer: LSPServerInfo = { + id: 'my-server', + extensions: ['.ext'], + root: nearestRoot(['config.json']), // or custom root function + async spawn(root) { + const proc = spawn('my-lsp', ['--stdio'], { cwd: root }) + return { process: proc } + }, +} +``` + +2. Add to `LSP_SERVERS` array in `server.ts` + +3. Export from `index.ts` + +4. Add tests in `__tests__/server.test.ts` + +## Auto-Download Pattern (Kotlin Example) + +For servers requiring runtime dependencies: + +```typescript +const KOTLIN_RUNTIME_DEPS = { + kotlinLsp: { url: '...', version: '...' }, + java: { + 'win-x64': { url: '...', javaHomePath: '...', javaPath: '...' }, + 'linux-x64': { url: '...', javaHomePath: '...', javaPath: '...' }, + // ... other platforms + } as Record, +} + +async function setupKotlinDependencies(platformId: PlatformId) { + const cacheDir = path.join(os.homedir(), '.cache', 'dora', 'kotlin-lsp') + // Check if exists, download and extract if not + // Verify files exist after download + return { javaHomePath, kotlinLspPath } +} +``` + +## Key APIs + +### LSPManager + +Main entry point for managing LSP clients: + +```typescript +const manager = new LSPManager(projectPath) + +// Touch file to initialize LSP +await manager.touchFile('src/index.ts', true) + +// Get diagnostics +const diags = await manager.diagnostics() + +// Get hover info +const hover = await manager.hover({ file, line, character }) + +// Search symbols +const symbols = await manager.workspaceSymbol('query') + +// Cleanup +await manager.shutdown() +``` + +### Server Utilities + +```typescript +import { getServerById, getServersForExtension } from '@pleaseai/code-lsp' + +const server = getServerById('typescript') +const servers = getServersForExtension('.ts') +``` + +## Testing + +```bash +bun test ./src +``` + +Tests cover: +- Server definitions (ID, extensions, root, spawn functions) +- LSP client lifecycle +- Manager operations diff --git a/packages/lsp/src/__tests__/server.test.ts b/packages/lsp/src/__tests__/server.test.ts index b2e9254..81d6d2e 100644 --- a/packages/lsp/src/__tests__/server.test.ts +++ b/packages/lsp/src/__tests__/server.test.ts @@ -4,6 +4,7 @@ import { getServerById, getServersForExtension, GoplsServer, + KotlinServer, LSP_SERVERS, OxlintServer, PyrightServer, @@ -22,6 +23,7 @@ describe('LSP_SERVERS', () => { expect(serverIds).toContain('pyright') expect(serverIds).toContain('gopls') expect(serverIds).toContain('rust-analyzer') + expect(serverIds).toContain('kotlin') }) }) @@ -120,6 +122,25 @@ describe('RustAnalyzerServer', () => { }) }) +describe('KotlinServer', () => { + test('has correct id', () => { + expect(KotlinServer.id).toBe('kotlin') + }) + + test('supports Kotlin extensions', () => { + expect(KotlinServer.extensions).toContain('.kt') + expect(KotlinServer.extensions).toContain('.kts') + }) + + test('has root function', () => { + expect(typeof KotlinServer.root).toBe('function') + }) + + test('has spawn function', () => { + expect(typeof KotlinServer.spawn).toBe('function') + }) +}) + describe('getServerById', () => { test('returns typescript server', () => { const server = getServerById('typescript') @@ -158,6 +179,22 @@ describe('getServersForExtension', () => { expect(serverIds).toContain('gopls') }) + test('returns servers for .kt extension', () => { + const servers = getServersForExtension('.kt') + expect(servers.length).toBeGreaterThan(0) + + const serverIds = servers.map(s => s.id) + expect(serverIds).toContain('kotlin') + }) + + test('returns servers for .kts extension', () => { + const servers = getServersForExtension('.kts') + expect(servers.length).toBeGreaterThan(0) + + const serverIds = servers.map(s => s.id) + expect(serverIds).toContain('kotlin') + }) + test('returns empty array for unknown extension', () => { const servers = getServersForExtension('.unknown') expect(servers).toEqual([]) diff --git a/packages/lsp/src/index.ts b/packages/lsp/src/index.ts index 93465c9..a72c471 100644 --- a/packages/lsp/src/index.ts +++ b/packages/lsp/src/index.ts @@ -396,6 +396,7 @@ export { getServerById, getServersForExtension, GoplsServer, + KotlinServer, LSP_SERVERS, type LSPServerHandle, type LSPServerInfo, diff --git a/packages/lsp/src/server.ts b/packages/lsp/src/server.ts index 25a6d26..1f89ce4 100644 --- a/packages/lsp/src/server.ts +++ b/packages/lsp/src/server.ts @@ -5,9 +5,12 @@ import type { ChildProcessWithoutNullStreams } from 'node:child_process' import { spawn } from 'node:child_process' +import { createWriteStream } from 'node:fs' import fs from 'node:fs/promises' +import os from 'node:os' import path from 'node:path' import process from 'node:process' +import { pipeline } from 'node:stream/promises' export interface LSPServerHandle { process: ChildProcessWithoutNullStreams @@ -26,6 +29,111 @@ export interface LSPServerInfo { spawn: (root: string) => Promise } +// ============================================================================= +// Platform Detection Utilities +// ============================================================================= + +/** + * Supported platform identifiers for auto-download dependencies + * Note: win-arm64 is not supported as JRE distributions are not available + */ +type PlatformId = 'win-x64' | 'linux-x64' | 'linux-arm64' | 'osx-x64' | 'osx-arm64' + +/** + * Get platform identifier for current system + */ +function getPlatformId(): PlatformId | undefined { + const platform = process.platform + const arch = process.arch + + const platformMap: Record = { + win32: 'win', + darwin: 'osx', + linux: 'linux', + } + + const archMap: Record = { + x64: 'x64', + arm64: 'arm64', + } + + const platformStr = platformMap[platform] + const archStr = archMap[arch] + + if (!platformStr || !archStr) + return undefined + + return `${platformStr}-${archStr}` as PlatformId +} + +// ============================================================================= +// Download Utilities +// ============================================================================= + +/** + * Download a file from URL to destination + */ +async function downloadFile(url: string, dest: string): Promise { + const response = await fetch(url, { redirect: 'follow' }) + if (!response.ok) { + throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`) + } + + const body = response.body + if (!body) { + throw new Error(`No response body for ${url}`) + } + + await fs.mkdir(path.dirname(dest), { recursive: true }) + const writeStream = createWriteStream(dest) + await pipeline(body as unknown as NodeJS.ReadableStream, writeStream) +} + +/** + * Extract a zip archive using system unzip command + */ +async function extractZip(zipPath: string, destDir: string): Promise { + await fs.mkdir(destDir, { recursive: true }) + + // Use system unzip command via Bun.spawn + const proc = Bun.spawn(['unzip', '-o', '-q', zipPath, '-d', destDir], { + stdout: 'pipe', + stderr: 'pipe', + }) + const exitCode = await proc.exited + + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text() + throw new Error(`Failed to extract ${zipPath}: ${stderr}`) + } +} + +/** + * Download and extract an archive + */ +async function downloadAndExtract(url: string, destDir: string): Promise { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'lsp-download-')) + const tempFile = path.join(tempDir, 'archive.zip') + + try { + console.warn(`[lsp] Downloading ${url}...`) + await downloadFile(url, tempFile) + console.warn(`[lsp] Extracting to ${destDir}...`) + await extractZip(tempFile, destDir) + console.warn(`[lsp] Download complete`) + } + finally { + // Cleanup temp files + await fs.rm(tempDir, { recursive: true, force: true }).catch((err) => { + console.warn(`[lsp] Failed to cleanup temp directory ${tempDir}:`, err instanceof Error ? err.message : err) + }) + } +} + +// ============================================================================= +// Root Detection Utilities +// ============================================================================= + /** * Find nearest directory containing one of the target files */ @@ -329,6 +437,243 @@ export const OxlintServer: LSPServerInfo = { }, } +// ============================================================================= +// Kotlin Language Server +// ============================================================================= + +/** + * Kotlin Language Server runtime dependency configuration + * Uses official JetBrains Kotlin LSP and bundled JRE 21 + */ +const KOTLIN_RUNTIME_DEPS = { + kotlinLsp: { + url: 'https://download-cdn.jetbrains.com/kotlin-lsp/0.253.10629/kotlin-0.253.10629.zip', + version: '0.253.10629', + }, + java: { + 'win-x64': { + url: 'https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-win32-x64-1.42.0-561.vsix', + javaHomePath: 'extension/jre/21.0.7-win32-x86_64', + javaPath: 'extension/jre/21.0.7-win32-x86_64/bin/java.exe', + }, + 'linux-x64': { + url: 'https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-x64-1.42.0-561.vsix', + javaHomePath: 'extension/jre/21.0.7-linux-x86_64', + javaPath: 'extension/jre/21.0.7-linux-x86_64/bin/java', + }, + 'linux-arm64': { + url: 'https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-arm64-1.42.0-561.vsix', + javaHomePath: 'extension/jre/21.0.7-linux-aarch64', + javaPath: 'extension/jre/21.0.7-linux-aarch64/bin/java', + }, + 'osx-x64': { + url: 'https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-x64-1.42.0-561.vsix', + javaHomePath: 'extension/jre/21.0.7-macosx-x86_64', + javaPath: 'extension/jre/21.0.7-macosx-x86_64/bin/java', + }, + 'osx-arm64': { + url: 'https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-arm64-1.42.0-561.vsix', + javaHomePath: 'extension/jre/21.0.7-macosx-aarch64', + javaPath: 'extension/jre/21.0.7-macosx-aarch64/bin/java', + }, + } as Record, +} + +/** + * Get the Kotlin LSP resources directory + */ +function getKotlinResourcesDir(): string { + // Store in user's home directory under .cache/dora/kotlin-lsp + return path.join(os.homedir(), '.cache', 'dora', 'kotlin-lsp') +} + +/** + * Setup Kotlin runtime dependencies (Java + Kotlin LSP) + * Downloads and extracts if not already present + */ +async function setupKotlinDependencies(platformId: PlatformId): Promise<{ + javaHomePath: string + kotlinLspPath: string +} | undefined> { + const resourcesDir = getKotlinResourcesDir() + const javaConfig = KOTLIN_RUNTIME_DEPS.java[platformId] + + if (!javaConfig) { + console.warn(`[kotlin] Unsupported platform: ${platformId}`) + return undefined + } + + // Setup Java + const javaDir = path.join(resourcesDir, 'java') + const javaHomePath = path.join(javaDir, javaConfig.javaHomePath) + const javaPath = path.join(javaDir, javaConfig.javaPath) + + try { + await fs.access(javaPath) + } + catch (err) { + // Check if it's actually a "not found" error vs other access issues + const isNotFound = err instanceof Error + && 'code' in err + && (err as NodeJS.ErrnoException).code === 'ENOENT' + + if (!isNotFound) { + console.error(`[kotlin] Cannot access Java at ${javaPath}:`, err instanceof Error ? err.message : err) + return undefined + } + + // Java not found, download it + console.warn(`[kotlin] Downloading Java 21 for ${platformId}...`) + try { + await downloadAndExtract(javaConfig.url, javaDir) + + // Make Java executable on Unix platforms + if (!platformId.startsWith('win-')) { + try { + await fs.chmod(javaPath, 0o755) + } + catch (chmodErr) { + console.error(`[kotlin] Failed to make Java executable at ${javaPath}:`, chmodErr instanceof Error ? chmodErr.message : chmodErr) + return undefined + } + } + } + catch (err) { + console.error(`[kotlin] Failed to download Java:`, err) + return undefined + } + } + + // Verify Java exists + try { + await fs.access(javaPath) + } + catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err) + console.error(`[kotlin] Java executable not accessible at ${javaPath}: ${errorMsg}`) + return undefined + } + + // Setup Kotlin LSP + const isWindows = platformId.startsWith('win-') + const kotlinLspScript = isWindows ? 'kotlin-lsp.cmd' : 'kotlin-lsp.sh' + const kotlinLspPath = path.join(resourcesDir, kotlinLspScript) + + try { + await fs.access(kotlinLspPath) + } + catch (err) { + // Check if it's actually a "not found" error vs other access issues + const isNotFound = err instanceof Error + && 'code' in err + && (err as NodeJS.ErrnoException).code === 'ENOENT' + + if (!isNotFound) { + console.error(`[kotlin] Cannot access Kotlin LSP at ${kotlinLspPath}:`, err instanceof Error ? err.message : err) + return undefined + } + + // Kotlin LSP not found, download it + console.warn(`[kotlin] Downloading Kotlin Language Server...`) + try { + await downloadAndExtract(KOTLIN_RUNTIME_DEPS.kotlinLsp.url, resourcesDir) + + // Make script executable on Unix platforms + if (!isWindows) { + try { + await fs.chmod(kotlinLspPath, 0o755) + } + catch (chmodErr) { + console.error(`[kotlin] Failed to make Kotlin LSP executable at ${kotlinLspPath}:`, chmodErr instanceof Error ? chmodErr.message : chmodErr) + return undefined + } + } + } + catch (err) { + console.error(`[kotlin] Failed to download Kotlin LSP:`, err) + return undefined + } + } + + // Verify Kotlin LSP exists + try { + await fs.access(kotlinLspPath) + } + catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err) + console.error(`[kotlin] Kotlin LSP script not accessible at ${kotlinLspPath}: ${errorMsg}`) + return undefined + } + + return { + javaHomePath, + kotlinLspPath, + } +} + +/** + * Kotlin Language Server + * Uses official JetBrains Kotlin LSP with auto-download + */ +export const KotlinServer: LSPServerInfo = { + id: 'kotlin', + extensions: ['.kt', '.kts'], + root: nearestRoot([ + 'build.gradle.kts', + 'build.gradle', + 'settings.gradle.kts', + 'settings.gradle', + 'pom.xml', + ]), + async spawn(root) { + const platformId = getPlatformId() + if (!platformId) { + console.warn(`[kotlin] Unsupported platform: ${process.platform}-${process.arch}`) + return undefined + } + + // Setup dependencies (downloads if needed) + const deps = await setupKotlinDependencies(platformId) + if (!deps) { + console.warn(`[kotlin] Failed to setup Kotlin LSP dependencies. Check previous logs for details.`) + return undefined + } + + const { javaHomePath, kotlinLspPath } = deps + + // Spawn Kotlin LSP with JAVA_HOME + try { + const proc = spawn(kotlinLspPath, ['--stdio'], { + cwd: root, + env: { + ...process.env, + JAVA_HOME: javaHomePath, + }, + }) + + // Log spawn errors + proc.on('error', (err) => { + console.error(`[kotlin] Kotlin LSP process error:`, err) + }) + + proc.on('exit', (code, signal) => { + if (code !== 0 && code !== null) { + console.error(`[kotlin] Kotlin LSP exited with code ${code}`) + } + if (signal) { + console.error(`[kotlin] Kotlin LSP killed by signal ${signal}`) + } + }) + + return { process: proc } + } + catch (err) { + console.error(`[kotlin] Failed to spawn Kotlin LSP:`, err) + return undefined + } + }, +} + /** * All available LSP servers */ @@ -339,6 +684,7 @@ export const LSP_SERVERS: LSPServerInfo[] = [ PyrightServer, GoplsServer, RustAnalyzerServer, + KotlinServer, ] /** diff --git a/ref/multispy b/ref/multispy new file mode 160000 index 0000000..ee0f6dc --- /dev/null +++ b/ref/multispy @@ -0,0 +1 @@ +Subproject commit ee0f6dcd1c0f4c5471d8078580343203b0c73fa7