From fece411fb2868561787236fc283ccdcf34540df6 Mon Sep 17 00:00:00 2001 From: Jonas Haugesen Date: Wed, 1 Apr 2026 20:04:59 +0200 Subject: [PATCH] removed lsp-pi --- pi/.pi/agent/extensions/lsp-pi/README.md | 178 --- pi/.pi/agent/extensions/lsp-pi/index.ts | 12 - pi/.pi/agent/extensions/lsp-pi/lsp-core.ts | 1129 ----------------- pi/.pi/agent/extensions/lsp-pi/lsp-tool.ts | 382 ------ pi/.pi/agent/extensions/lsp-pi/lsp.ts | 604 --------- pi/.pi/agent/extensions/lsp-pi/package.json | 54 - .../extensions/lsp-pi/tests/index.test.ts | 235 ---- .../lsp-pi/tests/lsp-integration.test.ts | 602 --------- .../agent/extensions/lsp-pi/tests/lsp.test.ts | 898 ------------- pi/.pi/agent/extensions/lsp-pi/tsconfig.json | 13 - 10 files changed, 4107 deletions(-) delete mode 100644 pi/.pi/agent/extensions/lsp-pi/README.md delete mode 100644 pi/.pi/agent/extensions/lsp-pi/index.ts delete mode 100644 pi/.pi/agent/extensions/lsp-pi/lsp-core.ts delete mode 100644 pi/.pi/agent/extensions/lsp-pi/lsp-tool.ts delete mode 100644 pi/.pi/agent/extensions/lsp-pi/lsp.ts delete mode 100644 pi/.pi/agent/extensions/lsp-pi/package.json delete mode 100644 pi/.pi/agent/extensions/lsp-pi/tests/index.test.ts delete mode 100644 pi/.pi/agent/extensions/lsp-pi/tests/lsp-integration.test.ts delete mode 100644 pi/.pi/agent/extensions/lsp-pi/tests/lsp.test.ts delete mode 100644 pi/.pi/agent/extensions/lsp-pi/tsconfig.json diff --git a/pi/.pi/agent/extensions/lsp-pi/README.md b/pi/.pi/agent/extensions/lsp-pi/README.md deleted file mode 100644 index 5f87170..0000000 --- a/pi/.pi/agent/extensions/lsp-pi/README.md +++ /dev/null @@ -1,178 +0,0 @@ -# LSP Extension - -Language Server Protocol integration for pi-coding-agent. - -## Highlights - -- **Hook** (`lsp.ts`): Auto-diagnostics (default at agent end; optional per `write`/`edit`) -- **Tool** (`lsp-tool.ts`): On-demand LSP queries (definitions, references, hover, symbols, diagnostics, signatures) -- Manages one LSP server per project root and reuses them across turns -- **Efficient**: Bounded memory usage via LRU cache and idle file cleanup -- Supports TypeScript/JavaScript, Vue, Svelte, Dart/Flutter, Python, Go, Kotlin, Swift, and Rust - -## Supported Languages - -| Language | Server | Detection | -|----------|--------|-----------| -| TypeScript/JavaScript | `typescript-language-server` | `package.json`, `tsconfig.json` | -| Vue | `vue-language-server` | `package.json`, `vite.config.ts` | -| Svelte | `svelteserver` | `svelte.config.js` | -| Dart/Flutter | `dart language-server` | `pubspec.yaml` | -| Python | `pyright-langserver` | `pyproject.toml`, `requirements.txt` | -| Go | `gopls` | `go.mod` | -| Kotlin | `kotlin-ls` | `settings.gradle(.kts)`, `build.gradle(.kts)`, `pom.xml` | -| Swift | `sourcekit-lsp` | `Package.swift`, Xcode (`*.xcodeproj` / `*.xcworkspace`) | -| Rust | `rust-analyzer` | `Cargo.toml` | - -### Known Limitations - -**rust-analyzer**: Very slow to initialize (30-60+ seconds) because it compiles the entire Rust project before returning diagnostics. This is a known rust-analyzer behavior, not a bug in this extension. For quick feedback, consider using `cargo check` directly. - -## Usage - -### Installation - -Install the package and enable extensions: -```bash -pi install npm:lsp-pi -pi config -``` - -Dependencies are installed automatically during `pi install`. - -### Prerequisites - -Install the language servers you need: - -```bash -# TypeScript/JavaScript -npm i -g typescript-language-server typescript - -# Vue -npm i -g @vue/language-server - -# Svelte -npm i -g svelte-language-server - -# Python -npm i -g pyright - -# Go (install gopls via go install) -go install golang.org/x/tools/gopls@latest - -# Kotlin (kotlin-ls) -brew install JetBrains/utils/kotlin-lsp - -# Swift (sourcekit-lsp; macOS) -# Usually available via Xcode / Command Line Tools -xcrun sourcekit-lsp --help - -# Rust (install via rustup) -rustup component add rust-analyzer -``` - -The extension spawns binaries from your PATH. - -## How It Works - -### Hook (auto-diagnostics) - -1. On `session_start`, warms up LSP for detected project type -2. Tracks files touched by `write`/`edit` -3. Default (`agent_end`): at agent end, sends touched files to LSP and posts a diagnostics message -4. Optional (`edit_write`): per `write`/`edit`, appends diagnostics to the tool result -5. Shows notification with diagnostic summary -6. **Memory Management**: Keeps up to 30 files open per LSP server (LRU eviction), automatically closes idle files (> 60s), and shuts down all LSP servers after 2 minutes of post-agent inactivity (servers restart lazily when files are read again). -7. **Robustness**: Reuses cached diagnostics if a server doesn't re-publish them for unchanged files, avoiding false timeouts on re-analysis. - -### Tool (on-demand queries) - -The `lsp` tool provides these actions: - -| Action | Description | Requires | -|--------|-------------|----------| -| `definition` | Jump to definition | `file` + (`line`/`column` or `query`) | -| `references` | Find all references | `file` + (`line`/`column` or `query`) | -| `hover` | Get type/docs info | `file` + (`line`/`column` or `query`) | -| `symbols` | List symbols in file | `file`, optional `query` filter | -| `diagnostics` | Get single file diagnostics | `file`, optional `severity` filter | -| `workspace-diagnostics` | Get diagnostics for multiple files | `files` array, optional `severity` filter | -| `signature` | Get function signature | `file` + (`line`/`column` or `query`) | -| `rename` | Rename symbol across files | `file` + (`line`/`column` or `query`) + `newName` | -| `codeAction` | Get available quick fixes/refactors | `file` + `line`/`column`, optional `endLine`/`endColumn` | - -**Query resolution**: For position-based actions, you can provide a `query` (symbol name) instead of `line`/`column`. The tool will find the symbol in the file and use its position. - -**Severity filtering**: For `diagnostics` and `workspace-diagnostics` actions, use the `severity` parameter to filter results: -- `all` (default): Show all diagnostics -- `error`: Only errors -- `warning`: Errors and warnings -- `info`: Errors, warnings, and info -- `hint`: All including hints - -**Workspace diagnostics**: The `workspace-diagnostics` action analyzes multiple files at once. Pass an array of file paths in the `files` parameter. Each file will be opened, analyzed by the appropriate LSP server, and diagnostics returned. Files are cleaned up after analysis to prevent memory bloat. - -```bash -# Find all TypeScript files and check for errors -find src -name "*.ts" -type f | xargs ... - -# Example tool call -lsp action=workspace-diagnostics files=["src/index.ts", "src/utils.ts"] severity=error -``` - -Example questions the LLM can answer using this tool: -- "Where is `handleSessionStart` defined in `lsp-hook.ts`?" -- "Find all references to `getManager`" -- "What type does `getDefinition` return?" -- "List symbols in `lsp-core.ts`" -- "Check all TypeScript files in src/ for errors" -- "Get only errors from `index.ts`" -- "Rename `oldFunction` to `newFunction`" -- "What quick fixes are available at line 10?" - -## Settings - -Use `/lsp` to configure the auto diagnostics hook: -- Mode: default at agent end; can run after each edit/write or be disabled -- Scope: session-only or global (`~/.pi/agent/settings.json`) - -To disable auto diagnostics, choose "Disabled" in `/lsp` or set in `~/.pi/agent/settings.json`: -```json -{ - "lsp": { - "hookMode": "disabled" - } -} -``` -Other values: `"agent_end"` (default) and `"edit_write"`. - -Agent-end mode analyzes files touched during the full agent response (after all tool calls complete) and posts a diagnostics message only once. Disabling the hook does not disable the `/lsp` tool. - -## File Structure - -| File | Purpose | -|------|---------| -| `lsp.ts` | Hook extension (auto-diagnostics; default at agent end) | -| `lsp-tool.ts` | Tool extension (on-demand LSP queries) | -| `lsp-core.ts` | LSPManager class, server configs, singleton manager | -| `package.json` | Declares both extensions via "pi" field | - -## Testing - -```bash -# Unit tests (root detection, configuration) -npm test - -# Tool tests -npm run test:tool - -# Integration tests (spawns real language servers) -npm run test:integration - -# Run rust-analyzer tests (slow, disabled by default) -RUST_LSP_TEST=1 npm run test:integration -``` - -## License - -MIT diff --git a/pi/.pi/agent/extensions/lsp-pi/index.ts b/pi/.pi/agent/extensions/lsp-pi/index.ts deleted file mode 100644 index 070665c..0000000 --- a/pi/.pi/agent/extensions/lsp-pi/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Combined lsp-pi extension entry point. - * Loads both the hook extension (lsp.ts) and the tool extension (lsp-tool.ts). - */ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import lspHook from "./lsp.js"; -import lspTool from "./lsp-tool.js"; - -export default function (pi: ExtensionAPI) { - lspHook(pi); - lspTool(pi); -} diff --git a/pi/.pi/agent/extensions/lsp-pi/lsp-core.ts b/pi/.pi/agent/extensions/lsp-pi/lsp-core.ts deleted file mode 100644 index 9862be5..0000000 --- a/pi/.pi/agent/extensions/lsp-pi/lsp-core.ts +++ /dev/null @@ -1,1129 +0,0 @@ -/** - * LSP Core - Language Server Protocol client management - */ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import * as path from "node:path"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import { pathToFileURL, fileURLToPath } from "node:url"; -import { - createMessageConnection, - StreamMessageReader, - StreamMessageWriter, - type MessageConnection, - InitializeRequest, - InitializedNotification, - DidOpenTextDocumentNotification, - DidChangeTextDocumentNotification, - DidCloseTextDocumentNotification, - DidSaveTextDocumentNotification, - PublishDiagnosticsNotification, - DocumentDiagnosticRequest, - WorkspaceDiagnosticRequest, - DefinitionRequest, - ReferencesRequest, - HoverRequest, - SignatureHelpRequest, - DocumentSymbolRequest, - RenameRequest, - CodeActionRequest, -} from "vscode-languageserver-protocol/node.js"; -import { - type Diagnostic, - type Location, - type LocationLink, - type DocumentSymbol, - type SymbolInformation, - type Hover, - type SignatureHelp, - type WorkspaceEdit, - type CodeAction, - type Command, - DiagnosticSeverity, - CodeActionKind, - DocumentDiagnosticReportKind, -} from "vscode-languageserver-protocol"; - -// Config -const INIT_TIMEOUT_MS = 30000; -const MAX_OPEN_FILES = 30; -const IDLE_TIMEOUT_MS = 60_000; -const CLEANUP_INTERVAL_MS = 30_000; - -export const LANGUAGE_IDS: Record = { - ".dart": "dart", ".ts": "typescript", ".tsx": "typescriptreact", - ".js": "javascript", ".jsx": "javascriptreact", ".mjs": "javascript", - ".cjs": "javascript", ".mts": "typescript", ".cts": "typescript", - ".vue": "vue", ".svelte": "svelte", ".astro": "astro", - ".py": "python", ".pyi": "python", ".go": "go", ".rs": "rust", - ".kt": "kotlin", ".kts": "kotlin", - ".swift": "swift", -}; - -// Types -interface LSPServerConfig { - id: string; - extensions: string[]; - findRoot: (file: string, cwd: string) => string | undefined; - spawn: (root: string) => Promise<{ process: ChildProcessWithoutNullStreams; initOptions?: Record } | undefined>; -} - -interface OpenFile { version: number; lastAccess: number; } - -interface LSPClient { - connection: MessageConnection; - process: ChildProcessWithoutNullStreams; - diagnostics: Map; - openFiles: Map; - listeners: Map void>>; - stderr: string[]; - capabilities?: any; - root: string; - closed: boolean; -} - -export interface FileDiagnosticItem { - file: string; - diagnostics: Diagnostic[]; - status: 'ok' | 'timeout' | 'error' | 'unsupported'; - error?: string; -} - -export interface FileDiagnosticsResult { items: FileDiagnosticItem[]; } - -// Utilities -const SEARCH_PATHS = [ - ...(process.env.PATH?.split(path.delimiter) || []), - "/usr/local/bin", "/opt/homebrew/bin", - `${process.env.HOME}/.pub-cache/bin`, `${process.env.HOME}/fvm/default/bin`, - `${process.env.HOME}/go/bin`, `${process.env.HOME}/.cargo/bin`, -]; - -function which(cmd: string): string | undefined { - const ext = process.platform === "win32" ? ".exe" : ""; - for (const dir of SEARCH_PATHS) { - const full = path.join(dir, cmd + ext); - try { if (fs.existsSync(full) && fs.statSync(full).isFile()) return full; } catch {} - } -} - -function normalizeFsPath(p: string): string { - try { - // realpathSync.native is faster on some platforms, but not always present - const fn: any = (fs as any).realpathSync?.native || fs.realpathSync; - return fn(p); - } catch { - return p; - } -} - -function findNearestFile(startDir: string, targets: string[], stopDir: string): string | undefined { - let current = path.resolve(startDir); - const stop = path.resolve(stopDir); - while (current.length >= stop.length) { - for (const t of targets) { - const candidate = path.join(current, t); - if (fs.existsSync(candidate)) return candidate; - } - const parent = path.dirname(current); - if (parent === current) break; - current = parent; - } -} - -function findRoot(file: string, cwd: string, markers: string[]): string | undefined { - const found = findNearestFile(path.dirname(file), markers, cwd); - return found ? path.dirname(found) : undefined; -} - -function timeout(promise: Promise, ms: number, name: string): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error(`${name} timed out`)), ms); - promise.then(r => { clearTimeout(timer); resolve(r); }, e => { clearTimeout(timer); reject(e); }); - }); -} - -function simpleSpawn(bin: string, args: string[] = ["--stdio"]) { - return async (root: string) => { - const cmd = which(bin); - if (!cmd) return undefined; - return { process: spawn(cmd, args, { cwd: root, stdio: ["pipe", "pipe", "pipe"] }) }; - }; -} - -async function spawnChecked(cmd: string, args: string[], cwd: string): Promise { - try { - const child = spawn(cmd, args, { cwd, stdio: ["pipe", "pipe", "pipe"] }); - - // If the process exits immediately (e.g. unsupported flag), treat it as a failure - return await new Promise((resolve) => { - let settled = false; - - const cleanup = () => { - child.removeListener("exit", onExit); - child.removeListener("error", onError); - }; - - let timer: NodeJS.Timeout | null = null; - - const finish = (value: ChildProcessWithoutNullStreams | undefined) => { - if (settled) return; - settled = true; - if (timer) clearTimeout(timer); - cleanup(); - resolve(value); - }; - - const onExit = () => finish(undefined); - const onError = () => finish(undefined); - - child.once("exit", onExit); - child.once("error", onError); - - timer = setTimeout(() => finish(child), 200); - (timer as any).unref?.(); - }); - } catch { - return undefined; - } -} - -async function spawnWithFallback(cmd: string, argsVariants: string[][], cwd: string): Promise { - for (const args of argsVariants) { - const child = await spawnChecked(cmd, args, cwd); - if (child) return child; - } - return undefined; -} - -function findRootKotlin(file: string, cwd: string): string | undefined { - // Prefer Gradle settings root for multi-module projects - const gradleRoot = findRoot(file, cwd, ["settings.gradle.kts", "settings.gradle"]); - if (gradleRoot) return gradleRoot; - - // Fallbacks for single-module Gradle or Maven builds - return findRoot(file, cwd, [ - "build.gradle.kts", - "build.gradle", - "gradlew", - "gradlew.bat", - "gradle.properties", - "pom.xml", - ]); -} - -function dirContainsNestedProjectFile(dir: string, dirSuffix: string, markerFile: string): boolean { - try { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const e of entries) { - if (!e.isDirectory()) continue; - if (!e.name.endsWith(dirSuffix)) continue; - if (fs.existsSync(path.join(dir, e.name, markerFile))) return true; - } - } catch { - // ignore - } - return false; -} - -function findRootSwift(file: string, cwd: string): string | undefined { - let current = path.resolve(path.dirname(file)); - const stop = path.resolve(cwd); - - while (current.length >= stop.length) { - if (fs.existsSync(path.join(current, "Package.swift"))) return current; - - // Xcode projects/workspaces store their marker files *inside* a directory - if (dirContainsNestedProjectFile(current, ".xcodeproj", "project.pbxproj")) return current; - if (dirContainsNestedProjectFile(current, ".xcworkspace", "contents.xcworkspacedata")) return current; - - const parent = path.dirname(current); - if (parent === current) break; - current = parent; - } - - return undefined; -} - -async function runCommand(cmd: string, args: string[], cwd: string): Promise { - return await new Promise((resolve) => { - try { - const p = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"] }); - p.on("error", () => resolve(false)); - p.on("exit", (code) => resolve(code === 0)); - } catch { - resolve(false); - } - }); -} - -async function ensureJetBrainsKotlinLspInstalled(): Promise { - // Opt-in download (to avoid surprising network activity) - const allowDownload = process.env.PI_LSP_AUTO_DOWNLOAD_KOTLIN_LSP === "1" || process.env.PI_LSP_AUTO_DOWNLOAD_KOTLIN_LSP === "true"; - const installDir = path.join(os.homedir(), ".pi", "agent", "lsp", "kotlin-ls"); - const launcher = process.platform === "win32" - ? path.join(installDir, "kotlin-lsp.cmd") - : path.join(installDir, "kotlin-lsp.sh"); - - if (fs.existsSync(launcher)) return launcher; - if (!allowDownload) return undefined; - - const curl = which("curl"); - const unzip = which("unzip"); - if (!curl || !unzip) return undefined; - - try { - // Determine latest version - const res = await fetch("https://api.github.com/repos/Kotlin/kotlin-lsp/releases/latest", { - headers: { "User-Agent": "pi-lsp" }, - }); - if (!res.ok) return undefined; - const release: any = await res.json(); - const versionRaw = (release?.name || release?.tag_name || "").toString(); - const version = versionRaw.replace(/^v/, ""); - if (!version) return undefined; - - // Map platform/arch to JetBrains naming - const platform = process.platform; - const arch = process.arch; - - let kotlinArch: string = arch; - if (arch === "arm64") kotlinArch = "aarch64"; - else if (arch === "x64") kotlinArch = "x64"; - - let kotlinPlatform: string = platform; - if (platform === "darwin") kotlinPlatform = "mac"; - else if (platform === "linux") kotlinPlatform = "linux"; - else if (platform === "win32") kotlinPlatform = "win"; - - const supportedCombos = new Set(["mac-x64", "mac-aarch64", "linux-x64", "linux-aarch64", "win-x64", "win-aarch64"]); - const combo = `${kotlinPlatform}-${kotlinArch}`; - if (!supportedCombos.has(combo)) return undefined; - - const assetName = `kotlin-lsp-${version}-${kotlinPlatform}-${kotlinArch}.zip`; - const url = `https://download-cdn.jetbrains.com/kotlin-lsp/${version}/${assetName}`; - - fs.mkdirSync(installDir, { recursive: true }); - const zipPath = path.join(installDir, "kotlin-lsp.zip"); - - const okDownload = await runCommand(curl, ["-L", "-o", zipPath, url], installDir); - if (!okDownload || !fs.existsSync(zipPath)) return undefined; - - const okUnzip = await runCommand(unzip, ["-o", zipPath, "-d", installDir], installDir); - try { fs.rmSync(zipPath, { force: true }); } catch {} - if (!okUnzip) return undefined; - - if (process.platform !== "win32") { - try { fs.chmodSync(launcher, 0o755); } catch {} - } - - return fs.existsSync(launcher) ? launcher : undefined; - } catch { - return undefined; - } -} - -async function spawnKotlinLanguageServer(root: string): Promise { - // Prefer JetBrains Kotlin LSP (Kotlin/kotlin-lsp) – better diagnostics for Gradle/Android projects. - const explicit = process.env.PI_LSP_KOTLIN_LSP_PATH; - if (explicit && fs.existsSync(explicit)) { - return spawnWithFallback(explicit, [["--stdio"]], root); - } - - const jetbrains = which("kotlin-lsp") || which("kotlin-lsp.sh") || which("kotlin-lsp.cmd") || await ensureJetBrainsKotlinLspInstalled(); - if (jetbrains) { - return spawnWithFallback(jetbrains, [["--stdio"]], root); - } - - // Fallback: org.javacs/kotlin-language-server (often lacks diagnostics without full classpath) - const kls = which("kotlin-language-server"); - if (!kls) return undefined; - return spawnWithFallback(kls, [[]], root); -} - -async function spawnSourcekitLsp(root: string): Promise { - const direct = which("sourcekit-lsp"); - if (direct) return spawnWithFallback(direct, [[], ["--stdio"]], root); - - // macOS/Xcode: sourcekit-lsp is often available via xcrun - const xcrun = which("xcrun"); - if (!xcrun) return undefined; - return spawnWithFallback(xcrun, [["sourcekit-lsp"], ["sourcekit-lsp", "--stdio"]], root); -} - -// Server Configs -export const LSP_SERVERS: LSPServerConfig[] = [ - { - id: "dart", extensions: [".dart"], - findRoot: (f, cwd) => findRoot(f, cwd, ["pubspec.yaml", "analysis_options.yaml"]), - spawn: async (root) => { - let dart = which("dart"); - const pubspec = path.join(root, "pubspec.yaml"); - if (fs.existsSync(pubspec)) { - try { - const content = fs.readFileSync(pubspec, "utf-8"); - if (content.includes("flutter:") || content.includes("sdk: flutter")) { - const flutter = which("flutter"); - if (flutter) { - const dir = path.dirname(fs.realpathSync(flutter)); - for (const p of ["cache/dart-sdk/bin/dart", "../cache/dart-sdk/bin/dart"]) { - const c = path.join(dir, p); - if (fs.existsSync(c)) { dart = c; break; } - } - } - } - } catch {} - } - if (!dart) return undefined; - return { process: spawn(dart, ["language-server", "--protocol=lsp"], { cwd: root, stdio: ["pipe", "pipe", "pipe"] }) }; - }, - }, - { - id: "typescript", extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"], - findRoot: (f, cwd) => { - if (findNearestFile(path.dirname(f), ["deno.json", "deno.jsonc"], cwd)) return undefined; - return findRoot(f, cwd, ["package.json", "tsconfig.json", "jsconfig.json"]); - }, - spawn: async (root) => { - const local = path.join(root, "node_modules/.bin/typescript-language-server"); - const cmd = fs.existsSync(local) ? local : which("typescript-language-server"); - if (!cmd) return undefined; - return { process: spawn(cmd, ["--stdio"], { cwd: root, stdio: ["pipe", "pipe", "pipe"] }) }; - }, - }, - { id: "vue", extensions: [".vue"], findRoot: (f, cwd) => findRoot(f, cwd, ["package.json", "vite.config.ts", "vite.config.js"]), spawn: simpleSpawn("vue-language-server") }, - { id: "svelte", extensions: [".svelte"], findRoot: (f, cwd) => findRoot(f, cwd, ["package.json", "svelte.config.js"]), spawn: simpleSpawn("svelteserver") }, - { id: "pyright", extensions: [".py", ".pyi"], findRoot: (f, cwd) => findRoot(f, cwd, ["pyproject.toml", "setup.py", "requirements.txt", "pyrightconfig.json"]), spawn: simpleSpawn("pyright-langserver") }, - { id: "gopls", extensions: [".go"], findRoot: (f, cwd) => findRoot(f, cwd, ["go.work"]) || findRoot(f, cwd, ["go.mod"]), spawn: simpleSpawn("gopls", []) }, - { - id: "kotlin", extensions: [".kt", ".kts"], - findRoot: (f, cwd) => findRootKotlin(f, cwd), - spawn: async (root) => { - const proc = await spawnKotlinLanguageServer(root); - if (!proc) return undefined; - return { process: proc }; - }, - }, - { - id: "swift", extensions: [".swift"], - findRoot: (f, cwd) => findRootSwift(f, cwd), - spawn: async (root) => { - const proc = await spawnSourcekitLsp(root); - if (!proc) return undefined; - return { process: proc }; - }, - }, - { id: "rust-analyzer", extensions: [".rs"], findRoot: (f, cwd) => findRoot(f, cwd, ["Cargo.toml"]), spawn: simpleSpawn("rust-analyzer", []) }, -]; - -// Singleton Manager -let sharedManager: LSPManager | null = null; -let managerCwd: string | null = null; - -export function getOrCreateManager(cwd: string): LSPManager { - if (!sharedManager || managerCwd !== cwd) { - sharedManager?.shutdown().catch(() => {}); - sharedManager = new LSPManager(cwd); - managerCwd = cwd; - } - return sharedManager; -} - -export function getManager(): LSPManager | null { return sharedManager; } - -export async function shutdownManager(): Promise { - const manager = sharedManager; - if (!manager) return; - - // Clear singleton pointers first so new requests never receive a manager - // that's currently being shut down. - sharedManager = null; - managerCwd = null; - - await manager.shutdown(); -} - -// LSP Manager -export class LSPManager { - private clients = new Map(); - private spawning = new Map>(); - private broken = new Set(); - private cwd: string; - private cleanupTimer: NodeJS.Timeout | null = null; - - constructor(cwd: string) { - this.cwd = cwd; - this.cleanupTimer = setInterval(() => this.cleanupIdleFiles(), CLEANUP_INTERVAL_MS); - this.cleanupTimer.unref(); - } - - private cleanupIdleFiles() { - const now = Date.now(); - for (const client of this.clients.values()) { - for (const [fp, state] of client.openFiles) { - if (now - state.lastAccess > IDLE_TIMEOUT_MS) this.closeFile(client, fp); - } - } - } - - private closeFile(client: LSPClient, absPath: string) { - if (!client.openFiles.has(absPath)) return; - client.openFiles.delete(absPath); - if (client.closed) return; - try { - void client.connection.sendNotification(DidCloseTextDocumentNotification.type, { - textDocument: { uri: pathToFileURL(absPath).href }, - }).catch(() => {}); - } catch {} - } - - private evictLRU(client: LSPClient) { - if (client.openFiles.size <= MAX_OPEN_FILES) return; - let oldest: { path: string; time: number } | null = null; - for (const [fp, s] of client.openFiles) { - if (!oldest || s.lastAccess < oldest.time) oldest = { path: fp, time: s.lastAccess }; - } - if (oldest) this.closeFile(client, oldest.path); - } - - private key(id: string, root: string) { return `${id}:${root}`; } - - private async initClient(config: LSPServerConfig, root: string): Promise { - const k = this.key(config.id, root); - try { - const handle = await config.spawn(root); - if (!handle) { this.broken.add(k); return undefined; } - - const reader = new StreamMessageReader(handle.process.stdout!); - const writer = new StreamMessageWriter(handle.process.stdin!); - const conn = createMessageConnection(reader, writer); - - // Prevent crashes from stream errors - handle.process.stdin?.on("error", () => {}); - handle.process.stdout?.on("error", () => {}); - - const stderr: string[] = []; - const MAX_STDERR_LINES = 200; - handle.process.stderr?.on("data", (chunk: Buffer) => { - try { - const text = chunk.toString("utf-8"); - for (const line of text.split(/\r?\n/)) { - if (!line.trim()) continue; - stderr.push(line); - if (stderr.length > MAX_STDERR_LINES) stderr.splice(0, stderr.length - MAX_STDERR_LINES); - } - } catch { - // ignore - } - }); - handle.process.stderr?.on("error", () => {}); - - const client: LSPClient = { - connection: conn, - process: handle.process, - diagnostics: new Map(), - openFiles: new Map(), - listeners: new Map(), - stderr, - root, - closed: false, - }; - - conn.onNotification("textDocument/publishDiagnostics", (params: { uri: string; diagnostics: Diagnostic[] }) => { - const fpRaw = decodeURIComponent(new URL(params.uri).pathname); - const fp = normalizeFsPath(fpRaw); - - client.diagnostics.set(fp, params.diagnostics); - // Notify both raw and normalized paths (macOS often reports /private/var vs /var) - const listeners1 = client.listeners.get(fp); - const listeners2 = fp !== fpRaw ? client.listeners.get(fpRaw) : undefined; - - listeners1?.slice().forEach(fn => { try { fn(); } catch { /* listener error */ } }); - listeners2?.slice().forEach(fn => { try { fn(); } catch { /* listener error */ } }); - }); - - // Handle errors to prevent crashes - conn.onError(() => {}); - conn.onClose(() => { client.closed = true; this.clients.delete(k); }); - - conn.onRequest("workspace/configuration", () => [handle.initOptions ?? {}]); - conn.onRequest("window/workDoneProgress/create", () => null); - conn.onRequest("client/registerCapability", () => {}); - conn.onRequest("client/unregisterCapability", () => {}); - conn.onRequest("workspace/workspaceFolders", () => [{ name: "workspace", uri: pathToFileURL(root).href }]); - - handle.process.on("exit", () => { client.closed = true; this.clients.delete(k); }); - handle.process.on("error", () => { client.closed = true; this.clients.delete(k); this.broken.add(k); }); - - conn.listen(); - - const initResult = await timeout(conn.sendRequest(InitializeRequest.method, { - rootUri: pathToFileURL(root).href, - rootPath: root, - processId: process.pid, - workspaceFolders: [{ name: "workspace", uri: pathToFileURL(root).href }], - initializationOptions: handle.initOptions ?? {}, - capabilities: { - window: { workDoneProgress: true }, - workspace: { configuration: true }, - textDocument: { - synchronization: { didSave: true, didOpen: true, didChange: true, didClose: true }, - publishDiagnostics: { versionSupport: true }, - diagnostic: { dynamicRegistration: false, relatedDocumentSupport: false }, - }, - }, - }), INIT_TIMEOUT_MS, `${config.id} init`); - - client.capabilities = (initResult as any)?.capabilities; - - void conn.sendNotification(InitializedNotification.type, {}).catch(() => {}); - if (handle.initOptions) { - void conn.sendNotification("workspace/didChangeConfiguration", { settings: handle.initOptions }).catch(() => {}); - } - return client; - } catch { this.broken.add(k); return undefined; } - } - - async getClientsForFile(filePath: string): Promise { - const ext = path.extname(filePath); - const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(this.cwd, filePath); - const clients: LSPClient[] = []; - - for (const config of LSP_SERVERS) { - if (!config.extensions.includes(ext)) continue; - const root = config.findRoot(absPath, this.cwd); - if (!root) continue; - const k = this.key(config.id, root); - if (this.broken.has(k)) continue; - - const existing = this.clients.get(k); - if (existing) { clients.push(existing); continue; } - - if (!this.spawning.has(k)) { - const p = this.initClient(config, root); - this.spawning.set(k, p); - p.finally(() => this.spawning.delete(k)); - } - const client = await this.spawning.get(k); - if (client) { this.clients.set(k, client); clients.push(client); } - } - return clients; - } - - private resolve(fp: string) { - const abs = path.isAbsolute(fp) ? fp : path.resolve(this.cwd, fp); - return normalizeFsPath(abs); - } - private langId(fp: string) { return LANGUAGE_IDS[path.extname(fp)] || "plaintext"; } - private readFile(fp: string): string | null { try { return fs.readFileSync(fp, "utf-8"); } catch { return null; } } - - private explainNoLsp(absPath: string): string { - const ext = path.extname(absPath); - - if (ext === ".kt" || ext === ".kts") { - const root = findRootKotlin(absPath, this.cwd); - if (!root) return `No Kotlin project root detected (looked for settings.gradle(.kts), build.gradle(.kts), gradlew, pom.xml under cwd)`; - - const hasJetbrains = !!(which("kotlin-lsp") || which("kotlin-lsp.sh") || which("kotlin-lsp.cmd") || process.env.PI_LSP_KOTLIN_LSP_PATH); - const hasKls = !!which("kotlin-language-server"); - - if (!hasJetbrains && !hasKls) { - return "No Kotlin LSP binary found. Install Kotlin/kotlin-lsp (recommended) or org.javacs/kotlin-language-server."; - } - - const k = this.key("kotlin", root); - if (this.broken.has(k)) return `Kotlin LSP failed to initialize for root: ${root}`; - - if (!hasJetbrains && hasKls) { - return "Kotlin LSP is running via kotlin-language-server, but that server often does not produce diagnostics for Gradle/Android projects. Prefer Kotlin/kotlin-lsp."; - } - - return `Kotlin LSP unavailable for root: ${root}`; - } - - if (ext === ".swift") { - const root = findRootSwift(absPath, this.cwd); - if (!root) return `No Swift project root detected (looked for Package.swift, *.xcodeproj, *.xcworkspace under cwd)`; - if (!which("sourcekit-lsp") && !which("xcrun")) return "sourcekit-lsp not found (and xcrun missing)"; - const k = this.key("swift", root); - if (this.broken.has(k)) return `sourcekit-lsp failed to initialize for root: ${root}`; - return `Swift LSP unavailable for root: ${root}`; - } - - return `No LSP for ${ext}`; - } - - private toPos(line: number, col: number) { return { line: Math.max(0, line - 1), character: Math.max(0, col - 1) }; } - - private normalizeLocs(result: Location | Location[] | LocationLink[] | null | undefined): Location[] { - if (!result) return []; - const items = Array.isArray(result) ? result : [result]; - if (!items.length) return []; - if ("uri" in items[0] && "range" in items[0]) return items as Location[]; - return (items as LocationLink[]).map(l => ({ uri: l.targetUri, range: l.targetSelectionRange ?? l.targetRange })); - } - - private normalizeSymbols(result: DocumentSymbol[] | SymbolInformation[] | null | undefined): DocumentSymbol[] { - if (!result?.length) return []; - const first = result[0]; - if ("location" in first) { - return (result as SymbolInformation[]).map(s => ({ - name: s.name, kind: s.kind, range: s.location.range, selectionRange: s.location.range, - detail: s.containerName, tags: s.tags, deprecated: s.deprecated, children: [], - })); - } - return result as DocumentSymbol[]; - } - - private async openOrUpdate(clients: LSPClient[], absPath: string, uri: string, langId: string, content: string, evict = true) { - const now = Date.now(); - for (const client of clients) { - if (client.closed) continue; - const state = client.openFiles.get(absPath); - try { - if (state) { - const v = state.version + 1; - client.openFiles.set(absPath, { version: v, lastAccess: now }); - void client.connection.sendNotification(DidChangeTextDocumentNotification.type, { - textDocument: { uri, version: v }, contentChanges: [{ text: content }], - }).catch(() => {}); - } else { - // For some servers (e.g. kotlin-language-server), diagnostics only start flowing after a didChange. - // We open at version 0, then immediately send a full-content didChange at version 1. - client.openFiles.set(absPath, { version: 1, lastAccess: now }); - void client.connection.sendNotification(DidOpenTextDocumentNotification.type, { - textDocument: { uri, languageId: langId, version: 0, text: content }, - }).catch(() => {}); - void client.connection.sendNotification(DidChangeTextDocumentNotification.type, { - textDocument: { uri, version: 1 }, contentChanges: [{ text: content }], - }).catch(() => {}); - if (evict) this.evictLRU(client); - } - // Send didSave to trigger analysis (important for TypeScript) - void client.connection.sendNotification(DidSaveTextDocumentNotification.type, { - textDocument: { uri }, text: content, - }).catch(() => {}); - } catch {} - } - } - - private async loadFile(filePath: string) { - const absPath = this.resolve(filePath); - const clients = await this.getClientsForFile(absPath); - if (!clients.length) return null; - const content = this.readFile(absPath); - if (content === null) return null; - return { clients, absPath, uri: pathToFileURL(absPath).href, langId: this.langId(absPath), content }; - } - - private waitForDiagnostics(client: LSPClient, absPath: string, timeoutMs: number, isNew: boolean): Promise { - return new Promise(resolve => { - if (client.closed) return resolve(false); - - let resolved = false; - let settleTimer: NodeJS.Timeout | null = null; - let listener: () => void = () => {}; - - const cleanupListener = () => { - const listeners = client.listeners.get(absPath); - if (!listeners) return; - const idx = listeners.indexOf(listener); - if (idx !== -1) listeners.splice(idx, 1); - if (listeners.length === 0) client.listeners.delete(absPath); - }; - - const finish = (value: boolean) => { - if (resolved) return; - resolved = true; - if (settleTimer) clearTimeout(settleTimer); - clearTimeout(timer); - cleanupListener(); - resolve(value); - }; - - // Some servers publish diagnostics multiple times (often empty first, then real results). - // For new documents, if diagnostics are still empty, debounce a bit. - listener = () => { - if (resolved) return; - - const current = client.diagnostics.get(absPath); - if (current && current.length > 0) return finish(true); - - if (!isNew) return finish(true); - - if (settleTimer) clearTimeout(settleTimer); - settleTimer = setTimeout(() => finish(true), 2500); - (settleTimer as any).unref?.(); - }; - - const timer = setTimeout(() => finish(false), timeoutMs); - (timer as any).unref?.(); - - const listeners = client.listeners.get(absPath) || []; - listeners.push(listener); - client.listeners.set(absPath, listeners); - }); - } - - private async pullDiagnostics(client: LSPClient, absPath: string, uri: string): Promise<{ diagnostics: Diagnostic[]; responded: boolean }> { - if (client.closed) return { diagnostics: [], responded: false }; - - // Only attempt Pull Diagnostics if the server advertises support. - // (Some servers throw and log noisy errors if we call these methods.) - if (!client.capabilities || !(client.capabilities as any).diagnosticProvider) { - return { diagnostics: [], responded: false }; - } - - // Prefer new Pull Diagnostics if supported by the server - try { - const res: any = await client.connection.sendRequest(DocumentDiagnosticRequest.method, { - textDocument: { uri }, - }); - - if (res?.kind === DocumentDiagnosticReportKind.Full) { - return { diagnostics: Array.isArray(res.items) ? res.items : [], responded: true }; - } - if (res?.kind === DocumentDiagnosticReportKind.Unchanged) { - return { diagnostics: client.diagnostics.get(absPath) || [], responded: true }; - } - if (Array.isArray(res?.items)) { - return { diagnostics: res.items, responded: true }; - } - return { diagnostics: [], responded: true }; - } catch { - // ignore - } - - // Fallback: some servers only support WorkspaceDiagnosticRequest - try { - const res: any = await client.connection.sendRequest(WorkspaceDiagnosticRequest.method, { - previousResultIds: [], - }); - - const items: any[] = res?.items || []; - const match = items.find((it: any) => it?.uri === uri); - if (match?.kind === DocumentDiagnosticReportKind.Full) { - return { diagnostics: Array.isArray(match.items) ? match.items : [], responded: true }; - } - if (Array.isArray(match?.items)) { - return { diagnostics: match.items, responded: true }; - } - return { diagnostics: [], responded: true }; - } catch { - return { diagnostics: [], responded: false }; - } - } - - async touchFileAndWait(filePath: string, timeoutMs: number): Promise<{ diagnostics: Diagnostic[]; receivedResponse: boolean; unsupported?: boolean; error?: string }> { - const absPath = this.resolve(filePath); - - if (!fs.existsSync(absPath)) { - return { diagnostics: [], receivedResponse: false, unsupported: true, error: "File not found" }; - } - - const clients = await this.getClientsForFile(absPath); - if (!clients.length) { - return { diagnostics: [], receivedResponse: false, unsupported: true, error: this.explainNoLsp(absPath) }; - } - - const content = this.readFile(absPath); - if (content === null) { - return { diagnostics: [], receivedResponse: false, unsupported: true, error: "Could not read file" }; - } - - const uri = pathToFileURL(absPath).href; - const langId = this.langId(absPath); - const isNew = clients.some(c => !c.openFiles.has(absPath)); - - const waits = clients.map(c => this.waitForDiagnostics(c, absPath, timeoutMs, isNew)); - await this.openOrUpdate(clients, absPath, uri, langId, content); - const results = await Promise.all(waits); - - let responded = results.some(r => r); - const diags: Diagnostic[] = []; - for (const c of clients) { - const d = c.diagnostics.get(absPath); - if (d) diags.push(...d); - } - if (!responded && clients.some(c => c.diagnostics.has(absPath))) responded = true; - - // If we didn't get pushed diagnostics (common for some servers), try pull diagnostics. - if (!responded || diags.length === 0) { - const pulled = await Promise.all(clients.map(c => this.pullDiagnostics(c, absPath, uri))); - for (let i = 0; i < clients.length; i++) { - const r = pulled[i]; - if (r.responded) responded = true; - if (r.diagnostics.length) { - clients[i].diagnostics.set(absPath, r.diagnostics); - diags.push(...r.diagnostics); - } - } - } - - return { diagnostics: diags, receivedResponse: responded }; - } - - async getDiagnosticsForFiles(files: string[], timeoutMs: number): Promise { - const unique = [...new Set(files.map(f => this.resolve(f)))]; - const results: FileDiagnosticItem[] = []; - const toClose: Map = new Map(); - - for (const absPath of unique) { - if (!fs.existsSync(absPath)) { - results.push({ file: absPath, diagnostics: [], status: 'error', error: 'File not found' }); - continue; - } - - let clients: LSPClient[]; - try { clients = await this.getClientsForFile(absPath); } - catch (e) { results.push({ file: absPath, diagnostics: [], status: 'error', error: String(e) }); continue; } - - if (!clients.length) { - results.push({ file: absPath, diagnostics: [], status: 'unsupported', error: this.explainNoLsp(absPath) }); - continue; - } - - const content = this.readFile(absPath); - if (!content) { - results.push({ file: absPath, diagnostics: [], status: 'error', error: 'Could not read file' }); - continue; - } - - const uri = pathToFileURL(absPath).href; - const langId = this.langId(absPath); - const isNew = clients.some(c => !c.openFiles.has(absPath)); - - for (const c of clients) { - if (!c.openFiles.has(absPath)) { - if (!toClose.has(c)) toClose.set(c, []); - toClose.get(c)!.push(absPath); - } - } - - const waits = clients.map(c => this.waitForDiagnostics(c, absPath, timeoutMs, isNew)); - await this.openOrUpdate(clients, absPath, uri, langId, content, false); - const waitResults = await Promise.all(waits); - - const diags: Diagnostic[] = []; - for (const c of clients) { const d = c.diagnostics.get(absPath); if (d) diags.push(...d); } - - let responded = waitResults.some(r => r) || diags.length > 0; - - if (!responded || diags.length === 0) { - const pulled = await Promise.all(clients.map(c => this.pullDiagnostics(c, absPath, uri))); - for (let i = 0; i < clients.length; i++) { - const r = pulled[i]; - if (r.responded) responded = true; - if (r.diagnostics.length) { - clients[i].diagnostics.set(absPath, r.diagnostics); - diags.push(...r.diagnostics); - } - } - } - - if (!responded && !diags.length) { - results.push({ file: absPath, diagnostics: [], status: 'timeout', error: 'LSP did not respond' }); - } else { - results.push({ file: absPath, diagnostics: diags, status: 'ok' }); - } - } - - // Cleanup opened files - for (const [c, fps] of toClose) { for (const fp of fps) this.closeFile(c, fp); } - for (const c of this.clients.values()) { while (c.openFiles.size > MAX_OPEN_FILES) this.evictLRU(c); } - - return { items: results }; - } - - async getDefinition(fp: string, line: number, col: number): Promise { - const l = await this.loadFile(fp); - if (!l) return []; - await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content); - const pos = this.toPos(line, col); - const results = await Promise.all(l.clients.map(async c => { - if (c.closed) return []; - try { return this.normalizeLocs(await c.connection.sendRequest(DefinitionRequest.type, { textDocument: { uri: l.uri }, position: pos })); } - catch { return []; } - })); - return results.flat(); - } - - async getReferences(fp: string, line: number, col: number): Promise { - const l = await this.loadFile(fp); - if (!l) return []; - await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content); - const pos = this.toPos(line, col); - const results = await Promise.all(l.clients.map(async c => { - if (c.closed) return []; - try { return this.normalizeLocs(await c.connection.sendRequest(ReferencesRequest.type, { textDocument: { uri: l.uri }, position: pos, context: { includeDeclaration: true } })); } - catch { return []; } - })); - return results.flat(); - } - - async getHover(fp: string, line: number, col: number): Promise { - const l = await this.loadFile(fp); - if (!l) return null; - await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content); - const pos = this.toPos(line, col); - for (const c of l.clients) { - if (c.closed) continue; - try { const r = await c.connection.sendRequest(HoverRequest.type, { textDocument: { uri: l.uri }, position: pos }); if (r) return r; } - catch {} - } - return null; - } - - async getSignatureHelp(fp: string, line: number, col: number): Promise { - const l = await this.loadFile(fp); - if (!l) return null; - await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content); - const pos = this.toPos(line, col); - for (const c of l.clients) { - if (c.closed) continue; - try { const r = await c.connection.sendRequest(SignatureHelpRequest.type, { textDocument: { uri: l.uri }, position: pos }); if (r) return r; } - catch {} - } - return null; - } - - async getDocumentSymbols(fp: string): Promise { - const l = await this.loadFile(fp); - if (!l) return []; - await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content); - const results = await Promise.all(l.clients.map(async c => { - if (c.closed) return []; - try { return this.normalizeSymbols(await c.connection.sendRequest(DocumentSymbolRequest.type, { textDocument: { uri: l.uri } })); } - catch { return []; } - })); - return results.flat(); - } - - async rename(fp: string, line: number, col: number, newName: string): Promise { - const l = await this.loadFile(fp); - if (!l) return null; - await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content); - const pos = this.toPos(line, col); - for (const c of l.clients) { - if (c.closed) continue; - try { - const r = await c.connection.sendRequest(RenameRequest.type, { - textDocument: { uri: l.uri }, - position: pos, - newName, - }); - if (r) return r; - } catch {} - } - return null; - } - - async getCodeActions(fp: string, startLine: number, startCol: number, endLine?: number, endCol?: number): Promise<(CodeAction | Command)[]> { - const l = await this.loadFile(fp); - if (!l) return []; - await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content); - - const start = this.toPos(startLine, startCol); - const end = this.toPos(endLine ?? startLine, endCol ?? startCol); - const range = { start, end }; - - // Get diagnostics for this range to include in context - const diagnostics: Diagnostic[] = []; - for (const c of l.clients) { - const fileDiags = c.diagnostics.get(l.absPath) || []; - for (const d of fileDiags) { - if (this.rangesOverlap(d.range, range)) diagnostics.push(d); - } - } - - const results = await Promise.all(l.clients.map(async c => { - if (c.closed) return []; - try { - const r = await c.connection.sendRequest(CodeActionRequest.type, { - textDocument: { uri: l.uri }, - range, - context: { diagnostics, only: [CodeActionKind.QuickFix, CodeActionKind.Refactor, CodeActionKind.Source] }, - }); - return r || []; - } catch { return []; } - })); - return results.flat(); - } - - private rangesOverlap(a: { start: { line: number; character: number }; end: { line: number; character: number } }, - b: { start: { line: number; character: number }; end: { line: number; character: number } }): boolean { - if (a.end.line < b.start.line || b.end.line < a.start.line) return false; - if (a.end.line === b.start.line && a.end.character < b.start.character) return false; - if (b.end.line === a.start.line && b.end.character < a.start.character) return false; - return true; - } - - async shutdown() { - if (this.cleanupTimer) { clearInterval(this.cleanupTimer); this.cleanupTimer = null; } - const clients = Array.from(this.clients.values()); - this.clients.clear(); - for (const c of clients) { - const wasClosed = c.closed; - c.closed = true; - if (!wasClosed) { - try { - await Promise.race([ - c.connection.sendRequest("shutdown"), - new Promise(r => setTimeout(r, 1000)) - ]); - } catch {} - try { void c.connection.sendNotification("exit").catch(() => {}); } catch {} - } - try { c.connection.end(); } catch {} - try { c.process.kill(); } catch {} - } - } -} - -// Diagnostic Formatting -export { DiagnosticSeverity }; -export type SeverityFilter = "all" | "error" | "warning" | "info" | "hint"; - -export function formatDiagnostic(d: Diagnostic): string { - const sev = ["", "ERROR", "WARN", "INFO", "HINT"][d.severity || 1]; - return `${sev} [${d.range.start.line + 1}:${d.range.start.character + 1}] ${d.message}`; -} - -export function filterDiagnosticsBySeverity(diags: Diagnostic[], filter: SeverityFilter): Diagnostic[] { - if (filter === "all") return diags; - const max = { error: 1, warning: 2, info: 3, hint: 4 }[filter]; - return diags.filter(d => (d.severity || 1) <= max); -} - -// URI utilities -export function uriToPath(uri: string): string { - if (uri.startsWith("file://")) try { return fileURLToPath(uri); } catch {} - return uri; -} - -// Symbol search -export function findSymbolPosition(symbols: DocumentSymbol[], query: string): { line: number; character: number } | null { - const q = query.toLowerCase(); - let exact: { line: number; character: number } | null = null; - let partial: { line: number; character: number } | null = null; - - const visit = (items: DocumentSymbol[]) => { - for (const sym of items) { - const name = String(sym?.name ?? "").toLowerCase(); - const pos = sym?.selectionRange?.start ?? sym?.range?.start; - if (pos && typeof pos.line === "number" && typeof pos.character === "number") { - if (!exact && name === q) exact = pos; - if (!partial && name.includes(q)) partial = pos; - } - if (sym?.children?.length) visit(sym.children); - } - }; - visit(symbols); - return exact ?? partial; -} - -export async function resolvePosition(manager: LSPManager, file: string, query: string): Promise<{ line: number; column: number } | null> { - const symbols = await manager.getDocumentSymbols(file); - const pos = findSymbolPosition(symbols, query); - return pos ? { line: pos.line + 1, column: pos.character + 1 } : null; -} diff --git a/pi/.pi/agent/extensions/lsp-pi/lsp-tool.ts b/pi/.pi/agent/extensions/lsp-pi/lsp-tool.ts deleted file mode 100644 index 8b789b3..0000000 --- a/pi/.pi/agent/extensions/lsp-pi/lsp-tool.ts +++ /dev/null @@ -1,382 +0,0 @@ -/** - * LSP Tool Extension for pi-coding-agent - * - * Provides Language Server Protocol tool for: - * - definitions, references, hover, signature help - * - document symbols, diagnostics, workspace diagnostics - * - rename, code actions - * - * Supported languages: - * - Dart/Flutter (dart language-server) - * - TypeScript/JavaScript (typescript-language-server) - * - Vue (vue-language-server) - * - Svelte (svelteserver) - * - Python (pyright-langserver) - * - Go (gopls) - * - Kotlin (kotlin-ls) - * - Swift (sourcekit-lsp) - * - Rust (rust-analyzer) - * - * Usage: - * pi --extension ./lsp-tool.ts - * - * Or use the combined lsp.ts extension for both hook and tool functionality. - */ - -import * as path from "node:path"; -import { Type, type Static } from "@sinclair/typebox"; -import { StringEnum } from "@mariozechner/pi-ai"; -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { Text } from "@mariozechner/pi-tui"; -import { getOrCreateManager, formatDiagnostic, filterDiagnosticsBySeverity, uriToPath, resolvePosition, type SeverityFilter } from "./lsp-core.js"; - -const PREVIEW_LINES = 10; - -const DIAGNOSTICS_WAIT_MS_DEFAULT = 3000; - -function diagnosticsWaitMsForFile(filePath: string): number { - const ext = path.extname(filePath).toLowerCase(); - if (ext === ".kt" || ext === ".kts") return 30000; - if (ext === ".swift") return 20000; - if (ext === ".rs") return 20000; - return DIAGNOSTICS_WAIT_MS_DEFAULT; -} - -const ACTIONS = ["definition", "references", "hover", "symbols", "diagnostics", "workspace-diagnostics", "signature", "rename", "codeAction"] as const; -const SEVERITY_FILTERS = ["all", "error", "warning", "info", "hint"] as const; - -const LspParams = Type.Object({ - action: StringEnum(ACTIONS), - file: Type.Optional(Type.String({ description: "File path (required for most actions)" })), - files: Type.Optional(Type.Array(Type.String(), { description: "File paths for workspace-diagnostics" })), - line: Type.Optional(Type.Number({ description: "Line (1-indexed). Required for position-based actions unless query provided." })), - column: Type.Optional(Type.Number({ description: "Column (1-indexed). Required for position-based actions unless query provided." })), - endLine: Type.Optional(Type.Number({ description: "End line for range-based actions (codeAction)" })), - endColumn: Type.Optional(Type.Number({ description: "End column for range-based actions (codeAction)" })), - query: Type.Optional(Type.String({ description: "Symbol name filter (for symbols) or to resolve position (for definition/references/hover/signature)" })), - newName: Type.Optional(Type.String({ description: "New name for rename action" })), - severity: Type.Optional(StringEnum(SEVERITY_FILTERS, { description: 'Filter diagnostics: "all"|"error"|"warning"|"info"|"hint"' })), -}); - -type LspParamsType = Static; - -function abortable(promise: Promise, signal?: AbortSignal): Promise { - if (!signal) return promise; - if (signal.aborted) return Promise.reject(new Error("aborted")); - - return new Promise((resolve, reject) => { - const onAbort = () => { - cleanup(); - reject(new Error("aborted")); - }; - - const cleanup = () => { - signal.removeEventListener("abort", onAbort); - }; - - signal.addEventListener("abort", onAbort, { once: true }); - - promise.then( - (value) => { - cleanup(); - resolve(value); - }, - (err) => { - cleanup(); - reject(err); - }, - ); - }); -} - -function isAbortedError(e: unknown): boolean { - return e instanceof Error && e.message === "aborted"; -} - -function cancelledToolResult() { - return { - content: [{ type: "text" as const, text: "Cancelled" }], - details: { cancelled: true }, - }; -} - -type ExecuteArgs = { - signal: AbortSignal | undefined; - onUpdate: ((update: { content: Array<{ type: "text"; text: string }>; details?: Record }) => void) | undefined; - ctx: { cwd: string }; -}; - -function isAbortSignalLike(value: unknown): value is AbortSignal { - return !!value - && typeof value === "object" - && "aborted" in value - && typeof (value as any).aborted === "boolean" - && typeof (value as any).addEventListener === "function"; -} - -function isContextLike(value: unknown): value is { cwd: string } { - return !!value && typeof value === "object" && typeof (value as any).cwd === "string"; -} - -function normalizeExecuteArgs(onUpdateArg: unknown, ctxArg: unknown, signalArg: unknown): ExecuteArgs { - // Runtime >= 0.51: (signal, onUpdate, ctx) - if (isContextLike(signalArg)) { - return { - signal: isAbortSignalLike(onUpdateArg) ? onUpdateArg : undefined, - onUpdate: typeof ctxArg === "function" ? ctxArg as ExecuteArgs["onUpdate"] : undefined, - ctx: signalArg, - }; - } - - // Runtime <= 0.50: (onUpdate, ctx, signal) - if (isContextLike(ctxArg)) { - return { - signal: isAbortSignalLike(signalArg) ? signalArg : undefined, - onUpdate: typeof onUpdateArg === "function" ? onUpdateArg as ExecuteArgs["onUpdate"] : undefined, - ctx: ctxArg, - }; - } - - throw new Error("Invalid tool execution context"); -} - -function formatLocation(loc: { uri: string; range?: { start?: { line: number; character: number } } }, cwd?: string): string { - const abs = uriToPath(loc.uri); - const display = cwd && path.isAbsolute(abs) ? path.relative(cwd, abs) : abs; - const { line, character: col } = loc.range?.start ?? {}; - return typeof line === "number" && typeof col === "number" ? `${display}:${line + 1}:${col + 1}` : display; -} - -function formatHover(contents: unknown): string { - if (typeof contents === "string") return contents; - if (Array.isArray(contents)) return contents.map(c => typeof c === "string" ? c : (c as any)?.value ?? "").filter(Boolean).join("\n\n"); - if (contents && typeof contents === "object" && "value" in contents) return String((contents as any).value); - return ""; -} - -function formatSignature(help: any): string { - if (!help?.signatures?.length) return "No signature help available."; - const sig = help.signatures[help.activeSignature ?? 0] ?? help.signatures[0]; - let text = sig.label ?? "Signature"; - if (sig.documentation) text += `\n${typeof sig.documentation === "string" ? sig.documentation : sig.documentation?.value ?? ""}`; - if (sig.parameters?.length) { - const params = sig.parameters.map((p: any) => typeof p.label === "string" ? p.label : Array.isArray(p.label) ? p.label.join("-") : "").filter(Boolean); - if (params.length) text += `\nParameters: ${params.join(", ")}`; - } - return text; -} - -function collectSymbols(symbols: any[], depth = 0, lines: string[] = [], query?: string): string[] { - for (const sym of symbols) { - const name = sym?.name ?? ""; - if (query && !name.toLowerCase().includes(query.toLowerCase())) { - if (sym.children?.length) collectSymbols(sym.children, depth + 1, lines, query); - continue; - } - const loc = sym?.range?.start ? `${sym.range.start.line + 1}:${sym.range.start.character + 1}` : ""; - lines.push(`${" ".repeat(depth)}${name}${loc ? ` (${loc})` : ""}`); - if (sym.children?.length) collectSymbols(sym.children, depth + 1, lines, query); - } - return lines; -} - -function formatWorkspaceEdit(edit: any, cwd?: string): string { - const lines: string[] = []; - - if (edit.documentChanges?.length) { - for (const change of edit.documentChanges) { - if (change.textDocument?.uri) { - const fp = uriToPath(change.textDocument.uri); - const display = cwd && path.isAbsolute(fp) ? path.relative(cwd, fp) : fp; - lines.push(`${display}:`); - for (const e of change.edits || []) { - const loc = `${e.range.start.line + 1}:${e.range.start.character + 1}`; - lines.push(` [${loc}] → "${e.newText}"`); - } - } - } - } - - if (edit.changes) { - for (const [uri, edits] of Object.entries(edit.changes)) { - const fp = uriToPath(uri); - const display = cwd && path.isAbsolute(fp) ? path.relative(cwd, fp) : fp; - lines.push(`${display}:`); - for (const e of edits as any[]) { - const loc = `${e.range.start.line + 1}:${e.range.start.character + 1}`; - lines.push(` [${loc}] → "${e.newText}"`); - } - } - } - - return lines.length ? lines.join("\n") : "No edits."; -} - -function formatCodeActions(actions: any[]): string[] { - return actions.map((a, i) => { - const title = a.title || a.command?.title || "Untitled action"; - const kind = a.kind ? ` (${a.kind})` : ""; - const isPreferred = a.isPreferred ? " ★" : ""; - return `${i + 1}. ${title}${kind}${isPreferred}`; - }); -} - -export default function (pi: ExtensionAPI) { - pi.registerTool({ - name: "lsp", - label: "LSP", - description: `Query language server for definitions, references, types, symbols, diagnostics, rename, and code actions. - -Actions: definition, references, hover, signature, rename (require file + line/column or query), symbols (file, optional query), diagnostics (file), workspace-diagnostics (files array), codeAction (file + position). -Use bash to find files: find src -name "*.ts" -type f`, - parameters: LspParams, - - async execute(_toolCallId, params, onUpdateArg, ctxArg, signalArg) { - const { signal, onUpdate, ctx } = normalizeExecuteArgs(onUpdateArg, ctxArg, signalArg); - if (signal?.aborted) return cancelledToolResult(); - if (onUpdate) { - onUpdate({ content: [{ type: "text", text: "Working..." }], details: { status: "working" } }); - } - - const manager = getOrCreateManager(ctx.cwd); - const { action, file, files, line, column, endLine, endColumn, query, newName, severity } = params as LspParamsType; - const sevFilter: SeverityFilter = severity || "all"; - const needsFile = action !== "workspace-diagnostics"; - const needsPos = ["definition", "references", "hover", "signature", "rename", "codeAction"].includes(action); - - try { - if (needsFile && !file) throw new Error(`Action "${action}" requires a file path.`); - - let rLine = line, rCol = column, fromQuery = false; - if (needsPos && (rLine === undefined || rCol === undefined) && query && file) { - const resolved = await abortable(resolvePosition(manager, file, query), signal); - if (resolved) { rLine = resolved.line; rCol = resolved.column; fromQuery = true; } - } - if (needsPos && (rLine === undefined || rCol === undefined)) { - throw new Error(`Action "${action}" requires line/column or a query matching a symbol.`); - } - - const qLine = query ? `query: ${query}\n` : ""; - const sevLine = sevFilter !== "all" ? `severity: ${sevFilter}\n` : ""; - const posLine = fromQuery && rLine && rCol ? `resolvedPosition: ${rLine}:${rCol}\n` : ""; - - switch (action) { - case "definition": { - const results = await abortable(manager.getDefinition(file!, rLine!, rCol!), signal); - const locs = results.map(l => formatLocation(l, ctx?.cwd)); - const payload = locs.length ? locs.join("\n") : fromQuery ? `${file}:${rLine}:${rCol}` : "No definitions found."; - return { content: [{ type: "text", text: `action: definition\n${qLine}${posLine}${payload}` }], details: results }; - } - case "references": { - const results = await abortable(manager.getReferences(file!, rLine!, rCol!), signal); - const locs = results.map(l => formatLocation(l, ctx?.cwd)); - return { content: [{ type: "text", text: `action: references\n${qLine}${posLine}${locs.length ? locs.join("\n") : "No references found."}` }], details: results }; - } - case "hover": { - const result = await abortable(manager.getHover(file!, rLine!, rCol!), signal); - const payload = result ? formatHover(result.contents) || "No hover information." : "No hover information."; - return { content: [{ type: "text", text: `action: hover\n${qLine}${posLine}${payload}` }], details: result ?? null }; - } - case "symbols": { - const symbols = await abortable(manager.getDocumentSymbols(file!), signal); - const lines = collectSymbols(symbols, 0, [], query); - const payload = lines.length ? lines.join("\n") : query ? `No symbols matching "${query}".` : "No symbols found."; - return { content: [{ type: "text", text: `action: symbols\n${qLine}${payload}` }], details: symbols }; - } - case "diagnostics": { - const result = await abortable(manager.touchFileAndWait(file!, diagnosticsWaitMsForFile(file!)), signal); - const filtered = filterDiagnosticsBySeverity(result.diagnostics, sevFilter); - const payload = (result as any).unsupported - ? `Unsupported: ${(result as any).error || "No LSP for this file."}` - : !result.receivedResponse - ? "Timeout: LSP server did not respond. Try again." - : filtered.length ? filtered.map(formatDiagnostic).join("\n") : "No diagnostics."; - return { content: [{ type: "text", text: `action: diagnostics\n${sevLine}${payload}` }], details: { ...result, diagnostics: filtered } }; - } - case "workspace-diagnostics": { - if (!files?.length) throw new Error('Action "workspace-diagnostics" requires a "files" array.'); - const waitMs = Math.max(...files.map(diagnosticsWaitMsForFile)); - const result = await abortable(manager.getDiagnosticsForFiles(files, waitMs), signal); - const out: string[] = []; - let errors = 0, warnings = 0, filesWithIssues = 0; - - for (const item of result.items) { - const display = ctx?.cwd && path.isAbsolute(item.file) ? path.relative(ctx.cwd, item.file) : item.file; - if (item.status !== 'ok') { out.push(`${display}: ${item.error || item.status}`); continue; } - const filtered = filterDiagnosticsBySeverity(item.diagnostics, sevFilter); - if (filtered.length) { - filesWithIssues++; - out.push(`${display}:`); - for (const d of filtered) { - if (d.severity === 1) errors++; else if (d.severity === 2) warnings++; - out.push(` ${formatDiagnostic(d)}`); - } - } - } - - const summary = `Analyzed ${result.items.length} file(s): ${errors} error(s), ${warnings} warning(s) in ${filesWithIssues} file(s)`; - return { content: [{ type: "text", text: `action: workspace-diagnostics\n${sevLine}${summary}\n\n${out.length ? out.join("\n") : "No diagnostics."}` }], details: result }; - } - case "signature": { - const result = await abortable(manager.getSignatureHelp(file!, rLine!, rCol!), signal); - return { content: [{ type: "text", text: `action: signature\n${qLine}${posLine}${formatSignature(result)}` }], details: result ?? null }; - } - case "rename": { - if (!newName) throw new Error('Action "rename" requires a "newName" parameter.'); - const result = await abortable(manager.rename(file!, rLine!, rCol!, newName), signal); - if (!result) return { content: [{ type: "text", text: `action: rename\n${qLine}${posLine}No rename available at this position.` }], details: null }; - const edits = formatWorkspaceEdit(result, ctx?.cwd); - return { content: [{ type: "text", text: `action: rename\n${qLine}${posLine}newName: ${newName}\n\n${edits}` }], details: result }; - } - case "codeAction": { - const result = await abortable(manager.getCodeActions(file!, rLine!, rCol!, endLine, endColumn), signal); - const actions = formatCodeActions(result); - return { content: [{ type: "text", text: `action: codeAction\n${qLine}${posLine}${actions.length ? actions.join("\n") : "No code actions available."}` }], details: result }; - } - } - } catch (e) { - if (signal?.aborted || isAbortedError(e)) return cancelledToolResult(); - throw e; - } - }, - - renderCall(args, theme) { - const params = args as LspParamsType; - let text = theme.fg("toolTitle", theme.bold("lsp ")) + theme.fg("accent", params.action || "..."); - if (params.file) text += " " + theme.fg("muted", params.file); - else if (params.files?.length) text += " " + theme.fg("muted", `${params.files.length} file(s)`); - if (params.query) text += " " + theme.fg("dim", `query="${params.query}"`); - else if (params.line !== undefined && params.column !== undefined) text += theme.fg("warning", `:${params.line}:${params.column}`); - if (params.severity && params.severity !== "all") text += " " + theme.fg("dim", `[${params.severity}]`); - return new Text(text, 0, 0); - }, - - renderResult(result, options, theme) { - if (options.isPartial) return new Text(theme.fg("warning", "Working..."), 0, 0); - - const textContent = (result.content?.find((c: any) => c.type === "text") as any)?.text || ""; - const lines = textContent.split("\n"); - - let headerEnd = 0; - for (let i = 0; i < lines.length; i++) { - if (/^(action|query|severity|resolvedPosition):/.test(lines[i])) headerEnd = i + 1; - else break; - } - - const header = lines.slice(0, headerEnd); - const content = lines.slice(headerEnd); - const maxLines = options.expanded ? content.length : PREVIEW_LINES; - const display = content.slice(0, maxLines); - const remaining = content.length - maxLines; - - let out = header.map((l: string) => theme.fg("muted", l)).join("\n"); - if (display.length) { - if (out) out += "\n"; - out += display.map((l: string) => theme.fg("toolOutput", l)).join("\n"); - } - if (remaining > 0) out += theme.fg("dim", `\n... (${remaining} more lines)`); - - return new Text(out, 0, 0); - }, - }); -} diff --git a/pi/.pi/agent/extensions/lsp-pi/lsp.ts b/pi/.pi/agent/extensions/lsp-pi/lsp.ts deleted file mode 100644 index 9c44748..0000000 --- a/pi/.pi/agent/extensions/lsp-pi/lsp.ts +++ /dev/null @@ -1,604 +0,0 @@ -/** - * LSP Hook Extension for pi-coding-agent - * - * Provides automatic diagnostics feedback (default: agent end). - * Can run after each write/edit or once per agent response. - * - * Usage: - * pi --extension ./lsp.ts - * - * Or load the directory to get both hook and tool: - * pi --extension ./lsp/ - */ - -import * as path from "node:path"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import { type ExtensionAPI, type ExtensionContext } from "@mariozechner/pi-coding-agent"; -import { Text } from "@mariozechner/pi-tui"; -import { type Diagnostic } from "vscode-languageserver-protocol"; -import { LSP_SERVERS, formatDiagnostic, getOrCreateManager, shutdownManager } from "./lsp-core.js"; - -type HookScope = "session" | "global"; -type HookMode = "edit_write" | "agent_end" | "disabled"; - -const DIAGNOSTICS_WAIT_MS_DEFAULT = 3000; - -function diagnosticsWaitMsForFile(filePath: string): number { - const ext = path.extname(filePath).toLowerCase(); - if (ext === ".kt" || ext === ".kts") return 30000; - if (ext === ".swift") return 20000; - if (ext === ".rs") return 20000; - return DIAGNOSTICS_WAIT_MS_DEFAULT; -} -const DIAGNOSTICS_PREVIEW_LINES = 10; -const LSP_IDLE_SHUTDOWN_MS = 2 * 60 * 1000; -const DIM = "\x1b[2m", GREEN = "\x1b[32m", YELLOW = "\x1b[33m", RESET = "\x1b[0m"; -const DEFAULT_HOOK_MODE: HookMode = "agent_end"; -const SETTINGS_NAMESPACE = "lsp"; -const LSP_CONFIG_ENTRY = "lsp-hook-config"; - -const WARMUP_MAP: Record = { - "pubspec.yaml": ".dart", - "package.json": ".ts", - "pyproject.toml": ".py", - "go.mod": ".go", - "Cargo.toml": ".rs", - "settings.gradle": ".kt", - "settings.gradle.kts": ".kt", - "build.gradle": ".kt", - "build.gradle.kts": ".kt", - "pom.xml": ".kt", - "gradlew": ".kt", - "gradle.properties": ".kt", - "Package.swift": ".swift", -}; - -const MODE_LABELS: Record = { - edit_write: "After each edit/write", - agent_end: "At agent end", - disabled: "Disabled", -}; - -function normalizeHookMode(value: unknown): HookMode | undefined { - if (value === "edit_write" || value === "agent_end" || value === "disabled") return value; - if (value === "turn_end") return "agent_end"; - return undefined; -} - -interface HookConfigEntry { - scope: HookScope; - hookMode?: HookMode; -} - -export default function (pi: ExtensionAPI) { - type LspActivity = "idle" | "loading" | "working"; - - let activeClients: Set = new Set(); - let statusUpdateFn: ((key: string, text: string | undefined) => void) | null = null; - let hookMode: HookMode = DEFAULT_HOOK_MODE; - let hookScope: HookScope = "global"; - let activity: LspActivity = "idle"; - let diagnosticsAbort: AbortController | null = null; - let shuttingDown = false; - let idleShutdownTimer: NodeJS.Timeout | null = null; - - const touchedFiles: Map = new Map(); - const globalSettingsPath = path.join(os.homedir(), ".pi", "agent", "settings.json"); - - function readSettingsFile(filePath: string): Record { - try { - if (!fs.existsSync(filePath)) return {}; - const raw = fs.readFileSync(filePath, "utf-8"); - const parsed = JSON.parse(raw); - return parsed && typeof parsed === "object" ? parsed as Record : {}; - } catch { - return {}; - } - } - - function getGlobalHookMode(): HookMode | undefined { - const settings = readSettingsFile(globalSettingsPath); - const lspSettings = settings[SETTINGS_NAMESPACE]; - const hookValue = (lspSettings as { hookMode?: unknown; hookEnabled?: unknown } | undefined)?.hookMode; - const normalized = normalizeHookMode(hookValue); - if (normalized) return normalized; - - const legacyEnabled = (lspSettings as { hookEnabled?: unknown } | undefined)?.hookEnabled; - if (typeof legacyEnabled === "boolean") return legacyEnabled ? "edit_write" : "disabled"; - return undefined; - } - - function setGlobalHookMode(mode: HookMode): boolean { - try { - const settings = readSettingsFile(globalSettingsPath); - const existing = settings[SETTINGS_NAMESPACE]; - const nextNamespace = (existing && typeof existing === "object") - ? { ...(existing as Record), hookMode: mode } - : { hookMode: mode }; - - settings[SETTINGS_NAMESPACE] = nextNamespace; - fs.mkdirSync(path.dirname(globalSettingsPath), { recursive: true }); - fs.writeFileSync(globalSettingsPath, JSON.stringify(settings, null, 2), "utf-8"); - return true; - } catch { - return false; - } - } - - function getLastHookEntry(ctx: ExtensionContext): HookConfigEntry | undefined { - const branchEntries = ctx.sessionManager.getBranch(); - let latest: HookConfigEntry | undefined; - - for (const entry of branchEntries) { - if (entry.type === "custom" && entry.customType === LSP_CONFIG_ENTRY) { - latest = entry.data as HookConfigEntry | undefined; - } - } - - return latest; - } - - function restoreHookState(ctx: ExtensionContext): void { - const entry = getLastHookEntry(ctx); - if (entry?.scope === "session") { - const normalized = normalizeHookMode(entry.hookMode); - if (normalized) { - hookMode = normalized; - hookScope = "session"; - return; - } - - const legacyEnabled = (entry as { hookEnabled?: unknown }).hookEnabled; - if (typeof legacyEnabled === "boolean") { - hookMode = legacyEnabled ? "edit_write" : "disabled"; - hookScope = "session"; - return; - } - } - - const globalSetting = getGlobalHookMode(); - hookMode = globalSetting ?? DEFAULT_HOOK_MODE; - hookScope = "global"; - } - - function persistHookEntry(entry: HookConfigEntry): void { - pi.appendEntry(LSP_CONFIG_ENTRY, entry); - } - - function labelForMode(mode: HookMode): string { - return MODE_LABELS[mode]; - } - - function messageContentToText(content: unknown): string { - if (typeof content === "string") return content; - if (Array.isArray(content)) { - return content - .map((item) => (item && typeof item === "object" && "type" in item && (item as any).type === "text") - ? String((item as any).text ?? "") - : "") - .filter(Boolean) - .join("\n"); - } - return ""; - } - - function formatDiagnosticsForDisplay(text: string): string { - return text - .replace(/\n?This file has errors, please fix\n/gi, "\n") - .replace(/<\/?file_diagnostics>\n?/gi, "") - .replace(/\n{3,}/g, "\n\n") - .trim(); - } - - function setActivity(next: LspActivity): void { - activity = next; - updateLspStatus(); - } - - function clearIdleShutdownTimer(): void { - if (!idleShutdownTimer) return; - clearTimeout(idleShutdownTimer); - idleShutdownTimer = null; - } - - async function shutdownLspServersForIdle(): Promise { - diagnosticsAbort?.abort(); - diagnosticsAbort = null; - setActivity("idle"); - - await shutdownManager(); - activeClients.clear(); - updateLspStatus(); - } - - function scheduleIdleShutdown(): void { - clearIdleShutdownTimer(); - - idleShutdownTimer = setTimeout(() => { - idleShutdownTimer = null; - if (shuttingDown) return; - void shutdownLspServersForIdle(); - }, LSP_IDLE_SHUTDOWN_MS); - - (idleShutdownTimer as any).unref?.(); - } - - function updateLspStatus(): void { - if (!statusUpdateFn) return; - - const clients = activeClients.size > 0 ? [...activeClients].join(", ") : ""; - const clientsText = clients ? `${DIM}${clients}${RESET}` : ""; - const activityHint = activity === "idle" ? "" : `${DIM}•${RESET}`; - - if (hookMode === "disabled") { - const text = clientsText - ? `${YELLOW}LSP${RESET} ${DIM}(tool)${RESET}: ${clientsText}` - : `${YELLOW}LSP${RESET} ${DIM}(tool)${RESET}`; - statusUpdateFn("lsp", text); - return; - } - - let text = `${GREEN}LSP${RESET}`; - if (activityHint) text += ` ${activityHint}`; - if (clientsText) text += ` ${clientsText}`; - statusUpdateFn("lsp", text); - } - - function normalizeFilePath(filePath: string, cwd: string): string { - return path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath); - } - - pi.registerMessageRenderer("lsp-diagnostics", (message, options, theme) => { - const content = formatDiagnosticsForDisplay(messageContentToText(message.content)); - if (!content) return new Text("", 0, 0); - - const expanded = options.expanded === true; - const lines = content.split("\n"); - const maxLines = expanded ? lines.length : DIAGNOSTICS_PREVIEW_LINES; - const display = lines.slice(0, maxLines); - const remaining = lines.length - display.length; - - const styledLines = display.map((line) => { - if (line.startsWith("File: ")) return theme.fg("muted", line); - return theme.fg("toolOutput", line); - }); - - if (!expanded && remaining > 0) { - styledLines.push(theme.fg("dim", `... (${remaining} more lines)`)); - } - - return new Text(styledLines.join("\n"), 0, 0); - }); - - function getServerConfig(filePath: string) { - const ext = path.extname(filePath); - return LSP_SERVERS.find((s) => s.extensions.includes(ext)); - } - - function ensureActiveClientForFile(filePath: string, cwd: string): string | undefined { - const absPath = normalizeFilePath(filePath, cwd); - const cfg = getServerConfig(absPath); - if (!cfg) return undefined; - - if (!activeClients.has(cfg.id)) { - activeClients.add(cfg.id); - updateLspStatus(); - } - - return absPath; - } - - function extractLspFiles(input: Record): string[] { - const files: string[] = []; - - if (typeof input.file === "string") files.push(input.file); - if (Array.isArray(input.files)) { - for (const item of input.files) { - if (typeof item === "string") files.push(item); - } - } - - return files; - } - - function buildDiagnosticsOutput( - filePath: string, - diagnostics: Diagnostic[], - cwd: string, - includeFileHeader: boolean, - ): { notification: string; errorCount: number; output: string } { - const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath); - const relativePath = path.relative(cwd, absPath); - const errorCount = diagnostics.filter((e) => e.severity === 1).length; - - const MAX = 5; - const lines = diagnostics.slice(0, MAX).map((e) => { - const sev = e.severity === 1 ? "ERROR" : "WARN"; - return `${sev}[${e.range.start.line + 1}] ${e.message.split("\n")[0]}`; - }); - - let notification = `📋 ${relativePath}\n${lines.join("\n")}`; - if (diagnostics.length > MAX) notification += `\n... +${diagnostics.length - MAX} more`; - - const header = includeFileHeader ? `File: ${relativePath}\n` : ""; - const output = `\n${header}This file has errors, please fix\n\n${diagnostics.map(formatDiagnostic).join("\n")}\n\n`; - - return { notification, errorCount, output }; - } - - async function collectDiagnostics( - filePath: string, - ctx: ExtensionContext, - includeWarnings: boolean, - includeFileHeader: boolean, - notify = true, - ): Promise { - const manager = getOrCreateManager(ctx.cwd); - const absPath = ensureActiveClientForFile(filePath, ctx.cwd); - if (!absPath) return undefined; - - try { - const result = await manager.touchFileAndWait(absPath, diagnosticsWaitMsForFile(absPath)); - if (!result.receivedResponse) return undefined; - - const diagnostics = includeWarnings - ? result.diagnostics - : result.diagnostics.filter((d) => d.severity === 1); - if (!diagnostics.length) return undefined; - - const report = buildDiagnosticsOutput(filePath, diagnostics, ctx.cwd, includeFileHeader); - - if (notify) { - if (ctx.hasUI) ctx.ui.notify(report.notification, report.errorCount > 0 ? "error" : "warning"); - else console.error(report.notification); - } - - return report.output; - } catch { - return undefined; - } - } - - pi.registerCommand("lsp", { - description: "LSP settings (auto diagnostics hook)", - handler: async (_args, ctx) => { - if (!ctx.hasUI) { - ctx.ui.notify("LSP settings require UI", "warning"); - return; - } - - const currentMark = " ✓"; - const modeOptions = ([ - "edit_write", - "agent_end", - "disabled", - ] as HookMode[]).map((mode) => ({ - mode, - label: mode === hookMode ? `${labelForMode(mode)}${currentMark}` : labelForMode(mode), - })); - - const modeChoice = await ctx.ui.select( - "LSP auto diagnostics hook mode:", - modeOptions.map((option) => option.label), - ); - if (!modeChoice) return; - - const nextMode = modeOptions.find((option) => option.label === modeChoice)?.mode; - if (!nextMode) return; - - const scopeOptions = [ - { - scope: "session" as HookScope, - label: "Session only", - }, - { - scope: "global" as HookScope, - label: "Global (all sessions)", - }, - ]; - - const scopeChoice = await ctx.ui.select( - "Apply LSP auto diagnostics hook setting to:", - scopeOptions.map((option) => option.label), - ); - if (!scopeChoice) return; - - const scope = scopeOptions.find((option) => option.label === scopeChoice)?.scope; - if (!scope) return; - if (scope === "global") { - const ok = setGlobalHookMode(nextMode); - if (!ok) { - ctx.ui.notify("Failed to update global settings", "error"); - return; - } - } - - hookMode = nextMode; - hookScope = scope; - touchedFiles.clear(); - persistHookEntry({ scope, hookMode: nextMode }); - updateLspStatus(); - ctx.ui.notify(`LSP hook: ${labelForMode(hookMode)} (${hookScope})`, "info"); - }, - }); - - pi.on("session_start", async (_event, ctx) => { - restoreHookState(ctx); - statusUpdateFn = ctx.hasUI && ctx.ui.setStatus ? ctx.ui.setStatus.bind(ctx.ui) : null; - updateLspStatus(); - - if (hookMode === "disabled") return; - - const manager = getOrCreateManager(ctx.cwd); - - for (const [marker, ext] of Object.entries(WARMUP_MAP)) { - if (fs.existsSync(path.join(ctx.cwd, marker))) { - setActivity("loading"); - manager.getClientsForFile(path.join(ctx.cwd, `dummy${ext}`)) - .then((clients) => { - if (clients.length > 0) { - const cfg = LSP_SERVERS.find((s) => s.extensions.includes(ext)); - if (cfg) activeClients.add(cfg.id); - } - }) - .catch(() => {}) - .finally(() => setActivity("idle")); - break; - } - } - }); - - pi.on("session_switch", async (_event, ctx) => { - restoreHookState(ctx); - updateLspStatus(); - }); - - pi.on("session_tree", async (_event, ctx) => { - restoreHookState(ctx); - updateLspStatus(); - }); - - pi.on("session_fork", async (_event, ctx) => { - restoreHookState(ctx); - updateLspStatus(); - }); - - pi.on("session_shutdown", async () => { - shuttingDown = true; - clearIdleShutdownTimer(); - diagnosticsAbort?.abort(); - diagnosticsAbort = null; - setActivity("idle"); - - await shutdownManager(); - activeClients.clear(); - statusUpdateFn?.("lsp", undefined); - }); - - pi.on("tool_call", async (event, ctx) => { - const input = (event.input && typeof event.input === "object") - ? event.input as Record - : {}; - - if (event.toolName === "lsp") { - clearIdleShutdownTimer(); - const files = extractLspFiles(input); - for (const file of files) { - ensureActiveClientForFile(file, ctx.cwd); - } - return; - } - - if (event.toolName !== "read" && event.toolName !== "write" && event.toolName !== "edit") return; - - clearIdleShutdownTimer(); - const filePath = typeof input.path === "string" ? input.path : undefined; - if (!filePath) return; - - const absPath = ensureActiveClientForFile(filePath, ctx.cwd); - if (!absPath) return; - - void getOrCreateManager(ctx.cwd).getClientsForFile(absPath).catch(() => {}); - }); - - pi.on("agent_start", async () => { - clearIdleShutdownTimer(); - diagnosticsAbort?.abort(); - diagnosticsAbort = null; - setActivity("idle"); - touchedFiles.clear(); - }); - - function agentWasAborted(event: any): boolean { - const messages = Array.isArray(event?.messages) ? event.messages : []; - return messages.some((m: any) => - m && - typeof m === "object" && - (m as any).role === "assistant" && - (((m as any).stopReason === "aborted") || ((m as any).stopReason === "error")) - ); - } - - pi.on("agent_end", async (event, ctx) => { - try { - if (hookMode !== "agent_end") return; - - if (agentWasAborted(event)) { - // Don't run diagnostics on aborted/error runs. - touchedFiles.clear(); - return; - } - - if (touchedFiles.size === 0) return; - if (!ctx.isIdle() || ctx.hasPendingMessages()) return; - - const abort = new AbortController(); - diagnosticsAbort?.abort(); - diagnosticsAbort = abort; - - setActivity("working"); - - const files = Array.from(touchedFiles.entries()); - touchedFiles.clear(); - - try { - const outputs: string[] = []; - for (const [filePath, includeWarnings] of files) { - if (shuttingDown || abort.signal.aborted) return; - if (!ctx.isIdle() || ctx.hasPendingMessages()) { - abort.abort(); - return; - } - - const output = await collectDiagnostics(filePath, ctx, includeWarnings, true, false); - if (abort.signal.aborted) return; - if (output) outputs.push(output); - } - - if (shuttingDown || abort.signal.aborted) return; - - if (outputs.length) { - pi.sendMessage({ - customType: "lsp-diagnostics", - content: outputs.join("\n"), - display: true, - }, { - triggerTurn: true, - deliverAs: "followUp", - }); - } - } finally { - if (diagnosticsAbort === abort) diagnosticsAbort = null; - if (!shuttingDown) setActivity("idle"); - } - } finally { - if (!shuttingDown) scheduleIdleShutdown(); - } - }); - - pi.on("tool_result", async (event, ctx) => { - if (event.toolName !== "write" && event.toolName !== "edit") return; - - const filePath = event.input.path as string; - if (!filePath) return; - - const absPath = ensureActiveClientForFile(filePath, ctx.cwd); - if (!absPath) return; - - if (hookMode === "disabled") return; - - if (hookMode === "agent_end") { - const includeWarnings = event.toolName === "write"; - const existing = touchedFiles.get(absPath) ?? false; - touchedFiles.set(absPath, existing || includeWarnings); - return; - } - - const includeWarnings = event.toolName === "write"; - const output = await collectDiagnostics(absPath, ctx, includeWarnings, false); - if (!output) return; - - return { content: [...event.content, { type: "text" as const, text: output }] as Array<{ type: "text"; text: string }> }; - }); -} diff --git a/pi/.pi/agent/extensions/lsp-pi/package.json b/pi/.pi/agent/extensions/lsp-pi/package.json deleted file mode 100644 index ba5e6ec..0000000 --- a/pi/.pi/agent/extensions/lsp-pi/package.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "name": "lsp-pi", - "version": "1.0.3", - "description": "LSP extension for pi-coding-agent - provides language server tool and diagnostics feedback for Dart/Flutter, TypeScript, Vue, Svelte, Python, Go, Kotlin, Swift, Rust", - "scripts": { - "test": "npx tsx tests/lsp.test.ts", - "test:tool": "npx tsx tests/index.test.ts", - "test:integration": "npx tsx tests/lsp-integration.test.ts", - "test:all": "npm test && npm run test:tool && npm run test:integration" - }, - "keywords": [ - "lsp", - "language-server", - "dart", - "flutter", - "typescript", - "vue", - "svelte", - "python", - "go", - "kotlin", - "swift", - "rust", - "pi-coding-agent", - "extension", - "pi-package" - ], - "author": "", - "license": "MIT", - "type": "module", - "pi": { - "extensions": [ - "./lsp.ts", - "./lsp-tool.ts" - ] - }, - "dependencies": { - "@sinclair/typebox": "^0.34.33", - "vscode-languageserver-protocol": "^3.17.5" - }, - "peerDependencies": { - "@mariozechner/pi-ai": "^0.50.0", - "@mariozechner/pi-coding-agent": "^0.50.0", - "@mariozechner/pi-tui": "^0.50.0" - }, - "devDependencies": { - "@mariozechner/pi-ai": "^0.50.0", - "@mariozechner/pi-coding-agent": "^0.50.0", - "@mariozechner/pi-tui": "^0.50.0", - "@types/node": "^24.10.2", - "tsx": "^4.21.0", - "typescript": "^5.9.3" - } -} \ No newline at end of file diff --git a/pi/.pi/agent/extensions/lsp-pi/tests/index.test.ts b/pi/.pi/agent/extensions/lsp-pi/tests/index.test.ts deleted file mode 100644 index be43d62..0000000 --- a/pi/.pi/agent/extensions/lsp-pi/tests/index.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -/** - * Unit tests for index.ts formatting functions - */ - -// ============================================================================ -// Test utilities -// ============================================================================ - -const tests: Array<{ name: string; fn: () => void | Promise }> = []; - -function test(name: string, fn: () => void | Promise) { - tests.push({ name, fn }); -} - -function assertEqual(actual: T, expected: T, message?: string) { - const a = JSON.stringify(actual); - const e = JSON.stringify(expected); - if (a !== e) throw new Error(message || `Expected ${e}, got ${a}`); -} - -// ============================================================================ -// Import the module to test internal functions -// We need to test via the execute function since formatters are private -// Or we can extract and test the logic directly -// ============================================================================ - -import { uriToPath, findSymbolPosition, formatDiagnostic, filterDiagnosticsBySeverity } from "../lsp-core.js"; - -// ============================================================================ -// uriToPath tests -// ============================================================================ - -test("uriToPath: converts file:// URI to path", () => { - const result = uriToPath("file:///Users/test/file.ts"); - assertEqual(result, "/Users/test/file.ts"); -}); - -test("uriToPath: handles encoded characters", () => { - const result = uriToPath("file:///Users/test/my%20file.ts"); - assertEqual(result, "/Users/test/my file.ts"); -}); - -test("uriToPath: passes through non-file URIs", () => { - const result = uriToPath("/some/path.ts"); - assertEqual(result, "/some/path.ts"); -}); - -test("uriToPath: handles invalid URIs gracefully", () => { - const result = uriToPath("not-a-valid-uri"); - assertEqual(result, "not-a-valid-uri"); -}); - -// ============================================================================ -// findSymbolPosition tests -// ============================================================================ - -test("findSymbolPosition: finds exact match", () => { - const symbols = [ - { name: "greet", range: { start: { line: 5, character: 10 }, end: { line: 5, character: 15 } }, selectionRange: { start: { line: 5, character: 10 }, end: { line: 5, character: 15 } }, kind: 12, children: [] }, - { name: "hello", range: { start: { line: 10, character: 0 }, end: { line: 10, character: 5 } }, selectionRange: { start: { line: 10, character: 0 }, end: { line: 10, character: 5 } }, kind: 12, children: [] }, - ]; - const pos = findSymbolPosition(symbols as any, "greet"); - assertEqual(pos, { line: 5, character: 10 }); -}); - -test("findSymbolPosition: finds partial match", () => { - const symbols = [ - { name: "getUserName", range: { start: { line: 3, character: 0 }, end: { line: 3, character: 11 } }, selectionRange: { start: { line: 3, character: 0 }, end: { line: 3, character: 11 } }, kind: 12, children: [] }, - ]; - const pos = findSymbolPosition(symbols as any, "user"); - assertEqual(pos, { line: 3, character: 0 }); -}); - -test("findSymbolPosition: prefers exact over partial", () => { - const symbols = [ - { name: "userName", range: { start: { line: 1, character: 0 }, end: { line: 1, character: 8 } }, selectionRange: { start: { line: 1, character: 0 }, end: { line: 1, character: 8 } }, kind: 12, children: [] }, - { name: "user", range: { start: { line: 5, character: 0 }, end: { line: 5, character: 4 } }, selectionRange: { start: { line: 5, character: 0 }, end: { line: 5, character: 4 } }, kind: 12, children: [] }, - ]; - const pos = findSymbolPosition(symbols as any, "user"); - assertEqual(pos, { line: 5, character: 0 }); -}); - -test("findSymbolPosition: searches nested children", () => { - const symbols = [ - { - name: "MyClass", - range: { start: { line: 0, character: 0 }, end: { line: 10, character: 0 } }, - selectionRange: { start: { line: 0, character: 0 }, end: { line: 0, character: 7 } }, - kind: 5, - children: [ - { name: "myMethod", range: { start: { line: 2, character: 2 }, end: { line: 4, character: 2 } }, selectionRange: { start: { line: 2, character: 2 }, end: { line: 2, character: 10 } }, kind: 6, children: [] }, - ] - }, - ]; - const pos = findSymbolPosition(symbols as any, "myMethod"); - assertEqual(pos, { line: 2, character: 2 }); -}); - -test("findSymbolPosition: returns null for no match", () => { - const symbols = [ - { name: "foo", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } }, selectionRange: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } }, kind: 12, children: [] }, - ]; - const pos = findSymbolPosition(symbols as any, "bar"); - assertEqual(pos, null); -}); - -test("findSymbolPosition: case insensitive", () => { - const symbols = [ - { name: "MyFunction", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 10 } }, selectionRange: { start: { line: 0, character: 0 }, end: { line: 0, character: 10 } }, kind: 12, children: [] }, - ]; - const pos = findSymbolPosition(symbols as any, "myfunction"); - assertEqual(pos, { line: 0, character: 0 }); -}); - -// ============================================================================ -// formatDiagnostic tests -// ============================================================================ - -test("formatDiagnostic: formats error", () => { - const diag = { - range: { start: { line: 5, character: 10 }, end: { line: 5, character: 15 } }, - message: "Type 'number' is not assignable to type 'string'", - severity: 1, - }; - const result = formatDiagnostic(diag as any); - assertEqual(result, "ERROR [6:11] Type 'number' is not assignable to type 'string'"); -}); - -test("formatDiagnostic: formats warning", () => { - const diag = { - range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }, - message: "Unused variable", - severity: 2, - }; - const result = formatDiagnostic(diag as any); - assertEqual(result, "WARN [1:1] Unused variable"); -}); - -test("formatDiagnostic: formats info", () => { - const diag = { - range: { start: { line: 2, character: 4 }, end: { line: 2, character: 10 } }, - message: "Consider using const", - severity: 3, - }; - const result = formatDiagnostic(diag as any); - assertEqual(result, "INFO [3:5] Consider using const"); -}); - -test("formatDiagnostic: formats hint", () => { - const diag = { - range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, - message: "Prefer arrow function", - severity: 4, - }; - const result = formatDiagnostic(diag as any); - assertEqual(result, "HINT [1:1] Prefer arrow function"); -}); - -// ============================================================================ -// filterDiagnosticsBySeverity tests -// ============================================================================ - -test("filterDiagnosticsBySeverity: all returns everything", () => { - const diags = [ - { severity: 1, message: "error", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } }, - { severity: 2, message: "warning", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } }, - { severity: 3, message: "info", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } }, - { severity: 4, message: "hint", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } }, - ]; - const result = filterDiagnosticsBySeverity(diags as any, "all"); - assertEqual(result.length, 4); -}); - -test("filterDiagnosticsBySeverity: error returns only errors", () => { - const diags = [ - { severity: 1, message: "error", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } }, - { severity: 2, message: "warning", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } }, - ]; - const result = filterDiagnosticsBySeverity(diags as any, "error"); - assertEqual(result.length, 1); - assertEqual(result[0].message, "error"); -}); - -test("filterDiagnosticsBySeverity: warning returns errors and warnings", () => { - const diags = [ - { severity: 1, message: "error", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } }, - { severity: 2, message: "warning", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } }, - { severity: 3, message: "info", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } }, - ]; - const result = filterDiagnosticsBySeverity(diags as any, "warning"); - assertEqual(result.length, 2); -}); - -test("filterDiagnosticsBySeverity: info returns errors, warnings, and info", () => { - const diags = [ - { severity: 1, message: "error", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } }, - { severity: 2, message: "warning", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } }, - { severity: 3, message: "info", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } }, - { severity: 4, message: "hint", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } }, - ]; - const result = filterDiagnosticsBySeverity(diags as any, "info"); - assertEqual(result.length, 3); -}); - -// ============================================================================ -// Run tests -// ============================================================================ - -async function runTests(): Promise { - console.log("Running index.ts unit tests...\n"); - - let passed = 0; - let failed = 0; - - for (const { name, fn } of tests) { - try { - await fn(); - console.log(` ${name}... ✓`); - passed++; - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - console.log(` ${name}... ✗`); - console.log(` Error: ${msg}\n`); - failed++; - } - } - - console.log(`\n${passed} passed, ${failed} failed`); - - if (failed > 0) { - process.exit(1); - } -} - -runTests(); diff --git a/pi/.pi/agent/extensions/lsp-pi/tests/lsp-integration.test.ts b/pi/.pi/agent/extensions/lsp-pi/tests/lsp-integration.test.ts deleted file mode 100644 index 248cc9b..0000000 --- a/pi/.pi/agent/extensions/lsp-pi/tests/lsp-integration.test.ts +++ /dev/null @@ -1,602 +0,0 @@ -/** - * Integration tests for LSP - spawns real language servers and detects errors - * - * Run with: npm run test:integration - * - * Skips tests if language server is not installed. - */ - -// Suppress stream errors from vscode-jsonrpc when LSP process exits -process.on('uncaughtException', (err) => { - if (err.message?.includes('write after end')) return; - console.error('Uncaught:', err); - process.exit(1); -}); - -import { mkdtemp, rm, writeFile, mkdir } from "fs/promises"; -import { existsSync, statSync } from "fs"; -import { tmpdir } from "os"; -import { join, delimiter } from "path"; -import { LSPManager } from "../lsp-core.js"; - -// ============================================================================ -// Test utilities -// ============================================================================ - -const tests: Array<{ name: string; fn: () => Promise }> = []; -let skipped = 0; - -function test(name: string, fn: () => Promise) { - tests.push({ name, fn }); -} - -function assert(condition: boolean, message: string) { - if (!condition) throw new Error(message); -} - -class SkipTest extends Error { - constructor(reason: string) { - super(reason); - this.name = "SkipTest"; - } -} - -function skip(reason: string): never { - throw new SkipTest(reason); -} - -// Search paths matching lsp-core.ts -const SEARCH_PATHS = [ - ...(process.env.PATH?.split(delimiter) || []), - "/usr/local/bin", - "/opt/homebrew/bin", - `${process.env.HOME || ""}/.pub-cache/bin`, - `${process.env.HOME || ""}/fvm/default/bin`, - `${process.env.HOME || ""}/go/bin`, - `${process.env.HOME || ""}/.cargo/bin`, -]; - -function commandExists(cmd: string): boolean { - for (const dir of SEARCH_PATHS) { - const full = join(dir, cmd); - try { - if (existsSync(full) && statSync(full).isFile()) return true; - } catch {} - } - return false; -} - -// ============================================================================ -// TypeScript -// ============================================================================ - -test("typescript: detects type errors", async () => { - if (!commandExists("typescript-language-server")) { - skip("typescript-language-server not installed"); - } - - const dir = await mkdtemp(join(tmpdir(), "lsp-ts-")); - const manager = new LSPManager(dir); - - try { - await writeFile(join(dir, "package.json"), "{}"); - await writeFile(join(dir, "tsconfig.json"), JSON.stringify({ - compilerOptions: { strict: true, noEmit: true } - })); - - // Code with type error - const file = join(dir, "index.ts"); - await writeFile(file, `const x: string = 123;`); - - const { diagnostics } = await manager.touchFileAndWait(file, 10000); - - assert(diagnostics.length > 0, `Expected errors, got ${diagnostics.length}`); - assert( - diagnostics.some(d => d.message.toLowerCase().includes("type") || d.severity === 1), - `Expected type error, got: ${diagnostics.map(d => d.message).join(", ")}` - ); - } finally { - await manager.shutdown(); - await rm(dir, { recursive: true, force: true }).catch(() => {}); - } -}); - -test("typescript: valid code has no errors", async () => { - if (!commandExists("typescript-language-server")) { - skip("typescript-language-server not installed"); - } - - const dir = await mkdtemp(join(tmpdir(), "lsp-ts-")); - const manager = new LSPManager(dir); - - try { - await writeFile(join(dir, "package.json"), "{}"); - await writeFile(join(dir, "tsconfig.json"), JSON.stringify({ - compilerOptions: { strict: true, noEmit: true } - })); - - const file = join(dir, "index.ts"); - await writeFile(file, `const x: string = "hello";`); - - const { diagnostics } = await manager.touchFileAndWait(file, 10000); - const errors = diagnostics.filter(d => d.severity === 1); - - assert(errors.length === 0, `Expected no errors, got: ${errors.map(d => d.message).join(", ")}`); - } finally { - await manager.shutdown(); - await rm(dir, { recursive: true, force: true }).catch(() => {}); - } -}); - -// ============================================================================ -// Dart -// ============================================================================ - -test("dart: detects type errors", async () => { - if (!commandExists("dart")) { - skip("dart not installed"); - } - - const dir = await mkdtemp(join(tmpdir(), "lsp-dart-")); - const manager = new LSPManager(dir); - - try { - await writeFile(join(dir, "pubspec.yaml"), "name: test_app\nenvironment:\n sdk: ^3.0.0"); - - await mkdir(join(dir, "lib")); - const file = join(dir, "lib/main.dart"); - // Type error: assigning int to String - await writeFile(file, ` -void main() { - String x = 123; - print(x); -} -`); - - const { diagnostics } = await manager.touchFileAndWait(file, 15000); - - assert(diagnostics.length > 0, `Expected errors, got ${diagnostics.length}`); - } finally { - await manager.shutdown(); - await rm(dir, { recursive: true, force: true }).catch(() => {}); - } -}); - -test("dart: valid code has no errors", async () => { - if (!commandExists("dart")) { - skip("dart not installed"); - } - - const dir = await mkdtemp(join(tmpdir(), "lsp-dart-")); - const manager = new LSPManager(dir); - - try { - await writeFile(join(dir, "pubspec.yaml"), "name: test_app\nenvironment:\n sdk: ^3.0.0"); - - await mkdir(join(dir, "lib")); - const file = join(dir, "lib/main.dart"); - await writeFile(file, ` -void main() { - String x = "hello"; - print(x); -} -`); - - const { diagnostics } = await manager.touchFileAndWait(file, 15000); - const errors = diagnostics.filter(d => d.severity === 1); - - assert(errors.length === 0, `Expected no errors, got: ${errors.map(d => d.message).join(", ")}`); - } finally { - await manager.shutdown(); - await rm(dir, { recursive: true, force: true }).catch(() => {}); - } -}); - -// ============================================================================ -// Rust -// ============================================================================ - -test("rust: detects type errors", async () => { - if (!commandExists("rust-analyzer")) { - skip("rust-analyzer not installed"); - } - - const dir = await mkdtemp(join(tmpdir(), "lsp-rust-")); - const manager = new LSPManager(dir); - - try { - await writeFile(join(dir, "Cargo.toml"), `[package]\nname = "test"\nversion = "0.1.0"\nedition = "2021"`); - - await mkdir(join(dir, "src")); - const file = join(dir, "src/main.rs"); - await writeFile(file, `fn main() {\n let x: i32 = "hello";\n}`); - - // rust-analyzer needs a LOT of time to initialize (compiles the project) - const { diagnostics } = await manager.touchFileAndWait(file, 60000); - - assert(diagnostics.length > 0, `Expected errors, got ${diagnostics.length}`); - } finally { - await manager.shutdown(); - await rm(dir, { recursive: true, force: true }).catch(() => {}); - } -}); - -test("rust: valid code has no errors", async () => { - if (!commandExists("rust-analyzer")) { - skip("rust-analyzer not installed"); - } - - const dir = await mkdtemp(join(tmpdir(), "lsp-rust-")); - const manager = new LSPManager(dir); - - try { - await writeFile(join(dir, "Cargo.toml"), `[package]\nname = "test"\nversion = "0.1.0"\nedition = "2021"`); - - await mkdir(join(dir, "src")); - const file = join(dir, "src/main.rs"); - await writeFile(file, `fn main() {\n let x = "hello";\n println!("{}", x);\n}`); - - const { diagnostics } = await manager.touchFileAndWait(file, 60000); - const errors = diagnostics.filter(d => d.severity === 1); - - assert(errors.length === 0, `Expected no errors, got: ${errors.map(d => d.message).join(", ")}`); - } finally { - await manager.shutdown(); - await rm(dir, { recursive: true, force: true }).catch(() => {}); - } -}); - -// ============================================================================ -// Go -// ============================================================================ - -test("go: detects type errors", async () => { - if (!commandExists("gopls")) { - skip("gopls not installed"); - } - - const dir = await mkdtemp(join(tmpdir(), "lsp-go-")); - const manager = new LSPManager(dir); - - try { - await writeFile(join(dir, "go.mod"), "module test\n\ngo 1.21"); - - const file = join(dir, "main.go"); - // Type error: cannot use int as string - await writeFile(file, `package main - -func main() { - var x string = 123 - println(x) -} -`); - - const { diagnostics } = await manager.touchFileAndWait(file, 15000); - - assert(diagnostics.length > 0, `Expected errors, got ${diagnostics.length}`); - } finally { - await manager.shutdown(); - await rm(dir, { recursive: true, force: true }).catch(() => {}); - } -}); - -test("go: valid code has no errors", async () => { - if (!commandExists("gopls")) { - skip("gopls not installed"); - } - - const dir = await mkdtemp(join(tmpdir(), "lsp-go-")); - const manager = new LSPManager(dir); - - try { - await writeFile(join(dir, "go.mod"), "module test\n\ngo 1.21"); - - const file = join(dir, "main.go"); - await writeFile(file, `package main - -func main() { - var x string = "hello" - println(x) -} -`); - - const { diagnostics } = await manager.touchFileAndWait(file, 15000); - const errors = diagnostics.filter(d => d.severity === 1); - - assert(errors.length === 0, `Expected no errors, got: ${errors.map(d => d.message).join(", ")}`); - } finally { - await manager.shutdown(); - await rm(dir, { recursive: true, force: true }).catch(() => {}); - } -}); - -// ============================================================================ -// Kotlin -// ============================================================================ - -test("kotlin: detects syntax errors", async () => { - if (!commandExists("kotlin-language-server")) { - skip("kotlin-language-server not installed"); - } - - const dir = await mkdtemp(join(tmpdir(), "lsp-kt-")); - const manager = new LSPManager(dir); - - try { - // Minimal Gradle markers so the LSP picks a root - await writeFile(join(dir, "settings.gradle.kts"), "rootProject.name = \"test\"\n"); - await writeFile(join(dir, "build.gradle.kts"), "// empty\n"); - - await mkdir(join(dir, "src/main/kotlin"), { recursive: true }); - const file = join(dir, "src/main/kotlin/Main.kt"); - - // Syntax error - await writeFile(file, "fun main() { val x = }\n"); - - const { diagnostics, receivedResponse } = await manager.touchFileAndWait(file, 30000); - - assert(receivedResponse, "Expected Kotlin LSP to respond"); - assert(diagnostics.length > 0, `Expected errors, got ${diagnostics.length}`); - } finally { - await manager.shutdown(); - await rm(dir, { recursive: true, force: true }).catch(() => {}); - } -}); - -test("kotlin: valid code has no errors", async () => { - if (!commandExists("kotlin-language-server")) { - skip("kotlin-language-server not installed"); - } - - const dir = await mkdtemp(join(tmpdir(), "lsp-kt-")); - const manager = new LSPManager(dir); - - try { - await writeFile(join(dir, "settings.gradle.kts"), "rootProject.name = \"test\"\n"); - await writeFile(join(dir, "build.gradle.kts"), "// empty\n"); - - await mkdir(join(dir, "src/main/kotlin"), { recursive: true }); - const file = join(dir, "src/main/kotlin/Main.kt"); - - await writeFile(file, "fun main() { val x = 1; println(x) }\n"); - - const { diagnostics, receivedResponse } = await manager.touchFileAndWait(file, 30000); - - assert(receivedResponse, "Expected Kotlin LSP to respond"); - const errors = diagnostics.filter(d => d.severity === 1); - assert(errors.length === 0, `Expected no errors, got: ${errors.map(d => d.message).join(", ")}`); - } finally { - await manager.shutdown(); - await rm(dir, { recursive: true, force: true }).catch(() => {}); - } -}); - -// ============================================================================ -// Python -// ============================================================================ - -test("python: detects type errors", async () => { - if (!commandExists("pyright-langserver")) { - skip("pyright-langserver not installed"); - } - - const dir = await mkdtemp(join(tmpdir(), "lsp-py-")); - const manager = new LSPManager(dir); - - try { - await writeFile(join(dir, "pyproject.toml"), `[project]\nname = "test"`); - - const file = join(dir, "main.py"); - // Type error with type annotation - await writeFile(file, ` -def greet(name: str) -> str: - return "Hello, " + name - -x: str = 123 # Type error -result = greet(456) # Type error -`); - - const { diagnostics } = await manager.touchFileAndWait(file, 10000); - - assert(diagnostics.length > 0, `Expected errors, got ${diagnostics.length}`); - } finally { - await manager.shutdown(); - await rm(dir, { recursive: true, force: true }).catch(() => {}); - } -}); - -test("python: valid code has no errors", async () => { - if (!commandExists("pyright-langserver")) { - skip("pyright-langserver not installed"); - } - - const dir = await mkdtemp(join(tmpdir(), "lsp-py-")); - const manager = new LSPManager(dir); - - try { - await writeFile(join(dir, "pyproject.toml"), `[project]\nname = "test"`); - - const file = join(dir, "main.py"); - await writeFile(file, ` -def greet(name: str) -> str: - return "Hello, " + name - -x: str = "world" -result = greet(x) -`); - - const { diagnostics } = await manager.touchFileAndWait(file, 10000); - const errors = diagnostics.filter(d => d.severity === 1); - - assert(errors.length === 0, `Expected no errors, got: ${errors.map(d => d.message).join(", ")}`); - } finally { - await manager.shutdown(); - await rm(dir, { recursive: true, force: true }).catch(() => {}); - } -}); - -// ============================================================================ -// Rename (TypeScript) -// ============================================================================ - -test("typescript: rename symbol", async () => { - if (!commandExists("typescript-language-server")) { - skip("typescript-language-server not installed"); - } - - const dir = await mkdtemp(join(tmpdir(), "lsp-ts-rename-")); - const manager = new LSPManager(dir); - - try { - await writeFile(join(dir, "package.json"), "{}"); - await writeFile(join(dir, "tsconfig.json"), JSON.stringify({ - compilerOptions: { strict: true, noEmit: true } - })); - - const file = join(dir, "index.ts"); - await writeFile(file, `function greet(name: string) { - return "Hello, " + name; -} -const result = greet("world"); -`); - - // Touch file first to ensure it's loaded - await manager.touchFileAndWait(file, 10000); - - // Rename 'greet' at line 1, col 10 - const edit = await manager.rename(file, 1, 10, "sayHello"); - - if (!edit) throw new Error("Expected rename to return WorkspaceEdit"); - assert( - edit.changes !== undefined || edit.documentChanges !== undefined, - "Expected changes or documentChanges in WorkspaceEdit" - ); - - // Should have edits for both the function definition and the call - const allEdits: any[] = []; - if (edit.changes) { - for (const edits of Object.values(edit.changes)) { - allEdits.push(...(edits as any[])); - } - } - if (edit.documentChanges) { - for (const change of edit.documentChanges as any[]) { - if (change.edits) allEdits.push(...change.edits); - } - } - - assert(allEdits.length >= 2, `Expected at least 2 edits (definition + usage), got ${allEdits.length}`); - } finally { - await manager.shutdown(); - await rm(dir, { recursive: true, force: true }).catch(() => {}); - } -}); - -// ============================================================================ -// Code Actions (TypeScript) -// ============================================================================ - -test("typescript: get code actions for error", async () => { - if (!commandExists("typescript-language-server")) { - skip("typescript-language-server not installed"); - } - - const dir = await mkdtemp(join(tmpdir(), "lsp-ts-actions-")); - const manager = new LSPManager(dir); - - try { - await writeFile(join(dir, "package.json"), "{}"); - await writeFile(join(dir, "tsconfig.json"), JSON.stringify({ - compilerOptions: { strict: true, noEmit: true } - })); - - const file = join(dir, "index.ts"); - // Missing import - should offer "Add import" code action - await writeFile(file, `const x: Promise = Promise.resolve("hello"); -console.log(x); -`); - - // Touch to get diagnostics first - await manager.touchFileAndWait(file, 10000); - - // Get code actions at line 1 - const actions = await manager.getCodeActions(file, 1, 1, 1, 50); - - // May or may not have actions depending on the code, but shouldn't throw - assert(Array.isArray(actions), "Expected array of code actions"); - } finally { - await manager.shutdown(); - await rm(dir, { recursive: true, force: true }).catch(() => {}); - } -}); - -test("typescript: code actions for missing function", async () => { - if (!commandExists("typescript-language-server")) { - skip("typescript-language-server not installed"); - } - - const dir = await mkdtemp(join(tmpdir(), "lsp-ts-actions2-")); - const manager = new LSPManager(dir); - - try { - await writeFile(join(dir, "package.json"), "{}"); - await writeFile(join(dir, "tsconfig.json"), JSON.stringify({ - compilerOptions: { strict: true, noEmit: true } - })); - - const file = join(dir, "index.ts"); - // Call undefined function - should offer quick fix - await writeFile(file, `const result = undefinedFunction(); -`); - - await manager.touchFileAndWait(file, 10000); - - // Get code actions where the error is - const actions = await manager.getCodeActions(file, 1, 16, 1, 33); - - // TypeScript should offer to create the function - assert(Array.isArray(actions), "Expected array of code actions"); - // Note: we don't assert on action count since it depends on TS version - } finally { - await manager.shutdown(); - await rm(dir, { recursive: true, force: true }).catch(() => {}); - } -}); - -// ============================================================================ -// Run tests -// ============================================================================ - -async function runTests(): Promise { - console.log("Running LSP integration tests...\n"); - console.log("Note: Tests are skipped if language server is not installed.\n"); - - let passed = 0; - let failed = 0; - - for (const { name, fn } of tests) { - try { - await fn(); - console.log(` ${name}... ✓`); - passed++; - } catch (error) { - if (error instanceof SkipTest) { - console.log(` ${name}... ⊘ (${error.message})`); - skipped++; - } else { - const msg = error instanceof Error ? error.message : String(error); - console.log(` ${name}... ✗`); - console.log(` Error: ${msg}\n`); - failed++; - } - } - } - - console.log(`\n${passed} passed, ${failed} failed, ${skipped} skipped`); - - if (failed > 0) { - process.exit(1); - } -} - -runTests(); diff --git a/pi/.pi/agent/extensions/lsp-pi/tests/lsp.test.ts b/pi/.pi/agent/extensions/lsp-pi/tests/lsp.test.ts deleted file mode 100644 index 9aed14d..0000000 --- a/pi/.pi/agent/extensions/lsp-pi/tests/lsp.test.ts +++ /dev/null @@ -1,898 +0,0 @@ -/** - * Tests for LSP hook - configuration and utility functions - * - * Run with: npm test - * - * These tests cover: - * - Project root detection for various languages - * - Language ID mappings - * - URI construction - * - Server configuration correctness - */ - -import { mkdtemp, rm, writeFile, mkdir } from "fs/promises"; -import { tmpdir } from "os"; -import { join } from "path"; -import { pathToFileURL } from "url"; -import { LSP_SERVERS, LANGUAGE_IDS } from "../lsp-core.js"; - -// ============================================================================ -// Test utilities -// ============================================================================ - -interface TestResult { - name: string; - passed: boolean; - error?: string; -} - -const tests: Array<{ name: string; fn: () => Promise }> = []; - -function test(name: string, fn: () => Promise) { - tests.push({ name, fn }); -} - -function assert(condition: boolean, message: string) { - if (!condition) throw new Error(message); -} - -function assertEquals(actual: T, expected: T, message: string) { - assert( - actual === expected, - `${message}\nExpected: ${JSON.stringify(expected)}\nActual: ${JSON.stringify(actual)}` - ); -} - -function assertIncludes(arr: string[], item: string, message: string) { - assert(arr.includes(item), `${message}\nArray: [${arr.join(", ")}]\nMissing: ${item}`); -} - -/** Create a temp directory with optional file structure */ -async function withTempDir( - structure: Record, // null = directory, string = file content - fn: (dir: string) => Promise -): Promise { - const dir = await mkdtemp(join(tmpdir(), "lsp-test-")); - try { - for (const [path, content] of Object.entries(structure)) { - const fullPath = join(dir, path); - if (content === null) { - await mkdir(fullPath, { recursive: true }); - } else { - await mkdir(join(dir, path.split("/").slice(0, -1).join("/")), { recursive: true }).catch(() => {}); - await writeFile(fullPath, content); - } - } - await fn(dir); - } finally { - await rm(dir, { recursive: true, force: true }).catch(() => {}); - } -} - -// ============================================================================ -// Language ID tests -// ============================================================================ - -test("LANGUAGE_IDS: TypeScript extensions", async () => { - assertEquals(LANGUAGE_IDS[".ts"], "typescript", ".ts should map to typescript"); - assertEquals(LANGUAGE_IDS[".tsx"], "typescriptreact", ".tsx should map to typescriptreact"); - assertEquals(LANGUAGE_IDS[".mts"], "typescript", ".mts should map to typescript"); - assertEquals(LANGUAGE_IDS[".cts"], "typescript", ".cts should map to typescript"); -}); - -test("LANGUAGE_IDS: JavaScript extensions", async () => { - assertEquals(LANGUAGE_IDS[".js"], "javascript", ".js should map to javascript"); - assertEquals(LANGUAGE_IDS[".jsx"], "javascriptreact", ".jsx should map to javascriptreact"); - assertEquals(LANGUAGE_IDS[".mjs"], "javascript", ".mjs should map to javascript"); - assertEquals(LANGUAGE_IDS[".cjs"], "javascript", ".cjs should map to javascript"); -}); - -test("LANGUAGE_IDS: Dart extension", async () => { - assertEquals(LANGUAGE_IDS[".dart"], "dart", ".dart should map to dart"); -}); - -test("LANGUAGE_IDS: Go extension", async () => { - assertEquals(LANGUAGE_IDS[".go"], "go", ".go should map to go"); -}); - -test("LANGUAGE_IDS: Rust extension", async () => { - assertEquals(LANGUAGE_IDS[".rs"], "rust", ".rs should map to rust"); -}); - -test("LANGUAGE_IDS: Kotlin extensions", async () => { - assertEquals(LANGUAGE_IDS[".kt"], "kotlin", ".kt should map to kotlin"); - assertEquals(LANGUAGE_IDS[".kts"], "kotlin", ".kts should map to kotlin"); -}); - -test("LANGUAGE_IDS: Swift extension", async () => { - assertEquals(LANGUAGE_IDS[".swift"], "swift", ".swift should map to swift"); -}); - -test("LANGUAGE_IDS: Python extensions", async () => { - assertEquals(LANGUAGE_IDS[".py"], "python", ".py should map to python"); - assertEquals(LANGUAGE_IDS[".pyi"], "python", ".pyi should map to python"); -}); - -test("LANGUAGE_IDS: Vue/Svelte/Astro extensions", async () => { - assertEquals(LANGUAGE_IDS[".vue"], "vue", ".vue should map to vue"); - assertEquals(LANGUAGE_IDS[".svelte"], "svelte", ".svelte should map to svelte"); - assertEquals(LANGUAGE_IDS[".astro"], "astro", ".astro should map to astro"); -}); - -// ============================================================================ -// Server configuration tests -// ============================================================================ - -test("LSP_SERVERS: has TypeScript server", async () => { - const server = LSP_SERVERS.find(s => s.id === "typescript"); - assert(server !== undefined, "Should have typescript server"); - assertIncludes(server!.extensions, ".ts", "Should handle .ts"); - assertIncludes(server!.extensions, ".tsx", "Should handle .tsx"); - assertIncludes(server!.extensions, ".js", "Should handle .js"); - assertIncludes(server!.extensions, ".jsx", "Should handle .jsx"); -}); - -test("LSP_SERVERS: has Dart server", async () => { - const server = LSP_SERVERS.find(s => s.id === "dart"); - assert(server !== undefined, "Should have dart server"); - assertIncludes(server!.extensions, ".dart", "Should handle .dart"); -}); - -test("LSP_SERVERS: has Rust Analyzer server", async () => { - const server = LSP_SERVERS.find(s => s.id === "rust-analyzer"); - assert(server !== undefined, "Should have rust-analyzer server"); - assertIncludes(server!.extensions, ".rs", "Should handle .rs"); -}); - -test("LSP_SERVERS: has Gopls server", async () => { - const server = LSP_SERVERS.find(s => s.id === "gopls"); - assert(server !== undefined, "Should have gopls server"); - assertIncludes(server!.extensions, ".go", "Should handle .go"); -}); - -test("LSP_SERVERS: has Kotlin server", async () => { - const server = LSP_SERVERS.find(s => s.id === "kotlin"); - assert(server !== undefined, "Should have kotlin server"); - assertIncludes(server!.extensions, ".kt", "Should handle .kt"); - assertIncludes(server!.extensions, ".kts", "Should handle .kts"); -}); - -test("LSP_SERVERS: has Swift server", async () => { - const server = LSP_SERVERS.find(s => s.id === "swift"); - assert(server !== undefined, "Should have swift server"); - assertIncludes(server!.extensions, ".swift", "Should handle .swift"); -}); - -test("LSP_SERVERS: has Pyright server", async () => { - const server = LSP_SERVERS.find(s => s.id === "pyright"); - assert(server !== undefined, "Should have pyright server"); - assertIncludes(server!.extensions, ".py", "Should handle .py"); - assertIncludes(server!.extensions, ".pyi", "Should handle .pyi"); -}); - -// ============================================================================ -// TypeScript root detection tests -// ============================================================================ - -test("typescript: finds root with package.json", async () => { - await withTempDir({ - "package.json": "{}", - "src/index.ts": "export const x = 1;", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "typescript")!; - const root = server.findRoot(join(dir, "src/index.ts"), dir); - assertEquals(root, dir, "Should find root at package.json location"); - }); -}); - -test("typescript: finds root with tsconfig.json", async () => { - await withTempDir({ - "tsconfig.json": "{}", - "src/index.ts": "export const x = 1;", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "typescript")!; - const root = server.findRoot(join(dir, "src/index.ts"), dir); - assertEquals(root, dir, "Should find root at tsconfig.json location"); - }); -}); - -test("typescript: finds root with jsconfig.json", async () => { - await withTempDir({ - "jsconfig.json": "{}", - "src/app.js": "const x = 1;", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "typescript")!; - const root = server.findRoot(join(dir, "src/app.js"), dir); - assertEquals(root, dir, "Should find root at jsconfig.json location"); - }); -}); - -test("typescript: returns undefined for deno projects", async () => { - await withTempDir({ - "deno.json": "{}", - "main.ts": "console.log('deno');", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "typescript")!; - const root = server.findRoot(join(dir, "main.ts"), dir); - assertEquals(root, undefined, "Should return undefined for deno projects"); - }); -}); - -test("typescript: nested package finds nearest root", async () => { - await withTempDir({ - "package.json": "{}", - "packages/web/package.json": "{}", - "packages/web/src/index.ts": "export const x = 1;", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "typescript")!; - const root = server.findRoot(join(dir, "packages/web/src/index.ts"), dir); - assertEquals(root, join(dir, "packages/web"), "Should find nearest package.json"); - }); -}); - -// ============================================================================ -// Dart root detection tests -// ============================================================================ - -test("dart: finds root with pubspec.yaml", async () => { - await withTempDir({ - "pubspec.yaml": "name: my_app", - "lib/main.dart": "void main() {}", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "dart")!; - const root = server.findRoot(join(dir, "lib/main.dart"), dir); - assertEquals(root, dir, "Should find root at pubspec.yaml location"); - }); -}); - -test("dart: finds root with analysis_options.yaml", async () => { - await withTempDir({ - "analysis_options.yaml": "linter: rules:", - "lib/main.dart": "void main() {}", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "dart")!; - const root = server.findRoot(join(dir, "lib/main.dart"), dir); - assertEquals(root, dir, "Should find root at analysis_options.yaml location"); - }); -}); - -test("dart: nested package finds nearest root", async () => { - await withTempDir({ - "pubspec.yaml": "name: monorepo", - "packages/core/pubspec.yaml": "name: core", - "packages/core/lib/core.dart": "void init() {}", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "dart")!; - const root = server.findRoot(join(dir, "packages/core/lib/core.dart"), dir); - assertEquals(root, join(dir, "packages/core"), "Should find nearest pubspec.yaml"); - }); -}); - -// ============================================================================ -// Rust root detection tests -// ============================================================================ - -test("rust: finds root with Cargo.toml", async () => { - await withTempDir({ - "Cargo.toml": "[package]\nname = \"my_crate\"", - "src/lib.rs": "pub fn hello() {}", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "rust-analyzer")!; - const root = server.findRoot(join(dir, "src/lib.rs"), dir); - assertEquals(root, dir, "Should find root at Cargo.toml location"); - }); -}); - -test("rust: nested workspace member finds nearest Cargo.toml", async () => { - await withTempDir({ - "Cargo.toml": "[workspace]\nmembers = [\"crates/*\"]", - "crates/core/Cargo.toml": "[package]\nname = \"core\"", - "crates/core/src/lib.rs": "pub fn init() {}", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "rust-analyzer")!; - const root = server.findRoot(join(dir, "crates/core/src/lib.rs"), dir); - assertEquals(root, join(dir, "crates/core"), "Should find nearest Cargo.toml"); - }); -}); - -// ============================================================================ -// Go root detection tests (including gopls bug fix verification) -// ============================================================================ - -test("gopls: finds root with go.mod", async () => { - await withTempDir({ - "go.mod": "module example.com/myapp", - "main.go": "package main", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "gopls")!; - const root = server.findRoot(join(dir, "main.go"), dir); - assertEquals(root, dir, "Should find root at go.mod location"); - }); -}); - -test("gopls: finds root with go.work (workspace)", async () => { - await withTempDir({ - "go.work": "go 1.21\nuse ./app", - "app/go.mod": "module example.com/app", - "app/main.go": "package main", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "gopls")!; - const root = server.findRoot(join(dir, "app/main.go"), dir); - assertEquals(root, dir, "Should find root at go.work location (workspace root)"); - }); -}); - -test("gopls: prefers go.work over go.mod", async () => { - await withTempDir({ - "go.work": "go 1.21\nuse ./app", - "go.mod": "module example.com/root", - "app/go.mod": "module example.com/app", - "app/main.go": "package main", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "gopls")!; - const root = server.findRoot(join(dir, "app/main.go"), dir); - // go.work is found first, so it should return the go.work location - assertEquals(root, dir, "Should prefer go.work over go.mod"); - }); -}); - -test("gopls: returns undefined when no go.mod or go.work (bug fix verification)", async () => { - await withTempDir({ - "main.go": "package main", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "gopls")!; - const root = server.findRoot(join(dir, "main.go"), dir); - // This test verifies the bug fix: previously this would return undefined - // because `undefined !== cwd` was true, skipping the go.mod check - assertEquals(root, undefined, "Should return undefined when no go.mod or go.work"); - }); -}); - -test("gopls: finds go.mod when go.work not present (bug fix verification)", async () => { - await withTempDir({ - "go.mod": "module example.com/myapp", - "cmd/server/main.go": "package main", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "gopls")!; - const root = server.findRoot(join(dir, "cmd/server/main.go"), dir); - // This is the key test for the bug fix - // Previously: findRoot(go.work) returns undefined, then `undefined !== cwd` is true, - // so it would return undefined without checking go.mod - // After fix: if go.work not found, falls through to check go.mod - assertEquals(root, dir, "Should find go.mod when go.work is not present"); - }); -}); - -// ============================================================================ -// Kotlin root detection tests -// ============================================================================ - -test("kotlin: finds root with settings.gradle.kts", async () => { - await withTempDir({ - "settings.gradle.kts": "rootProject.name = \"myapp\"", - "app/src/main/kotlin/Main.kt": "fun main() {}", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "kotlin")!; - const root = server.findRoot(join(dir, "app/src/main/kotlin/Main.kt"), dir); - assertEquals(root, dir, "Should find root at settings.gradle.kts location"); - }); -}); - -test("kotlin: prefers settings.gradle(.kts) over nested build.gradle", async () => { - await withTempDir({ - "settings.gradle": "rootProject.name = 'root'", - "app/build.gradle": "plugins {}", - "app/src/main/kotlin/Main.kt": "fun main() {}", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "kotlin")!; - const root = server.findRoot(join(dir, "app/src/main/kotlin/Main.kt"), dir); - assertEquals(root, dir, "Should prefer settings.gradle at workspace root"); - }); -}); - -test("kotlin: finds root with pom.xml", async () => { - await withTempDir({ - "pom.xml": "", - "src/main/kotlin/Main.kt": "fun main() {}", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "kotlin")!; - const root = server.findRoot(join(dir, "src/main/kotlin/Main.kt"), dir); - assertEquals(root, dir, "Should find root at pom.xml location"); - }); -}); - -// ============================================================================ -// Swift root detection tests -// ============================================================================ - -test("swift: finds root with Package.swift", async () => { - await withTempDir({ - "Package.swift": "// swift-tools-version: 5.9", - "Sources/App/main.swift": "print(\"hi\")", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "swift")!; - const root = server.findRoot(join(dir, "Sources/App/main.swift"), dir); - assertEquals(root, dir, "Should find root at Package.swift location"); - }); -}); - -test("swift: finds root with Xcode project", async () => { - await withTempDir({ - "MyApp.xcodeproj/project.pbxproj": "// pbxproj", - "MyApp/main.swift": "print(\"hi\")", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "swift")!; - const root = server.findRoot(join(dir, "MyApp/main.swift"), dir); - assertEquals(root, dir, "Should find root at Xcode project location"); - }); -}); - -test("swift: finds root with Xcode workspace", async () => { - await withTempDir({ - "MyApp.xcworkspace/contents.xcworkspacedata": "", - "MyApp/main.swift": "print(\"hi\")", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "swift")!; - const root = server.findRoot(join(dir, "MyApp/main.swift"), dir); - assertEquals(root, dir, "Should find root at Xcode workspace location"); - }); -}); - -// ============================================================================ -// Python root detection tests -// ============================================================================ - -test("pyright: finds root with pyproject.toml", async () => { - await withTempDir({ - "pyproject.toml": "[project]\nname = \"myapp\"", - "src/main.py": "print('hello')", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "pyright")!; - const root = server.findRoot(join(dir, "src/main.py"), dir); - assertEquals(root, dir, "Should find root at pyproject.toml location"); - }); -}); - -test("pyright: finds root with setup.py", async () => { - await withTempDir({ - "setup.py": "from setuptools import setup", - "myapp/main.py": "print('hello')", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "pyright")!; - const root = server.findRoot(join(dir, "myapp/main.py"), dir); - assertEquals(root, dir, "Should find root at setup.py location"); - }); -}); - -test("pyright: finds root with requirements.txt", async () => { - await withTempDir({ - "requirements.txt": "flask>=2.0", - "app.py": "from flask import Flask", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "pyright")!; - const root = server.findRoot(join(dir, "app.py"), dir); - assertEquals(root, dir, "Should find root at requirements.txt location"); - }); -}); - -// ============================================================================ -// URI construction tests (pathToFileURL) -// ============================================================================ - -test("pathToFileURL: handles simple paths", async () => { - const uri = pathToFileURL("/home/user/project/file.ts").href; - assertEquals(uri, "file:///home/user/project/file.ts", "Should create proper file URI"); -}); - -test("pathToFileURL: encodes special characters", async () => { - const uri = pathToFileURL("/home/user/my project/file.ts").href; - assert(uri.includes("my%20project"), "Should URL-encode spaces"); -}); - -test("pathToFileURL: handles unicode", async () => { - const uri = pathToFileURL("/home/user/项目/file.ts").href; - // pathToFileURL properly encodes unicode - assert(uri.startsWith("file:///"), "Should start with file:///"); - assert(uri.includes("file.ts"), "Should contain filename"); -}); - -// ============================================================================ -// Vue/Svelte root detection tests -// ============================================================================ - -test("vue: finds root with package.json", async () => { - await withTempDir({ - "package.json": "{}", - "src/App.vue": "", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "vue")!; - const root = server.findRoot(join(dir, "src/App.vue"), dir); - assertEquals(root, dir, "Should find root at package.json location"); - }); -}); - -test("vue: finds root with vite.config.ts", async () => { - await withTempDir({ - "vite.config.ts": "export default {}", - "src/App.vue": "", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "vue")!; - const root = server.findRoot(join(dir, "src/App.vue"), dir); - assertEquals(root, dir, "Should find root at vite.config.ts location"); - }); -}); - -test("svelte: finds root with svelte.config.js", async () => { - await withTempDir({ - "svelte.config.js": "export default {}", - "src/App.svelte": "", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "svelte")!; - const root = server.findRoot(join(dir, "src/App.svelte"), dir); - assertEquals(root, dir, "Should find root at svelte.config.js location"); - }); -}); - -// ============================================================================ -// Additional Rust tests (parity with TypeScript) -// ============================================================================ - -test("rust: finds root in src subdirectory", async () => { - await withTempDir({ - "Cargo.toml": "[package]\nname = \"myapp\"", - "src/main.rs": "fn main() {}", - "src/lib.rs": "pub mod utils;", - "src/utils/mod.rs": "pub fn helper() {}", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "rust-analyzer")!; - const root = server.findRoot(join(dir, "src/utils/mod.rs"), dir); - assertEquals(root, dir, "Should find root from deeply nested src file"); - }); -}); - -test("rust: workspace with multiple crates", async () => { - await withTempDir({ - "Cargo.toml": "[workspace]\nmembers = [\"crates/*\"]", - "crates/api/Cargo.toml": "[package]\nname = \"api\"", - "crates/api/src/lib.rs": "pub fn serve() {}", - "crates/core/Cargo.toml": "[package]\nname = \"core\"", - "crates/core/src/lib.rs": "pub fn init() {}", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "rust-analyzer")!; - // Each crate should find its own Cargo.toml - const apiRoot = server.findRoot(join(dir, "crates/api/src/lib.rs"), dir); - const coreRoot = server.findRoot(join(dir, "crates/core/src/lib.rs"), dir); - assertEquals(apiRoot, join(dir, "crates/api"), "API crate should find its Cargo.toml"); - assertEquals(coreRoot, join(dir, "crates/core"), "Core crate should find its Cargo.toml"); - }); -}); - -test("rust: returns undefined when no Cargo.toml", async () => { - await withTempDir({ - "main.rs": "fn main() {}", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "rust-analyzer")!; - const root = server.findRoot(join(dir, "main.rs"), dir); - assertEquals(root, undefined, "Should return undefined when no Cargo.toml"); - }); -}); - -// ============================================================================ -// Additional Dart tests (parity with TypeScript) -// ============================================================================ - -test("dart: Flutter project with pubspec.yaml", async () => { - await withTempDir({ - "pubspec.yaml": "name: my_flutter_app\ndependencies:\n flutter:\n sdk: flutter", - "lib/main.dart": "import 'package:flutter/material.dart';", - "lib/screens/home.dart": "class HomeScreen {}", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "dart")!; - const root = server.findRoot(join(dir, "lib/screens/home.dart"), dir); - assertEquals(root, dir, "Should find root for Flutter project"); - }); -}); - -test("dart: returns undefined when no marker files", async () => { - await withTempDir({ - "main.dart": "void main() {}", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "dart")!; - const root = server.findRoot(join(dir, "main.dart"), dir); - assertEquals(root, undefined, "Should return undefined when no pubspec.yaml or analysis_options.yaml"); - }); -}); - -test("dart: monorepo with multiple packages", async () => { - await withTempDir({ - "pubspec.yaml": "name: monorepo", - "packages/auth/pubspec.yaml": "name: auth", - "packages/auth/lib/auth.dart": "class Auth {}", - "packages/ui/pubspec.yaml": "name: ui", - "packages/ui/lib/widgets.dart": "class Button {}", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "dart")!; - const authRoot = server.findRoot(join(dir, "packages/auth/lib/auth.dart"), dir); - const uiRoot = server.findRoot(join(dir, "packages/ui/lib/widgets.dart"), dir); - assertEquals(authRoot, join(dir, "packages/auth"), "Auth package should find its pubspec"); - assertEquals(uiRoot, join(dir, "packages/ui"), "UI package should find its pubspec"); - }); -}); - -// ============================================================================ -// Additional Python tests (parity with TypeScript) -// ============================================================================ - -test("pyright: finds root with pyrightconfig.json", async () => { - await withTempDir({ - "pyrightconfig.json": "{}", - "src/app.py": "print('hello')", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "pyright")!; - const root = server.findRoot(join(dir, "src/app.py"), dir); - assertEquals(root, dir, "Should find root at pyrightconfig.json location"); - }); -}); - -test("pyright: returns undefined when no marker files", async () => { - await withTempDir({ - "script.py": "print('hello')", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "pyright")!; - const root = server.findRoot(join(dir, "script.py"), dir); - assertEquals(root, undefined, "Should return undefined when no Python project markers"); - }); -}); - -test("pyright: monorepo with multiple packages", async () => { - await withTempDir({ - "pyproject.toml": "[project]\nname = \"monorepo\"", - "packages/api/pyproject.toml": "[project]\nname = \"api\"", - "packages/api/src/main.py": "from flask import Flask", - "packages/worker/pyproject.toml": "[project]\nname = \"worker\"", - "packages/worker/src/tasks.py": "def process(): pass", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "pyright")!; - const apiRoot = server.findRoot(join(dir, "packages/api/src/main.py"), dir); - const workerRoot = server.findRoot(join(dir, "packages/worker/src/tasks.py"), dir); - assertEquals(apiRoot, join(dir, "packages/api"), "API package should find its pyproject.toml"); - assertEquals(workerRoot, join(dir, "packages/worker"), "Worker package should find its pyproject.toml"); - }); -}); - -// ============================================================================ -// Additional Go tests -// ============================================================================ - -test("gopls: monorepo with multiple modules", async () => { - await withTempDir({ - "go.work": "go 1.21\nuse (\n ./api\n ./worker\n)", - "api/go.mod": "module example.com/api", - "api/main.go": "package main", - "worker/go.mod": "module example.com/worker", - "worker/main.go": "package main", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "gopls")!; - // With go.work present, all files should use workspace root - const apiRoot = server.findRoot(join(dir, "api/main.go"), dir); - const workerRoot = server.findRoot(join(dir, "worker/main.go"), dir); - assertEquals(apiRoot, dir, "API module should use go.work root"); - assertEquals(workerRoot, dir, "Worker module should use go.work root"); - }); -}); - -test("gopls: nested cmd directory", async () => { - await withTempDir({ - "go.mod": "module example.com/myapp", - "cmd/server/main.go": "package main", - "cmd/cli/main.go": "package main", - "internal/db/db.go": "package db", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "gopls")!; - const serverRoot = server.findRoot(join(dir, "cmd/server/main.go"), dir); - const cliRoot = server.findRoot(join(dir, "cmd/cli/main.go"), dir); - const dbRoot = server.findRoot(join(dir, "internal/db/db.go"), dir); - assertEquals(serverRoot, dir, "cmd/server should find go.mod at root"); - assertEquals(cliRoot, dir, "cmd/cli should find go.mod at root"); - assertEquals(dbRoot, dir, "internal/db should find go.mod at root"); - }); -}); - -// ============================================================================ -// Additional TypeScript tests -// ============================================================================ - -test("typescript: pnpm workspace", async () => { - await withTempDir({ - "package.json": "{}", - "pnpm-workspace.yaml": "packages:\n - packages/*", - "packages/web/package.json": "{}", - "packages/web/src/App.tsx": "export const App = () => null;", - "packages/api/package.json": "{}", - "packages/api/src/index.ts": "export const handler = () => {};", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "typescript")!; - const webRoot = server.findRoot(join(dir, "packages/web/src/App.tsx"), dir); - const apiRoot = server.findRoot(join(dir, "packages/api/src/index.ts"), dir); - assertEquals(webRoot, join(dir, "packages/web"), "Web package should find its package.json"); - assertEquals(apiRoot, join(dir, "packages/api"), "API package should find its package.json"); - }); -}); - -test("typescript: returns undefined when no config files", async () => { - await withTempDir({ - "script.ts": "const x = 1;", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "typescript")!; - const root = server.findRoot(join(dir, "script.ts"), dir); - assertEquals(root, undefined, "Should return undefined when no package.json or tsconfig.json"); - }); -}); - -test("typescript: prefers nearest tsconfig over package.json", async () => { - await withTempDir({ - "package.json": "{}", - "apps/web/tsconfig.json": "{}", - "apps/web/src/index.ts": "export const x = 1;", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "typescript")!; - const root = server.findRoot(join(dir, "apps/web/src/index.ts"), dir); - // Should find tsconfig.json first (it's nearer than root package.json) - assertEquals(root, join(dir, "apps/web"), "Should find nearest config file"); - }); -}); - -// ============================================================================ -// Additional Vue/Svelte tests -// ============================================================================ - -test("vue: Nuxt project", async () => { - await withTempDir({ - "package.json": "{}", - "nuxt.config.ts": "export default {}", - "pages/index.vue": "", - "components/Button.vue": "", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "vue")!; - const pagesRoot = server.findRoot(join(dir, "pages/index.vue"), dir); - const componentsRoot = server.findRoot(join(dir, "components/Button.vue"), dir); - assertEquals(pagesRoot, dir, "Pages should find root"); - assertEquals(componentsRoot, dir, "Components should find root"); - }); -}); - -test("vue: returns undefined when no config", async () => { - await withTempDir({ - "App.vue": "", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "vue")!; - const root = server.findRoot(join(dir, "App.vue"), dir); - assertEquals(root, undefined, "Should return undefined when no package.json or vite.config"); - }); -}); - -test("svelte: SvelteKit project", async () => { - await withTempDir({ - "package.json": "{}", - "svelte.config.js": "export default {}", - "src/routes/+page.svelte": "", - "src/lib/components/Button.svelte": "", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "svelte")!; - const routeRoot = server.findRoot(join(dir, "src/routes/+page.svelte"), dir); - const libRoot = server.findRoot(join(dir, "src/lib/components/Button.svelte"), dir); - assertEquals(routeRoot, dir, "Route should find root"); - assertEquals(libRoot, dir, "Lib component should find root"); - }); -}); - -test("svelte: returns undefined when no config", async () => { - await withTempDir({ - "App.svelte": "", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "svelte")!; - const root = server.findRoot(join(dir, "App.svelte"), dir); - assertEquals(root, undefined, "Should return undefined when no package.json or svelte.config.js"); - }); -}); - -// ============================================================================ -// Stop boundary tests (findNearestFile respects cwd boundary) -// ============================================================================ - -test("stop boundary: does not search above cwd", async () => { - await withTempDir({ - "package.json": "{}", // This is at root - "projects/myapp/src/index.ts": "export const x = 1;", - // Note: no package.json in projects/myapp - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "typescript")!; - // When cwd is set to projects/myapp, it should NOT find the root package.json - const projectDir = join(dir, "projects/myapp"); - const root = server.findRoot(join(projectDir, "src/index.ts"), projectDir); - assertEquals(root, undefined, "Should not find package.json above cwd boundary"); - }); -}); - -test("stop boundary: finds marker at cwd level", async () => { - await withTempDir({ - "projects/myapp/package.json": "{}", - "projects/myapp/src/index.ts": "export const x = 1;", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "typescript")!; - const projectDir = join(dir, "projects/myapp"); - const root = server.findRoot(join(projectDir, "src/index.ts"), projectDir); - assertEquals(root, projectDir, "Should find package.json at cwd level"); - }); -}); - -// ============================================================================ -// Edge cases -// ============================================================================ - -test("edge: deeply nested file finds correct root", async () => { - await withTempDir({ - "package.json": "{}", - "src/components/ui/buttons/primary/Button.tsx": "export const Button = () => null;", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "typescript")!; - const root = server.findRoot(join(dir, "src/components/ui/buttons/primary/Button.tsx"), dir); - assertEquals(root, dir, "Should find root even for deeply nested files"); - }); -}); - -test("edge: file at root level finds root", async () => { - await withTempDir({ - "package.json": "{}", - "index.ts": "console.log('root');", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "typescript")!; - const root = server.findRoot(join(dir, "index.ts"), dir); - assertEquals(root, dir, "Should find root for file at root level"); - }); -}); - -test("edge: no marker files returns undefined", async () => { - await withTempDir({ - "random.ts": "const x = 1;", - }, async (dir) => { - const server = LSP_SERVERS.find(s => s.id === "typescript")!; - const root = server.findRoot(join(dir, "random.ts"), dir); - assertEquals(root, undefined, "Should return undefined when no marker files"); - }); -}); - -// ============================================================================ -// Run tests -// ============================================================================ - -async function runTests(): Promise { - console.log("Running LSP tests...\n"); - - const results: TestResult[] = []; - let passed = 0; - let failed = 0; - - for (const { name, fn } of tests) { - try { - await fn(); - results.push({ name, passed: true }); - console.log(` ${name}... ✓`); - passed++; - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - results.push({ name, passed: false, error: errorMsg }); - console.log(` ${name}... ✗`); - console.log(` Error: ${errorMsg}\n`); - failed++; - } - } - - console.log(`\n${passed} passed, ${failed} failed`); - - if (failed > 0) { - process.exit(1); - } -} - -runTests(); diff --git a/pi/.pi/agent/extensions/lsp-pi/tsconfig.json b/pi/.pi/agent/extensions/lsp-pi/tsconfig.json deleted file mode 100644 index 508e975..0000000 --- a/pi/.pi/agent/extensions/lsp-pi/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "esModuleInterop": true, - "strict": true, - "skipLibCheck": true, - "noEmit": true, - "lib": ["ES2022"] - }, - "include": ["*.ts"] -}