oh-my-opencode 5.0.0-beta.21 → 5.0.0-beta.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +290 -288
- package/dist/cli-node/index.js +290 -288
- package/dist/index.js +1675 -1657
- package/dist/skills/debugging/SKILL.md +2 -0
- package/dist/skills/debugging/references/methodology/00-setup.md +12 -0
- package/dist/skills/debugging/references/methodology/02-investigate.md +21 -0
- package/dist/skills/debugging/references/runtimes/go.md +15 -1
- package/dist/skills/debugging/references/runtimes/native-binary.md +38 -0
- package/dist/skills/debugging/references/runtimes/node.md +25 -0
- package/dist/skills/debugging/references/runtimes/python.md +13 -0
- package/dist/skills/debugging/references/runtimes/rust.md +41 -0
- package/dist/skills/debugging/references/scripts/dap.mjs +267 -0
- package/dist/skills/debugging/references/scripts/dap.test.ts +86 -0
- package/dist/skills/debugging/references/scripts/fixture-adapter.mjs +59 -0
- package/dist/skills/debugging/references/tools/dap.md +103 -0
- package/dist/skills/debugging/references/tools/frida.md +193 -0
- package/dist/tools/delegate-task/types.d.ts +3 -0
- package/dist/tui.js +131 -147
- package/package.json +18 -15
- package/packages/lsp-core/src/index.ts +1 -0
- package/packages/lsp-core/src/lsp/client-wrapper.ts +25 -5
- package/packages/lsp-core/src/lsp/client.ts +26 -0
- package/packages/lsp-core/src/lsp/connection.ts +16 -0
- package/packages/lsp-core/src/lsp/constants.ts +1 -0
- package/packages/lsp-core/src/lsp/format-document.test.ts +143 -0
- package/packages/lsp-core/src/lsp/format-document.ts +90 -0
- package/packages/lsp-core/src/lsp/manager-max-clients.test.ts +151 -0
- package/packages/lsp-core/src/lsp/manager.ts +34 -1
- package/packages/lsp-core/src/lsp/server-definitions.ts +29 -0
- package/packages/lsp-core/src/lsp/server-installation.test.ts +376 -0
- package/packages/lsp-core/src/lsp/server-installation.ts +200 -27
- package/packages/lsp-core/src/lsp/server-resolution-local-binary.test.ts +170 -0
- package/packages/lsp-core/src/lsp/server-resolution.ts +18 -5
- package/packages/lsp-core/src/lsp/transport.ts +9 -0
- package/packages/lsp-core/src/lsp/types.ts +8 -0
- package/packages/lsp-core/src/tool-surface.test.ts +13 -1
- package/packages/lsp-core/src/tools/definitions.ts +14 -0
- package/packages/lsp-core/src/tools/format.test.ts +56 -0
- package/packages/lsp-core/src/tools/format.ts +56 -0
- package/packages/lsp-core/src/tools/index.ts +1 -0
- package/packages/lsp-core/src/tools/types.ts +10 -0
- package/packages/lsp-daemon/dist/cli.js +432 -144
- package/packages/lsp-daemon/dist/client.d.ts +10 -0
- package/packages/lsp-daemon/dist/client.js +401 -107
- package/packages/lsp-daemon/dist/daemon-client.d.ts +1 -0
- package/packages/lsp-daemon/dist/daemon-client.js +3 -0
- package/packages/lsp-daemon/dist/index.js +395 -107
- package/packages/lsp-daemon/package.json +1 -1
- package/packages/lsp-tools-mcp/dist/cli.js +384 -99
- package/packages/lsp-tools-mcp/dist/lsp/manager.js +53 -0
- package/packages/lsp-tools-mcp/dist/mcp.js +384 -99
- package/packages/lsp-tools-mcp/dist/tools.js +385 -99
- package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/dist/cli.js +2 -2
- package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
- package/packages/omo-codex/plugin/components/codegraph/dist/cli.js +69 -35
- package/packages/omo-codex/plugin/components/codegraph/dist/serve.js +21 -2
- package/packages/omo-codex/plugin/components/codegraph/package.json +1 -1
- package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
- package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
- package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +3 -3
- package/packages/omo-codex/plugin/components/lsp/dist/cli.js +404 -121
- package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
- package/packages/omo-codex/plugin/components/rules/dist/cli.js +3 -3
- package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
- package/packages/omo-codex/plugin/components/rules/package.json +1 -1
- package/packages/omo-codex/plugin/components/rules/src/post-compact-budget.ts +3 -3
- package/packages/omo-codex/plugin/components/rules/test/post-compact-budget.test.ts +4 -4
- package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/dist/cli.js +30 -15
- package/packages/omo-codex/plugin/components/telemetry/dist/posthog.js +30 -15
- package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-commands.js +5 -1
- package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-output.d.ts +2 -1
- package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-output.js +11 -1
- package/packages/omo-codex/plugin/components/ulw-loop/dist/cli.js +22 -10
- package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +4 -4
- package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/src/cli-commands.ts +5 -1
- package/packages/omo-codex/plugin/components/ulw-loop/src/cli-output.ts +13 -1
- package/packages/omo-codex/plugin/components/ulw-loop/src/cli.ts +3 -5
- package/packages/omo-codex/plugin/components/ulw-loop/test/cli-entrypoint.test.ts +26 -2
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-codegraph-init-guidance.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-codegraph-bootstrap.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
- package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
- package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
- package/packages/omo-codex/plugin/hooks/subagent-stop-checking-ulw-execute-continuation.json +1 -1
- package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/model-catalog.json +3 -3
- package/packages/omo-codex/plugin/package-lock.json +13 -13
- package/packages/omo-codex/plugin/package.json +1 -1
- package/packages/omo-codex/plugin/scripts/migrate-codex-config/catalog.mjs +3 -3
- package/packages/omo-codex/plugin/skills/debugging/SKILL.md +2 -0
- package/packages/omo-codex/plugin/skills/debugging/references/methodology/00-setup.md +12 -0
- package/packages/omo-codex/plugin/skills/debugging/references/methodology/02-investigate.md +21 -0
- package/packages/omo-codex/plugin/skills/debugging/references/runtimes/go.md +15 -1
- package/packages/omo-codex/plugin/skills/debugging/references/runtimes/native-binary.md +38 -0
- package/packages/omo-codex/plugin/skills/debugging/references/runtimes/node.md +25 -0
- package/packages/omo-codex/plugin/skills/debugging/references/runtimes/python.md +13 -0
- package/packages/omo-codex/plugin/skills/debugging/references/runtimes/rust.md +41 -0
- package/packages/omo-codex/plugin/skills/debugging/references/scripts/dap.mjs +267 -0
- package/packages/omo-codex/plugin/skills/debugging/references/scripts/fixture-adapter.mjs +59 -0
- package/packages/omo-codex/plugin/skills/debugging/references/tools/dap.md +103 -0
- package/packages/omo-codex/plugin/skills/debugging/references/tools/frida.md +193 -0
- package/packages/omo-codex/plugin/test/aggregate-model-catalog.test.mjs +1 -1
- package/packages/omo-codex/plugin/test/auto-update.test.mjs +2 -2
- package/packages/omo-codex/plugin/test/migrate-codex-config.test.mjs +8 -8
- package/packages/omo-codex/scripts/install-config-reasoning.test.mjs +2 -2
- package/packages/omo-codex/scripts/install-dist/install-local.mjs +38 -23
- package/packages/shared-skills/skills/debugging/SKILL.md +2 -0
- package/packages/shared-skills/skills/debugging/references/methodology/00-setup.md +12 -0
- package/packages/shared-skills/skills/debugging/references/methodology/02-investigate.md +21 -0
- package/packages/shared-skills/skills/debugging/references/runtimes/go.md +15 -1
- package/packages/shared-skills/skills/debugging/references/runtimes/native-binary.md +38 -0
- package/packages/shared-skills/skills/debugging/references/runtimes/node.md +25 -0
- package/packages/shared-skills/skills/debugging/references/runtimes/python.md +13 -0
- package/packages/shared-skills/skills/debugging/references/runtimes/rust.md +41 -0
- package/packages/shared-skills/skills/debugging/references/scripts/dap.mjs +267 -0
- package/packages/shared-skills/skills/debugging/references/scripts/dap.test.ts +86 -0
- package/packages/shared-skills/skills/debugging/references/scripts/fixture-adapter.mjs +59 -0
- package/packages/shared-skills/skills/debugging/references/tools/dap.md +103 -0
- package/packages/shared-skills/skills/debugging/references/tools/frida.md +193 -0
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from "./errors.js";
|
|
18
18
|
import { getLspManager, type LspManager } from "./manager.js";
|
|
19
19
|
import { findWorkspaceRootOutsideContext } from "./outside-context-workspace.js";
|
|
20
|
+
import { LSP_LOCAL_INSTALL_HINTS } from "./server-definitions.js";
|
|
20
21
|
import { loadInstallDecision } from "./server-install-state.js";
|
|
21
22
|
import { findServerForExtension } from "./server-resolution.js";
|
|
22
23
|
import type { ServerLookupResult } from "./types.js";
|
|
@@ -138,6 +139,26 @@ export function formatServerLookupError(result: Exclude<ServerLookupResult, { st
|
|
|
138
139
|
].join("\n");
|
|
139
140
|
}
|
|
140
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Builds the install guidance, offering the repo-local install first.
|
|
144
|
+
*
|
|
145
|
+
* A repo-local install is resolvable without touching the global environment, so it is the
|
|
146
|
+
* recommended route whenever the server ships as a project dependency.
|
|
147
|
+
*/
|
|
148
|
+
function formatInstallOptions(serverId: string, installHint: string): string[] {
|
|
149
|
+
const localHint = LSP_LOCAL_INSTALL_HINTS[serverId];
|
|
150
|
+
if (localHint === undefined) {
|
|
151
|
+
return ["To install, run:", ` ${installHint}`];
|
|
152
|
+
}
|
|
153
|
+
return [
|
|
154
|
+
"To install in THIS repository (preferred — no global install needed):",
|
|
155
|
+
` ${localHint}`,
|
|
156
|
+
"",
|
|
157
|
+
"Or install it globally:",
|
|
158
|
+
` ${installHint}`,
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
|
|
141
162
|
function formatNotInstalled(result: Extract<ServerLookupResult, { status: "not_installed" }>): string {
|
|
142
163
|
const { server, installHint } = result;
|
|
143
164
|
const extensions = server.extensions.join(", ");
|
|
@@ -154,20 +175,20 @@ function formatNotInstalled(result: Extract<ServerLookupResult, { status: "not_i
|
|
|
154
175
|
`Command not found: ${server.command[0]}`,
|
|
155
176
|
"",
|
|
156
177
|
];
|
|
178
|
+
const installOptions = formatInstallOptions(server.id, installHint);
|
|
157
179
|
|
|
158
180
|
if (decision === "allowed") {
|
|
159
181
|
return [
|
|
160
182
|
...header,
|
|
161
183
|
"The user has pre-authorized LSP installation. Run the install command, then retry this tool:",
|
|
162
|
-
|
|
184
|
+
...installOptions.slice(1),
|
|
163
185
|
].join("\n");
|
|
164
186
|
}
|
|
165
187
|
|
|
166
188
|
if (!context.capabilities.installDecisionTool) {
|
|
167
189
|
return [
|
|
168
190
|
...header,
|
|
169
|
-
|
|
170
|
-
` ${installHint}`,
|
|
191
|
+
...installOptions,
|
|
171
192
|
"",
|
|
172
193
|
"ACTION REQUIRED — ASK THE USER whether to install this LSP server.",
|
|
173
194
|
"Install-decision recording is unavailable in this harness; proceed without LSP if the user declines.",
|
|
@@ -176,8 +197,7 @@ function formatNotInstalled(result: Extract<ServerLookupResult, { status: "not_i
|
|
|
176
197
|
|
|
177
198
|
return [
|
|
178
199
|
...header,
|
|
179
|
-
|
|
180
|
-
` ${installHint}`,
|
|
200
|
+
...installOptions,
|
|
181
201
|
"",
|
|
182
202
|
"ACTION REQUIRED — ASK THE USER whether to install this LSP server.",
|
|
183
203
|
"- If the user agrees: run the install command above, then retry this tool.",
|
|
@@ -6,6 +6,7 @@ import type { LspClientTimeoutOptions } from "./transport.js";
|
|
|
6
6
|
import type {
|
|
7
7
|
Diagnostic,
|
|
8
8
|
DocumentSymbol,
|
|
9
|
+
FormattingOptions,
|
|
9
10
|
Location,
|
|
10
11
|
LocationLink,
|
|
11
12
|
PrepareRenameDefaultBehavior,
|
|
@@ -13,6 +14,7 @@ import type {
|
|
|
13
14
|
Range,
|
|
14
15
|
ResolvedServer,
|
|
15
16
|
SymbolInfo,
|
|
17
|
+
TextEdit,
|
|
16
18
|
WorkspaceEdit,
|
|
17
19
|
} from "./types.js";
|
|
18
20
|
import { LspRequestTimeoutError } from "./errors.js";
|
|
@@ -133,6 +135,30 @@ export class LspClient extends LspClientConnection {
|
|
|
133
135
|
}, options);
|
|
134
136
|
}
|
|
135
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Requests whole-document formatting edits, returning null when the server never advertised the
|
|
140
|
+
* capability so callers can report unavailability instead of surfacing a method-not-found error.
|
|
141
|
+
*/
|
|
142
|
+
async formatDocument(
|
|
143
|
+
filePath: string,
|
|
144
|
+
options: FormattingOptions,
|
|
145
|
+
signal?: AbortSignal,
|
|
146
|
+
): Promise<TextEdit[] | null> {
|
|
147
|
+
if (!this.isDocumentFormattingSupported()) return null;
|
|
148
|
+
const absPath = this.resolveWorkspacePath(filePath);
|
|
149
|
+
await this.openFile(absPath);
|
|
150
|
+
const requestOptions = signal === undefined ? {} : { signal };
|
|
151
|
+
const edits = await this.sendRequest<TextEdit[] | null>(
|
|
152
|
+
"textDocument/formatting",
|
|
153
|
+
{
|
|
154
|
+
textDocument: { uri: pathToFileURL(absPath).href },
|
|
155
|
+
options,
|
|
156
|
+
},
|
|
157
|
+
requestOptions,
|
|
158
|
+
);
|
|
159
|
+
return edits ?? [];
|
|
160
|
+
}
|
|
161
|
+
|
|
136
162
|
async workspaceSymbols(query: string, signal?: AbortSignal): Promise<SymbolInfo[]> {
|
|
137
163
|
const options = signal === undefined ? {} : { signal };
|
|
138
164
|
return this.sendRequest<SymbolInfo[]>("workspace/symbol", { query }, options);
|
|
@@ -4,6 +4,7 @@ import { LspClientTransport } from "./transport.js";
|
|
|
4
4
|
|
|
5
5
|
interface InitializeCapabilities {
|
|
6
6
|
readonly diagnosticProvider?: unknown;
|
|
7
|
+
readonly documentFormattingProvider?: unknown;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
function supportsDiagnosticPull(capabilities: InitializeCapabilities | undefined): boolean {
|
|
@@ -11,6 +12,19 @@ function supportsDiagnosticPull(capabilities: InitializeCapabilities | undefined
|
|
|
11
12
|
return Object.hasOwn(capabilities, "diagnosticProvider");
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Reads the whole-document formatting capability the server advertised at initialize time.
|
|
17
|
+
*
|
|
18
|
+
* The provider is either a boolean or an options object, and an explicit `false` means the
|
|
19
|
+
* server refuses the request, so both shapes are checked instead of mere key presence.
|
|
20
|
+
*/
|
|
21
|
+
function supportsDocumentFormatting(capabilities: InitializeCapabilities | undefined): boolean {
|
|
22
|
+
if (capabilities === undefined) return false;
|
|
23
|
+
const provider = capabilities.documentFormattingProvider;
|
|
24
|
+
if (provider === undefined || provider === null || provider === false) return false;
|
|
25
|
+
return provider === true || typeof provider === "object";
|
|
26
|
+
}
|
|
27
|
+
|
|
14
28
|
export class LspClientConnection extends LspClientTransport {
|
|
15
29
|
async initialize(): Promise<void> {
|
|
16
30
|
const rootUri = pathToFileURL(this.root).href;
|
|
@@ -28,6 +42,7 @@ export class LspClientConnection extends LspClientTransport {
|
|
|
28
42
|
references: {},
|
|
29
43
|
documentSymbol: { hierarchicalDocumentSymbolSupport: true },
|
|
30
44
|
publishDiagnostics: {},
|
|
45
|
+
formatting: { dynamicRegistration: false },
|
|
31
46
|
rename: {
|
|
32
47
|
prepareSupport: true,
|
|
33
48
|
prepareSupportDefaultBehavior: 1,
|
|
@@ -71,6 +86,7 @@ export class LspClientConnection extends LspClientTransport {
|
|
|
71
86
|
{ timeoutMs: this.initializeTimeoutMs },
|
|
72
87
|
);
|
|
73
88
|
this.setDiagnosticPullSupported(supportsDiagnosticPull(result?.capabilities));
|
|
89
|
+
this.setDocumentFormattingSupported(supportsDocumentFormatting(result?.capabilities));
|
|
74
90
|
await this.sendNotification("initialized", {});
|
|
75
91
|
await this.sendNotification("workspace/didChangeConfiguration", {
|
|
76
92
|
settings: { json: { validate: { enable: true } } },
|
|
@@ -6,6 +6,7 @@ export const DEFAULT_MAX_DIRECTORY_FILES = 50;
|
|
|
6
6
|
export const REQUEST_TIMEOUT_MS = 15_000;
|
|
7
7
|
export const INIT_TIMEOUT_MS = 60_000;
|
|
8
8
|
export const IDLE_TIMEOUT_MS = 5 * 60_000;
|
|
9
|
+
export const MAX_RESIDENT_CLIENTS = 6;
|
|
9
10
|
export const REAPER_INTERVAL_MS = 60_000;
|
|
10
11
|
export const STOP_HARD_KILL_TIMEOUT_MS = 5_000;
|
|
11
12
|
export const STOP_SIGKILL_GRACE_MS = 1_000;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
6
|
+
|
|
7
|
+
import type { LspClient } from "./client.js";
|
|
8
|
+
import { formatDocumentWithClient } from "./format-document.js";
|
|
9
|
+
import type { TextEdit } from "./types.js";
|
|
10
|
+
|
|
11
|
+
const workspaces: string[] = [];
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
for (const workspace of workspaces.splice(0)) rmSync(workspace, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function workspaceWith(fileName: string, content: string): { readonly root: string; readonly filePath: string } {
|
|
18
|
+
const root = mkdtempSync(join(tmpdir(), "lsp-format-"));
|
|
19
|
+
workspaces.push(root);
|
|
20
|
+
const filePath = join(root, fileName);
|
|
21
|
+
writeFileSync(filePath, content, "utf-8");
|
|
22
|
+
return { root, filePath };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function stubClient(edits: TextEdit[] | null): LspClient {
|
|
26
|
+
return {
|
|
27
|
+
async formatDocument() {
|
|
28
|
+
return edits;
|
|
29
|
+
},
|
|
30
|
+
async openFile() {},
|
|
31
|
+
} as Pick<LspClient, "formatDocument" | "openFile"> as LspClient;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("formatDocumentWithClient", () => {
|
|
35
|
+
it("#given a server returning text edits #when the document is formatted #then the file is rewritten and line deltas are reported", async () => {
|
|
36
|
+
// given
|
|
37
|
+
const { filePath } = workspaceWith("sample.ts", "const a=1\nconst b =2\nconst c=3\n");
|
|
38
|
+
const edits: TextEdit[] = [
|
|
39
|
+
{ range: { start: { line: 0, character: 0 }, end: { line: 1, character: 12 } }, newText: "const a = 1;\nconst b = 2;" },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
// when
|
|
43
|
+
const result = await formatDocumentWithClient(stubClient(edits), filePath);
|
|
44
|
+
|
|
45
|
+
// then
|
|
46
|
+
expect(result.status).toBe("formatted");
|
|
47
|
+
expect(readFileSync(filePath, "utf-8")).toBe("const a = 1;\nconst b = 2;\nconst c=3\n");
|
|
48
|
+
expect(result).toMatchObject({ status: "formatted", linesAdded: 2, linesRemoved: 2 });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("#given a server returning an edit that adds lines #when the document is formatted #then the added and removed line counts differ", async () => {
|
|
52
|
+
// given
|
|
53
|
+
const { filePath } = workspaceWith("sample.ts", "const a=1;const b=2\n");
|
|
54
|
+
const edits: TextEdit[] = [
|
|
55
|
+
{ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 19 } }, newText: "const a = 1;\nconst b = 2;" },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
// when
|
|
59
|
+
const result = await formatDocumentWithClient(stubClient(edits), filePath);
|
|
60
|
+
|
|
61
|
+
// then
|
|
62
|
+
expect(result).toMatchObject({ status: "formatted", linesAdded: 2, linesRemoved: 1 });
|
|
63
|
+
expect(readFileSync(filePath, "utf-8")).toBe("const a = 1;\nconst b = 2;\n");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("#given a server that does not advertise document formatting #when the document is formatted #then the result is unavailable and the file bytes are untouched", async () => {
|
|
67
|
+
// given
|
|
68
|
+
const original = "const a=1\n";
|
|
69
|
+
const { filePath } = workspaceWith("sample.ts", original);
|
|
70
|
+
const before = statSync(filePath);
|
|
71
|
+
|
|
72
|
+
// when
|
|
73
|
+
const result = await formatDocumentWithClient(stubClient(null), filePath);
|
|
74
|
+
|
|
75
|
+
// then
|
|
76
|
+
expect(result).toEqual({ status: "unavailable", reason: "capability_not_advertised" });
|
|
77
|
+
expect(readFileSync(filePath)).toEqual(Buffer.from(original));
|
|
78
|
+
expect(statSync(filePath).mtimeMs).toBe(before.mtimeMs);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("#given a server returning no edits #when the document is formatted #then the result is unchanged and the file bytes are untouched", async () => {
|
|
82
|
+
// given
|
|
83
|
+
const original = "const a = 1;\n";
|
|
84
|
+
const { filePath } = workspaceWith("sample.ts", original);
|
|
85
|
+
|
|
86
|
+
// when
|
|
87
|
+
const result = await formatDocumentWithClient(stubClient([]), filePath);
|
|
88
|
+
|
|
89
|
+
// then
|
|
90
|
+
expect(result).toEqual({ status: "unchanged", linesAdded: 0, linesRemoved: 0 });
|
|
91
|
+
expect(readFileSync(filePath)).toEqual(Buffer.from(original));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("#given edits that reproduce the current bytes #when the document is formatted #then the result is unchanged", async () => {
|
|
95
|
+
// given
|
|
96
|
+
const original = "const a = 1;\n";
|
|
97
|
+
const { filePath } = workspaceWith("sample.ts", original);
|
|
98
|
+
const edits: TextEdit[] = [
|
|
99
|
+
{ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 12 } }, newText: "const a = 1;" },
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
// when
|
|
103
|
+
const result = await formatDocumentWithClient(stubClient(edits), filePath);
|
|
104
|
+
|
|
105
|
+
// then
|
|
106
|
+
expect(result.status).toBe("unchanged");
|
|
107
|
+
expect(readFileSync(filePath, "utf-8")).toBe(original);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("#given the file shrinks after the server computed its edits #when the edits are applied #then the stale edit is rejected instead of corrupting the file", async () => {
|
|
111
|
+
// given
|
|
112
|
+
const { filePath } = workspaceWith("sample.ts", "const a=1\nconst b=2\n");
|
|
113
|
+
const staleEdits: TextEdit[] = [
|
|
114
|
+
{ range: { start: { line: 1, character: 0 }, end: { line: 1, character: 9 } }, newText: "const b = 2;" },
|
|
115
|
+
];
|
|
116
|
+
const racingClient = {
|
|
117
|
+
async formatDocument() {
|
|
118
|
+
writeFileSync(filePath, "short\n", "utf-8");
|
|
119
|
+
return staleEdits;
|
|
120
|
+
},
|
|
121
|
+
async openFile() {},
|
|
122
|
+
} as Pick<LspClient, "formatDocument" | "openFile"> as LspClient;
|
|
123
|
+
|
|
124
|
+
// when / then
|
|
125
|
+
await expect(formatDocumentWithClient(racingClient, filePath)).rejects.toThrow(/outside line/);
|
|
126
|
+
expect(readFileSync(filePath, "utf-8")).toBe("short\n");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("#given a formatted document #when the write completes #then no temporary file is left behind", async () => {
|
|
130
|
+
// given
|
|
131
|
+
const { root, filePath } = workspaceWith("sample.ts", "const a=1\n");
|
|
132
|
+
const edits: TextEdit[] = [
|
|
133
|
+
{ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 9 } }, newText: "const a = 1;" },
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
// when
|
|
137
|
+
await formatDocumentWithClient(stubClient(edits), filePath);
|
|
138
|
+
|
|
139
|
+
// then
|
|
140
|
+
expect(readFileSync(filePath, "utf-8")).toBe("const a = 1;\n");
|
|
141
|
+
expect(() => statSync(join(root, "sample.ts.tmp"))).toThrow();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import type { LspClient } from "./client.js";
|
|
4
|
+
import type { FormattingOptions, TextEdit } from "./types.js";
|
|
5
|
+
import { normalizeTextEdits } from "./workspace-edit-text.js";
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_FORMATTING_OPTIONS: FormattingOptions = {
|
|
8
|
+
tabSize: 4,
|
|
9
|
+
insertSpaces: false,
|
|
10
|
+
trimTrailingWhitespace: true,
|
|
11
|
+
insertFinalNewline: true,
|
|
12
|
+
trimFinalNewlines: true,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type FormatDocumentResult =
|
|
16
|
+
| { readonly status: "formatted"; readonly linesAdded: number; readonly linesRemoved: number }
|
|
17
|
+
| { readonly status: "unchanged"; readonly linesAdded: 0; readonly linesRemoved: 0 }
|
|
18
|
+
| { readonly status: "unavailable"; readonly reason: "capability_not_advertised" };
|
|
19
|
+
|
|
20
|
+
export interface FormatDocumentOptions {
|
|
21
|
+
readonly formattingOptions?: FormattingOptions;
|
|
22
|
+
readonly signal?: AbortSignal;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const UNCHANGED: FormatDocumentResult = { status: "unchanged", linesAdded: 0, linesRemoved: 0 };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Formats one document through the resident language server and commits the result to disk.
|
|
29
|
+
*
|
|
30
|
+
* The server is asked for whole-document edits rather than formatted text, so the edits are
|
|
31
|
+
* validated and applied with the same normalization the workspace-edit path uses. Nothing is
|
|
32
|
+
* written when the server declines the capability or when the edits reproduce the current bytes,
|
|
33
|
+
* which keeps a no-op format from touching file mtimes that downstream tooling watches.
|
|
34
|
+
*/
|
|
35
|
+
export async function formatDocumentWithClient(
|
|
36
|
+
client: LspClient,
|
|
37
|
+
filePath: string,
|
|
38
|
+
options: FormatDocumentOptions = {},
|
|
39
|
+
): Promise<FormatDocumentResult> {
|
|
40
|
+
const edits = await client.formatDocument(
|
|
41
|
+
filePath,
|
|
42
|
+
options.formattingOptions ?? DEFAULT_FORMATTING_OPTIONS,
|
|
43
|
+
options.signal,
|
|
44
|
+
);
|
|
45
|
+
if (edits === null) return { status: "unavailable", reason: "capability_not_advertised" };
|
|
46
|
+
if (edits.length === 0) return UNCHANGED;
|
|
47
|
+
|
|
48
|
+
const before = readFileSync(filePath, "utf-8");
|
|
49
|
+
const normalized = normalizeTextEdits(before, edits, 0);
|
|
50
|
+
if (normalized.text === before) return UNCHANGED;
|
|
51
|
+
|
|
52
|
+
writeAtomically(filePath, normalized.text);
|
|
53
|
+
await client.openFile(filePath);
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
status: "formatted",
|
|
57
|
+
...lineDelta(normalized.edits),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Counts the lines each edit removes from the document and adds back in its place.
|
|
63
|
+
*
|
|
64
|
+
* Counting from the edits instead of diffing the whole file keeps the report proportional to what
|
|
65
|
+
* the server actually rewrote, so an unrelated line never shows up as churn.
|
|
66
|
+
*/
|
|
67
|
+
function lineDelta(edits: readonly TextEdit[]): { readonly linesAdded: number; readonly linesRemoved: number } {
|
|
68
|
+
let linesAdded = 0;
|
|
69
|
+
let linesRemoved = 0;
|
|
70
|
+
for (const edit of edits) {
|
|
71
|
+
linesRemoved += edit.range.end.line - edit.range.start.line + 1;
|
|
72
|
+
linesAdded += edit.newText.split("\n").length;
|
|
73
|
+
}
|
|
74
|
+
return { linesAdded, linesRemoved };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function writeAtomically(filePath: string, content: string): void {
|
|
78
|
+
const tempPath = `${filePath}.omo-format.tmp`;
|
|
79
|
+
writeFileSync(tempPath, content, "utf-8");
|
|
80
|
+
try {
|
|
81
|
+
renameSync(tempPath, filePath);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
try {
|
|
84
|
+
unlinkSync(tempPath);
|
|
85
|
+
} catch {
|
|
86
|
+
// The rename failure is the actionable error; a leftover temp file must not mask it.
|
|
87
|
+
}
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type { LspClient } from "./client.js";
|
|
4
|
+
import { LspManager } from "./manager.js";
|
|
5
|
+
import type { ResolvedServer } from "./types.js";
|
|
6
|
+
|
|
7
|
+
const SERVER: ResolvedServer = {
|
|
8
|
+
id: "typescript",
|
|
9
|
+
command: ["typescript-language-server", "--stdio"],
|
|
10
|
+
extensions: [".ts"],
|
|
11
|
+
priority: 1,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
class FakeLspClient {
|
|
15
|
+
alive = true;
|
|
16
|
+
stopCallCount = 0;
|
|
17
|
+
|
|
18
|
+
constructor(readonly root: string) {}
|
|
19
|
+
|
|
20
|
+
async start(): Promise<void> {}
|
|
21
|
+
|
|
22
|
+
async initialize(): Promise<void> {}
|
|
23
|
+
|
|
24
|
+
isAlive(): boolean {
|
|
25
|
+
return this.alive;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
command(): string[] {
|
|
29
|
+
return [...SERVER.command];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async stop(): Promise<void> {
|
|
33
|
+
this.stopCallCount += 1;
|
|
34
|
+
this.alive = false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function managerWithFakes(maxResidentClients: number): {
|
|
39
|
+
readonly manager: LspManager;
|
|
40
|
+
readonly clients: Map<string, FakeLspClient>;
|
|
41
|
+
readonly clock: { value: number };
|
|
42
|
+
} {
|
|
43
|
+
const clients = new Map<string, FakeLspClient>();
|
|
44
|
+
const clock = { value: 1_000 };
|
|
45
|
+
const manager = new LspManager({
|
|
46
|
+
maxResidentClients,
|
|
47
|
+
reaperIntervalMs: 60_000,
|
|
48
|
+
idleTimeoutMs: 60_000_000,
|
|
49
|
+
now: () => clock.value,
|
|
50
|
+
clientFactory: (root) => {
|
|
51
|
+
const client = new FakeLspClient(root);
|
|
52
|
+
clients.set(root, client);
|
|
53
|
+
return client as unknown as LspClient;
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
return { manager, clients, clock };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("LspManager resident client cap", () => {
|
|
60
|
+
it("#given a cap of two idle clients #when a third client is admitted #then the least recently used one is stopped and evicted", async () => {
|
|
61
|
+
// given
|
|
62
|
+
const { manager, clients, clock } = managerWithFakes(2);
|
|
63
|
+
try {
|
|
64
|
+
await manager.getClient("/root-a", SERVER);
|
|
65
|
+
manager.releaseClient("/root-a", SERVER.id);
|
|
66
|
+
clock.value += 10;
|
|
67
|
+
await manager.getClient("/root-b", SERVER);
|
|
68
|
+
manager.releaseClient("/root-b", SERVER.id);
|
|
69
|
+
clock.value += 10;
|
|
70
|
+
|
|
71
|
+
// when
|
|
72
|
+
await manager.getClient("/root-c", SERVER);
|
|
73
|
+
manager.releaseClient("/root-c", SERVER.id);
|
|
74
|
+
|
|
75
|
+
// then
|
|
76
|
+
expect(manager.clientCount()).toBe(2);
|
|
77
|
+
expect(manager.hasClient("/root-a", SERVER.id)).toBe(false);
|
|
78
|
+
expect(manager.hasClient("/root-b", SERVER.id)).toBe(true);
|
|
79
|
+
expect(manager.hasClient("/root-c", SERVER.id)).toBe(true);
|
|
80
|
+
expect(clients.get("/root-a")?.stopCallCount).toBe(1);
|
|
81
|
+
} finally {
|
|
82
|
+
await manager.stopAll();
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("#given the least recently used client is mid-request #when a new client is admitted #then the busy client survives and an idle one is evicted", async () => {
|
|
87
|
+
// given
|
|
88
|
+
const { manager, clients, clock } = managerWithFakes(2);
|
|
89
|
+
try {
|
|
90
|
+
await manager.getClient("/root-busy", SERVER);
|
|
91
|
+
clock.value += 10;
|
|
92
|
+
await manager.getClient("/root-idle", SERVER);
|
|
93
|
+
manager.releaseClient("/root-idle", SERVER.id);
|
|
94
|
+
clock.value += 10;
|
|
95
|
+
|
|
96
|
+
// when
|
|
97
|
+
await manager.getClient("/root-new", SERVER);
|
|
98
|
+
manager.releaseClient("/root-new", SERVER.id);
|
|
99
|
+
|
|
100
|
+
// then
|
|
101
|
+
expect(manager.hasClient("/root-busy", SERVER.id)).toBe(true);
|
|
102
|
+
expect(manager.hasClient("/root-idle", SERVER.id)).toBe(false);
|
|
103
|
+
expect(clients.get("/root-busy")?.stopCallCount).toBe(0);
|
|
104
|
+
expect(clients.get("/root-idle")?.stopCallCount).toBe(1);
|
|
105
|
+
} finally {
|
|
106
|
+
manager.releaseClient("/root-busy", SERVER.id);
|
|
107
|
+
await manager.stopAll();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("#given every resident client is busy #when a new client is admitted #then nothing is evicted and the cap is exceeded rather than breaking a live request", async () => {
|
|
112
|
+
// given
|
|
113
|
+
const { manager, clients } = managerWithFakes(1);
|
|
114
|
+
try {
|
|
115
|
+
await manager.getClient("/root-busy", SERVER);
|
|
116
|
+
|
|
117
|
+
// when
|
|
118
|
+
await manager.getClient("/root-second", SERVER);
|
|
119
|
+
|
|
120
|
+
// then
|
|
121
|
+
expect(manager.clientCount()).toBe(2);
|
|
122
|
+
expect(clients.get("/root-busy")?.stopCallCount).toBe(0);
|
|
123
|
+
} finally {
|
|
124
|
+
manager.releaseClient("/root-busy", SERVER.id);
|
|
125
|
+
manager.releaseClient("/root-second", SERVER.id);
|
|
126
|
+
await manager.stopAll();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("#given warmup requests beyond the cap #when a warm client is admitted #then the idle least recently used client is evicted too", async () => {
|
|
131
|
+
// given
|
|
132
|
+
const { manager, clients, clock } = managerWithFakes(1);
|
|
133
|
+
try {
|
|
134
|
+
await manager.getClient("/root-first", SERVER);
|
|
135
|
+
manager.releaseClient("/root-first", SERVER.id);
|
|
136
|
+
clock.value += 10;
|
|
137
|
+
|
|
138
|
+
// when
|
|
139
|
+
manager.warmupClient("/root-warm", SERVER);
|
|
140
|
+
await Promise.resolve();
|
|
141
|
+
await Promise.resolve();
|
|
142
|
+
|
|
143
|
+
// then
|
|
144
|
+
expect(manager.hasClient("/root-first", SERVER.id)).toBe(false);
|
|
145
|
+
expect(clients.get("/root-first")?.stopCallCount).toBe(1);
|
|
146
|
+
expect(manager.hasClient("/root-warm", SERVER.id)).toBe(true);
|
|
147
|
+
} finally {
|
|
148
|
+
await manager.stopAll();
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { reportBestEffortCleanupError } from "./cleanup-errors.js";
|
|
2
2
|
import { LspClient } from "./client.js";
|
|
3
|
-
import { IDLE_TIMEOUT_MS, INIT_TIMEOUT_MS, REAPER_INTERVAL_MS } from "./constants.js";
|
|
3
|
+
import { IDLE_TIMEOUT_MS, INIT_TIMEOUT_MS, MAX_RESIDENT_CLIENTS, REAPER_INTERVAL_MS } from "./constants.js";
|
|
4
4
|
import { installProcessSignalCleanup } from "./process-signal-cleanup.js";
|
|
5
5
|
import type { ResolvedServer } from "./types.js";
|
|
6
6
|
|
|
@@ -28,6 +28,7 @@ export interface ClientSnapshot {
|
|
|
28
28
|
export interface LspManagerOptions {
|
|
29
29
|
idleTimeoutMs?: number;
|
|
30
30
|
initTimeoutMs?: number;
|
|
31
|
+
maxResidentClients?: number;
|
|
31
32
|
reaperIntervalMs?: number;
|
|
32
33
|
clientFactory?: (root: string, server: ResolvedServer) => LspClient;
|
|
33
34
|
now?: () => number;
|
|
@@ -80,6 +81,7 @@ export class LspManager {
|
|
|
80
81
|
|
|
81
82
|
private readonly idleTimeoutMs: number;
|
|
82
83
|
private readonly initTimeoutMs: number;
|
|
84
|
+
private readonly maxResidentClients: number;
|
|
83
85
|
private readonly reaperIntervalMs: number;
|
|
84
86
|
private readonly clientFactory: (root: string, server: ResolvedServer) => LspClient;
|
|
85
87
|
private readonly now: () => number;
|
|
@@ -87,6 +89,7 @@ export class LspManager {
|
|
|
87
89
|
constructor(options: LspManagerOptions = {}) {
|
|
88
90
|
this.idleTimeoutMs = options.idleTimeoutMs ?? IDLE_TIMEOUT_MS;
|
|
89
91
|
this.initTimeoutMs = options.initTimeoutMs ?? INIT_TIMEOUT_MS;
|
|
92
|
+
this.maxResidentClients = options.maxResidentClients ?? MAX_RESIDENT_CLIENTS;
|
|
90
93
|
this.reaperIntervalMs = options.reaperIntervalMs ?? REAPER_INTERVAL_MS;
|
|
91
94
|
this.clientFactory = options.clientFactory ?? ((root, server) => new LspClient(root, server));
|
|
92
95
|
this.now = options.now ?? (() => Date.now());
|
|
@@ -134,6 +137,32 @@ export class LspManager {
|
|
|
134
137
|
}
|
|
135
138
|
}
|
|
136
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Frees capacity for one new client by stopping the least recently used idle client.
|
|
142
|
+
*
|
|
143
|
+
* Only clients with no in-flight work are eligible, so a resident server is never torn down
|
|
144
|
+
* underneath a live request; when every resident client is busy the cap is exceeded instead.
|
|
145
|
+
*/
|
|
146
|
+
private evictForAdmission(): void {
|
|
147
|
+
while (this.clients.size >= this.maxResidentClients) {
|
|
148
|
+
const victim = this.leastRecentlyUsedIdleClient();
|
|
149
|
+
if (!victim) return;
|
|
150
|
+
this.clients.delete(victim.key);
|
|
151
|
+
void stopClientBestEffort(victim.managed.client);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private leastRecentlyUsedIdleClient(): { readonly key: string; readonly managed: ManagedClient } | null {
|
|
156
|
+
let candidate: { readonly key: string; readonly managed: ManagedClient } | null = null;
|
|
157
|
+
for (const [key, managed] of this.clients) {
|
|
158
|
+
if (managed.refCount > 0 || managed.pendingWaiters > 0 || managed.isInitializing) continue;
|
|
159
|
+
if (candidate === null || managed.lastUsedAt < candidate.managed.lastUsedAt) {
|
|
160
|
+
candidate = { key, managed };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return candidate;
|
|
164
|
+
}
|
|
165
|
+
|
|
137
166
|
private async tryDeleteIfOrphaned(key: string, managed: ManagedClient): Promise<void> {
|
|
138
167
|
if (
|
|
139
168
|
managed.refCount === 0 &&
|
|
@@ -197,6 +226,8 @@ export class LspManager {
|
|
|
197
226
|
return managed.client;
|
|
198
227
|
}
|
|
199
228
|
|
|
229
|
+
this.evictForAdmission();
|
|
230
|
+
|
|
200
231
|
const client = this.clientFactory(root, server);
|
|
201
232
|
const initStartedAt = this.now();
|
|
202
233
|
const initPromise = (async () => {
|
|
@@ -264,6 +295,8 @@ export class LspManager {
|
|
|
264
295
|
const key = this.getKey(root, server.id);
|
|
265
296
|
if (this.clients.has(key)) return;
|
|
266
297
|
|
|
298
|
+
this.evictForAdmission();
|
|
299
|
+
|
|
267
300
|
const client = this.clientFactory(root, server);
|
|
268
301
|
const initStartedAt = this.now();
|
|
269
302
|
const initPromise = (async () => {
|
|
@@ -49,6 +49,35 @@ export const LSP_INSTALL_HINTS: Record<string, string> = {
|
|
|
49
49
|
"Install: dotnet tool install -g roslyn-language-server --prerelease (requires v5.8.0+). See https://github.com/dotnet/razor",
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Repo-local install commands, offered ahead of the global hints in `LSP_INSTALL_HINTS`.
|
|
54
|
+
*
|
|
55
|
+
* A devDependency (or project venv) is resolvable by `resolveServerBinary()` without any
|
|
56
|
+
* global install, and it pins the version the project actually expects. Only servers that
|
|
57
|
+
* are genuinely distributed per-project belong here; toolchain-wide servers (rust-analyzer,
|
|
58
|
+
* gopls, clangd, ...) keep their global hint alone.
|
|
59
|
+
*/
|
|
60
|
+
export const LSP_LOCAL_INSTALL_HINTS: Record<string, string> = {
|
|
61
|
+
typescript: "bun add -d typescript-language-server typescript",
|
|
62
|
+
vue: "bun add -d @vue/language-server",
|
|
63
|
+
eslint: "bun add -d vscode-langservers-extracted",
|
|
64
|
+
oxlint: "bun add -d oxlint",
|
|
65
|
+
biome: "bun add -d @biomejs/biome",
|
|
66
|
+
svelte: "bun add -d svelte-language-server",
|
|
67
|
+
astro: "bun add -d @astrojs/language-server",
|
|
68
|
+
bash: "bun add -d bash-language-server",
|
|
69
|
+
"bash-ls": "bun add -d bash-language-server",
|
|
70
|
+
"yaml-ls": "bun add -d yaml-language-server",
|
|
71
|
+
php: "bun add -d intelephense",
|
|
72
|
+
prisma: "bun add -d prisma",
|
|
73
|
+
dockerfile: "bun add -d dockerfile-language-server-nodejs",
|
|
74
|
+
pyright: "uv add --dev pyright",
|
|
75
|
+
basedpyright: "uv add --dev basedpyright",
|
|
76
|
+
ruff: "uv add --dev ruff",
|
|
77
|
+
ty: "uv add --dev ty",
|
|
78
|
+
"ruby-lsp": "bundle add ruby-lsp --group development",
|
|
79
|
+
};
|
|
80
|
+
|
|
52
81
|
export const BUILTIN_SERVERS: Record<string, Omit<LspServerConfig, "id">> = {
|
|
53
82
|
typescript: {
|
|
54
83
|
command: ["typescript-language-server", "--stdio"],
|