From acd62bde2ef541c8110693c9179e608506a2be48 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Sat, 17 Oct 2026 23:41:30 +0100 Subject: [PATCH 1/3] refactor: remove the LSP subsystem (#144) Delete the language-server integration: src/lsp/, the lsp tool, the `debug lsp` command, the runtime language-server downloads into the data bin dir, and the LSP-only Archive and Filesystem.normalizePath helpers. edit, write and apply_patch no longer wait on diagnostics or append a block, and read no longer warms a client. Drop the AICTRL_DISABLE_LSP_DOWNLOAD, AICTRL_EXPERIMENTAL_LSP_TY and AICTRL_EXPERIMENTAL_LSP_TOOL flags (AICTRL_EXPERIMENTAL no longer enables an lsp tool) and the vscode-jsonrpc / vscode-languageserver-types deps. Existing configs keep loading: the `lsp` block and the `lsp` permission rule stay in the schema as deprecated no-ops and log one warning. Sessions persisted with lsp tool parts or diagnostics metadata still continue; SymbolSource keeps its Range schema inline. Co-Authored-By: Claude Opus 5.5 --- .opencode/agent/translator.md | 5 - .opencode/agent/triage.md | 1 - CONTRIBUTING.md | 2 +- bun.lock | 4 - packages/cli/BUN_SHELL_MIGRATION_PLAN.md | 2 - packages/cli/package.json | 2 - packages/cli/script/schema.ts | 2 +- packages/cli/src/cli/cmd/debug/index.ts | 2 - packages/cli/src/cli/cmd/debug/lsp.ts | 52 - packages/cli/src/config/config.ts | 59 +- packages/cli/src/flag/flag.ts | 3 - packages/cli/src/lsp/client.ts | 251 -- packages/cli/src/lsp/index.ts | 485 ---- packages/cli/src/lsp/language.ts | 120 - packages/cli/src/lsp/server.ts | 2057 ----------------- packages/cli/src/project/bootstrap.ts | 2 - packages/cli/src/session/message-v2.ts | 17 +- packages/cli/src/session/prompt.ts | 26 +- packages/cli/src/tool/apply_patch.ts | 29 +- packages/cli/src/tool/edit.ts | 20 +- packages/cli/src/tool/lsp.ts | 97 - packages/cli/src/tool/lsp.txt | 19 - packages/cli/src/tool/read.ts | 3 - packages/cli/src/tool/registry.ts | 2 - packages/cli/src/tool/write.ts | 27 +- packages/cli/src/util/archive.ts | 16 - packages/cli/src/util/filesystem.ts | 15 - .../cli/fixture/pre-lsp-removal-session.json | 384 +++ packages/cli/test/cli/run-no-lsp.test.ts | 176 ++ packages/cli/test/config/config.test.ts | 49 +- .../cli/test/fixture/lsp/fake-lsp-server.js | 77 - packages/cli/test/lsp/client.test.ts | 95 - 32 files changed, 647 insertions(+), 3454 deletions(-) delete mode 100644 packages/cli/src/cli/cmd/debug/lsp.ts delete mode 100644 packages/cli/src/lsp/client.ts delete mode 100644 packages/cli/src/lsp/index.ts delete mode 100644 packages/cli/src/lsp/language.ts delete mode 100644 packages/cli/src/lsp/server.ts delete mode 100644 packages/cli/src/tool/lsp.ts delete mode 100644 packages/cli/src/tool/lsp.txt delete mode 100644 packages/cli/src/util/archive.ts create mode 100644 packages/cli/test/cli/fixture/pre-lsp-removal-session.json create mode 100644 packages/cli/test/cli/run-no-lsp.test.ts delete mode 100644 packages/cli/test/fixture/lsp/fake-lsp-server.js delete mode 100644 packages/cli/test/lsp/client.test.ts diff --git a/.opencode/agent/translator.md b/.opencode/agent/translator.md index 516cd49c..140fa34b 100644 --- a/.opencode/agent/translator.md +++ b/.opencode/agent/translator.md @@ -225,7 +225,6 @@ JSONC K2 LLM LM -LSP M2 MCP MR @@ -417,7 +416,6 @@ aictrl.server.close() /init /instance/dispose /log -/lsp /mcp /mnt/ /mnt/c/ @@ -575,7 +573,6 @@ AICTRL_DISABLE_AUTOCOMPACT AICTRL_DISABLE_AUTOUPDATE AICTRL_DISABLE_DEFAULT_PLUGINS AICTRL_DISABLE_FILETIME_CHECK -AICTRL_DISABLE_LSP_DOWNLOAD AICTRL_DISABLE_MODELS_FETCH AICTRL_DISABLE_PRUNE AICTRL_ENABLE_EXA @@ -583,8 +580,6 @@ AICTRL_ENABLE_EXPERIMENTAL_MODELS AICTRL_EXPERIMENTAL AICTRL_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS AICTRL_EXPERIMENTAL_EXA -AICTRL_EXPERIMENTAL_LSP_TOOL -AICTRL_EXPERIMENTAL_LSP_TY AICTRL_EXPERIMENTAL_OUTPUT_TOKEN_MAX AICTRL_EXPERIMENTAL_OXFMT AICTRL_EXPERIMENTAL_PLAN_MODE diff --git a/.opencode/agent/triage.md b/.opencode/agent/triage.md index 6aee54df..d0115d56 100644 --- a/.opencode/agent/triage.md +++ b/.opencode/agent/triage.md @@ -61,7 +61,6 @@ Use for core server issues in `packages/aictrl/`, excluding `packages/aictrl/src Examples: -- LSP server behavior - Harness behavior (agent + tools) - Feature requests for server behavior - Agent context construction diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7b673268..58fbd5b7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,7 +3,7 @@ We want to make it easy for you to contribute to Aictrl. Here are the most common type of changes that get merged: - Bug fixes -- Additional LSPs / Formatters +- Additional formatters - Improvements to LLM performance - Support for new providers - Fixes for environment-specific quirks diff --git a/bun.lock b/bun.lock index 89c649a2..a9d7951b 100644 --- a/bun.lock +++ b/bun.lock @@ -69,7 +69,6 @@ "tree-sitter-bash": "0.25.0", "turndown": "7.2.0", "ulid": "catalog:", - "vscode-jsonrpc": "8.2.1", "web-tree-sitter": "0.25.10", "xdg-basedir": "5.1.0", "yargs": "18.0.0", @@ -120,7 +119,6 @@ "drizzle-orm": "1.0.0-beta.12-a5629fb", "google-auth-library": "10.5.0", "typescript": "catalog:", - "vscode-languageserver-types": "3.17.5", "why-is-node-running": "3.2.2", "zod-to-json-schema": "3.24.5", }, @@ -1883,8 +1881,6 @@ "vscode-jsonrpc": ["vscode-jsonrpc@8.2.1", "", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="], - "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], - "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], diff --git a/packages/cli/BUN_SHELL_MIGRATION_PLAN.md b/packages/cli/BUN_SHELL_MIGRATION_PLAN.md index e4079abd..a1afa682 100644 --- a/packages/cli/BUN_SHELL_MIGRATION_PLAN.md +++ b/packages/cli/BUN_SHELL_MIGRATION_PLAN.md @@ -15,7 +15,6 @@ Current baseline from audit: - Largest hotspots: - `src/cli/cmd/github.ts` (33) - `src/worktree/index.ts` (22) - - `src/lsp/server.ts` (21) - `src/installation/index.ts` (20) - `src/snapshot/index.ts` (18) @@ -80,7 +79,6 @@ Migrate these first: - `src/cli/cmd/github.ts` - `src/worktree/index.ts` -- `src/lsp/server.ts` - `src/installation/index.ts` - `src/snapshot/index.ts` diff --git a/packages/cli/package.json b/packages/cli/package.json index 76d2f456..04e11024 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -77,7 +77,6 @@ "drizzle-kit": "1.0.0-beta.12-a5629fb", "drizzle-orm": "1.0.0-beta.12-a5629fb", "typescript": "catalog:", - "vscode-languageserver-types": "3.17.5", "why-is-node-running": "3.2.2", "zod-to-json-schema": "3.24.5" }, @@ -121,7 +120,6 @@ "tree-sitter-bash": "0.25.0", "turndown": "7.2.0", "ulid": "catalog:", - "vscode-jsonrpc": "8.2.1", "web-tree-sitter": "0.25.10", "xdg-basedir": "5.1.0", "yargs": "18.0.0", diff --git a/packages/cli/script/schema.ts b/packages/cli/script/schema.ts index 61d11ea7..ea937b76 100755 --- a/packages/cli/script/schema.ts +++ b/packages/cli/script/schema.ts @@ -44,7 +44,7 @@ function generate(schema: z.ZodType) { allowTrailingCommas?: boolean } - // used for json lsps since config supports jsonc + // config files are JSONC, so editors validating against this schema must allow comments result.allowComments = true result.allowTrailingCommas = true diff --git a/packages/cli/src/cli/cmd/debug/index.ts b/packages/cli/src/cli/cmd/debug/index.ts index 8da6ff55..eb47601e 100644 --- a/packages/cli/src/cli/cmd/debug/index.ts +++ b/packages/cli/src/cli/cmd/debug/index.ts @@ -3,7 +3,6 @@ import { bootstrap } from "../../bootstrap" import { cmd } from "../cmd" import { ConfigCommand } from "./config" import { FileCommand } from "./file" -import { LSPCommand } from "./lsp" import { RipgrepCommand } from "./ripgrep" import { ScrapCommand } from "./scrap" import { SkillCommand } from "./skill" @@ -16,7 +15,6 @@ export const DebugCommand = cmd({ builder: (yargs) => yargs .command(ConfigCommand) - .command(LSPCommand) .command(RipgrepCommand) .command(FileCommand) .command(ScrapCommand) diff --git a/packages/cli/src/cli/cmd/debug/lsp.ts b/packages/cli/src/cli/cmd/debug/lsp.ts deleted file mode 100644 index d83c4ed8..00000000 --- a/packages/cli/src/cli/cmd/debug/lsp.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { LSP } from "../../../lsp" -import { bootstrap } from "../../bootstrap" -import { cmd } from "../cmd" -import { Log } from "../../../util/log" -import { EOL } from "os" - -export const LSPCommand = cmd({ - command: "lsp", - describe: "LSP debugging utilities", - builder: (yargs) => - yargs.command(DiagnosticsCommand).command(SymbolsCommand).command(DocumentSymbolsCommand).demandCommand(), - async handler() {}, -}) - -const DiagnosticsCommand = cmd({ - command: "diagnostics ", - describe: "get diagnostics for a file", - builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - await LSP.touchFile(args.file, true) - await Bun.sleep(1000) - process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL) - }) - }, -}) - -export const SymbolsCommand = cmd({ - command: "symbols ", - describe: "search workspace symbols", - builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - using _ = Log.Default.time("symbols") - const results = await LSP.workspaceSymbol(args.query) - process.stdout.write(JSON.stringify(results, null, 2) + EOL) - }) - }, -}) - -export const DocumentSymbolsCommand = cmd({ - command: "document-symbols ", - describe: "get symbols from a document", - builder: (yargs) => yargs.positional("uri", { type: "string", demandOption: true }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - using _ = Log.Default.time("document-symbols") - const results = await LSP.documentSymbol(args.uri) - process.stdout.write(JSON.stringify(results, null, 2) + EOL) - }) - }, -}) diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 3b0297d1..47a78bb8 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -20,7 +20,6 @@ import { printParseErrorCode, } from "jsonc-parser" import { Instance } from "../project/instance" -import { LSPServer } from "../lsp/server" import { BunProc } from "@/bun" import { ConfigMarkdown } from "./markdown" import { constants, existsSync } from "fs" @@ -630,7 +629,9 @@ export namespace Config { webfetch: PermissionAction.optional(), websearch: PermissionAction.optional(), codesearch: PermissionAction.optional(), - lsp: PermissionRule.optional(), + lsp: PermissionRule.optional().describe( + "@deprecated The LSP subsystem was removed; this rule has no effect.", + ), doom_loop: PermissionAction.optional(), skill: PermissionRule.optional(), }) @@ -1087,42 +1088,7 @@ export namespace Config { ), ]) .optional(), - lsp: z - .union([ - z.literal(false), - z.record( - z.string(), - z.union([ - z.object({ - disabled: z.literal(true), - }), - z.object({ - command: z.array(z.string()), - extensions: z.array(z.string()).optional(), - disabled: z.boolean().optional(), - env: z.record(z.string(), z.string()).optional(), - initialization: z.record(z.string(), z.any()).optional(), - }), - ]), - ), - ]) - .optional() - .refine( - (data) => { - if (!data) return true - if (typeof data === "boolean") return true - const serverIds = new Set(Object.values(LSPServer).map((s) => s.id)) - - return Object.entries(data).every(([id, config]) => { - if (config.disabled) return true - if (serverIds.has(id)) return true - return Boolean(config.extensions) - }) - }, - { - error: "For custom LSP servers, 'extensions' array is required.", - }, - ), + lsp: z.unknown().optional().describe("@deprecated The LSP subsystem was removed; this key is ignored."), instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"), layout: Layout.optional().describe("@deprecated Always uses stretch layout."), permission: Permission.optional(), @@ -1211,6 +1177,17 @@ export namespace Config { return load(text, { path: filepath }) } + // The LSP subsystem was removed (#144). Configs written before then may still set + // the `lsp` block or an `lsp` permission rule; both load as no-ops with one warning. + function withoutDeprecatedLsp(config: Record, source: string) { + const permission = config.permission + const hasLspPermission = typeof permission === "object" && permission !== null && "lsp" in permission + if (!("lsp" in config) && !hasLspPermission) return config + log.warn("lsp config is deprecated and ignored; the LSP subsystem was removed", { path: source }) + const { lsp: _, ...rest } = config + return rest + } + async function load(text: string, options: { path: string } | { dir: string; source: string }) { const original = text const source = "path" in options ? options.path : options.source @@ -1222,7 +1199,7 @@ export namespace Config { const normalized = (() => { if (!data || typeof data !== "object" || Array.isArray(data)) return data - const copy = { ...(data as Record) } + const copy = withoutDeprecatedLsp({ ...(data as Record) }, source) const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy if (!hadLegacy) return copy delete copy.theme @@ -1292,9 +1269,7 @@ export namespace Config { } function globalConfigFile() { - const candidates = ["aictrl.jsonc", "aictrl.json", "config.json"].map((file) => - path.join(Global.Path.config, file), - ) + const candidates = ["aictrl.jsonc", "aictrl.json", "config.json"].map((file) => path.join(Global.Path.config, file)) for (const file of candidates) { if (existsSync(file)) return file } diff --git a/packages/cli/src/flag/flag.ts b/packages/cli/src/flag/flag.ts index fe66672b..a2a93153 100644 --- a/packages/cli/src/flag/flag.ts +++ b/packages/cli/src/flag/flag.ts @@ -14,7 +14,6 @@ export namespace Flag { export const AICTRL_DISABLE_PRUNE = truthy("AICTRL_DISABLE_PRUNE") export const AICTRL_PERMISSION = process.env["AICTRL_PERMISSION"] export const AICTRL_DISABLE_DEFAULT_PLUGINS = truthy("AICTRL_DISABLE_DEFAULT_PLUGINS") - export const AICTRL_DISABLE_LSP_DOWNLOAD = truthy("AICTRL_DISABLE_LSP_DOWNLOAD") export const AICTRL_ENABLE_EXPERIMENTAL_MODELS = truthy("AICTRL_ENABLE_EXPERIMENTAL_MODELS") export const AICTRL_DISABLE_AUTOCOMPACT = truthy("AICTRL_DISABLE_AUTOCOMPACT") export const AICTRL_DISABLE_MODELS_FETCH = truthy("AICTRL_DISABLE_MODELS_FETCH") @@ -31,8 +30,6 @@ export namespace Flag { export const AICTRL_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS = number("AICTRL_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS") export const AICTRL_EXPERIMENTAL_OUTPUT_TOKEN_MAX = number("AICTRL_EXPERIMENTAL_OUTPUT_TOKEN_MAX") export const AICTRL_EXPERIMENTAL_OXFMT = AICTRL_EXPERIMENTAL || truthy("AICTRL_EXPERIMENTAL_OXFMT") - export const AICTRL_EXPERIMENTAL_LSP_TY = truthy("AICTRL_EXPERIMENTAL_LSP_TY") - export const AICTRL_EXPERIMENTAL_LSP_TOOL = AICTRL_EXPERIMENTAL || truthy("AICTRL_EXPERIMENTAL_LSP_TOOL") export const AICTRL_DISABLE_FILETIME_CHECK = truthy("AICTRL_DISABLE_FILETIME_CHECK") export const AICTRL_EXPERIMENTAL_PLAN_MODE = AICTRL_EXPERIMENTAL || truthy("AICTRL_EXPERIMENTAL_PLAN_MODE") export const AICTRL_MODELS_URL = process.env["AICTRL_MODELS_URL"] diff --git a/packages/cli/src/lsp/client.ts b/packages/cli/src/lsp/client.ts deleted file mode 100644 index 495176f0..00000000 --- a/packages/cli/src/lsp/client.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" -import path from "path" -import { pathToFileURL, fileURLToPath } from "url" -import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node" -import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types" -import { Log } from "../util/log" -import { LANGUAGE_EXTENSIONS } from "./language" -import z from "zod" -import type { LSPServer } from "./server" -import { NamedError } from "@aictrl/util/error" -import { withTimeout } from "../util/timeout" -import { Instance } from "../project/instance" -import { Filesystem } from "../util/filesystem" - -const DIAGNOSTICS_DEBOUNCE_MS = 150 - -export namespace LSPClient { - const log = Log.create({ service: "lsp.client" }) - - export type Info = NonNullable>> - - export type Diagnostic = VSCodeDiagnostic - - export const InitializeError = NamedError.create( - "LSPInitializeError", - z.object({ - serverID: z.string(), - }), - ) - - export const Event = { - Diagnostics: BusEvent.define( - "lsp.client.diagnostics", - z.object({ - serverID: z.string(), - path: z.string(), - }), - ), - } - - export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) { - const l = log.clone().tag("serverID", input.serverID) - l.info("starting client") - - const connection = createMessageConnection( - new StreamMessageReader(input.server.process.stdout as any), - new StreamMessageWriter(input.server.process.stdin as any), - ) - - const diagnostics = new Map() - connection.onNotification("textDocument/publishDiagnostics", (params) => { - const filePath = Filesystem.normalizePath(fileURLToPath(params.uri)) - l.info("textDocument/publishDiagnostics", { - path: filePath, - count: params.diagnostics.length, - }) - const exists = diagnostics.has(filePath) - diagnostics.set(filePath, params.diagnostics) - if (!exists && input.serverID === "typescript") return - Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID }) - }) - connection.onRequest("window/workDoneProgress/create", (params) => { - l.info("window/workDoneProgress/create", params) - return null - }) - connection.onRequest("workspace/configuration", async () => { - // Return server initialization options - return [input.server.initialization ?? {}] - }) - connection.onRequest("client/registerCapability", async () => {}) - connection.onRequest("client/unregisterCapability", async () => {}) - connection.onRequest("workspace/workspaceFolders", async () => [ - { - name: "workspace", - uri: pathToFileURL(input.root).href, - }, - ]) - connection.listen() - - l.info("sending initialize") - await withTimeout( - connection.sendRequest("initialize", { - rootUri: pathToFileURL(input.root).href, - processId: input.server.process.pid, - workspaceFolders: [ - { - name: "workspace", - uri: pathToFileURL(input.root).href, - }, - ], - initializationOptions: { - ...input.server.initialization, - }, - capabilities: { - window: { - workDoneProgress: true, - }, - workspace: { - configuration: true, - didChangeWatchedFiles: { - dynamicRegistration: true, - }, - }, - textDocument: { - synchronization: { - didOpen: true, - didChange: true, - }, - publishDiagnostics: { - versionSupport: true, - }, - }, - }, - }), - 45_000, - ).catch((err) => { - l.error("initialize error", { error: err }) - throw new InitializeError( - { serverID: input.serverID }, - { - cause: err, - }, - ) - }) - - await connection.sendNotification("initialized", {}) - - if (input.server.initialization) { - await connection.sendNotification("workspace/didChangeConfiguration", { - settings: input.server.initialization, - }) - } - - const files: { - [path: string]: number - } = {} - - const result = { - root: input.root, - get serverID() { - return input.serverID - }, - get connection() { - return connection - }, - notify: { - async open(input: { path: string }) { - input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path) - const text = await Filesystem.readText(input.path) - const extension = path.extname(input.path) - const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext" - - const version = files[input.path] - if (version !== undefined) { - log.info("workspace/didChangeWatchedFiles", input) - await connection.sendNotification("workspace/didChangeWatchedFiles", { - changes: [ - { - uri: pathToFileURL(input.path).href, - type: 2, // Changed - }, - ], - }) - - const next = version + 1 - files[input.path] = next - log.info("textDocument/didChange", { - path: input.path, - version: next, - }) - await connection.sendNotification("textDocument/didChange", { - textDocument: { - uri: pathToFileURL(input.path).href, - version: next, - }, - contentChanges: [{ text }], - }) - return - } - - log.info("workspace/didChangeWatchedFiles", input) - await connection.sendNotification("workspace/didChangeWatchedFiles", { - changes: [ - { - uri: pathToFileURL(input.path).href, - type: 1, // Created - }, - ], - }) - - log.info("textDocument/didOpen", input) - diagnostics.delete(input.path) - await connection.sendNotification("textDocument/didOpen", { - textDocument: { - uri: pathToFileURL(input.path).href, - languageId, - version: 0, - text, - }, - }) - files[input.path] = 0 - return - }, - }, - get diagnostics() { - return diagnostics - }, - async waitForDiagnostics(input: { path: string }) { - const normalizedPath = Filesystem.normalizePath( - path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path), - ) - log.info("waiting for diagnostics", { path: normalizedPath }) - let unsub: () => void - let debounceTimer: ReturnType | undefined - return await withTimeout( - new Promise((resolve) => { - unsub = Bus.subscribe(Event.Diagnostics, (event) => { - if (event.properties.path === normalizedPath && event.properties.serverID === result.serverID) { - // Debounce to allow LSP to send follow-up diagnostics (e.g., semantic after syntax) - if (debounceTimer) clearTimeout(debounceTimer) - debounceTimer = setTimeout(() => { - log.info("got diagnostics", { path: normalizedPath }) - unsub?.() - resolve() - }, DIAGNOSTICS_DEBOUNCE_MS) - } - }) - }), - 3000, - ) - .catch(() => {}) - .finally(() => { - if (debounceTimer) clearTimeout(debounceTimer) - unsub?.() - }) - }, - async shutdown() { - l.info("shutting down") - connection.end() - connection.dispose() - input.server.process.kill() - l.info("shutdown") - }, - } - - l.info("initialized") - - return result - } -} diff --git a/packages/cli/src/lsp/index.ts b/packages/cli/src/lsp/index.ts deleted file mode 100644 index 50a4fe39..00000000 --- a/packages/cli/src/lsp/index.ts +++ /dev/null @@ -1,485 +0,0 @@ -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" -import { Log } from "../util/log" -import { LSPClient } from "./client" -import path from "path" -import { pathToFileURL, fileURLToPath } from "url" -import { LSPServer } from "./server" -import z from "zod" -import { Config } from "../config/config" -import { spawn } from "child_process" -import { Instance } from "../project/instance" -import { Flag } from "@/flag/flag" - -export namespace LSP { - const log = Log.create({ service: "lsp" }) - - export const Event = { - Updated: BusEvent.define("lsp.updated", z.object({})), - } - - export const Range = z - .object({ - start: z.object({ - line: z.number(), - character: z.number(), - }), - end: z.object({ - line: z.number(), - character: z.number(), - }), - }) - .meta({ - ref: "Range", - }) - export type Range = z.infer - - export const Symbol = z - .object({ - name: z.string(), - kind: z.number(), - location: z.object({ - uri: z.string(), - range: Range, - }), - }) - .meta({ - ref: "Symbol", - }) - export type Symbol = z.infer - - export const DocumentSymbol = z - .object({ - name: z.string(), - detail: z.string().optional(), - kind: z.number(), - range: Range, - selectionRange: Range, - }) - .meta({ - ref: "DocumentSymbol", - }) - export type DocumentSymbol = z.infer - - const filterExperimentalServers = (servers: Record) => { - if (Flag.AICTRL_EXPERIMENTAL_LSP_TY) { - // If experimental flag is enabled, disable pyright - if (servers["pyright"]) { - log.info("LSP server pyright is disabled because AICTRL_EXPERIMENTAL_LSP_TY is enabled") - delete servers["pyright"] - } - } else { - // If experimental flag is disabled, disable ty - if (servers["ty"]) { - delete servers["ty"] - } - } - } - - const state = Instance.state( - async () => { - const clients: LSPClient.Info[] = [] - const servers: Record = {} - const cfg = await Config.get() - - if (cfg.lsp === false) { - log.info("all LSPs are disabled") - return { - broken: new Set(), - servers, - clients, - spawning: new Map>(), - } - } - - for (const server of Object.values(LSPServer)) { - servers[server.id] = server - } - - filterExperimentalServers(servers) - - for (const [name, item] of Object.entries(cfg.lsp ?? {})) { - const existing = servers[name] - if (item.disabled) { - log.info(`LSP server ${name} is disabled`) - delete servers[name] - continue - } - servers[name] = { - ...existing, - id: name, - root: existing?.root ?? (async () => Instance.directory), - extensions: item.extensions ?? existing?.extensions ?? [], - spawn: async (root) => { - return { - process: spawn(item.command[0], item.command.slice(1), { - cwd: root, - env: { - ...process.env, - ...item.env, - }, - }), - initialization: item.initialization, - } - }, - } - } - - log.info("enabled LSP servers", { - serverIds: Object.values(servers) - .map((server) => server.id) - .join(", "), - }) - - return { - broken: new Set(), - servers, - clients, - spawning: new Map>(), - } - }, - async (state) => { - await Promise.all(state.clients.map((client) => client.shutdown())) - }, - ) - - export async function init() { - return state() - } - - export const Status = z - .object({ - id: z.string(), - name: z.string(), - root: z.string(), - status: z.union([z.literal("connected"), z.literal("error")]), - }) - .meta({ - ref: "LSPStatus", - }) - export type Status = z.infer - - export async function status() { - return state().then((x) => { - const result: Status[] = [] - for (const client of x.clients) { - result.push({ - id: client.serverID, - name: x.servers[client.serverID].id, - root: path.relative(Instance.directory, client.root), - status: "connected", - }) - } - return result - }) - } - - async function getClients(file: string) { - const s = await state() - const extension = path.parse(file).ext || file - const result: LSPClient.Info[] = [] - - async function schedule(server: LSPServer.Info, root: string, key: string) { - const handle = await server - .spawn(root) - .then((value) => { - if (!value) s.broken.add(key) - return value - }) - .catch((err) => { - s.broken.add(key) - log.error(`Failed to spawn LSP server ${server.id}`, { error: err }) - return undefined - }) - - if (!handle) return undefined - log.info("spawned lsp server", { serverID: server.id }) - - const client = await LSPClient.create({ - serverID: server.id, - server: handle, - root, - }).catch((err) => { - s.broken.add(key) - handle.process.kill() - log.error(`Failed to initialize LSP client ${server.id}`, { error: err }) - return undefined - }) - - if (!client) { - handle.process.kill() - return undefined - } - - const existing = s.clients.find((x) => x.root === root && x.serverID === server.id) - if (existing) { - handle.process.kill() - return existing - } - - s.clients.push(client) - return client - } - - for (const server of Object.values(s.servers)) { - if (server.extensions.length && !server.extensions.includes(extension)) continue - - const root = await server.root(file) - if (!root) continue - if (s.broken.has(root + server.id)) continue - - const match = s.clients.find((x) => x.root === root && x.serverID === server.id) - if (match) { - result.push(match) - continue - } - - const inflight = s.spawning.get(root + server.id) - if (inflight) { - const client = await inflight - if (!client) continue - result.push(client) - continue - } - - const task = schedule(server, root, root + server.id) - s.spawning.set(root + server.id, task) - - task.finally(() => { - if (s.spawning.get(root + server.id) === task) { - s.spawning.delete(root + server.id) - } - }) - - const client = await task - if (!client) continue - - result.push(client) - Bus.publish(Event.Updated, {}) - } - - return result - } - - export async function hasClients(file: string) { - const s = await state() - const extension = path.parse(file).ext || file - for (const server of Object.values(s.servers)) { - if (server.extensions.length && !server.extensions.includes(extension)) continue - const root = await server.root(file) - if (!root) continue - if (s.broken.has(root + server.id)) continue - return true - } - return false - } - - export async function touchFile(input: string, waitForDiagnostics?: boolean) { - log.info("touching file", { file: input }) - const clients = await getClients(input) - await Promise.all( - clients.map(async (client) => { - const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve() - await client.notify.open({ path: input }) - return wait - }), - ).catch((err) => { - log.error("failed to touch file", { err, file: input }) - }) - } - - export async function diagnostics() { - const results: Record = {} - for (const result of await runAll(async (client) => client.diagnostics)) { - for (const [path, diagnostics] of result.entries()) { - const arr = results[path] || [] - arr.push(...diagnostics) - results[path] = arr - } - } - return results - } - - export async function hover(input: { file: string; line: number; character: number }) { - return run(input.file, (client) => { - return client.connection - .sendRequest("textDocument/hover", { - textDocument: { - uri: pathToFileURL(input.file).href, - }, - position: { - line: input.line, - character: input.character, - }, - }) - .catch(() => null) - }) - } - - enum SymbolKind { - File = 1, - Module = 2, - Namespace = 3, - Package = 4, - Class = 5, - Method = 6, - Property = 7, - Field = 8, - Constructor = 9, - Enum = 10, - Interface = 11, - Function = 12, - Variable = 13, - Constant = 14, - String = 15, - Number = 16, - Boolean = 17, - Array = 18, - Object = 19, - Key = 20, - Null = 21, - EnumMember = 22, - Struct = 23, - Event = 24, - Operator = 25, - TypeParameter = 26, - } - - const kinds = [ - SymbolKind.Class, - SymbolKind.Function, - SymbolKind.Method, - SymbolKind.Interface, - SymbolKind.Variable, - SymbolKind.Constant, - SymbolKind.Struct, - SymbolKind.Enum, - ] - - export async function workspaceSymbol(query: string) { - return runAll((client) => - client.connection - .sendRequest("workspace/symbol", { - query, - }) - .then((result: any) => result.filter((x: LSP.Symbol) => kinds.includes(x.kind))) - .then((result: any) => result.slice(0, 10)) - .catch(() => []), - ).then((result) => result.flat() as LSP.Symbol[]) - } - - export async function documentSymbol(uri: string) { - const file = fileURLToPath(uri) - return run(file, (client) => - client.connection - .sendRequest("textDocument/documentSymbol", { - textDocument: { - uri, - }, - }) - .catch(() => []), - ) - .then((result) => result.flat() as (LSP.DocumentSymbol | LSP.Symbol)[]) - .then((result) => result.filter(Boolean)) - } - - export async function definition(input: { file: string; line: number; character: number }) { - return run(input.file, (client) => - client.connection - .sendRequest("textDocument/definition", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - }) - .catch(() => null), - ).then((result) => result.flat().filter(Boolean)) - } - - export async function references(input: { file: string; line: number; character: number }) { - return run(input.file, (client) => - client.connection - .sendRequest("textDocument/references", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - context: { includeDeclaration: true }, - }) - .catch(() => []), - ).then((result) => result.flat().filter(Boolean)) - } - - export async function implementation(input: { file: string; line: number; character: number }) { - return run(input.file, (client) => - client.connection - .sendRequest("textDocument/implementation", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - }) - .catch(() => null), - ).then((result) => result.flat().filter(Boolean)) - } - - export async function prepareCallHierarchy(input: { file: string; line: number; character: number }) { - return run(input.file, (client) => - client.connection - .sendRequest("textDocument/prepareCallHierarchy", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - }) - .catch(() => []), - ).then((result) => result.flat().filter(Boolean)) - } - - export async function incomingCalls(input: { file: string; line: number; character: number }) { - return run(input.file, async (client) => { - const items = (await client.connection - .sendRequest("textDocument/prepareCallHierarchy", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - }) - .catch(() => [])) as any[] - if (!items?.length) return [] - return client.connection.sendRequest("callHierarchy/incomingCalls", { item: items[0] }).catch(() => []) - }).then((result) => result.flat().filter(Boolean)) - } - - export async function outgoingCalls(input: { file: string; line: number; character: number }) { - return run(input.file, async (client) => { - const items = (await client.connection - .sendRequest("textDocument/prepareCallHierarchy", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - }) - .catch(() => [])) as any[] - if (!items?.length) return [] - return client.connection.sendRequest("callHierarchy/outgoingCalls", { item: items[0] }).catch(() => []) - }).then((result) => result.flat().filter(Boolean)) - } - - async function runAll(input: (client: LSPClient.Info) => Promise): Promise { - const clients = await state().then((x) => x.clients) - const tasks = clients.map((x) => input(x)) - return Promise.all(tasks) - } - - async function run(file: string, input: (client: LSPClient.Info) => Promise): Promise { - const clients = await getClients(file) - const tasks = clients.map((x) => input(x)) - return Promise.all(tasks) - } - - export namespace Diagnostic { - export function pretty(diagnostic: LSPClient.Diagnostic) { - const severityMap = { - 1: "ERROR", - 2: "WARN", - 3: "INFO", - 4: "HINT", - } - - const severity = severityMap[diagnostic.severity || 1] - const line = diagnostic.range.start.line + 1 - const col = diagnostic.range.start.character + 1 - - return `${severity} [${line}:${col}] ${diagnostic.message}` - } - } -} diff --git a/packages/cli/src/lsp/language.ts b/packages/cli/src/lsp/language.ts deleted file mode 100644 index 58f4c848..00000000 --- a/packages/cli/src/lsp/language.ts +++ /dev/null @@ -1,120 +0,0 @@ -export const LANGUAGE_EXTENSIONS: Record = { - ".abap": "abap", - ".bat": "bat", - ".bib": "bibtex", - ".bibtex": "bibtex", - ".clj": "clojure", - ".cljs": "clojure", - ".cljc": "clojure", - ".edn": "clojure", - ".coffee": "coffeescript", - ".c": "c", - ".cpp": "cpp", - ".cxx": "cpp", - ".cc": "cpp", - ".c++": "cpp", - ".cs": "csharp", - ".css": "css", - ".d": "d", - ".pas": "pascal", - ".pascal": "pascal", - ".diff": "diff", - ".patch": "diff", - ".dart": "dart", - ".dockerfile": "dockerfile", - ".ex": "elixir", - ".exs": "elixir", - ".erl": "erlang", - ".ets": "typescript", - ".hrl": "erlang", - ".fs": "fsharp", - ".fsi": "fsharp", - ".fsx": "fsharp", - ".fsscript": "fsharp", - ".gitcommit": "git-commit", - ".gitrebase": "git-rebase", - ".go": "go", - ".groovy": "groovy", - ".gleam": "gleam", - ".hbs": "handlebars", - ".handlebars": "handlebars", - ".hs": "haskell", - ".lhs": "haskell", - ".html": "html", - ".htm": "html", - ".ini": "ini", - ".java": "java", - ".jl": "julia", - ".js": "javascript", - ".kt": "kotlin", - ".kts": "kotlin", - ".jsx": "javascriptreact", - ".json": "json", - ".tex": "latex", - ".latex": "latex", - ".less": "less", - ".lua": "lua", - ".makefile": "makefile", - makefile: "makefile", - ".md": "markdown", - ".markdown": "markdown", - ".m": "objective-c", - ".mm": "objective-cpp", - ".pl": "perl", - ".pm": "perl", - ".pm6": "perl6", - ".php": "php", - ".ps1": "powershell", - ".psm1": "powershell", - ".pug": "jade", - ".jade": "jade", - ".py": "python", - ".r": "r", - ".cshtml": "razor", - ".razor": "razor", - ".rb": "ruby", - ".rake": "ruby", - ".gemspec": "ruby", - ".ru": "ruby", - ".erb": "erb", - ".html.erb": "erb", - ".js.erb": "erb", - ".css.erb": "erb", - ".json.erb": "erb", - ".rs": "rust", - ".scss": "scss", - ".sass": "sass", - ".scala": "scala", - ".shader": "shaderlab", - ".sh": "shellscript", - ".bash": "shellscript", - ".zsh": "shellscript", - ".ksh": "shellscript", - ".sql": "sql", - ".svelte": "svelte", - ".swift": "swift", - ".ts": "typescript", - ".tsx": "typescriptreact", - ".mts": "typescript", - ".cts": "typescript", - ".mtsx": "typescriptreact", - ".ctsx": "typescriptreact", - ".xml": "xml", - ".xsl": "xsl", - ".yaml": "yaml", - ".yml": "yaml", - ".mjs": "javascript", - ".cjs": "javascript", - ".vue": "vue", - ".zig": "zig", - ".zon": "zig", - ".astro": "astro", - ".ml": "ocaml", - ".mli": "ocaml", - ".tf": "terraform", - ".tfvars": "terraform-vars", - ".hcl": "hcl", - ".nix": "nix", - ".typ": "typst", - ".typc": "typst", -} as const diff --git a/packages/cli/src/lsp/server.ts b/packages/cli/src/lsp/server.ts deleted file mode 100644 index 06e4899a..00000000 --- a/packages/cli/src/lsp/server.ts +++ /dev/null @@ -1,2057 +0,0 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "child_process" -import path from "path" -import os from "os" -import { Global } from "../global" -import { Log } from "../util/log" -import { BunProc } from "../bun" -import { $ } from "bun" -import { text } from "node:stream/consumers" -import fs from "fs/promises" -import { Filesystem } from "../util/filesystem" -import { Instance } from "../project/instance" -import { Flag } from "../flag/flag" -import { Archive } from "../util/archive" -import { Process } from "../util/process" - -export namespace LSPServer { - const log = Log.create({ service: "lsp.server" }) - const pathExists = async (p: string) => - fs - .stat(p) - .then(() => true) - .catch(() => false) - - export interface Handle { - process: ChildProcessWithoutNullStreams - initialization?: Record - } - - type RootFunction = (file: string) => Promise - - const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): RootFunction => { - return async (file) => { - if (excludePatterns) { - const excludedFiles = Filesystem.up({ - targets: excludePatterns, - start: path.dirname(file), - stop: Instance.directory, - }) - const excluded = await excludedFiles.next() - await excludedFiles.return() - if (excluded.value) return undefined - } - const files = Filesystem.up({ - targets: includePatterns, - start: path.dirname(file), - stop: Instance.directory, - }) - const first = await files.next() - await files.return() - if (!first.value) return Instance.directory - return path.dirname(first.value) - } - } - - export interface Info { - id: string - extensions: string[] - global?: boolean - root: RootFunction - spawn(root: string): Promise - } - - export const Deno: Info = { - id: "deno", - root: async (file) => { - const files = Filesystem.up({ - targets: ["deno.json", "deno.jsonc"], - start: path.dirname(file), - stop: Instance.directory, - }) - const first = await files.next() - await files.return() - if (!first.value) return undefined - return path.dirname(first.value) - }, - extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"], - async spawn(root) { - const deno = Bun.which("deno") - if (!deno) { - log.info("deno not found, please install deno first") - return - } - return { - process: spawn(deno, ["lsp"], { - cwd: root, - }), - } - }, - } - - export const Typescript: Info = { - id: "typescript", - root: NearestRoot( - ["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"], - ["deno.json", "deno.jsonc"], - ), - extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"], - async spawn(root) { - const tsserver = await Bun.resolve("typescript/lib/tsserver.js", Instance.directory).catch(() => {}) - log.info("typescript server", { tsserver }) - if (!tsserver) return - const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], { - cwd: root, - env: { - ...process.env, - BUN_BE_BUN: "1", - }, - }) - return { - process: proc, - initialization: { - tsserver: { - path: tsserver, - }, - }, - } - }, - } - - export const Vue: Info = { - id: "vue", - extensions: [".vue"], - root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), - async spawn(root) { - let binary = Bun.which("vue-language-server") - const args: string[] = [] - if (!binary) { - const js = path.join( - Global.Path.bin, - "node_modules", - "@vue", - "language-server", - "bin", - "vue-language-server.js", - ) - if (!(await Filesystem.exists(js))) { - if (Flag.AICTRL_DISABLE_LSP_DOWNLOAD) return - await Process.spawn([BunProc.which(), "install", "@vue/language-server"], { - cwd: Global.Path.bin, - env: { - ...process.env, - BUN_BE_BUN: "1", - }, - stdout: "pipe", - stderr: "pipe", - stdin: "pipe", - }).exited - } - binary = BunProc.which() - args.push("run", js) - } - args.push("--stdio") - const proc = spawn(binary, args, { - cwd: root, - env: { - ...process.env, - BUN_BE_BUN: "1", - }, - }) - return { - process: proc, - initialization: { - // Leave empty; the server will auto-detect workspace TypeScript. - }, - } - }, - } - - export const ESLint: Info = { - id: "eslint", - root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), - extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"], - async spawn(root) { - const eslint = await Bun.resolve("eslint", Instance.directory).catch(() => {}) - if (!eslint) return - log.info("spawning eslint server") - const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js") - if (!(await Filesystem.exists(serverPath))) { - if (Flag.AICTRL_DISABLE_LSP_DOWNLOAD) return - log.info("downloading and building VS Code ESLint server") - const response = await fetch("https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip") - if (!response.ok) return - - const zipPath = path.join(Global.Path.bin, "vscode-eslint.zip") - if (response.body) await Filesystem.writeStream(zipPath, response.body) - - const ok = await Archive.extractZip(zipPath, Global.Path.bin) - .then(() => true) - .catch((error) => { - log.error("Failed to extract vscode-eslint archive", { error }) - return false - }) - if (!ok) return - await fs.rm(zipPath, { force: true }) - - const extractedPath = path.join(Global.Path.bin, "vscode-eslint-main") - const finalPath = path.join(Global.Path.bin, "vscode-eslint") - - const stats = await fs.stat(finalPath).catch(() => undefined) - if (stats) { - log.info("removing old eslint installation", { path: finalPath }) - await fs.rm(finalPath, { force: true, recursive: true }) - } - await fs.rename(extractedPath, finalPath) - - const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm" - await $`${npmCmd} install`.cwd(finalPath).quiet() - await $`${npmCmd} run compile`.cwd(finalPath).quiet() - - log.info("installed VS Code ESLint server", { serverPath }) - } - - const proc = spawn(BunProc.which(), [serverPath, "--stdio"], { - cwd: root, - env: { - ...process.env, - BUN_BE_BUN: "1", - }, - }) - - return { - process: proc, - } - }, - } - - export const Oxlint: Info = { - id: "oxlint", - root: NearestRoot([ - ".oxlintrc.json", - "package-lock.json", - "bun.lockb", - "bun.lock", - "pnpm-lock.yaml", - "yarn.lock", - "package.json", - ]), - extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".astro", ".svelte"], - async spawn(root) { - const ext = process.platform === "win32" ? ".cmd" : "" - - const serverTarget = path.join("node_modules", ".bin", "oxc_language_server" + ext) - const lintTarget = path.join("node_modules", ".bin", "oxlint" + ext) - - const resolveBin = async (target: string) => { - const localBin = path.join(root, target) - if (await Filesystem.exists(localBin)) return localBin - - const candidates = Filesystem.up({ - targets: [target], - start: root, - stop: Instance.worktree, - }) - const first = await candidates.next() - await candidates.return() - if (first.value) return first.value - - return undefined - } - - let lintBin = await resolveBin(lintTarget) - if (!lintBin) { - const found = Bun.which("oxlint") - if (found) lintBin = found - } - - if (lintBin) { - const proc = Process.spawn([lintBin, "--help"], { stdout: "pipe" }) - await proc.exited - if (proc.stdout) { - const help = await text(proc.stdout) - if (help.includes("--lsp")) { - return { - process: spawn(lintBin, ["--lsp"], { - cwd: root, - }), - } - } - } - } - - let serverBin = await resolveBin(serverTarget) - if (!serverBin) { - const found = Bun.which("oxc_language_server") - if (found) serverBin = found - } - if (serverBin) { - return { - process: spawn(serverBin, [], { - cwd: root, - }), - } - } - - log.info("oxlint not found, please install oxlint") - return - }, - } - - export const Biome: Info = { - id: "biome", - root: NearestRoot([ - "biome.json", - "biome.jsonc", - "package-lock.json", - "bun.lockb", - "bun.lock", - "pnpm-lock.yaml", - "yarn.lock", - ]), - extensions: [ - ".ts", - ".tsx", - ".js", - ".jsx", - ".mjs", - ".cjs", - ".mts", - ".cts", - ".json", - ".jsonc", - ".vue", - ".astro", - ".svelte", - ".css", - ".graphql", - ".gql", - ".html", - ], - async spawn(root) { - const localBin = path.join(root, "node_modules", ".bin", "biome") - let bin: string | undefined - if (await Filesystem.exists(localBin)) bin = localBin - if (!bin) { - const found = Bun.which("biome") - if (found) bin = found - } - - let args = ["lsp-proxy", "--stdio"] - - if (!bin) { - const resolved = await Bun.resolve("biome", root).catch(() => undefined) - if (!resolved) return - bin = BunProc.which() - args = ["x", "biome", "lsp-proxy", "--stdio"] - } - - const proc = spawn(bin, args, { - cwd: root, - env: { - ...process.env, - BUN_BE_BUN: "1", - }, - }) - - return { - process: proc, - } - }, - } - - export const Gopls: Info = { - id: "gopls", - root: async (file) => { - const work = await NearestRoot(["go.work"])(file) - if (work) return work - return NearestRoot(["go.mod", "go.sum"])(file) - }, - extensions: [".go"], - async spawn(root) { - let bin = Bun.which("gopls", { - PATH: process.env["PATH"] + path.delimiter + Global.Path.bin, - }) - if (!bin) { - if (!Bun.which("go")) return - if (Flag.AICTRL_DISABLE_LSP_DOWNLOAD) return - - log.info("installing gopls") - const proc = Process.spawn(["go", "install", "golang.org/x/tools/gopls@latest"], { - env: { ...process.env, GOBIN: Global.Path.bin }, - stdout: "pipe", - stderr: "pipe", - stdin: "pipe", - }) - const exit = await proc.exited - if (exit !== 0) { - log.error("Failed to install gopls") - return - } - bin = path.join(Global.Path.bin, "gopls" + (process.platform === "win32" ? ".exe" : "")) - log.info(`installed gopls`, { - bin, - }) - } - return { - process: spawn(bin!, { - cwd: root, - }), - } - }, - } - - export const Rubocop: Info = { - id: "ruby-lsp", - root: NearestRoot(["Gemfile"]), - extensions: [".rb", ".rake", ".gemspec", ".ru"], - async spawn(root) { - let bin = Bun.which("rubocop", { - PATH: process.env["PATH"] + path.delimiter + Global.Path.bin, - }) - if (!bin) { - const ruby = Bun.which("ruby") - const gem = Bun.which("gem") - if (!ruby || !gem) { - log.info("Ruby not found, please install Ruby first") - return - } - if (Flag.AICTRL_DISABLE_LSP_DOWNLOAD) return - log.info("installing rubocop") - const proc = Process.spawn(["gem", "install", "rubocop", "--bindir", Global.Path.bin], { - stdout: "pipe", - stderr: "pipe", - stdin: "pipe", - }) - const exit = await proc.exited - if (exit !== 0) { - log.error("Failed to install rubocop") - return - } - bin = path.join(Global.Path.bin, "rubocop" + (process.platform === "win32" ? ".exe" : "")) - log.info(`installed rubocop`, { - bin, - }) - } - return { - process: spawn(bin!, ["--lsp"], { - cwd: root, - }), - } - }, - } - - export const Ty: Info = { - id: "ty", - extensions: [".py", ".pyi"], - root: NearestRoot([ - "pyproject.toml", - "ty.toml", - "setup.py", - "setup.cfg", - "requirements.txt", - "Pipfile", - "pyrightconfig.json", - ]), - async spawn(root) { - if (!Flag.AICTRL_EXPERIMENTAL_LSP_TY) { - return undefined - } - - let binary = Bun.which("ty") - - const initialization: Record = {} - - const potentialVenvPaths = [process.env["VIRTUAL_ENV"], path.join(root, ".venv"), path.join(root, "venv")].filter( - (p): p is string => p !== undefined, - ) - for (const venvPath of potentialVenvPaths) { - const isWindows = process.platform === "win32" - const potentialPythonPath = isWindows - ? path.join(venvPath, "Scripts", "python.exe") - : path.join(venvPath, "bin", "python") - if (await Filesystem.exists(potentialPythonPath)) { - initialization["pythonPath"] = potentialPythonPath - break - } - } - - if (!binary) { - for (const venvPath of potentialVenvPaths) { - const isWindows = process.platform === "win32" - const potentialTyPath = isWindows - ? path.join(venvPath, "Scripts", "ty.exe") - : path.join(venvPath, "bin", "ty") - if (await Filesystem.exists(potentialTyPath)) { - binary = potentialTyPath - break - } - } - } - - if (!binary) { - log.error("ty not found, please install ty first") - return - } - - const proc = spawn(binary, ["server"], { - cwd: root, - }) - - return { - process: proc, - initialization, - } - }, - } - - export const Pyright: Info = { - id: "pyright", - extensions: [".py", ".pyi"], - root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]), - async spawn(root) { - let binary = Bun.which("pyright-langserver") - const args = [] - if (!binary) { - const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js") - if (!(await Filesystem.exists(js))) { - if (Flag.AICTRL_DISABLE_LSP_DOWNLOAD) return - await Process.spawn([BunProc.which(), "install", "pyright"], { - cwd: Global.Path.bin, - env: { - ...process.env, - BUN_BE_BUN: "1", - }, - }).exited - } - binary = BunProc.which() - args.push(...["run", js]) - } - args.push("--stdio") - - const initialization: Record = {} - - const potentialVenvPaths = [process.env["VIRTUAL_ENV"], path.join(root, ".venv"), path.join(root, "venv")].filter( - (p): p is string => p !== undefined, - ) - for (const venvPath of potentialVenvPaths) { - const isWindows = process.platform === "win32" - const potentialPythonPath = isWindows - ? path.join(venvPath, "Scripts", "python.exe") - : path.join(venvPath, "bin", "python") - if (await Filesystem.exists(potentialPythonPath)) { - initialization["pythonPath"] = potentialPythonPath - break - } - } - - const proc = spawn(binary, args, { - cwd: root, - env: { - ...process.env, - BUN_BE_BUN: "1", - }, - }) - return { - process: proc, - initialization, - } - }, - } - - export const ElixirLS: Info = { - id: "elixir-ls", - extensions: [".ex", ".exs"], - root: NearestRoot(["mix.exs", "mix.lock"]), - async spawn(root) { - let binary = Bun.which("elixir-ls") - if (!binary) { - const elixirLsPath = path.join(Global.Path.bin, "elixir-ls") - binary = path.join( - Global.Path.bin, - "elixir-ls-master", - "release", - process.platform === "win32" ? "language_server.bat" : "language_server.sh", - ) - - if (!(await Filesystem.exists(binary))) { - const elixir = Bun.which("elixir") - if (!elixir) { - log.error("elixir is required to run elixir-ls") - return - } - - if (Flag.AICTRL_DISABLE_LSP_DOWNLOAD) return - log.info("downloading elixir-ls from GitHub releases") - - const response = await fetch("https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip") - if (!response.ok) return - const zipPath = path.join(Global.Path.bin, "elixir-ls.zip") - if (response.body) await Filesystem.writeStream(zipPath, response.body) - - const ok = await Archive.extractZip(zipPath, Global.Path.bin) - .then(() => true) - .catch((error) => { - log.error("Failed to extract elixir-ls archive", { error }) - return false - }) - if (!ok) return - - await fs.rm(zipPath, { - force: true, - recursive: true, - }) - - await $`mix deps.get && mix compile && mix elixir_ls.release2 -o release` - .quiet() - .cwd(path.join(Global.Path.bin, "elixir-ls-master")) - .env({ MIX_ENV: "prod", ...process.env }) - - log.info(`installed elixir-ls`, { - path: elixirLsPath, - }) - } - } - - return { - process: spawn(binary, { - cwd: root, - }), - } - }, - } - - export const Zls: Info = { - id: "zls", - extensions: [".zig", ".zon"], - root: NearestRoot(["build.zig"]), - async spawn(root) { - let bin = Bun.which("zls", { - PATH: process.env["PATH"] + path.delimiter + Global.Path.bin, - }) - - if (!bin) { - const zig = Bun.which("zig") - if (!zig) { - log.error("Zig is required to use zls. Please install Zig first.") - return - } - - if (Flag.AICTRL_DISABLE_LSP_DOWNLOAD) return - log.info("downloading zls from GitHub releases") - - const releaseResponse = await fetch("https://api.github.com/repos/zigtools/zls/releases/latest") - if (!releaseResponse.ok) { - log.error("Failed to fetch zls release info") - return - } - - const release = (await releaseResponse.json()) as any - - const platform = process.platform - const arch = process.arch - let assetName = "" - - let zlsArch: string = arch - if (arch === "arm64") zlsArch = "aarch64" - else if (arch === "x64") zlsArch = "x86_64" - else if (arch === "ia32") zlsArch = "x86" - - let zlsPlatform: string = platform - if (platform === "darwin") zlsPlatform = "macos" - else if (platform === "win32") zlsPlatform = "windows" - - const ext = platform === "win32" ? "zip" : "tar.xz" - - assetName = `zls-${zlsArch}-${zlsPlatform}.${ext}` - - const supportedCombos = [ - "zls-x86_64-linux.tar.xz", - "zls-x86_64-macos.tar.xz", - "zls-x86_64-windows.zip", - "zls-aarch64-linux.tar.xz", - "zls-aarch64-macos.tar.xz", - "zls-aarch64-windows.zip", - "zls-x86-linux.tar.xz", - "zls-x86-windows.zip", - ] - - if (!supportedCombos.includes(assetName)) { - log.error(`Platform ${platform} and architecture ${arch} is not supported by zls`) - return - } - - const asset = release.assets.find((a: any) => a.name === assetName) - if (!asset) { - log.error(`Could not find asset ${assetName} in latest zls release`) - return - } - - const downloadUrl = asset.browser_download_url - const downloadResponse = await fetch(downloadUrl) - if (!downloadResponse.ok) { - log.error("Failed to download zls") - return - } - - const tempPath = path.join(Global.Path.bin, assetName) - if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) - - if (ext === "zip") { - const ok = await Archive.extractZip(tempPath, Global.Path.bin) - .then(() => true) - .catch((error) => { - log.error("Failed to extract zls archive", { error }) - return false - }) - if (!ok) return - } else { - await $`tar -xf ${tempPath}`.cwd(Global.Path.bin).quiet().nothrow() - } - - await fs.rm(tempPath, { force: true }) - - bin = path.join(Global.Path.bin, "zls" + (platform === "win32" ? ".exe" : "")) - - if (!(await Filesystem.exists(bin))) { - log.error("Failed to extract zls binary") - return - } - - if (platform !== "win32") { - await $`chmod +x ${bin}`.quiet().nothrow() - } - - log.info(`installed zls`, { bin }) - } - - return { - process: spawn(bin, { - cwd: root, - }), - } - }, - } - - export const CSharp: Info = { - id: "csharp", - root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]), - extensions: [".cs"], - async spawn(root) { - let bin = Bun.which("csharp-ls", { - PATH: process.env["PATH"] + path.delimiter + Global.Path.bin, - }) - if (!bin) { - if (!Bun.which("dotnet")) { - log.error(".NET SDK is required to install csharp-ls") - return - } - - if (Flag.AICTRL_DISABLE_LSP_DOWNLOAD) return - log.info("installing csharp-ls via dotnet tool") - const proc = Process.spawn(["dotnet", "tool", "install", "csharp-ls", "--tool-path", Global.Path.bin], { - stdout: "pipe", - stderr: "pipe", - stdin: "pipe", - }) - const exit = await proc.exited - if (exit !== 0) { - log.error("Failed to install csharp-ls") - return - } - - bin = path.join(Global.Path.bin, "csharp-ls" + (process.platform === "win32" ? ".exe" : "")) - log.info(`installed csharp-ls`, { bin }) - } - - return { - process: spawn(bin, { - cwd: root, - }), - } - }, - } - - export const FSharp: Info = { - id: "fsharp", - root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]), - extensions: [".fs", ".fsi", ".fsx", ".fsscript"], - async spawn(root) { - let bin = Bun.which("fsautocomplete", { - PATH: process.env["PATH"] + path.delimiter + Global.Path.bin, - }) - if (!bin) { - if (!Bun.which("dotnet")) { - log.error(".NET SDK is required to install fsautocomplete") - return - } - - if (Flag.AICTRL_DISABLE_LSP_DOWNLOAD) return - log.info("installing fsautocomplete via dotnet tool") - const proc = Process.spawn(["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin], { - stdout: "pipe", - stderr: "pipe", - stdin: "pipe", - }) - const exit = await proc.exited - if (exit !== 0) { - log.error("Failed to install fsautocomplete") - return - } - - bin = path.join(Global.Path.bin, "fsautocomplete" + (process.platform === "win32" ? ".exe" : "")) - log.info(`installed fsautocomplete`, { bin }) - } - - return { - process: spawn(bin, { - cwd: root, - }), - } - }, - } - - export const SourceKit: Info = { - id: "sourcekit-lsp", - extensions: [".swift", ".objc", "objcpp"], - root: NearestRoot(["Package.swift", "*.xcodeproj", "*.xcworkspace"]), - async spawn(root) { - // Check if sourcekit-lsp is available in the PATH - // This is installed with the Swift toolchain - const sourcekit = Bun.which("sourcekit-lsp") - if (sourcekit) { - return { - process: spawn(sourcekit, { - cwd: root, - }), - } - } - - // If sourcekit-lsp not found, check if xcrun is available - // This is specific to macOS where sourcekit-lsp is typically installed with Xcode - if (!Bun.which("xcrun")) return - - const lspLoc = await $`xcrun --find sourcekit-lsp`.quiet().nothrow() - - if (lspLoc.exitCode !== 0) return - - const bin = lspLoc.text().trim() - - return { - process: spawn(bin, { - cwd: root, - }), - } - }, - } - - export const RustAnalyzer: Info = { - id: "rust", - root: async (root) => { - const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(root) - if (crateRoot === undefined) { - return undefined - } - let currentDir = crateRoot - - while (currentDir !== path.dirname(currentDir)) { - // Stop at filesystem root - const cargoTomlPath = path.join(currentDir, "Cargo.toml") - try { - const cargoTomlContent = await Filesystem.readText(cargoTomlPath) - if (cargoTomlContent.includes("[workspace]")) { - return currentDir - } - } catch (err) { - // File doesn't exist or can't be read, continue searching up - } - - const parentDir = path.dirname(currentDir) - if (parentDir === currentDir) break // Reached filesystem root - currentDir = parentDir - - // Stop if we've gone above the app root - if (!currentDir.startsWith(Instance.worktree)) break - } - - return crateRoot - }, - extensions: [".rs"], - async spawn(root) { - const bin = Bun.which("rust-analyzer") - if (!bin) { - log.info("rust-analyzer not found in path, please install it") - return - } - return { - process: spawn(bin, { - cwd: root, - }), - } - }, - } - - export const Clangd: Info = { - id: "clangd", - root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd", "CMakeLists.txt", "Makefile"]), - extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"], - async spawn(root) { - const args = ["--background-index", "--clang-tidy"] - const fromPath = Bun.which("clangd") - if (fromPath) { - return { - process: spawn(fromPath, args, { - cwd: root, - }), - } - } - - const ext = process.platform === "win32" ? ".exe" : "" - const direct = path.join(Global.Path.bin, "clangd" + ext) - if (await Filesystem.exists(direct)) { - return { - process: spawn(direct, args, { - cwd: root, - }), - } - } - - const entries = await fs.readdir(Global.Path.bin, { withFileTypes: true }).catch(() => []) - for (const entry of entries) { - if (!entry.isDirectory()) continue - if (!entry.name.startsWith("clangd_")) continue - const candidate = path.join(Global.Path.bin, entry.name, "bin", "clangd" + ext) - if (await Filesystem.exists(candidate)) { - return { - process: spawn(candidate, args, { - cwd: root, - }), - } - } - } - - if (Flag.AICTRL_DISABLE_LSP_DOWNLOAD) return - log.info("downloading clangd from GitHub releases") - - const releaseResponse = await fetch("https://api.github.com/repos/clangd/clangd/releases/latest") - if (!releaseResponse.ok) { - log.error("Failed to fetch clangd release info") - return - } - - const release: { - tag_name?: string - assets?: { name?: string; browser_download_url?: string }[] - } = await releaseResponse.json() - - const tag = release.tag_name - if (!tag) { - log.error("clangd release did not include a tag name") - return - } - const platform = process.platform - const tokens: Record = { - darwin: "mac", - linux: "linux", - win32: "windows", - } - const token = tokens[platform] - if (!token) { - log.error(`Platform ${platform} is not supported by clangd auto-download`) - return - } - - const assets = release.assets ?? [] - const valid = (item: { name?: string; browser_download_url?: string }) => { - if (!item.name) return false - if (!item.browser_download_url) return false - if (!item.name.includes(token)) return false - return item.name.includes(tag) - } - - const asset = - assets.find((item) => valid(item) && item.name?.endsWith(".zip")) ?? - assets.find((item) => valid(item) && item.name?.endsWith(".tar.xz")) ?? - assets.find((item) => valid(item)) - if (!asset?.name || !asset.browser_download_url) { - log.error("clangd could not match release asset", { tag, platform }) - return - } - - const name = asset.name - const downloadResponse = await fetch(asset.browser_download_url) - if (!downloadResponse.ok) { - log.error("Failed to download clangd") - return - } - - const archive = path.join(Global.Path.bin, name) - const buf = await downloadResponse.arrayBuffer() - if (buf.byteLength === 0) { - log.error("Failed to write clangd archive") - return - } - await Filesystem.write(archive, Buffer.from(buf)) - - const zip = name.endsWith(".zip") - const tar = name.endsWith(".tar.xz") - if (!zip && !tar) { - log.error("clangd encountered unsupported asset", { asset: name }) - return - } - - if (zip) { - const ok = await Archive.extractZip(archive, Global.Path.bin) - .then(() => true) - .catch((error) => { - log.error("Failed to extract clangd archive", { error }) - return false - }) - if (!ok) return - } - if (tar) { - await $`tar -xf ${archive}`.cwd(Global.Path.bin).quiet().nothrow() - } - await fs.rm(archive, { force: true }) - - const bin = path.join(Global.Path.bin, "clangd_" + tag, "bin", "clangd" + ext) - if (!(await Filesystem.exists(bin))) { - log.error("Failed to extract clangd binary") - return - } - - if (platform !== "win32") { - await $`chmod +x ${bin}`.quiet().nothrow() - } - - await fs.unlink(path.join(Global.Path.bin, "clangd")).catch(() => {}) - await fs.symlink(bin, path.join(Global.Path.bin, "clangd")).catch(() => {}) - - log.info(`installed clangd`, { bin }) - - return { - process: spawn(bin, args, { - cwd: root, - }), - } - }, - } - - export const Svelte: Info = { - id: "svelte", - extensions: [".svelte"], - root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), - async spawn(root) { - let binary = Bun.which("svelteserver") - const args: string[] = [] - if (!binary) { - const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js") - if (!(await Filesystem.exists(js))) { - if (Flag.AICTRL_DISABLE_LSP_DOWNLOAD) return - await Process.spawn([BunProc.which(), "install", "svelte-language-server"], { - cwd: Global.Path.bin, - env: { - ...process.env, - BUN_BE_BUN: "1", - }, - stdout: "pipe", - stderr: "pipe", - stdin: "pipe", - }).exited - } - binary = BunProc.which() - args.push("run", js) - } - args.push("--stdio") - const proc = spawn(binary, args, { - cwd: root, - env: { - ...process.env, - BUN_BE_BUN: "1", - }, - }) - return { - process: proc, - initialization: {}, - } - }, - } - - export const Astro: Info = { - id: "astro", - extensions: [".astro"], - root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), - async spawn(root) { - const tsserver = await Bun.resolve("typescript/lib/tsserver.js", Instance.directory).catch(() => {}) - if (!tsserver) { - log.info("typescript not found, required for Astro language server") - return - } - const tsdk = path.dirname(tsserver) - - let binary = Bun.which("astro-ls") - const args: string[] = [] - if (!binary) { - const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js") - if (!(await Filesystem.exists(js))) { - if (Flag.AICTRL_DISABLE_LSP_DOWNLOAD) return - await Process.spawn([BunProc.which(), "install", "@astrojs/language-server"], { - cwd: Global.Path.bin, - env: { - ...process.env, - BUN_BE_BUN: "1", - }, - stdout: "pipe", - stderr: "pipe", - stdin: "pipe", - }).exited - } - binary = BunProc.which() - args.push("run", js) - } - args.push("--stdio") - const proc = spawn(binary, args, { - cwd: root, - env: { - ...process.env, - BUN_BE_BUN: "1", - }, - }) - return { - process: proc, - initialization: { - typescript: { - tsdk, - }, - }, - } - }, - } - - export const JDTLS: Info = { - id: "jdtls", - root: NearestRoot(["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"]), - extensions: [".java"], - async spawn(root) { - const java = Bun.which("java") - if (!java) { - log.error("Java 21 or newer is required to run the JDTLS. Please install it first.") - return - } - const javaMajorVersion = await $`java -version` - .quiet() - .nothrow() - .then(({ stderr }) => { - const m = /"(\d+)\.\d+\.\d+"/.exec(stderr.toString()) - return !m ? undefined : parseInt(m[1]) - }) - if (javaMajorVersion == null || javaMajorVersion < 21) { - log.error("JDTLS requires at least Java 21.") - return - } - const distPath = path.join(Global.Path.bin, "jdtls") - const launcherDir = path.join(distPath, "plugins") - const installed = await pathExists(launcherDir) - if (!installed) { - if (Flag.AICTRL_DISABLE_LSP_DOWNLOAD) return - log.info("Downloading JDTLS LSP server.") - await fs.mkdir(distPath, { recursive: true }) - const releaseURL = - "https://www.eclipse.org/downloads/download.php?file=/jdtls/snapshots/jdt-language-server-latest.tar.gz" - const archiveName = "release.tar.gz" - - log.info("Downloading JDTLS archive", { url: releaseURL, dest: distPath }) - const curlResult = await $`curl -L -o ${archiveName} '${releaseURL}'`.cwd(distPath).quiet().nothrow() - if (curlResult.exitCode !== 0) { - log.error("Failed to download JDTLS", { exitCode: curlResult.exitCode, stderr: curlResult.stderr.toString() }) - return - } - - log.info("Extracting JDTLS archive") - const tarResult = await $`tar -xzf ${archiveName}`.cwd(distPath).quiet().nothrow() - if (tarResult.exitCode !== 0) { - log.error("Failed to extract JDTLS", { exitCode: tarResult.exitCode, stderr: tarResult.stderr.toString() }) - return - } - - await fs.rm(path.join(distPath, archiveName), { force: true }) - log.info("JDTLS download and extraction completed") - } - const jarFileName = await $`ls org.eclipse.equinox.launcher_*.jar` - .cwd(launcherDir) - .quiet() - .nothrow() - .then(({ stdout }) => stdout.toString().trim()) - const launcherJar = path.join(launcherDir, jarFileName) - if (!(await pathExists(launcherJar))) { - log.error(`Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`) - return - } - const configFile = path.join( - distPath, - (() => { - switch (process.platform) { - case "darwin": - return "config_mac" - case "linux": - return "config_linux" - case "win32": - return "config_win" - default: - return "config_linux" - } - })(), - ) - const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "aictrl-jdtls-data")) - return { - process: spawn( - java, - [ - "-jar", - launcherJar, - "-configuration", - configFile, - "-data", - dataDir, - "-Declipse.application=org.eclipse.jdt.ls.core.id1", - "-Dosgi.bundles.defaultStartLevel=4", - "-Declipse.product=org.eclipse.jdt.ls.core.product", - "-Dlog.level=ALL", - "--add-modules=ALL-SYSTEM", - "--add-opens java.base/java.util=ALL-UNNAMED", - "--add-opens java.base/java.lang=ALL-UNNAMED", - ], - { - cwd: root, - }, - ), - } - }, - } - - export const KotlinLS: Info = { - id: "kotlin-ls", - extensions: [".kt", ".kts"], - root: async (file) => { - // 1) Nearest Gradle root (multi-project or included build) - const settingsRoot = await NearestRoot(["settings.gradle.kts", "settings.gradle"])(file) - if (settingsRoot) return settingsRoot - // 2) Gradle wrapper (strong root signal) - const wrapperRoot = await NearestRoot(["gradlew", "gradlew.bat"])(file) - if (wrapperRoot) return wrapperRoot - // 3) Single-project or module-level build - const buildRoot = await NearestRoot(["build.gradle.kts", "build.gradle"])(file) - if (buildRoot) return buildRoot - // 4) Maven fallback - return NearestRoot(["pom.xml"])(file) - }, - async spawn(root) { - const distPath = path.join(Global.Path.bin, "kotlin-ls") - const launcherScript = - process.platform === "win32" ? path.join(distPath, "kotlin-lsp.cmd") : path.join(distPath, "kotlin-lsp.sh") - const installed = await Filesystem.exists(launcherScript) - if (!installed) { - if (Flag.AICTRL_DISABLE_LSP_DOWNLOAD) return - log.info("Downloading Kotlin Language Server from GitHub.") - - const releaseResponse = await fetch("https://api.github.com/repos/Kotlin/kotlin-lsp/releases/latest") - if (!releaseResponse.ok) { - log.error("Failed to fetch kotlin-lsp release info") - return - } - - const release = await releaseResponse.json() - const version = release.name?.replace(/^v/, "") - - if (!version) { - log.error("Could not determine Kotlin LSP version from release") - return - } - - 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 = ["mac-x64", "mac-aarch64", "linux-x64", "linux-aarch64", "win-x64", "win-aarch64"] - - const combo = `${kotlinPlatform}-${kotlinArch}` - - if (!supportedCombos.includes(combo)) { - log.error(`Platform ${platform}/${arch} is not supported by Kotlin LSP`) - return - } - - const assetName = `kotlin-lsp-${version}-${kotlinPlatform}-${kotlinArch}.zip` - const releaseURL = `https://download-cdn.jetbrains.com/kotlin-lsp/${version}/${assetName}` - - await fs.mkdir(distPath, { recursive: true }) - const archivePath = path.join(distPath, "kotlin-ls.zip") - await $`curl -L -o '${archivePath}' '${releaseURL}'`.quiet().nothrow() - const ok = await Archive.extractZip(archivePath, distPath) - .then(() => true) - .catch((error) => { - log.error("Failed to extract Kotlin LS archive", { error }) - return false - }) - if (!ok) return - await fs.rm(archivePath, { force: true }) - if (process.platform !== "win32") { - await $`chmod +x ${launcherScript}`.quiet().nothrow() - } - log.info("Installed Kotlin Language Server", { path: launcherScript }) - } - if (!(await Filesystem.exists(launcherScript))) { - log.error(`Failed to locate the Kotlin LS launcher script in the installed directory: ${distPath}.`) - return - } - return { - process: spawn(launcherScript, ["--stdio"], { - cwd: root, - }), - } - }, - } - - export const YamlLS: Info = { - id: "yaml-ls", - extensions: [".yaml", ".yml"], - root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), - async spawn(root) { - let binary = Bun.which("yaml-language-server") - const args: string[] = [] - if (!binary) { - const js = path.join( - Global.Path.bin, - "node_modules", - "yaml-language-server", - "out", - "server", - "src", - "server.js", - ) - const exists = await Filesystem.exists(js) - if (!exists) { - if (Flag.AICTRL_DISABLE_LSP_DOWNLOAD) return - await Process.spawn([BunProc.which(), "install", "yaml-language-server"], { - cwd: Global.Path.bin, - env: { - ...process.env, - BUN_BE_BUN: "1", - }, - stdout: "pipe", - stderr: "pipe", - stdin: "pipe", - }).exited - } - binary = BunProc.which() - args.push("run", js) - } - args.push("--stdio") - const proc = spawn(binary, args, { - cwd: root, - env: { - ...process.env, - BUN_BE_BUN: "1", - }, - }) - return { - process: proc, - } - }, - } - - export const LuaLS: Info = { - id: "lua-ls", - root: NearestRoot([ - ".luarc.json", - ".luarc.jsonc", - ".luacheckrc", - ".stylua.toml", - "stylua.toml", - "selene.toml", - "selene.yml", - ]), - extensions: [".lua"], - async spawn(root) { - let bin = Bun.which("lua-language-server", { - PATH: process.env["PATH"] + path.delimiter + Global.Path.bin, - }) - - if (!bin) { - if (Flag.AICTRL_DISABLE_LSP_DOWNLOAD) return - log.info("downloading lua-language-server from GitHub releases") - - const releaseResponse = await fetch("https://api.github.com/repos/LuaLS/lua-language-server/releases/latest") - if (!releaseResponse.ok) { - log.error("Failed to fetch lua-language-server release info") - return - } - - const release = await releaseResponse.json() - - const platform = process.platform - const arch = process.arch - let assetName = "" - - let lualsArch: string = arch - if (arch === "arm64") lualsArch = "arm64" - else if (arch === "x64") lualsArch = "x64" - else if (arch === "ia32") lualsArch = "ia32" - - let lualsPlatform: string = platform - if (platform === "darwin") lualsPlatform = "darwin" - else if (platform === "linux") lualsPlatform = "linux" - else if (platform === "win32") lualsPlatform = "win32" - - const ext = platform === "win32" ? "zip" : "tar.gz" - - assetName = `lua-language-server-${release.tag_name}-${lualsPlatform}-${lualsArch}.${ext}` - - const supportedCombos = [ - "darwin-arm64.tar.gz", - "darwin-x64.tar.gz", - "linux-x64.tar.gz", - "linux-arm64.tar.gz", - "win32-x64.zip", - "win32-ia32.zip", - ] - - const assetSuffix = `${lualsPlatform}-${lualsArch}.${ext}` - if (!supportedCombos.includes(assetSuffix)) { - log.error(`Platform ${platform} and architecture ${arch} is not supported by lua-language-server`) - return - } - - const asset = release.assets.find((a: any) => a.name === assetName) - if (!asset) { - log.error(`Could not find asset ${assetName} in latest lua-language-server release`) - return - } - - const downloadUrl = asset.browser_download_url - const downloadResponse = await fetch(downloadUrl) - if (!downloadResponse.ok) { - log.error("Failed to download lua-language-server") - return - } - - const tempPath = path.join(Global.Path.bin, assetName) - if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) - - // Unlike zls which is a single self-contained binary, - // lua-language-server needs supporting files (meta/, locale/, etc.) - // Extract entire archive to dedicated directory to preserve all files - const installDir = path.join(Global.Path.bin, `lua-language-server-${lualsArch}-${lualsPlatform}`) - - // Remove old installation if exists - const stats = await fs.stat(installDir).catch(() => undefined) - if (stats) { - await fs.rm(installDir, { force: true, recursive: true }) - } - - await fs.mkdir(installDir, { recursive: true }) - - if (ext === "zip") { - const ok = await Archive.extractZip(tempPath, installDir) - .then(() => true) - .catch((error) => { - log.error("Failed to extract lua-language-server archive", { error }) - return false - }) - if (!ok) return - } else { - const ok = await $`tar -xzf ${tempPath} -C ${installDir}` - .quiet() - .then(() => true) - .catch((error) => { - log.error("Failed to extract lua-language-server archive", { error }) - return false - }) - if (!ok) return - } - - await fs.rm(tempPath, { force: true }) - - // Binary is located in bin/ subdirectory within the extracted archive - bin = path.join(installDir, "bin", "lua-language-server" + (platform === "win32" ? ".exe" : "")) - - if (!(await Filesystem.exists(bin))) { - log.error("Failed to extract lua-language-server binary") - return - } - - if (platform !== "win32") { - const ok = await $`chmod +x ${bin}`.quiet().catch((error) => { - log.error("Failed to set executable permission for lua-language-server binary", { - error, - }) - }) - if (!ok) return - } - - log.info(`installed lua-language-server`, { bin }) - } - - return { - process: spawn(bin, { - cwd: root, - }), - } - }, - } - - export const PHPIntelephense: Info = { - id: "php intelephense", - extensions: [".php"], - root: NearestRoot(["composer.json", "composer.lock", ".php-version"]), - async spawn(root) { - let binary = Bun.which("intelephense") - const args: string[] = [] - if (!binary) { - const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js") - if (!(await Filesystem.exists(js))) { - if (Flag.AICTRL_DISABLE_LSP_DOWNLOAD) return - await Process.spawn([BunProc.which(), "install", "intelephense"], { - cwd: Global.Path.bin, - env: { - ...process.env, - BUN_BE_BUN: "1", - }, - stdout: "pipe", - stderr: "pipe", - stdin: "pipe", - }).exited - } - binary = BunProc.which() - args.push("run", js) - } - args.push("--stdio") - const proc = spawn(binary, args, { - cwd: root, - env: { - ...process.env, - BUN_BE_BUN: "1", - }, - }) - return { - process: proc, - initialization: { - telemetry: { - enabled: false, - }, - }, - } - }, - } - - export const Prisma: Info = { - id: "prisma", - extensions: [".prisma"], - root: NearestRoot(["schema.prisma", "prisma/schema.prisma", "prisma"], ["package.json"]), - async spawn(root) { - const prisma = Bun.which("prisma") - if (!prisma) { - log.info("prisma not found, please install prisma") - return - } - return { - process: spawn(prisma, ["language-server"], { - cwd: root, - }), - } - }, - } - - export const Dart: Info = { - id: "dart", - extensions: [".dart"], - root: NearestRoot(["pubspec.yaml", "analysis_options.yaml"]), - async spawn(root) { - const dart = Bun.which("dart") - if (!dart) { - log.info("dart not found, please install dart first") - return - } - return { - process: spawn(dart, ["language-server", "--lsp"], { - cwd: root, - }), - } - }, - } - - export const Ocaml: Info = { - id: "ocaml-lsp", - extensions: [".ml", ".mli"], - root: NearestRoot(["dune-project", "dune-workspace", ".merlin", "opam"]), - async spawn(root) { - const bin = Bun.which("ocamllsp") - if (!bin) { - log.info("ocamllsp not found, please install ocaml-lsp-server") - return - } - return { - process: spawn(bin, { - cwd: root, - }), - } - }, - } - export const BashLS: Info = { - id: "bash", - extensions: [".sh", ".bash", ".zsh", ".ksh"], - root: async () => Instance.directory, - async spawn(root) { - let binary = Bun.which("bash-language-server") - const args: string[] = [] - if (!binary) { - const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js") - if (!(await Filesystem.exists(js))) { - if (Flag.AICTRL_DISABLE_LSP_DOWNLOAD) return - await Process.spawn([BunProc.which(), "install", "bash-language-server"], { - cwd: Global.Path.bin, - env: { - ...process.env, - BUN_BE_BUN: "1", - }, - stdout: "pipe", - stderr: "pipe", - stdin: "pipe", - }).exited - } - binary = BunProc.which() - args.push("run", js) - } - args.push("start") - const proc = spawn(binary, args, { - cwd: root, - env: { - ...process.env, - BUN_BE_BUN: "1", - }, - }) - return { - process: proc, - } - }, - } - - export const TerraformLS: Info = { - id: "terraform", - extensions: [".tf", ".tfvars"], - root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]), - async spawn(root) { - let bin = Bun.which("terraform-ls", { - PATH: process.env["PATH"] + path.delimiter + Global.Path.bin, - }) - - if (!bin) { - if (Flag.AICTRL_DISABLE_LSP_DOWNLOAD) return - log.info("downloading terraform-ls from HashiCorp releases") - - const releaseResponse = await fetch("https://api.releases.hashicorp.com/v1/releases/terraform-ls/latest") - if (!releaseResponse.ok) { - log.error("Failed to fetch terraform-ls release info") - return - } - - const release = (await releaseResponse.json()) as { - version?: string - builds?: { arch?: string; os?: string; url?: string }[] - } - - const platform = process.platform - const arch = process.arch - - const tfArch = arch === "arm64" ? "arm64" : "amd64" - const tfPlatform = platform === "win32" ? "windows" : platform - - const builds = release.builds ?? [] - const build = builds.find((b) => b.arch === tfArch && b.os === tfPlatform) - if (!build?.url) { - log.error(`Could not find build for ${tfPlatform}/${tfArch} terraform-ls release version ${release.version}`) - return - } - - const downloadResponse = await fetch(build.url) - if (!downloadResponse.ok) { - log.error("Failed to download terraform-ls") - return - } - - const tempPath = path.join(Global.Path.bin, "terraform-ls.zip") - if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) - - const ok = await Archive.extractZip(tempPath, Global.Path.bin) - .then(() => true) - .catch((error) => { - log.error("Failed to extract terraform-ls archive", { error }) - return false - }) - if (!ok) return - await fs.rm(tempPath, { force: true }) - - bin = path.join(Global.Path.bin, "terraform-ls" + (platform === "win32" ? ".exe" : "")) - - if (!(await Filesystem.exists(bin))) { - log.error("Failed to extract terraform-ls binary") - return - } - - if (platform !== "win32") { - await $`chmod +x ${bin}`.quiet().nothrow() - } - - log.info(`installed terraform-ls`, { bin }) - } - - return { - process: spawn(bin, ["serve"], { - cwd: root, - }), - initialization: { - experimentalFeatures: { - prefillRequiredFields: true, - validateOnSave: true, - }, - }, - } - }, - } - - export const TexLab: Info = { - id: "texlab", - extensions: [".tex", ".bib"], - root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]), - async spawn(root) { - let bin = Bun.which("texlab", { - PATH: process.env["PATH"] + path.delimiter + Global.Path.bin, - }) - - if (!bin) { - if (Flag.AICTRL_DISABLE_LSP_DOWNLOAD) return - log.info("downloading texlab from GitHub releases") - - const response = await fetch("https://api.github.com/repos/latex-lsp/texlab/releases/latest") - if (!response.ok) { - log.error("Failed to fetch texlab release info") - return - } - - const release = (await response.json()) as { - tag_name?: string - assets?: { name?: string; browser_download_url?: string }[] - } - const version = release.tag_name?.replace("v", "") - if (!version) { - log.error("texlab release did not include a version tag") - return - } - - const platform = process.platform - const arch = process.arch - - const texArch = arch === "arm64" ? "aarch64" : "x86_64" - const texPlatform = platform === "darwin" ? "macos" : platform === "win32" ? "windows" : "linux" - const ext = platform === "win32" ? "zip" : "tar.gz" - const assetName = `texlab-${texArch}-${texPlatform}.${ext}` - - const assets = release.assets ?? [] - const asset = assets.find((a) => a.name === assetName) - if (!asset?.browser_download_url) { - log.error(`Could not find asset ${assetName} in texlab release`) - return - } - - const downloadResponse = await fetch(asset.browser_download_url) - if (!downloadResponse.ok) { - log.error("Failed to download texlab") - return - } - - const tempPath = path.join(Global.Path.bin, assetName) - if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) - - if (ext === "zip") { - const ok = await Archive.extractZip(tempPath, Global.Path.bin) - .then(() => true) - .catch((error) => { - log.error("Failed to extract texlab archive", { error }) - return false - }) - if (!ok) return - } - if (ext === "tar.gz") { - await $`tar -xzf ${tempPath}`.cwd(Global.Path.bin).quiet().nothrow() - } - - await fs.rm(tempPath, { force: true }) - - bin = path.join(Global.Path.bin, "texlab" + (platform === "win32" ? ".exe" : "")) - - if (!(await Filesystem.exists(bin))) { - log.error("Failed to extract texlab binary") - return - } - - if (platform !== "win32") { - await $`chmod +x ${bin}`.quiet().nothrow() - } - - log.info("installed texlab", { bin }) - } - - return { - process: spawn(bin, { - cwd: root, - }), - } - }, - } - - export const DockerfileLS: Info = { - id: "dockerfile", - extensions: [".dockerfile", "Dockerfile"], - root: async () => Instance.directory, - async spawn(root) { - let binary = Bun.which("docker-langserver") - const args: string[] = [] - if (!binary) { - const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js") - if (!(await Filesystem.exists(js))) { - if (Flag.AICTRL_DISABLE_LSP_DOWNLOAD) return - await Process.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], { - cwd: Global.Path.bin, - env: { - ...process.env, - BUN_BE_BUN: "1", - }, - stdout: "pipe", - stderr: "pipe", - stdin: "pipe", - }).exited - } - binary = BunProc.which() - args.push("run", js) - } - args.push("--stdio") - const proc = spawn(binary, args, { - cwd: root, - env: { - ...process.env, - BUN_BE_BUN: "1", - }, - }) - return { - process: proc, - } - }, - } - - export const Gleam: Info = { - id: "gleam", - extensions: [".gleam"], - root: NearestRoot(["gleam.toml"]), - async spawn(root) { - const gleam = Bun.which("gleam") - if (!gleam) { - log.info("gleam not found, please install gleam first") - return - } - return { - process: spawn(gleam, ["lsp"], { - cwd: root, - }), - } - }, - } - - export const Clojure: Info = { - id: "clojure-lsp", - extensions: [".clj", ".cljs", ".cljc", ".edn"], - root: NearestRoot(["deps.edn", "project.clj", "shadow-cljs.edn", "bb.edn", "build.boot"]), - async spawn(root) { - let bin = Bun.which("clojure-lsp") - if (!bin && process.platform === "win32") { - bin = Bun.which("clojure-lsp.exe") - } - if (!bin) { - log.info("clojure-lsp not found, please install clojure-lsp first") - return - } - return { - process: spawn(bin, ["listen"], { - cwd: root, - }), - } - }, - } - - export const Nixd: Info = { - id: "nixd", - extensions: [".nix"], - root: async (file) => { - // First, look for flake.nix - the most reliable Nix project root indicator - const flakeRoot = await NearestRoot(["flake.nix"])(file) - if (flakeRoot && flakeRoot !== Instance.directory) return flakeRoot - - // If no flake.nix, fall back to git repository root - if (Instance.worktree && Instance.worktree !== Instance.directory) return Instance.worktree - - // Finally, use the instance directory as fallback - return Instance.directory - }, - async spawn(root) { - const nixd = Bun.which("nixd") - if (!nixd) { - log.info("nixd not found, please install nixd first") - return - } - return { - process: spawn(nixd, [], { - cwd: root, - env: { - ...process.env, - }, - }), - } - }, - } - - export const Tinymist: Info = { - id: "tinymist", - extensions: [".typ", ".typc"], - root: NearestRoot(["typst.toml"]), - async spawn(root) { - let bin = Bun.which("tinymist", { - PATH: process.env["PATH"] + path.delimiter + Global.Path.bin, - }) - - if (!bin) { - if (Flag.AICTRL_DISABLE_LSP_DOWNLOAD) return - log.info("downloading tinymist from GitHub releases") - - const response = await fetch("https://api.github.com/repos/Myriad-Dreamin/tinymist/releases/latest") - if (!response.ok) { - log.error("Failed to fetch tinymist release info") - return - } - - const release = (await response.json()) as { - tag_name?: string - assets?: { name?: string; browser_download_url?: string }[] - } - - const platform = process.platform - const arch = process.arch - - const tinymistArch = arch === "arm64" ? "aarch64" : "x86_64" - let tinymistPlatform: string - let ext: string - - if (platform === "darwin") { - tinymistPlatform = "apple-darwin" - ext = "tar.gz" - } else if (platform === "win32") { - tinymistPlatform = "pc-windows-msvc" - ext = "zip" - } else { - tinymistPlatform = "unknown-linux-gnu" - ext = "tar.gz" - } - - const assetName = `tinymist-${tinymistArch}-${tinymistPlatform}.${ext}` - - const assets = release.assets ?? [] - const asset = assets.find((a) => a.name === assetName) - if (!asset?.browser_download_url) { - log.error(`Could not find asset ${assetName} in tinymist release`) - return - } - - const downloadResponse = await fetch(asset.browser_download_url) - if (!downloadResponse.ok) { - log.error("Failed to download tinymist") - return - } - - const tempPath = path.join(Global.Path.bin, assetName) - if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) - - if (ext === "zip") { - const ok = await Archive.extractZip(tempPath, Global.Path.bin) - .then(() => true) - .catch((error) => { - log.error("Failed to extract tinymist archive", { error }) - return false - }) - if (!ok) return - } else { - await $`tar -xzf ${tempPath} --strip-components=1`.cwd(Global.Path.bin).quiet().nothrow() - } - - await fs.rm(tempPath, { force: true }) - - bin = path.join(Global.Path.bin, "tinymist" + (platform === "win32" ? ".exe" : "")) - - if (!(await Filesystem.exists(bin))) { - log.error("Failed to extract tinymist binary") - return - } - - if (platform !== "win32") { - await $`chmod +x ${bin}`.quiet().nothrow() - } - - log.info("installed tinymist", { bin }) - } - - return { - process: spawn(bin, { cwd: root }), - } - }, - } - - export const HLS: Info = { - id: "haskell-language-server", - extensions: [".hs", ".lhs"], - root: NearestRoot(["stack.yaml", "cabal.project", "hie.yaml", "*.cabal"]), - async spawn(root) { - const bin = Bun.which("haskell-language-server-wrapper") - if (!bin) { - log.info("haskell-language-server-wrapper not found, please install haskell-language-server") - return - } - return { - process: spawn(bin, ["--lsp"], { - cwd: root, - }), - } - }, - } - - export const JuliaLS: Info = { - id: "julials", - extensions: [".jl"], - root: NearestRoot(["Project.toml", "Manifest.toml", "*.jl"]), - async spawn(root) { - const julia = Bun.which("julia") - if (!julia) { - log.info("julia not found, please install julia first (https://julialang.org/downloads/)") - return - } - return { - process: spawn(julia, ["--startup-file=no", "--history-file=no", "-e", "using LanguageServer; runserver()"], { - cwd: root, - }), - } - }, - } -} diff --git a/packages/cli/src/project/bootstrap.ts b/packages/cli/src/project/bootstrap.ts index a2be3733..cfcf4301 100644 --- a/packages/cli/src/project/bootstrap.ts +++ b/packages/cli/src/project/bootstrap.ts @@ -1,6 +1,5 @@ import { Plugin } from "../plugin" import { Format } from "../format" -import { LSP } from "../lsp" import { FileWatcher } from "../file/watcher" import { File } from "../file" import { Project } from "./project" @@ -18,7 +17,6 @@ export async function InstanceBootstrap() { await Plugin.init() ShareNext.init() Format.init() - await LSP.init() FileWatcher.init() File.init() Vcs.init() diff --git a/packages/cli/src/session/message-v2.ts b/packages/cli/src/session/message-v2.ts index 18a0dbe1..526bc33d 100644 --- a/packages/cli/src/session/message-v2.ts +++ b/packages/cli/src/session/message-v2.ts @@ -3,7 +3,6 @@ import z from "zod" import { NamedError } from "@aictrl/util/error" import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai" import { Identifier } from "../id/id" -import { LSP } from "../lsp" import { Snapshot } from "@/snapshot" import { fn } from "@/util/fn" import { Database, eq, desc, inArray } from "@/storage/db" @@ -171,10 +170,24 @@ export namespace MessageV2 { ref: "FileSource", }) + const Position = z.object({ + line: z.number(), + character: z.number(), + }) + + const Range = z + .object({ + start: Position, + end: Position, + }) + .meta({ + ref: "Range", + }) + export const SymbolSource = FilePartSourceBase.extend({ type: z.literal("symbol"), path: z.string(), - range: LSP.Range, + range: Range, name: z.string(), kind: z.number().int(), }).meta({ diff --git a/packages/cli/src/session/prompt.ts b/packages/cli/src/session/prompt.ts index 3fbe6a77..c756445a 100644 --- a/packages/cli/src/session/prompt.ts +++ b/packages/cli/src/session/prompt.ts @@ -25,7 +25,6 @@ import MAX_STEPS from "../session/prompt/max-steps.txt" import { defer } from "../util/defer" import { ToolRegistry } from "../tool/registry" import { MCP } from "../mcp" -import { LSP } from "../lsp" import { ReadTool } from "../tool/read" import { FileTime } from "../file/time" import { Flag } from "../flag/flag" @@ -1306,7 +1305,6 @@ export namespace SessionPrompt { break case "file:": log.info("file", { mime: part.mime }) - // have to normalize, symbol search returns absolute paths // Decode the pathname since URL constructor doesn't automatically decode it const filepath = fileURLToPath(part.url) const s = Filesystem.stat(filepath) @@ -1323,28 +1321,8 @@ export namespace SessionPrompt { end: url.searchParams.get("end"), } if (range.start != null) { - const filePathURI = part.url.split("?")[0] - let start = parseInt(range.start) - let end = range.end ? parseInt(range.end) : undefined - // some LSP servers (eg, gopls) don't give full range in - // workspace/symbol searches, so we'll try to find the - // symbol in the document to get the full range - if (start === end) { - const symbols = await LSP.documentSymbol(filePathURI).catch(() => []) - for (const symbol of symbols) { - let range: LSP.Range | undefined - if ("range" in symbol) { - range = symbol.range - } else if ("location" in symbol) { - range = symbol.location.range - } - if (range?.start?.line && range?.start?.line === start) { - start = range.start.line - end = range?.end?.line ?? start - break - } - } - } + const start = parseInt(range.start) + const end = range.end ? parseInt(range.end) : undefined offset = Math.max(start, 1) if (end) { limit = end - (offset - 1) diff --git a/packages/cli/src/tool/apply_patch.ts b/packages/cli/src/tool/apply_patch.ts index 06293b6e..2c5695da 100644 --- a/packages/cli/src/tool/apply_patch.ts +++ b/packages/cli/src/tool/apply_patch.ts @@ -9,8 +9,6 @@ import { Patch } from "../patch" import { createTwoFilesPatch, diffLines } from "diff" import { assertExternalDirectory } from "./external-directory" import { trimDiff } from "./edit" -import { LSP } from "../lsp" -import { Filesystem } from "../util/filesystem" import DESCRIPTION from "./apply_patch.txt" import { File } from "../file" @@ -231,14 +229,6 @@ export const ApplyPatchTool = Tool.define("apply_patch", { await Bus.publish(FileWatcher.Event.Updated, update) } - // Notify LSP of file changes and collect diagnostics - for (const change of fileChanges) { - if (change.type === "delete") continue - const target = change.movePath ?? change.filePath - await LSP.touchFile(target, true) - } - const diagnostics = await LSP.diagnostics() - // Generate output summary const summaryLines = fileChanges.map((change) => { if (change.type === "add") { @@ -250,30 +240,13 @@ export const ApplyPatchTool = Tool.define("apply_patch", { const target = change.movePath ?? change.filePath return `M ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}` }) - let output = `Success. Updated the following files:\n${summaryLines.join("\n")}` - - // Report LSP errors for changed files - const MAX_DIAGNOSTICS_PER_FILE = 20 - for (const change of fileChanges) { - if (change.type === "delete") continue - const target = change.movePath ?? change.filePath - const normalized = Filesystem.normalizePath(target) - const issues = diagnostics[normalized] ?? [] - const errors = issues.filter((item) => item.severity === 1) - if (errors.length > 0) { - const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE) - const suffix = - errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : "" - output += `\n\nLSP errors detected in ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}, please fix:\n\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n` - } - } + const output = `Success. Updated the following files:\n${summaryLines.join("\n")}` return { title: output, metadata: { diff: totalDiff, files, - diagnostics, }, output, } diff --git a/packages/cli/src/tool/edit.ts b/packages/cli/src/tool/edit.ts index 7a097d3f..abdaf8dd 100644 --- a/packages/cli/src/tool/edit.ts +++ b/packages/cli/src/tool/edit.ts @@ -6,7 +6,6 @@ import z from "zod" import * as path from "path" import { Tool } from "./tool" -import { LSP } from "../lsp" import { createTwoFilesPatch, diffLines } from "diff" import DESCRIPTION from "./edit.txt" import { File } from "../file" @@ -18,8 +17,6 @@ import { Instance } from "../project/instance" import { Snapshot } from "@/snapshot" import { assertExternalDirectory } from "./external-directory" -const MAX_DIAGNOSTICS_PER_FILE = 20 - function normalizeLineEndings(text: string): string { return text.replaceAll("\r\n", "\n") } @@ -124,31 +121,16 @@ export const EditTool = Tool.define("edit", { metadata: { diff, filediff, - diagnostics: {}, }, }) - let output = "Edit applied successfully." - await LSP.touchFile(filePath, true) - const diagnostics = await LSP.diagnostics() - const normalizedFilePath = Filesystem.normalizePath(filePath) - const issues = diagnostics[normalizedFilePath] ?? [] - const errors = issues.filter((item) => item.severity === 1) - if (errors.length > 0) { - const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE) - const suffix = - errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : "" - output += `\n\nLSP errors detected in this file, please fix:\n\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n` - } - return { metadata: { - diagnostics, diff, filediff, }, title: `${path.relative(Instance.worktree, filePath)}`, - output, + output: "Edit applied successfully.", } }, }) diff --git a/packages/cli/src/tool/lsp.ts b/packages/cli/src/tool/lsp.ts deleted file mode 100644 index 52aef0f9..00000000 --- a/packages/cli/src/tool/lsp.ts +++ /dev/null @@ -1,97 +0,0 @@ -import z from "zod" -import { Tool } from "./tool" -import path from "path" -import { LSP } from "../lsp" -import DESCRIPTION from "./lsp.txt" -import { Instance } from "../project/instance" -import { pathToFileURL } from "url" -import { assertExternalDirectory } from "./external-directory" -import { Filesystem } from "../util/filesystem" - -const operations = [ - "goToDefinition", - "findReferences", - "hover", - "documentSymbol", - "workspaceSymbol", - "goToImplementation", - "prepareCallHierarchy", - "incomingCalls", - "outgoingCalls", -] as const - -export const LspTool = Tool.define("lsp", { - description: DESCRIPTION, - parameters: z.object({ - operation: z.enum(operations).describe("The LSP operation to perform"), - filePath: z.string().describe("The absolute or relative path to the file"), - line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"), - character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"), - }), - execute: async (args, ctx) => { - const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath) - await assertExternalDirectory(ctx, file) - - await ctx.ask({ - permission: "lsp", - patterns: ["*"], - always: ["*"], - metadata: {}, - }) - const uri = pathToFileURL(file).href - const position = { - file, - line: args.line - 1, - character: args.character - 1, - } - - const relPath = path.relative(Instance.worktree, file) - const title = `${args.operation} ${relPath}:${args.line}:${args.character}` - - const exists = await Filesystem.exists(file) - if (!exists) { - throw new Error(`File not found: ${file}`) - } - - const available = await LSP.hasClients(file) - if (!available) { - throw new Error("No LSP server available for this file type.") - } - - await LSP.touchFile(file, true) - - const result: unknown[] = await (async () => { - switch (args.operation) { - case "goToDefinition": - return LSP.definition(position) - case "findReferences": - return LSP.references(position) - case "hover": - return LSP.hover(position) - case "documentSymbol": - return LSP.documentSymbol(uri) - case "workspaceSymbol": - return LSP.workspaceSymbol("") - case "goToImplementation": - return LSP.implementation(position) - case "prepareCallHierarchy": - return LSP.prepareCallHierarchy(position) - case "incomingCalls": - return LSP.incomingCalls(position) - case "outgoingCalls": - return LSP.outgoingCalls(position) - } - })() - - const output = (() => { - if (result.length === 0) return `No results found for ${args.operation}` - return JSON.stringify(result, null, 2) - })() - - return { - title, - metadata: { result }, - output, - } - }, -}) diff --git a/packages/cli/src/tool/lsp.txt b/packages/cli/src/tool/lsp.txt deleted file mode 100644 index 5a50a571..00000000 --- a/packages/cli/src/tool/lsp.txt +++ /dev/null @@ -1,19 +0,0 @@ -Interact with Language Server Protocol (LSP) servers to get code intelligence features. - -Supported operations: -- goToDefinition: Find where a symbol is defined -- findReferences: Find all references to a symbol -- hover: Get hover information (documentation, type info) for a symbol -- documentSymbol: Get all symbols (functions, classes, variables) in a document -- workspaceSymbol: Search for symbols across the entire workspace -- goToImplementation: Find implementations of an interface or abstract method -- prepareCallHierarchy: Get call hierarchy item at a position (functions/methods) -- incomingCalls: Find all functions/methods that call the function at a position -- outgoingCalls: Find all functions/methods called by the function at a position - -All operations require: -- filePath: The file to operate on -- line: The line number (1-based, as shown in editors) -- character: The character offset (1-based, as shown in editors) - -Note: LSP servers must be configured for the file type. If no server is available, an error will be returned. diff --git a/packages/cli/src/tool/read.ts b/packages/cli/src/tool/read.ts index 98dc7ce5..74831bb1 100644 --- a/packages/cli/src/tool/read.ts +++ b/packages/cli/src/tool/read.ts @@ -4,7 +4,6 @@ import * as fs from "fs/promises" import * as path from "path" import { createInterface } from "readline" import { Tool } from "./tool" -import { LSP } from "../lsp" import { FileTime } from "../file/time" import DESCRIPTION from "./read.txt" import { Instance } from "../project/instance" @@ -234,8 +233,6 @@ export const ReadTool = Tool.define("read", { } output += "\n" - // just warms the lsp client - LSP.touchFile(filepath, false) FileTime.read(ctx.sessionID, filepath) await emitSkillResourceEvent(ctx.sessionID, filepath) diff --git a/packages/cli/src/tool/registry.ts b/packages/cli/src/tool/registry.ts index 1a4684f8..8bb9e9ae 100644 --- a/packages/cli/src/tool/registry.ts +++ b/packages/cli/src/tool/registry.ts @@ -25,7 +25,6 @@ import { CodeSearchTool } from "./codesearch" import { Flag } from "@/flag/flag" import { HeadlessRun } from "@/flag/headless-run" import { Log } from "@/util/log" -import { LspTool } from "./lsp" import { Truncate } from "./truncation" import { ApplyPatchTool } from "./apply_patch" @@ -144,7 +143,6 @@ export namespace ToolRegistry { CodeSearchTool, SkillTool, ApplyPatchTool, - ...(Flag.AICTRL_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), ...(config.experimental?.batch_tool === true ? [BatchTool] : []), ...(Flag.AICTRL_EXPERIMENTAL_PLAN_MODE && Flag.AICTRL_CLIENT === "cli" ? [PlanExitTool] : []), ...custom, diff --git a/packages/cli/src/tool/write.ts b/packages/cli/src/tool/write.ts index 8c1e53cc..24d2ee13 100644 --- a/packages/cli/src/tool/write.ts +++ b/packages/cli/src/tool/write.ts @@ -1,7 +1,6 @@ import z from "zod" import * as path from "path" import { Tool } from "./tool" -import { LSP } from "../lsp" import { createTwoFilesPatch } from "diff" import DESCRIPTION from "./write.txt" import { Bus } from "../bus" @@ -13,9 +12,6 @@ import { Instance } from "../project/instance" import { trimDiff } from "./edit" import { assertExternalDirectory } from "./external-directory" -const MAX_DIAGNOSTICS_PER_FILE = 20 -const MAX_PROJECT_DIAGNOSTICS_FILES = 5 - export const WriteTool = Tool.define("write", { description: DESCRIPTION, parameters: z.object({ @@ -51,34 +47,13 @@ export const WriteTool = Tool.define("write", { }) FileTime.read(ctx.sessionID, filepath) - let output = "Wrote file successfully." - await LSP.touchFile(filepath, true) - const diagnostics = await LSP.diagnostics() - const normalizedFilepath = Filesystem.normalizePath(filepath) - let projectDiagnosticsCount = 0 - for (const [file, issues] of Object.entries(diagnostics)) { - const errors = issues.filter((item) => item.severity === 1) - if (errors.length === 0) continue - const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE) - const suffix = - errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : "" - if (file === normalizedFilepath) { - output += `\n\nLSP errors detected in this file, please fix:\n\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n` - continue - } - if (projectDiagnosticsCount >= MAX_PROJECT_DIAGNOSTICS_FILES) continue - projectDiagnosticsCount++ - output += `\n\nLSP errors detected in other files:\n\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n` - } - return { title: path.relative(Instance.worktree, filepath), metadata: { - diagnostics, filepath, exists: exists, }, - output, + output: "Wrote file successfully.", } }, }) diff --git a/packages/cli/src/util/archive.ts b/packages/cli/src/util/archive.ts deleted file mode 100644 index 34a1738a..00000000 --- a/packages/cli/src/util/archive.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { $ } from "bun" -import path from "path" - -export namespace Archive { - export async function extractZip(zipPath: string, destDir: string) { - if (process.platform === "win32") { - const winZipPath = path.resolve(zipPath) - const winDestDir = path.resolve(destDir) - // $global:ProgressPreference suppresses PowerShell's blue progress bar popup - const cmd = `$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -Path '${winZipPath}' -DestinationPath '${winDestDir}' -Force` - await $`powershell -NoProfile -NonInteractive -Command ${cmd}`.quiet() - } else { - await $`unzip -o -q ${zipPath} -d ${destDir}`.quiet() - } - } -} diff --git a/packages/cli/src/util/filesystem.ts b/packages/cli/src/util/filesystem.ts index 7ad10209..9fe6b151 100644 --- a/packages/cli/src/util/filesystem.ts +++ b/packages/cli/src/util/filesystem.ts @@ -1,7 +1,6 @@ import { chmod, mkdir, readFile, realpath, writeFile } from "fs/promises" import { createWriteStream, existsSync, statSync } from "fs" import { lookup } from "mime-types" -import { realpathSync } from "fs" import { dirname, join, relative } from "path" import { Readable } from "stream" import { pipeline } from "stream/promises" @@ -99,20 +98,6 @@ export namespace Filesystem { return lookup(p) || "application/octet-stream" } - /** - * On Windows, normalize a path to its canonical casing using the filesystem. - * This is needed because Windows paths are case-insensitive but LSP servers - * may return paths with different casing than what we send them. - */ - export function normalizePath(p: string): string { - if (process.platform !== "win32") return p - try { - return realpathSync.native(p) - } catch { - return p - } - } - export function windowsPath(p: string): string { if (process.platform !== "win32") return p return ( diff --git a/packages/cli/test/cli/fixture/pre-lsp-removal-session.json b/packages/cli/test/cli/fixture/pre-lsp-removal-session.json new file mode 100644 index 00000000..05f23d60 --- /dev/null +++ b/packages/cli/test/cli/fixture/pre-lsp-removal-session.json @@ -0,0 +1,384 @@ +{ + "info": { + "id": "ses_eb3fea7bcffek9bjINhSiASkm4", + "slug": "pre-lsp-removal", + "projectID": "__PROJECT_ID__", + "directory": "__DIRECTORY__", + "title": "Pre-#144 session with LSP history", + "version": "local", + "permission": [ + { + "permission": "question", + "action": "deny", + "pattern": "*" + }, + { + "permission": "plan_enter", + "action": "deny", + "pattern": "*" + }, + { + "permission": "plan_exit", + "action": "deny", + "pattern": "*" + } + ], + "time": { + "created": 1792276518979, + "updated": 1792276519005 + } + }, + "messages": [ + { + "info": { + "role": "user", + "time": { + "created": 1792276518998 + }, + "agent": "build", + "model": { + "providerID": "fixture", + "modelID": "gemini-fixture" + }, + "id": "msg_14c015856001yoQ56RrccFgDBq", + "sessionID": "ses_eb3fea7bcffek9bjINhSiASkm4" + }, + "parts": [ + { + "type": "text", + "text": "Check the types in a.ts.", + "id": "prt_14c015857001BZsX7aOjwSxEuq", + "sessionID": "ses_eb3fea7bcffek9bjINhSiASkm4", + "messageID": "msg_14c015856001yoQ56RrccFgDBq" + }, + { + "type": "file", + "mime": "text/plain", + "filename": "a.ts", + "url": "file://__DIRECTORY__/a.ts?start=1&end=1", + "source": { + "type": "symbol", + "path": "__DIRECTORY__/a.ts", + "name": "n", + "kind": 13, + "range": { + "start": { + "line": 0, + "character": 6 + }, + "end": { + "line": 0, + "character": 7 + } + }, + "text": { + "value": "@n", + "start": 0, + "end": 2 + } + }, + "id": "prt_14c015857001BZsX7aOjwSxEuZ", + "sessionID": "ses_eb3fea7bcffek9bjINhSiASkm4", + "messageID": "msg_14c015856001yoQ56RrccFgDBq" + } + ] + }, + { + "info": { + "role": "assistant", + "time": { + "created": 1792276519018, + "completed": 1792276522235 + }, + "parentID": "msg_14c015856001yoQ56RrccFgDBq", + "modelID": "gemini-fixture", + "providerID": "fixture", + "mode": "build", + "agent": "build", + "path": { + "cwd": "__DIRECTORY__", + "root": "__DIRECTORY__" + }, + "cost": 0, + "tokens": { + "total": 10, + "input": 7, + "output": 3, + "reasoning": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "usageStatus": "reported", + "finish": "tool-calls", + "id": "msg_14c01586a001NhWwWmtdVXrj0b", + "sessionID": "ses_eb3fea7bcffek9bjINhSiASkm4" + }, + "parts": [ + { + "type": "step-start", + "id": "prt_14c0158a0001AfjIFeOkIH0yD6", + "sessionID": "ses_eb3fea7bcffek9bjINhSiASkm4", + "messageID": "msg_14c01586a001NhWwWmtdVXrj0b" + }, + { + "type": "tool", + "callID": "lspCall0001", + "tool": "lsp", + "state": { + "status": "completed", + "input": { + "operation": "hover", + "filePath": "__DIRECTORY__/a.ts", + "line": 1, + "character": 7 + }, + "output": "[{\"contents\":{\"kind\":\"markdown\",\"value\":\"const n: number\"}}]", + "title": "hover a.ts:1:7", + "metadata": { + "result": [ + { + "contents": { + "kind": "markdown", + "value": "const n: number" + } + } + ], + "truncated": false + }, + "time": { + "start": 1792276568000, + "end": 1792276568100 + } + }, + "metadata": { + "providerExecuted": false + }, + "id": "prt_14c0158a3001yCIVtVsPUjsHqy", + "sessionID": "ses_eb3fea7bcffek9bjINhSiASkm4", + "messageID": "msg_14c01586a001NhWwWmtdVXrj0b" + }, + { + "type": "step-finish", + "reason": "tool-calls", + "cost": 0, + "tokens": { + "total": 10, + "input": 7, + "output": 3, + "reasoning": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "id": "prt_14c0164f7001IWPttCI411TuX7", + "sessionID": "ses_eb3fea7bcffek9bjINhSiASkm4", + "messageID": "msg_14c01586a001NhWwWmtdVXrj0b" + } + ] + }, + { + "info": { + "role": "assistant", + "time": { + "created": 1792276568506, + "completed": 1792276569055 + }, + "parentID": "msg_14c015856001yoQ56RrccFgDBq", + "modelID": "gemini-fixture", + "providerID": "fixture", + "mode": "build", + "agent": "build", + "path": { + "cwd": "__DIRECTORY__", + "root": "__DIRECTORY__" + }, + "cost": 0, + "tokens": { + "total": 10, + "input": 7, + "output": 3, + "reasoning": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "usageStatus": "reported", + "finish": "tool-calls", + "id": "msg_14c0219ba0015ilaVztlPEUuUs", + "sessionID": "ses_eb3fea7bcffek9bjINhSiASkm4" + }, + "parts": [ + { + "type": "step-start", + "id": "prt_14c0219c2001ZteaGHFAIs1OJo", + "sessionID": "ses_eb3fea7bcffek9bjINhSiASkm4", + "messageID": "msg_14c0219ba0015ilaVztlPEUuUs" + }, + { + "type": "tool", + "callID": "7Fn41QAnQ0gx27wx", + "tool": "edit", + "state": { + "status": "completed", + "input": { + "filePath": "__DIRECTORY__/a.ts", + "oldString": "'x'", + "newString": "'y'" + }, + "output": "Edit applied successfully.\n\nLSP errors detected in this file, please fix:\n\nERROR [1:7] Type 'string' is not assignable to type 'number'.\n", + "title": "a.ts", + "metadata": { + "diagnostics": { + "__DIRECTORY__/a.ts": [ + { + "range": { + "start": { + "line": 0, + "character": 6 + }, + "end": { + "line": 0, + "character": 7 + } + }, + "message": "Type 'string' is not assignable to type 'number'.", + "severity": 1, + "code": 2322, + "source": "typescript" + }, + { + "range": { + "start": { + "line": 0, + "character": 6 + }, + "end": { + "line": 0, + "character": 7 + } + }, + "message": "'n' is declared but its value is never read.", + "severity": 4, + "code": 6133, + "source": "typescript" + } + ] + }, + "diff": "Index: __DIRECTORY__/a.ts\n===================================================================\n--- __DIRECTORY__/a.ts\n+++ __DIRECTORY__/a.ts\n@@ -1,2 +1,2 @@\n-const n: number = 'x'\n+const n: number = 'y'\n export {}\n", + "filediff": { + "file": "__DIRECTORY__/a.ts", + "before": "const n: number = 'x'\nexport {}\n", + "after": "const n: number = 'y'\nexport {}\n", + "additions": 1, + "deletions": 1 + }, + "truncated": false + }, + "time": { + "start": 1792276568517, + "end": 1792276569051 + } + }, + "metadata": { + "providerExecuted": false + }, + "id": "prt_14c0219c4001C5WEq2Wx00fUvm", + "sessionID": "ses_eb3fea7bcffek9bjINhSiASkm4", + "messageID": "msg_14c0219ba0015ilaVztlPEUuUs" + }, + { + "type": "step-finish", + "reason": "tool-calls", + "cost": 0, + "tokens": { + "total": 10, + "input": 7, + "output": 3, + "reasoning": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "id": "prt_14c021bdc00178or2dthQ3jI4l", + "sessionID": "ses_eb3fea7bcffek9bjINhSiASkm4", + "messageID": "msg_14c0219ba0015ilaVztlPEUuUs" + } + ] + }, + { + "info": { + "role": "assistant", + "time": { + "created": 1792276569207, + "completed": 1792276569220 + }, + "parentID": "msg_14c015856001yoQ56RrccFgDBq", + "modelID": "gemini-fixture", + "providerID": "fixture", + "mode": "build", + "agent": "build", + "path": { + "cwd": "__DIRECTORY__", + "root": "__DIRECTORY__" + }, + "cost": 0, + "tokens": { + "total": 10, + "input": 7, + "output": 3, + "reasoning": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "usageStatus": "reported", + "finish": "stop", + "id": "msg_14c021c77001O0my47Ed7EA6Ql", + "sessionID": "ses_eb3fea7bcffek9bjINhSiASkm4" + }, + "parts": [ + { + "type": "step-start", + "id": "prt_14c021c7e001rF7Hc7wrG7zhQp", + "sessionID": "ses_eb3fea7bcffek9bjINhSiASkm4", + "messageID": "msg_14c021c77001O0my47Ed7EA6Ql" + }, + { + "type": "text", + "text": "Done.", + "time": { + "start": 1792276569215, + "end": 1792276569217 + }, + "id": "prt_14c021c7f001yQds6Lfx1tzAHf", + "sessionID": "ses_eb3fea7bcffek9bjINhSiASkm4", + "messageID": "msg_14c021c77001O0my47Ed7EA6Ql" + }, + { + "type": "step-finish", + "reason": "stop", + "cost": 0, + "tokens": { + "total": 10, + "input": 7, + "output": 3, + "reasoning": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "id": "prt_14c021c82001DYJK3JElN524U0", + "sessionID": "ses_eb3fea7bcffek9bjINhSiASkm4", + "messageID": "msg_14c021c77001O0my47Ed7EA6Ql" + } + ] + } + ] +} diff --git a/packages/cli/test/cli/run-no-lsp.test.ts b/packages/cli/test/cli/run-no-lsp.test.ts new file mode 100644 index 00000000..1c51c9a1 --- /dev/null +++ b/packages/cli/test/cli/run-no-lsp.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from "bun:test" +import { $ } from "bun" +import fs from "fs/promises" +import path from "path" +import { tmpdir } from "../fixture/fixture" + +const entry = path.resolve(import.meta.dir, "../../src/index.ts") +const preRemovalSession = path.resolve(import.meta.dir, "fixture/pre-lsp-removal-session.json") + +type ModelPart = Record + +/** + * A fixture Gemini server that answers the nth request with the nth turn (the last turn + * repeats) and records every request body. + */ +function fixtureModel(turns: ModelPart[][]) { + const requests: any[] = [] + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + requests.push(await request.json()) + const parts = turns[Math.min(requests.length, turns.length) - 1] + const chunk = { + candidates: [{ index: 0, content: { role: "model", parts }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 7, candidatesTokenCount: 3, totalTokenCount: 10 }, + } + return new Response(`data: ${JSON.stringify(chunk)}\n\n`, { headers: { "content-type": "text/event-stream" } }) + }, + }) + return { server, requests } +} + +/** A git repo with its own home and XDG directories, so no outside config or state applies. */ +async function workspace() { + const repo = await tmpdir({ git: true }) + const home = await tmpdir() + const env = { + ...process.env, + HOME: home.path, + XDG_CONFIG_HOME: path.join(home.path, ".config"), + XDG_DATA_HOME: path.join(home.path, ".local/share"), + XDG_CACHE_HOME: path.join(home.path, ".cache"), + AICTRL_TEST_HOME: home.path, + AICTRL_DISABLE_DEFAULT_PLUGINS: "true", + AICTRL_DISABLE_MODELS_FETCH: "true", + AICTRL_DISABLE_AUTOCOMPACT: "true", + } + return { repo: repo.path, home: home.path, env, binDir: path.join(home.path, ".local/share", "aictrl", "bin") } +} + +async function useFixtureModel(repo: string, port: number) { + await Bun.write( + path.join(repo, "aictrl.json"), + JSON.stringify({ + provider: { + fixture: { + npm: "@ai-sdk/google", + options: { apiKey: "fixture", baseURL: `http://127.0.0.1:${port}` }, + models: { "gemini-fixture": { name: "fixture", limit: { context: 100000, output: 1000 } } }, + }, + }, + agent: { title: { disable: true } }, + }), + ) +} + +async function aictrl(args: string[], cwd: string, env: Record) { + const proc = Bun.spawn(["bun", "run", "--conditions=browser", entry, ...args], { + cwd, + env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }) + const timeout = setTimeout(() => proc.kill("SIGKILL"), 20000) + try { + const [stdout, stderr, exit] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + const events = stdout + .split("\n") + .filter((line) => line.startsWith("{")) + .map((line) => JSON.parse(line)) + return { exit, stdout, output: stderr + stdout, events } + } finally { + clearTimeout(timeout) + proc.kill("SIGKILL") + } +} + +function declaredTools(request: any): string[] { + return (request.tools ?? []).flatMap((tool: any) => tool.functionDeclarations ?? []).map((tool: any) => tool.name) +} + +describe("headless run without the LSP subsystem (#144)", () => { + test("write and edit on .ts and .py files return plain outputs and download no language servers", async () => { + const ws = await workspace() + const ts = path.join(ws.repo, "a.ts") + const py = path.join(ws.repo, "b.py") + // Both files are broken on purpose: a language server would have reported errors. + const model = fixtureModel([ + [{ functionCall: { name: "write", args: { filePath: ts, content: "const n: number = 'x'\nexport {}\n" } } }], + [{ functionCall: { name: "write", args: { filePath: py, content: "def f(:\n return undefined_name\n" } } }], + [{ functionCall: { name: "edit", args: { filePath: ts, oldString: "'x'", newString: "'y'" } } }], + [{ functionCall: { name: "edit", args: { filePath: py, oldString: "undefined_name", newString: "other" } } }], + [{ text: "Done." }], + ]) + try { + await useFixtureModel(ws.repo, model.server.port!) + // The removed flag must not bring the `lsp` tool back. + const env = { ...ws.env, AICTRL_EXPERIMENTAL_LSP_TOOL: "true" } + + const result = await aictrl( + ["run", "--format", "json", "--model", "fixture/gemini-fixture", "Fix the files."], + ws.repo, + env, + ) + + expect(result.exit, result.output).toBe(0) + const tools = result.events.filter((event) => event.type === "tool_use").map((event) => event.part) + expect(tools.map((part) => [part.tool, part.state.status, part.state.output])).toEqual([ + ["write", "completed", "Wrote file successfully."], + ["write", "completed", "Wrote file successfully."], + ["edit", "completed", "Edit applied successfully."], + ["edit", "completed", "Edit applied successfully."], + ]) + for (const part of tools) expect(part.state.metadata).not.toHaveProperty("diagnostics") + expect(result.stdout).not.toContain(" [])).toEqual([]) + } finally { + model.server.stop(true) + } + }, 30000) + + test("a session persisted with lsp tool parts and diagnostics metadata continues", async () => { + const ws = await workspace() + const model = fixtureModel([[{ text: "Continued." }]]) + try { + await useFixtureModel(ws.repo, model.server.port!) + await Bun.write(path.join(ws.repo, "a.ts"), "const n: number = 'y'\nexport {}\n") + const projectID = (await $`git rev-list --max-parents=0 HEAD`.cwd(ws.repo).text()).trim() + const fixture = (await Bun.file(preRemovalSession).text()) + .replaceAll("__DIRECTORY__", ws.repo) + .replaceAll("__PROJECT_ID__", projectID) + const sessionID = JSON.parse(fixture).info.id + const exported = path.join(ws.home, "session.json") + await Bun.write(exported, fixture) + + const imported = await aictrl(["import", exported], ws.repo, ws.env) + expect(imported.exit, imported.output).toBe(0) + expect(imported.stdout).toContain(`Imported session: ${sessionID}`) + + const result = await aictrl( + ["run", "--continue", "--format", "json", "--model", "fixture/gemini-fixture", "Carry on."], + ws.repo, + ws.env, + ) + + expect(result.exit, result.output).toBe(0) + expect(result.events.filter((event) => event.type === "session_complete")).toHaveLength(1) + expect(new Set(result.events.map((event) => event.sessionID).filter(Boolean))).toEqual(new Set([sessionID])) + expect(model.requests).toHaveLength(1) + // The replayed history still carries the old lsp call and the diagnostics-bearing edit output. + const history = JSON.stringify(model.requests[0].contents) + expect(history).toContain('"name":"lsp"') + expect(history).toContain(" { }) }) +describe("removed LSP config (#144)", () => { + async function loadWithWarnings(config: object) { + const warn = spyOn(Log.create({ service: "config" }), "warn") + await using tmp = await tmpdir({ init: (dir) => writeConfig(dir, config) }) + try { + return await Instance.provide({ + directory: tmp.path, + fn: async () => ({ + config: await Config.get(), + lspWarnings: warn.mock.calls.filter(([message]) => String(message).includes("lsp")), + }), + }) + } finally { + warn.mockRestore() + } + } + + test("an lsp block and an lsp permission rule load as deprecated no-ops with one warning", async () => { + const { config, lspWarnings } = await loadWithWarnings({ + model: "test/model", + // A custom server without `extensions` was rejected before #144; any shape is accepted now. + lsp: { typescript: { disabled: true }, custom: { command: ["custom-ls"] } }, + permission: { edit: "allow", lsp: "deny" }, + }) + + expect(config.model).toBe("test/model") + expect(config).not.toHaveProperty("lsp") + expect(config.permission?.edit).toBe("allow") + expect(lspWarnings).toHaveLength(1) + }) + + test("lsp: false loads with one warning", async () => { + const { config, lspWarnings } = await loadWithWarnings({ model: "test/model", lsp: false }) + + expect(config.model).toBe("test/model") + expect(config).not.toHaveProperty("lsp") + expect(lspWarnings).toHaveLength(1) + }) + + test("a config without lsp keys logs no lsp warning", async () => { + const { lspWarnings } = await loadWithWarnings({ model: "test/model", permission: { edit: "allow" } }) + + expect(lspWarnings).toHaveLength(0) + }) +}) + test("loads JSONC config file", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/cli/test/fixture/lsp/fake-lsp-server.js b/packages/cli/test/fixture/lsp/fake-lsp-server.js deleted file mode 100644 index 39e57880..00000000 --- a/packages/cli/test/fixture/lsp/fake-lsp-server.js +++ /dev/null @@ -1,77 +0,0 @@ -// Simple JSON-RPC 2.0 LSP-like fake server over stdio -// Implements a minimal LSP handshake and triggers a request upon notification - -const net = require("net") - -let nextId = 1 - -function encode(message) { - const json = JSON.stringify(message) - const header = `Content-Length: ${Buffer.byteLength(json, "utf8")}\r\n\r\n` - return Buffer.concat([Buffer.from(header, "utf8"), Buffer.from(json, "utf8")]) -} - -function decodeFrames(buffer) { - const results = [] - let idx - while ((idx = buffer.indexOf("\r\n\r\n")) !== -1) { - const header = buffer.slice(0, idx).toString("utf8") - const m = /Content-Length:\s*(\d+)/i.exec(header) - const len = m ? parseInt(m[1], 10) : 0 - const bodyStart = idx + 4 - const bodyEnd = bodyStart + len - if (buffer.length < bodyEnd) break - const body = buffer.slice(bodyStart, bodyEnd).toString("utf8") - results.push(body) - buffer = buffer.slice(bodyEnd) - } - return { messages: results, rest: buffer } -} - -let readBuffer = Buffer.alloc(0) - -process.stdin.on("data", (chunk) => { - readBuffer = Buffer.concat([readBuffer, chunk]) - const { messages, rest } = decodeFrames(readBuffer) - readBuffer = rest - for (const m of messages) handle(m) -}) - -function send(msg) { - process.stdout.write(encode(msg)) -} - -function sendRequest(method, params) { - const id = nextId++ - send({ jsonrpc: "2.0", id, method, params }) - return id -} - -function handle(raw) { - let data - try { - data = JSON.parse(raw) - } catch { - return - } - if (data.method === "initialize") { - send({ jsonrpc: "2.0", id: data.id, result: { capabilities: {} } }) - return - } - if (data.method === "initialized") { - return - } - if (data.method === "workspace/didChangeConfiguration") { - return - } - if (data.method === "test/trigger") { - const method = data.params && data.params.method - if (method) sendRequest(method, {}) - return - } - if (typeof data.id !== "undefined") { - // Respond OK to any request from client to keep transport flowing - send({ jsonrpc: "2.0", id: data.id, result: null }) - return - } -} diff --git a/packages/cli/test/lsp/client.test.ts b/packages/cli/test/lsp/client.test.ts deleted file mode 100644 index c2ba3ac5..00000000 --- a/packages/cli/test/lsp/client.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, test, beforeEach } from "bun:test" -import path from "path" -import { LSPClient } from "../../src/lsp/client" -import { LSPServer } from "../../src/lsp/server" -import { Instance } from "../../src/project/instance" -import { Log } from "../../src/util/log" - -// Minimal fake LSP server that speaks JSON-RPC over stdio -function spawnFakeServer() { - const { spawn } = require("child_process") - const serverPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js") - return { - process: spawn(process.execPath, [serverPath], { - stdio: "pipe", - }), - } -} - -describe("LSPClient interop", () => { - beforeEach(async () => { - await Log.init({ print: true }) - }) - - test("handles workspace/workspaceFolders request", async () => { - const handle = spawnFakeServer() as any - - const client = await Instance.provide({ - directory: process.cwd(), - fn: () => - LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: process.cwd(), - }), - }) - - await client.connection.sendNotification("test/trigger", { - method: "workspace/workspaceFolders", - }) - - await new Promise((r) => setTimeout(r, 100)) - - expect(client.connection).toBeDefined() - - await client.shutdown() - }) - - test("handles client/registerCapability request", async () => { - const handle = spawnFakeServer() as any - - const client = await Instance.provide({ - directory: process.cwd(), - fn: () => - LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: process.cwd(), - }), - }) - - await client.connection.sendNotification("test/trigger", { - method: "client/registerCapability", - }) - - await new Promise((r) => setTimeout(r, 100)) - - expect(client.connection).toBeDefined() - - await client.shutdown() - }) - - test("handles client/unregisterCapability request", async () => { - const handle = spawnFakeServer() as any - - const client = await Instance.provide({ - directory: process.cwd(), - fn: () => - LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: process.cwd(), - }), - }) - - await client.connection.sendNotification("test/trigger", { - method: "client/unregisterCapability", - }) - - await new Promise((r) => setTimeout(r, 100)) - - expect(client.connection).toBeDefined() - - await client.shutdown() - }) -}) From e95c2d0928ceb1673e9859d391b5d093bdd63929 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Sat, 17 Oct 2026 23:55:43 +0100 Subject: [PATCH 2/3] fix: keep deprecated lsp config on rewrite and scope review follow-ups (#144) Keep the accepted `lsp` block in the parsed config instead of stripping it, so Config.update no longer drops it from disk; it stays a no-op and each config file that sets it warns once. Drop the deleted util/archive.ts from the Bun shell migration plan, and let the no-download assertion ignore a ripgrep install on hosts without rg. Co-Authored-By: Claude Opus 5.5 --- packages/cli/BUN_SHELL_MIGRATION_PLAN.md | 1 - packages/cli/src/config/config.ts | 14 ++++----- packages/cli/test/cli/run-no-lsp.test.ts | 9 +++++- packages/cli/test/config/config.test.ts | 39 ++++++++++++++++++++++-- 4 files changed, 52 insertions(+), 11 deletions(-) diff --git a/packages/cli/BUN_SHELL_MIGRATION_PLAN.md b/packages/cli/BUN_SHELL_MIGRATION_PLAN.md index a1afa682..15ce464a 100644 --- a/packages/cli/BUN_SHELL_MIGRATION_PLAN.md +++ b/packages/cli/BUN_SHELL_MIGRATION_PLAN.md @@ -99,7 +99,6 @@ Migrate git-centric call sites to `Process.git*` helpers: Migrate residual non-git usages: - `src/cli/cmd/tui/util/clipboard.ts` -- `src/util/archive.ts` - `src/file/ripgrep.ts` - `src/tool/bash.ts` - `src/cli/cmd/uninstall.ts` diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 47a78bb8..7c92b799 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -1177,15 +1177,14 @@ export namespace Config { return load(text, { path: filepath }) } - // The LSP subsystem was removed (#144). Configs written before then may still set - // the `lsp` block or an `lsp` permission rule; both load as no-ops with one warning. - function withoutDeprecatedLsp(config: Record, source: string) { + // The LSP subsystem was removed (#144). Configs written before then may still set the + // `lsp` block or an `lsp` permission rule. The schema accepts both as no-ops and keeps + // them, so rewriting a config file preserves them; each config file that sets one warns once. + function warnDeprecatedLsp(config: Record, source: string) { const permission = config.permission const hasLspPermission = typeof permission === "object" && permission !== null && "lsp" in permission - if (!("lsp" in config) && !hasLspPermission) return config + if (!("lsp" in config) && !hasLspPermission) return log.warn("lsp config is deprecated and ignored; the LSP subsystem was removed", { path: source }) - const { lsp: _, ...rest } = config - return rest } async function load(text: string, options: { path: string } | { dir: string; source: string }) { @@ -1199,7 +1198,8 @@ export namespace Config { const normalized = (() => { if (!data || typeof data !== "object" || Array.isArray(data)) return data - const copy = withoutDeprecatedLsp({ ...(data as Record) }, source) + const copy = { ...(data as Record) } + warnDeprecatedLsp(copy, source) const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy if (!hadLegacy) return copy delete copy.theme diff --git a/packages/cli/test/cli/run-no-lsp.test.ts b/packages/cli/test/cli/run-no-lsp.test.ts index 1c51c9a1..23b8c20e 100644 --- a/packages/cli/test/cli/run-no-lsp.test.ts +++ b/packages/cli/test/cli/run-no-lsp.test.ts @@ -91,6 +91,10 @@ async function aictrl(args: string[], cwd: string, env: Record tool.functionDeclarations ?? []).map((tool: any) => tool.name) } @@ -130,7 +134,10 @@ describe("headless run without the LSP subsystem (#144)", () => { for (const part of tools) expect(part.state.metadata).not.toHaveProperty("diagnostics") expect(result.stdout).not.toContain(" [])).toEqual([]) + // The bin dir is also where ripgrep lands on hosts without a system `rg`; anything else + // there would be a downloaded language server. + const installed = await fs.readdir(ws.binDir).catch(() => []) + expect(installed.filter((name) => !isRipgrep(name))).toEqual([]) } finally { model.server.stop(true) } diff --git a/packages/cli/test/config/config.test.ts b/packages/cli/test/config/config.test.ts index d7c1e28d..7a791034 100644 --- a/packages/cli/test/config/config.test.ts +++ b/packages/cli/test/config/config.test.ts @@ -105,7 +105,6 @@ describe("removed LSP config (#144)", () => { }) expect(config.model).toBe("test/model") - expect(config).not.toHaveProperty("lsp") expect(config.permission?.edit).toBe("allow") expect(lspWarnings).toHaveLength(1) }) @@ -114,10 +113,46 @@ describe("removed LSP config (#144)", () => { const { config, lspWarnings } = await loadWithWarnings({ model: "test/model", lsp: false }) expect(config.model).toBe("test/model") - expect(config).not.toHaveProperty("lsp") expect(lspWarnings).toHaveLength(1) }) + test("each config layer that sets lsp warns once with its own path", async () => { + const warn = spyOn(Log.create({ service: "config" }), "warn") + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { lsp: false }) + await writeConfig(path.join(dir, ".aictrl"), { permission: { lsp: "deny" } }) + }, + }) + try { + await Instance.provide({ directory: tmp.path, fn: () => Config.get() }) + const paths = warn.mock.calls + .filter(([message]) => String(message).includes("lsp")) + .map(([, extra]) => extra?.path) + expect(paths.toSorted()).toEqual( + [path.join(tmp.path, ".aictrl", "aictrl.json"), path.join(tmp.path, "aictrl.json")].toSorted(), + ) + } finally { + warn.mockRestore() + } + }) + + test("Config.update keeps a deprecated lsp block on disk", async () => { + await using tmp = await tmpdir({ + init: (dir) => writeConfig(dir, { lsp: { custom: { command: ["ls"] } } }, "config.json"), + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Config.update({ model: "updated/model" }) + + const written = await Filesystem.readJson>(path.join(tmp.path, "config.json")) + expect(written.model).toBe("updated/model") + expect(written.lsp).toEqual({ custom: { command: ["ls"] } }) + }, + }) + }) + test("a config without lsp keys logs no lsp warning", async () => { const { lspWarnings } = await loadWithWarnings({ model: "test/model", permission: { edit: "allow" } }) From 546f16360551f4a566b27b6ce98b95bb9e84ec17 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Sun, 18 Oct 2026 00:08:36 +0100 Subject: [PATCH 3/3] fix: warn on an lsp rule set through AICTRL_PERMISSION (#144) The env permission override is merged after config files are parsed, so an `lsp` rule there was accepted silently. Warn for it the same way as for config files, and cover it with an end-to-end test. Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/config/config.ts | 4 +++- packages/cli/test/cli/run-no-lsp.test.ts | 14 +++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 7c92b799..38968337 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -196,7 +196,9 @@ export namespace Config { } if (Flag.AICTRL_PERMISSION) { - result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.AICTRL_PERMISSION)) + const permission = JSON.parse(Flag.AICTRL_PERMISSION) + warnDeprecatedLsp({ permission }, "AICTRL_PERMISSION") + result.permission = mergeDeep(result.permission ?? {}, permission) } // Backwards compatibility: legacy top-level `tools` config diff --git a/packages/cli/test/cli/run-no-lsp.test.ts b/packages/cli/test/cli/run-no-lsp.test.ts index 23b8c20e..1e58178b 100644 --- a/packages/cli/test/cli/run-no-lsp.test.ts +++ b/packages/cli/test/cli/run-no-lsp.test.ts @@ -82,7 +82,7 @@ async function aictrl(args: string[], cwd: string, env: Record line.startsWith("{")) + .filter((line) => line.startsWith('{"type":')) .map((line) => JSON.parse(line)) return { exit, stdout, output: stderr + stdout, events } } finally { @@ -143,6 +143,18 @@ describe("headless run without the LSP subsystem (#144)", () => { } }, 30000) + test("an lsp rule in AICTRL_PERMISSION loads as a no-op with one deprecation warning", async () => { + const ws = await workspace() + const env = { ...ws.env, AICTRL_PERMISSION: JSON.stringify({ edit: "allow", lsp: "deny" }) } + + const result = await aictrl(["--print-logs", "--log-level", "WARN", "debug", "config"], ws.repo, env) + + expect(result.exit, result.output).toBe(0) + const warnings = result.output.split("\n").filter((line) => line.includes("lsp config is deprecated")) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toContain("path=AICTRL_PERMISSION") + }, 30000) + test("a session persisted with lsp tool parts and diagnostics metadata continues", async () => { const ws = await workspace() const model = fixtureModel([[{ text: "Continued." }]])