oh-my-opencode 4.18.0 → 4.18.2
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/.agents/skills/codex-qa/scripts/lsp-e2e.sh +3677 -0
- package/.agents/skills/opencode-qa/scripts/lsp-e2e.sh +3154 -0
- package/.agents/skills/work-with-pr/SKILL.md +16 -37
- package/.agents/skills/work-with-pr-workspace/evals/evals.json +3 -3
- package/.opencode/skills/work-with-pr/SKILL.md +16 -37
- package/.opencode/skills/work-with-pr-workspace/evals/evals.json +3 -3
- package/bin/AGENTS.md +33 -0
- package/dist/cli/index.js +500 -158
- package/dist/cli-node/index.js +500 -158
- package/dist/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.d.ts +6 -0
- package/dist/hooks/category-skill-reminder/hook.d.ts +9 -1
- package/dist/hooks/comment-checker/hook.d.ts +8 -1
- package/dist/hooks/todo-continuation-enforcer/types.d.ts +3 -0
- package/dist/index.js +956 -702
- package/dist/plugin/messages-transform.d.ts +1 -0
- package/dist/plugin-handlers/prometheus-agent-config-builder.d.ts +2 -0
- package/dist/tui.js +43 -4
- package/package.json +16 -16
- package/packages/git-bash-mcp/dist/cli.js +81 -19
- package/packages/lsp-core/package.json +4 -0
- package/packages/lsp-core/src/index.ts +1 -0
- package/packages/lsp-core/src/lsp/cleanup-errors.test.ts +18 -0
- package/packages/lsp-core/src/lsp/cleanup-errors.ts +12 -3
- package/packages/lsp-core/src/lsp/client-diagnostics-freshness.integration.test.ts +261 -0
- package/packages/lsp-core/src/lsp/client-wrapper.test.ts +63 -0
- package/packages/lsp-core/src/lsp/client-wrapper.ts +35 -5
- package/packages/lsp-core/src/lsp/client.ts +262 -80
- package/packages/lsp-core/src/lsp/config-loader.ts +5 -17
- package/packages/lsp-core/src/lsp/connection.ts +12 -6
- package/packages/lsp-core/src/lsp/directory-diagnostics.test.ts +221 -0
- package/packages/lsp-core/src/lsp/directory-diagnostics.ts +61 -28
- package/packages/lsp-core/src/lsp/errors.ts +11 -0
- package/packages/lsp-core/src/lsp/fixtures/diagnostics-freshness-contract-probe.ts +283 -0
- package/packages/lsp-core/src/lsp/fixtures/workspace-edit-contract-probe.ts +196 -0
- package/packages/lsp-core/src/lsp/fixtures/workspace-edit-server.mjs +215 -0
- package/packages/lsp-core/src/lsp/formatters.ts +3 -0
- package/packages/lsp-core/src/lsp/json-rpc-connection-cancellation.test.ts +97 -0
- package/packages/lsp-core/src/lsp/json-rpc-connection.ts +73 -5
- package/packages/lsp-core/src/lsp/server-install-state.ts +3 -6
- package/packages/lsp-core/src/lsp/transport-protocol.ts +52 -0
- package/packages/lsp-core/src/lsp/transport.ts +96 -70
- package/packages/lsp-core/src/lsp/workspace-apply-edit-failure.ts +19 -0
- package/packages/lsp-core/src/lsp/workspace-apply-edit-lease.integration.test.ts +214 -0
- package/packages/lsp-core/src/lsp/workspace-apply-edit-sync.integration.test.ts +113 -0
- package/packages/lsp-core/src/lsp/workspace-apply-edit-test-support.ts +163 -0
- package/packages/lsp-core/src/lsp/workspace-apply-edit.integration.test.ts +163 -0
- package/packages/lsp-core/src/lsp/workspace-document-state.test.ts +67 -0
- package/packages/lsp-core/src/lsp/workspace-document-state.ts +368 -0
- package/packages/lsp-core/src/lsp/workspace-edit-adversarial.test.ts +113 -0
- package/packages/lsp-core/src/lsp/workspace-edit-commit.test.ts +140 -0
- package/packages/lsp-core/src/lsp/workspace-edit-commit.ts +220 -0
- package/packages/lsp-core/src/lsp/workspace-edit-contract-evidence.test.ts +56 -0
- package/packages/lsp-core/src/lsp/workspace-edit-contract-evidence.ts +30 -0
- package/packages/lsp-core/src/lsp/workspace-edit-fingerprint.ts +44 -0
- package/packages/lsp-core/src/lsp/workspace-edit-options.test.ts +147 -0
- package/packages/lsp-core/src/lsp/workspace-edit-parse-helpers.ts +59 -0
- package/packages/lsp-core/src/lsp/workspace-edit-parser.ts +130 -0
- package/packages/lsp-core/src/lsp/workspace-edit-path.ts +98 -0
- package/packages/lsp-core/src/lsp/workspace-edit-plan-types.ts +60 -0
- package/packages/lsp-core/src/lsp/workspace-edit-plan.ts +73 -0
- package/packages/lsp-core/src/lsp/workspace-edit-prevalidation.test.ts +174 -0
- package/packages/lsp-core/src/lsp/workspace-edit-resource-parser.ts +89 -0
- package/packages/lsp-core/src/lsp/workspace-edit-simulation.ts +183 -0
- package/packages/lsp-core/src/lsp/workspace-edit-snapshot.ts +53 -0
- package/packages/lsp-core/src/lsp/workspace-edit-text.ts +125 -0
- package/packages/lsp-core/src/lsp/workspace-edit-types.ts +121 -0
- package/packages/lsp-core/src/lsp/workspace-edit.characterization.test.ts +95 -0
- package/packages/lsp-core/src/lsp/workspace-edit.ts +49 -200
- package/packages/lsp-core/src/lsp/workspace-mutation-controller.ts +182 -0
- package/packages/lsp-core/src/mcp.ts +18 -7
- package/packages/lsp-core/src/missing-dependency-result.test.ts +105 -0
- package/packages/lsp-core/src/missing-dependency-result.ts +57 -0
- package/packages/lsp-core/src/post-edit/index.ts +1 -0
- package/packages/lsp-core/src/post-edit/orchestration.test.ts +157 -0
- package/packages/lsp-core/src/post-edit/orchestration.ts +178 -0
- package/packages/lsp-core/src/request-context.test.ts +171 -0
- package/packages/lsp-core/src/request-context.ts +222 -9
- package/packages/lsp-core/src/tool-surface.test.ts +4 -1
- package/packages/lsp-core/src/tools/diagnostics.ts +32 -13
- package/packages/lsp-core/src/tools/navigation.ts +12 -12
- package/packages/lsp-core/src/tools/rename.ts +10 -15
- package/packages/lsp-core/src/tools/symbols.ts +11 -11
- package/packages/lsp-core/src/tools/types.ts +2 -1
- package/packages/lsp-daemon/dist/cli.js +3330 -764
- package/packages/lsp-daemon/dist/client.d.ts +105 -0
- package/packages/lsp-daemon/dist/client.js +5995 -0
- package/packages/lsp-daemon/dist/daemon-client.d.ts +12 -7
- package/packages/lsp-daemon/dist/daemon-client.js +139 -32
- package/packages/lsp-daemon/dist/daemon-server.d.ts +1 -0
- package/packages/lsp-daemon/dist/daemon-server.js +40 -15
- package/packages/lsp-daemon/dist/ensure-daemon.d.ts +10 -8
- package/packages/lsp-daemon/dist/ensure-daemon.js +135 -51
- package/packages/lsp-daemon/dist/index.d.ts +2 -2
- package/packages/lsp-daemon/dist/index.js +3093 -786
- package/packages/lsp-daemon/dist/ipc-protocol.d.ts +46 -0
- package/packages/lsp-daemon/dist/ipc-protocol.js +187 -0
- package/packages/lsp-daemon/dist/lock.js +14 -4
- package/packages/lsp-daemon/dist/ownership.d.ts +49 -0
- package/packages/lsp-daemon/dist/ownership.js +168 -0
- package/packages/lsp-daemon/dist/paths.d.ts +33 -9
- package/packages/lsp-daemon/dist/paths.js +72 -33
- package/packages/lsp-daemon/dist/proxy.d.ts +5 -0
- package/packages/lsp-daemon/dist/proxy.js +123 -16
- package/packages/lsp-daemon/dist/request-routing.d.ts +7 -2
- package/packages/lsp-daemon/dist/request-routing.js +71 -22
- package/packages/lsp-daemon/dist/run-daemon.js +9 -2
- package/packages/lsp-daemon/dist/runtime-contract.d.ts +21 -0
- package/packages/lsp-daemon/dist/runtime-contract.js +58 -0
- package/packages/lsp-daemon/dist/socket-jsonrpc.js +6 -1
- package/packages/lsp-daemon/package.json +12 -3
- package/packages/lsp-tools-mcp/dist/cli.js +2189 -454
- package/packages/lsp-tools-mcp/dist/lsp/manager.js +1741 -148
- package/packages/lsp-tools-mcp/dist/mcp.js +2206 -471
- package/packages/lsp-tools-mcp/dist/request-context.js +176 -6
- package/packages/lsp-tools-mcp/dist/tools.js +2119 -447
- package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
- package/packages/omo-codex/plugin/.mcp.json +2 -1
- 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 +100 -28
- package/packages/omo-codex/plugin/components/codegraph/dist/serve.js +100 -28
- package/packages/omo-codex/plugin/components/codegraph/package.json +1 -1
- package/packages/omo-codex/plugin/components/codegraph/src/mcp-bridge.ts +21 -9
- package/packages/omo-codex/plugin/components/codegraph/test/mcp-bridge-fixtures.ts +35 -0
- package/packages/omo-codex/plugin/components/codegraph/test/serve-mcp-bridge-lifecycle.test.ts +69 -0
- package/packages/omo-codex/plugin/components/codegraph/test/serve-mcp-bridge.test.ts +57 -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/.mcp.json +2 -1
- package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +55 -0
- package/packages/omo-codex/plugin/components/lsp/dist/cli.js +3033 -936
- package/packages/omo-codex/plugin/components/lsp/dist/codex-hook-cli.js +0 -4
- package/packages/omo-codex/plugin/components/lsp/dist/codex-hook.d.ts +5 -2
- package/packages/omo-codex/plugin/components/lsp/dist/codex-hook.js +41 -62
- package/packages/omo-codex/plugin/components/lsp/dist/daemon-cli-path.d.ts +1 -1
- package/packages/omo-codex/plugin/components/lsp/dist/daemon-cli-path.js +24 -15
- package/packages/omo-codex/plugin/components/lsp/dist/lsp-session-state.d.ts +3 -7
- package/packages/omo-codex/plugin/components/lsp/dist/lsp-session-state.js +23 -49
- package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/lsp/package.json +3 -2
- package/packages/omo-codex/plugin/components/lsp/scripts/build-lsp-daemon.mjs +31 -1
- package/packages/omo-codex/plugin/components/lsp/scripts/build-lsp-daemon.test.mjs +76 -0
- package/packages/omo-codex/plugin/components/lsp/scripts/build-runtime.mjs +201 -0
- package/packages/omo-codex/plugin/components/lsp/scripts/build-runtime.test.mjs +55 -0
- package/packages/omo-codex/plugin/components/lsp/src/codex-hook-cli.ts +0 -4
- package/packages/omo-codex/plugin/components/lsp/src/codex-hook.ts +49 -71
- package/packages/omo-codex/plugin/components/lsp/src/daemon-cli-path.ts +26 -15
- package/packages/omo-codex/plugin/components/lsp/src/lsp-session-state.ts +26 -64
- package/packages/omo-codex/plugin/components/lsp/test/codex-hook-unavailable.test.ts +16 -17
- package/packages/omo-codex/plugin/components/lsp/test/codex-hook.test.ts +30 -4
- package/packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts +20 -5
- package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.5.md +1 -1
- package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.6.md +5 -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/start-work-continuation/README.md +2 -2
- package/packages/omo-codex/plugin/components/start-work-continuation/directive.md +3 -3
- package/packages/omo-codex/plugin/components/start-work-continuation/dist/cli.js +2 -2
- package/packages/omo-codex/plugin/components/start-work-continuation/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/start-work-continuation/package.json +1 -1
- package/packages/omo-codex/plugin/components/start-work-continuation/src/boulder-reader.ts +1 -1
- package/packages/omo-codex/plugin/components/start-work-continuation/src/codex-hook.ts +1 -1
- package/packages/omo-codex/plugin/components/start-work-continuation/test/boulder-reader.test.ts +15 -1
- package/packages/omo-codex/plugin/components/start-work-continuation/test/codex-hook.test.ts +20 -0
- 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/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/directive.md +50 -33
- 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/ultrawork/skills/ultrawork/SKILL.md +50 -33
- package/packages/omo-codex/plugin/components/ulw-loop/directive.md +50 -33
- package/packages/omo-codex/plugin/components/ulw-loop/dist/cli.js +3 -3
- package/packages/omo-codex/plugin/components/ulw-loop/dist/stop-resume-hook.js +5 -6
- 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/skills/ulw-loop/SKILL.md +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/references/full-workflow.md +6 -5
- package/packages/omo-codex/plugin/components/ulw-loop/src/stop-resume-hook.ts +5 -6
- package/packages/omo-codex/plugin/components/ulw-loop/test/stop-resume-hook.test.ts +2 -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-start-work-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-start-work-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/package-lock.json +26 -14
- package/packages/omo-codex/plugin/package.json +1 -1
- package/packages/omo-codex/plugin/scripts/build-bundled-mcp-runtimes.mjs +2 -3
- package/packages/omo-codex/plugin/scripts/build-components.mjs +13 -1
- package/packages/omo-codex/plugin/scripts/sync-skills.mjs +9 -1
- package/packages/omo-codex/plugin/skills/review-work/SKILL.md +7 -0
- package/packages/omo-codex/plugin/skills/start-work/SKILL.md +2 -1
- package/packages/omo-codex/plugin/skills/ultrawork/SKILL.md +50 -33
- package/packages/omo-codex/plugin/skills/ulw-loop/SKILL.md +1 -1
- package/packages/omo-codex/plugin/skills/ulw-loop/references/full-workflow.md +6 -5
- package/packages/omo-codex/plugin/test/aggregate-build.test.mjs +8 -0
- package/packages/omo-codex/plugin/test/component-bundled-cli.test.mjs +128 -15
- package/packages/omo-codex/plugin/test/install-time-build-runtime.test.mjs +10 -0
- package/packages/omo-codex/plugin/test/lsp-prebuild-layouts.test.mjs +2 -0
- package/packages/omo-codex/plugin/test/mcp-research-servers.test.mjs +1 -0
- package/packages/omo-codex/plugin/test/sync-skills-orchestration.test.mjs +7 -0
- package/packages/omo-codex/plugin/test/sync-skills-test-support.mjs +34 -3
- package/packages/omo-codex/scripts/install-dist/install-local.mjs +328 -63
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Diagnostic } from "./types.js";
|
|
2
|
+
|
|
3
|
+
interface ConfigurationItem {
|
|
4
|
+
readonly section?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface DiagnosticsParams {
|
|
8
|
+
readonly uri: string;
|
|
9
|
+
readonly diagnostics: Diagnostic[];
|
|
10
|
+
readonly version?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
14
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function parseConfigurationItems(params: unknown): ConfigurationItem[] {
|
|
18
|
+
if (!isRecord(params) || !Array.isArray(params["items"])) return [];
|
|
19
|
+
const items: ConfigurationItem[] = [];
|
|
20
|
+
for (const item of params["items"]) {
|
|
21
|
+
if (!isRecord(item)) continue;
|
|
22
|
+
const section = item["section"];
|
|
23
|
+
items.push(section === undefined || typeof section !== "string" ? {} : { section });
|
|
24
|
+
}
|
|
25
|
+
return items;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseDiagnosticsParams(params: unknown): DiagnosticsParams | null {
|
|
29
|
+
if (!isRecord(params) || typeof params["uri"] !== "string") return null;
|
|
30
|
+
const diagnostics = Array.isArray(params["diagnostics"]) ? params["diagnostics"].filter(isDiagnostic) : [];
|
|
31
|
+
const version = typeof params["version"] === "number" ? params["version"] : undefined;
|
|
32
|
+
return { uri: params["uri"], diagnostics, ...(version === undefined ? {} : { version }) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createLspSpawnEnv(
|
|
36
|
+
_root: string,
|
|
37
|
+
input: Record<string, string | undefined>,
|
|
38
|
+
): Record<string, string | undefined> {
|
|
39
|
+
return { ...input };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isDiagnostic(value: unknown): value is Diagnostic {
|
|
43
|
+
return isRecord(value) && isRange(value["range"]) && typeof value["message"] === "string";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isRange(value: unknown): value is Diagnostic["range"] {
|
|
47
|
+
return isRecord(value) && isPosition(value["start"]) && isPosition(value["end"]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isPosition(value: unknown): value is Diagnostic["range"]["start"] {
|
|
51
|
+
return isRecord(value) && typeof value["line"] === "number" && typeof value["character"] === "number";
|
|
52
|
+
}
|
|
@@ -3,41 +3,28 @@ import { INIT_TIMEOUT_MS, REQUEST_TIMEOUT_MS, STOP_HARD_KILL_TIMEOUT_MS, STOP_SI
|
|
|
3
3
|
import { LspConnectionClosedError, LspProcessExitedError, LspRequestTimeoutError } from "./errors.js";
|
|
4
4
|
import { JsonRpcConnection } from "./json-rpc-connection.js";
|
|
5
5
|
import { type SpawnedProcess, spawnProcess } from "./process.js";
|
|
6
|
+
import { createLspSpawnEnv, parseConfigurationItems, parseDiagnosticsParams } from "./transport-protocol.js";
|
|
6
7
|
import type { Diagnostic, ResolvedServer } from "./types.js";
|
|
8
|
+
import type { WorkspaceApplyEditResponse } from "./workspace-mutation-controller.js";
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
section?: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
interface DiagnosticsParams {
|
|
13
|
-
uri: string;
|
|
14
|
-
diagnostics: Diagnostic[];
|
|
15
|
-
}
|
|
10
|
+
export { createLspSpawnEnv } from "./transport-protocol.js";
|
|
16
11
|
|
|
17
|
-
|
|
18
|
-
|
|
12
|
+
export interface LspClientTimeoutOptions {
|
|
13
|
+
requestTimeoutMs?: number;
|
|
14
|
+
initializeTimeoutMs?: number;
|
|
19
15
|
}
|
|
20
16
|
|
|
21
|
-
|
|
22
|
-
if (!isRecord(params) || !Array.isArray(params["items"])) return [];
|
|
23
|
-
const items: ConfigurationItem[] = [];
|
|
24
|
-
for (const item of params["items"]) {
|
|
25
|
-
if (!isRecord(item)) continue;
|
|
26
|
-
const section = item["section"];
|
|
27
|
-
items.push(section === undefined || typeof section !== "string" ? {} : { section });
|
|
28
|
-
}
|
|
29
|
-
return items;
|
|
30
|
-
}
|
|
17
|
+
type WorkspaceApplyEditHandler = (params: unknown) => Promise<WorkspaceApplyEditResponse>;
|
|
31
18
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const diagnostics = Array.isArray(params["diagnostics"]) ? params["diagnostics"].filter(isDiagnostic) : [];
|
|
35
|
-
return { uri: params["uri"], diagnostics };
|
|
36
|
-
}
|
|
19
|
+
class LspClientNotStartedError extends Error {
|
|
20
|
+
override readonly name = "LspClientNotStartedError";
|
|
37
21
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
22
|
+
constructor(
|
|
23
|
+
readonly serverId: string,
|
|
24
|
+
readonly root: string,
|
|
25
|
+
) {
|
|
26
|
+
super("LSP client not started");
|
|
27
|
+
}
|
|
41
28
|
}
|
|
42
29
|
|
|
43
30
|
export class LspClientTransport {
|
|
@@ -48,6 +35,8 @@ export class LspClientTransport {
|
|
|
48
35
|
protected readonly diagnosticsStore = new Map<string, Diagnostic[]>();
|
|
49
36
|
protected readonly requestTimeoutMs: number;
|
|
50
37
|
protected readonly initializeTimeoutMs: number;
|
|
38
|
+
private workspaceApplyEditHandler: WorkspaceApplyEditHandler | null = null;
|
|
39
|
+
private diagnosticPullSupported = false;
|
|
51
40
|
|
|
52
41
|
constructor(
|
|
53
42
|
protected readonly root: string,
|
|
@@ -66,6 +55,30 @@ export class LspClientTransport {
|
|
|
66
55
|
return [...this.server.command];
|
|
67
56
|
}
|
|
68
57
|
|
|
58
|
+
protected setWorkspaceApplyEditHandler(handler: WorkspaceApplyEditHandler): void {
|
|
59
|
+
this.workspaceApplyEditHandler = handler;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
protected hasWorkspaceApplyEditHandler(): boolean {
|
|
63
|
+
return this.workspaceApplyEditHandler !== null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
protected setDiagnosticPullSupported(supported: boolean): void {
|
|
67
|
+
this.diagnosticPullSupported = supported;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
protected isDiagnosticPullSupported(): boolean {
|
|
71
|
+
return this.diagnosticPullSupported;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
protected handlePublishDiagnostics(params: {
|
|
75
|
+
readonly uri: string;
|
|
76
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
77
|
+
readonly version?: number;
|
|
78
|
+
}): void {
|
|
79
|
+
this.diagnosticsStore.set(params.uri, [...params.diagnostics]);
|
|
80
|
+
}
|
|
81
|
+
|
|
69
82
|
async start(): Promise<void> {
|
|
70
83
|
const env = createLspSpawnEnv(this.root, {
|
|
71
84
|
...process.env,
|
|
@@ -78,8 +91,6 @@ export class LspClientTransport {
|
|
|
78
91
|
});
|
|
79
92
|
this.startStderrReading();
|
|
80
93
|
|
|
81
|
-
await new Promise<void>((resolve) => setTimeout(resolve, 100));
|
|
82
|
-
|
|
83
94
|
if (this.proc.exitCode !== null) {
|
|
84
95
|
const stderr = this.stderrBuffer.join("\n");
|
|
85
96
|
throw new LspProcessExitedError(this.server.id, this.root, this.proc.exitCode, stderr.slice(-2000));
|
|
@@ -90,7 +101,7 @@ export class LspClientTransport {
|
|
|
90
101
|
this.connection.onNotification("textDocument/publishDiagnostics", (params) => {
|
|
91
102
|
const diagnosticsParams = parseDiagnosticsParams(params);
|
|
92
103
|
if (diagnosticsParams?.uri) {
|
|
93
|
-
this.
|
|
104
|
+
this.handlePublishDiagnostics(diagnosticsParams);
|
|
94
105
|
}
|
|
95
106
|
});
|
|
96
107
|
|
|
@@ -104,6 +115,9 @@ export class LspClientTransport {
|
|
|
104
115
|
|
|
105
116
|
this.connection.onRequest("client/registerCapability", () => null);
|
|
106
117
|
this.connection.onRequest("window/workDoneProgress/create", () => null);
|
|
118
|
+
if (this.workspaceApplyEditHandler) {
|
|
119
|
+
this.connection.onRequest("workspace/applyEdit", this.workspaceApplyEditHandler);
|
|
120
|
+
}
|
|
107
121
|
|
|
108
122
|
this.connection.onClose(() => {
|
|
109
123
|
this.processExited = true;
|
|
@@ -140,12 +154,16 @@ export class LspClientTransport {
|
|
|
140
154
|
|
|
141
155
|
protected sendRequest<T>(method: string): Promise<T>;
|
|
142
156
|
protected sendRequest<T>(method: string, params: unknown): Promise<T>;
|
|
143
|
-
protected sendRequest<T>(
|
|
157
|
+
protected sendRequest<T>(
|
|
158
|
+
method: string,
|
|
159
|
+
params: unknown,
|
|
160
|
+
options: { timeoutMs?: number; signal?: AbortSignal },
|
|
161
|
+
): Promise<T>;
|
|
144
162
|
protected async sendRequest<T>(
|
|
145
163
|
method: string,
|
|
146
|
-
...args: [] | [unknown] | [unknown, { timeoutMs?: number }]
|
|
164
|
+
...args: [] | [unknown] | [unknown, { timeoutMs?: number; signal?: AbortSignal }]
|
|
147
165
|
): Promise<T> {
|
|
148
|
-
if (!this.connection) throw new
|
|
166
|
+
if (!this.connection) throw new LspClientNotStartedError(this.server.id, this.root);
|
|
149
167
|
|
|
150
168
|
if (this.processExited || (this.proc && this.proc.exitCode !== null)) {
|
|
151
169
|
const stderrTail = this.stderrBuffer.slice(-10).join("\n");
|
|
@@ -157,25 +175,22 @@ export class LspClientTransport {
|
|
|
157
175
|
);
|
|
158
176
|
}
|
|
159
177
|
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
178
|
+
const options = args[1];
|
|
179
|
+
const timeoutMs = options?.timeoutMs ?? this.requestTimeoutMs;
|
|
180
|
+
const timeoutController = new AbortController();
|
|
181
|
+
const timeoutHandle = setTimeout(() => {
|
|
182
|
+
const stderrTail = this.stderrBuffer.slice(-5).join("\n");
|
|
183
|
+
timeoutController.abort(new LspRequestTimeoutError(method, stderrTail || undefined));
|
|
184
|
+
}, timeoutMs);
|
|
185
|
+
const combinedSignal = combineAbortSignals(options?.signal, timeoutController.signal);
|
|
168
186
|
|
|
169
187
|
try {
|
|
170
|
-
const
|
|
188
|
+
const result =
|
|
171
189
|
args.length === 0
|
|
172
|
-
? this.connection.sendRequest<T>(method)
|
|
173
|
-
: this.connection.sendRequest<T>(method, args[0]);
|
|
174
|
-
const result = await Promise.race([requestPromise, timeoutPromise]);
|
|
175
|
-
if (timeoutHandle !== null) clearTimeout(timeoutHandle);
|
|
190
|
+
? await this.connection.sendRequest<T>(method, undefined, { signal: combinedSignal.signal })
|
|
191
|
+
: await this.connection.sendRequest<T>(method, args[0], { signal: combinedSignal.signal });
|
|
176
192
|
return result;
|
|
177
193
|
} catch (error) {
|
|
178
|
-
if (timeoutHandle !== null) clearTimeout(timeoutHandle);
|
|
179
194
|
if (this.processExited || (this.proc && this.proc.exitCode !== null)) {
|
|
180
195
|
throw new LspProcessExitedError(
|
|
181
196
|
this.server.id,
|
|
@@ -188,6 +203,9 @@ export class LspClientTransport {
|
|
|
188
203
|
throw new LspConnectionClosedError(this.server.id, this.root, error.message);
|
|
189
204
|
}
|
|
190
205
|
throw error;
|
|
206
|
+
} finally {
|
|
207
|
+
clearTimeout(timeoutHandle);
|
|
208
|
+
combinedSignal.dispose();
|
|
191
209
|
}
|
|
192
210
|
}
|
|
193
211
|
|
|
@@ -219,17 +237,17 @@ export class LspClientTransport {
|
|
|
219
237
|
try {
|
|
220
238
|
await this.sendRequest<null>("shutdown");
|
|
221
239
|
} catch (error) {
|
|
222
|
-
reportBestEffortCleanupError("shutdown request", error);
|
|
240
|
+
reportBestEffortCleanupError("shutdown request", error instanceof Error ? error : String(error));
|
|
223
241
|
}
|
|
224
242
|
try {
|
|
225
243
|
await this.sendNotification("exit");
|
|
226
244
|
} catch (error) {
|
|
227
|
-
reportBestEffortCleanupError("exit notification", error);
|
|
245
|
+
reportBestEffortCleanupError("exit notification", error instanceof Error ? error : String(error));
|
|
228
246
|
}
|
|
229
247
|
try {
|
|
230
248
|
this.connection.dispose();
|
|
231
249
|
} catch (error) {
|
|
232
|
-
reportBestEffortCleanupError("connection dispose", error);
|
|
250
|
+
reportBestEffortCleanupError("connection dispose", error instanceof Error ? error : String(error));
|
|
233
251
|
}
|
|
234
252
|
this.connection = null;
|
|
235
253
|
}
|
|
@@ -262,11 +280,11 @@ export class LspClientTransport {
|
|
|
262
280
|
new Promise<void>((resolve) => setTimeout(resolve, STOP_SIGKILL_GRACE_MS)),
|
|
263
281
|
]);
|
|
264
282
|
} catch (error) {
|
|
265
|
-
reportBestEffortCleanupError("hard process kill", error);
|
|
283
|
+
reportBestEffortCleanupError("hard process kill", error instanceof Error ? error : String(error));
|
|
266
284
|
}
|
|
267
285
|
}
|
|
268
286
|
} catch (error) {
|
|
269
|
-
reportBestEffortCleanupError("process stop", error);
|
|
287
|
+
reportBestEffortCleanupError("process stop", error instanceof Error ? error : String(error));
|
|
270
288
|
}
|
|
271
289
|
}
|
|
272
290
|
|
|
@@ -279,21 +297,29 @@ export class LspClientTransport {
|
|
|
279
297
|
}
|
|
280
298
|
}
|
|
281
299
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
):
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
300
|
+
function combineAbortSignals(
|
|
301
|
+
primary: AbortSignal | undefined,
|
|
302
|
+
secondary: AbortSignal,
|
|
303
|
+
): { readonly signal: AbortSignal; readonly dispose: () => void } {
|
|
304
|
+
const controller = new AbortController();
|
|
305
|
+
const abortFrom = (signal: AbortSignal): void => {
|
|
306
|
+
if (!controller.signal.aborted) controller.abort(signal.reason);
|
|
307
|
+
};
|
|
308
|
+
const onPrimaryAbort = (): void => {
|
|
309
|
+
if (primary) abortFrom(primary);
|
|
310
|
+
};
|
|
311
|
+
const onSecondaryAbort = (): void => abortFrom(secondary);
|
|
312
|
+
|
|
313
|
+
if (primary?.aborted) abortFrom(primary);
|
|
314
|
+
else primary?.addEventListener("abort", onPrimaryAbort, { once: true });
|
|
315
|
+
if (secondary.aborted) abortFrom(secondary);
|
|
316
|
+
else secondary.addEventListener("abort", onSecondaryAbort, { once: true });
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
signal: controller.signal,
|
|
320
|
+
dispose: () => {
|
|
321
|
+
primary?.removeEventListener("abort", onPrimaryAbort);
|
|
322
|
+
secondary.removeEventListener("abort", onSecondaryAbort);
|
|
323
|
+
},
|
|
324
|
+
};
|
|
299
325
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type WorkspaceApplyEditConcurrentPhase = "applying" | "settled";
|
|
2
|
+
|
|
3
|
+
const CONCURRENT_FAILURE_REASON_BY_PHASE: Readonly<Record<WorkspaceApplyEditConcurrentPhase, string>> = {
|
|
4
|
+
applying: "workspace/applyEdit is already in progress for this workspace mutation",
|
|
5
|
+
settled: "workspace/applyEdit was already handled for this workspace mutation",
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const CANONICAL_CONCURRENT_WORKSPACE_APPLY_EDIT_FAILURE_REASON =
|
|
9
|
+
"workspace/applyEdit concurrent for this workspace mutation";
|
|
10
|
+
|
|
11
|
+
export function workspaceApplyEditConcurrentFailureReason(phase: WorkspaceApplyEditConcurrentPhase): string {
|
|
12
|
+
return CONCURRENT_FAILURE_REASON_BY_PHASE[phase];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function canonicalizeWorkspaceApplyEditFailureReason(reason: string): string {
|
|
16
|
+
return Object.values(CONCURRENT_FAILURE_REASON_BY_PHASE).includes(reason)
|
|
17
|
+
? CANONICAL_CONCURRENT_WORKSPACE_APPLY_EDIT_FAILURE_REASON
|
|
18
|
+
: reason;
|
|
19
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
createWorkspaceEditTestHarness,
|
|
7
|
+
readEvents,
|
|
8
|
+
renameTextEdit,
|
|
9
|
+
waitForEventCount,
|
|
10
|
+
} from "./workspace-apply-edit-test-support.js";
|
|
11
|
+
|
|
12
|
+
const harness = createWorkspaceEditTestHarness();
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await harness.cleanup();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("LspClient workspace mutation lease", () => {
|
|
19
|
+
it("#given no mutating request #when the server calls applyEdit #then the request is rejected as unscoped", async () => {
|
|
20
|
+
const context = await harness.makeClient({ unscopedApplyEdit: renameTextEdit("before", "after", { version: 1 }) });
|
|
21
|
+
|
|
22
|
+
const responses = await waitForEventCount(
|
|
23
|
+
context.events,
|
|
24
|
+
(event) => event.type === "clientResponse" && event.method === "workspace/applyEdit",
|
|
25
|
+
1,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
expect(responses[0]?.result).toEqual({
|
|
29
|
+
applied: false,
|
|
30
|
+
failureReason: expect.stringContaining("active workspace mutation"),
|
|
31
|
+
});
|
|
32
|
+
expect(readFileSync(context.source, "utf-8")).toBe("const before = 1;\n");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("#given one valid server apply #when the server applies twice #then the repeated request is rejected", async () => {
|
|
36
|
+
const context = await harness.makeClient({
|
|
37
|
+
renameSteps: [
|
|
38
|
+
{ applyEdit: renameTextEdit("before", "after", { version: 1 }), applyTwice: true, renameResult: "same" },
|
|
39
|
+
],
|
|
40
|
+
});
|
|
41
|
+
await context.client.openFile(context.source);
|
|
42
|
+
|
|
43
|
+
const result = await context.client.rename(context.source, 1, 6, "after");
|
|
44
|
+
|
|
45
|
+
expect(result.apply.success).toBe(true);
|
|
46
|
+
const responses = readEvents(context.events).filter(
|
|
47
|
+
(event) => event.type === "clientResponse" && event.method === "workspace/applyEdit",
|
|
48
|
+
);
|
|
49
|
+
expect(responses).toHaveLength(2);
|
|
50
|
+
expect(responses[0]?.result).toEqual({ applied: true });
|
|
51
|
+
expect(responses[1]?.result).toEqual({
|
|
52
|
+
applied: false,
|
|
53
|
+
failureReason: expect.stringContaining("already handled"),
|
|
54
|
+
});
|
|
55
|
+
expect(readFileSync(context.source, "utf-8")).toBe("const after = 1;\n");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("#given two concurrent server applies #when one enters commit #then the other is rejected without a second write", async () => {
|
|
59
|
+
const context = await harness.makeClient({
|
|
60
|
+
renameSteps: [
|
|
61
|
+
{
|
|
62
|
+
applyEdit: renameTextEdit("before", "after", { version: 1 }),
|
|
63
|
+
applyConcurrently: true,
|
|
64
|
+
renameResult: "same",
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
});
|
|
68
|
+
let writes = 0;
|
|
69
|
+
context.client.setWorkspaceEditIo({
|
|
70
|
+
writeFile(path, content) {
|
|
71
|
+
writes += 1;
|
|
72
|
+
writeFileSync(path, content, "utf-8");
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
await context.client.openFile(context.source);
|
|
76
|
+
|
|
77
|
+
const result = await context.client.rename(context.source, 1, 6, "after");
|
|
78
|
+
|
|
79
|
+
expect(result.apply.success).toBe(true);
|
|
80
|
+
expect(writes).toBe(1);
|
|
81
|
+
const responses = readEvents(context.events).filter(
|
|
82
|
+
(event) => event.type === "clientResponse" && event.method === "workspace/applyEdit",
|
|
83
|
+
);
|
|
84
|
+
expect(responses.map((response) => response.result)).toContainEqual({ applied: true });
|
|
85
|
+
const rejection = responses.find(
|
|
86
|
+
(response) => typeof response.result === "object" && response.result !== null && "failureReason" in response.result,
|
|
87
|
+
);
|
|
88
|
+
expect(rejection?.result).toMatchObject({ applied: false });
|
|
89
|
+
expect(JSON.stringify(rejection?.result)).toMatch(/already (?:in progress|handled)/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("#given a rename lease is held #when another rename starts #then it is rejected without a second request", async () => {
|
|
93
|
+
const context = await harness.makeClient({
|
|
94
|
+
renameSteps: [
|
|
95
|
+
{
|
|
96
|
+
renameResult: "edit",
|
|
97
|
+
renameEdit: renameTextEdit("before", "after", { version: 1 }),
|
|
98
|
+
responseDelayMs: 150,
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
});
|
|
102
|
+
await context.client.openFile(context.source);
|
|
103
|
+
const firstPromise = context.client.rename(context.source, 1, 6, "after");
|
|
104
|
+
await waitForEventCount(
|
|
105
|
+
context.events,
|
|
106
|
+
(event) => event.type === "clientRequest" && event.method === "textDocument/rename",
|
|
107
|
+
1,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const concurrent = await context.client.rename(context.source, 1, 6, "other_");
|
|
111
|
+
const first = await firstPromise;
|
|
112
|
+
|
|
113
|
+
expect(concurrent.apply.success).toBe(false);
|
|
114
|
+
expect(concurrent.apply.errors.join("\n")).toContain("workspace mutation is already in progress");
|
|
115
|
+
expect(first.apply.success).toBe(true);
|
|
116
|
+
const requests = readEvents(context.events).filter(
|
|
117
|
+
(event) => event.type === "clientRequest" && event.method === "textDocument/rename",
|
|
118
|
+
);
|
|
119
|
+
expect(requests).toHaveLength(1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("#given no server apply #when rename directly returns an edit #then it is applied exactly once", async () => {
|
|
123
|
+
const context = await harness.makeClient({
|
|
124
|
+
renameSteps: [{ renameResult: "edit", renameEdit: renameTextEdit("before", "after", { version: 1 }) }],
|
|
125
|
+
});
|
|
126
|
+
await context.client.openFile(context.source);
|
|
127
|
+
|
|
128
|
+
const result = await context.client.rename(context.source, 1, 6, "after");
|
|
129
|
+
|
|
130
|
+
expect(result.apply.success).toBe(true);
|
|
131
|
+
expect(readFileSync(context.source, "utf-8")).toBe("const after = 1;\n");
|
|
132
|
+
expect(readEvents(context.events).filter((event) => event.method === "workspace/applyEdit")).toHaveLength(0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("#given cancellation before a delayed server apply #when the rename request is aborted #then no file is mutated", async () => {
|
|
136
|
+
const controller = new AbortController();
|
|
137
|
+
const context = await harness.makeClient({
|
|
138
|
+
renameSteps: [
|
|
139
|
+
{
|
|
140
|
+
applyEdit: renameTextEdit("before", "after", { version: 1 }),
|
|
141
|
+
applyDelayMs: 100,
|
|
142
|
+
renameResult: "same",
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
});
|
|
146
|
+
await context.client.openFile(context.source);
|
|
147
|
+
const rename = context.client.rename(context.source, 1, 6, "after", controller.signal);
|
|
148
|
+
await waitForEventCount(
|
|
149
|
+
context.events,
|
|
150
|
+
(event) => event.type === "clientRequest" && event.method === "textDocument/rename",
|
|
151
|
+
1,
|
|
152
|
+
);
|
|
153
|
+
controller.abort();
|
|
154
|
+
|
|
155
|
+
await expect(rename).rejects.toThrow(/cancel/i);
|
|
156
|
+
|
|
157
|
+
const [cancel] = await waitForEventCount(
|
|
158
|
+
context.events,
|
|
159
|
+
(event) => event.type === "clientNotification" && event.method === "$/cancelRequest",
|
|
160
|
+
1,
|
|
161
|
+
);
|
|
162
|
+
expect(cancel?.params).toEqual({ id: 2 });
|
|
163
|
+
expect(readFileSync(context.source, "utf-8")).toBe("const before = 1;\n");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("#given cancellation after the commit gate #when applyEdit finishes #then rename reports one completed late-abort mutation", async () => {
|
|
167
|
+
const controller = new AbortController();
|
|
168
|
+
const context = await harness.makeClient({
|
|
169
|
+
renameSteps: [{ applyEdit: renameTextEdit("before", "after", { version: 1 }), renameResult: "same" }],
|
|
170
|
+
});
|
|
171
|
+
let writes = 0;
|
|
172
|
+
context.client.setWorkspaceEditIo({
|
|
173
|
+
writeFile(path, content) {
|
|
174
|
+
writes += 1;
|
|
175
|
+
writeFileSync(path, content, "utf-8");
|
|
176
|
+
controller.abort();
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
await context.client.openFile(context.source);
|
|
180
|
+
|
|
181
|
+
const result = await context.client.rename(context.source, 1, 6, "after", controller.signal);
|
|
182
|
+
|
|
183
|
+
expect(result.apply).toMatchObject({ success: true, lateAbort: true, totalEdits: 1 });
|
|
184
|
+
expect(writes).toBe(1);
|
|
185
|
+
expect(readFileSync(context.source, "utf-8")).toBe("const after = 1;\n");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("#given lease-backed and bare clients #when initialized #then applyEdit and annotations are advertised honestly", async () => {
|
|
189
|
+
const active = await harness.makeClient({});
|
|
190
|
+
const inactive = await harness.makeBareConnection({});
|
|
191
|
+
|
|
192
|
+
const activeCapabilities = initializeCapabilities(active.events);
|
|
193
|
+
const inactiveCapabilities = initializeCapabilities(inactive.events);
|
|
194
|
+
expect(activeCapabilities.workspace?.applyEdit).toBe(true);
|
|
195
|
+
expect(inactiveCapabilities.workspace?.applyEdit).toBeUndefined();
|
|
196
|
+
expect(activeCapabilities.textDocument?.rename?.honorsChangeAnnotations).toBeUndefined();
|
|
197
|
+
expect(activeCapabilities.workspace?.workspaceEdit?.changeAnnotationSupport).toBeUndefined();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
interface InitializeCapabilities {
|
|
202
|
+
readonly workspace?: {
|
|
203
|
+
readonly applyEdit?: boolean;
|
|
204
|
+
readonly workspaceEdit?: { readonly changeAnnotationSupport?: unknown };
|
|
205
|
+
};
|
|
206
|
+
readonly textDocument?: { readonly rename?: { readonly honorsChangeAnnotations?: boolean } };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function initializeCapabilities(path: string): InitializeCapabilities {
|
|
210
|
+
const initialize = readEvents(path).find(
|
|
211
|
+
(event) => event.type === "clientRequest" && event.method === "initialize",
|
|
212
|
+
);
|
|
213
|
+
return (initialize?.params as { capabilities?: InitializeCapabilities } | undefined)?.capabilities ?? {};
|
|
214
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
|
|
4
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
5
|
+
|
|
6
|
+
import type { WorkspaceEdit } from "./types.js";
|
|
7
|
+
import {
|
|
8
|
+
createWorkspaceEditTestHarness,
|
|
9
|
+
readEvents,
|
|
10
|
+
renameTextEdit,
|
|
11
|
+
} from "./workspace-apply-edit-test-support.js";
|
|
12
|
+
|
|
13
|
+
const harness = createWorkspaceEditTestHarness();
|
|
14
|
+
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
await harness.cleanup();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("LspClient workspace edit synchronization", () => {
|
|
20
|
+
it("#given an open edited document #when applyEdit commits #then didChange precedes didSave with the next version", async () => {
|
|
21
|
+
const context = await harness.makeClient({
|
|
22
|
+
renameSteps: [{ applyEdit: renameTextEdit("before", "after", { version: 1 }), renameResult: "same" }],
|
|
23
|
+
});
|
|
24
|
+
await context.client.openFile(context.source);
|
|
25
|
+
|
|
26
|
+
await context.client.rename(context.source, 1, 6, "after");
|
|
27
|
+
|
|
28
|
+
const notifications = readEvents(context.events).filter((event) => event.type === "clientNotification");
|
|
29
|
+
const changeIndex = notifications.findIndex((event) => event.method === "textDocument/didChange");
|
|
30
|
+
const saveIndex = notifications.findIndex((event) => event.method === "textDocument/didSave");
|
|
31
|
+
expect(changeIndex).toBeGreaterThan(-1);
|
|
32
|
+
expect(saveIndex).toBeGreaterThan(changeIndex);
|
|
33
|
+
expect(notifications[changeIndex]?.params).toMatchObject({ textDocument: { version: 2 } });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("#given an open file resource rename #when applyEdit commits #then didClose and didOpen move document state", async () => {
|
|
37
|
+
const edit: WorkspaceEdit = {
|
|
38
|
+
documentChanges: [{ kind: "rename", oldUri: "file:///placeholder", newUri: "file:///destination" }],
|
|
39
|
+
};
|
|
40
|
+
const context = await harness.makeClient({ renameSteps: [{ applyEdit: edit, renameResult: "null" }] });
|
|
41
|
+
await context.client.openFile(context.source);
|
|
42
|
+
|
|
43
|
+
const result = await context.client.rename(context.source, 1, 6, "after");
|
|
44
|
+
|
|
45
|
+
expect(result.apply.success).toBe(true);
|
|
46
|
+
expect(existsSync(context.source)).toBe(false);
|
|
47
|
+
expect(readFileSync(context.destination, "utf-8")).toBe("const before = 1;\n");
|
|
48
|
+
expect(context.client.getOpenDocumentVersion(context.source)).toBeUndefined();
|
|
49
|
+
expect(context.client.getOpenDocumentVersion(context.destination)).toBe(1);
|
|
50
|
+
const methods = readEvents(context.events).map((event) => event.method);
|
|
51
|
+
expect(methods.indexOf("textDocument/didClose")).toBeLessThan(methods.lastIndexOf("textDocument/didOpen"));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("#given an open file deletion #when applyEdit commits #then didClose removes document state", async () => {
|
|
55
|
+
const edit: WorkspaceEdit = { documentChanges: [{ kind: "delete", uri: "file:///placeholder" }] };
|
|
56
|
+
const context = await harness.makeClient({ renameSteps: [{ applyEdit: edit, renameResult: "null" }] });
|
|
57
|
+
await context.client.openFile(context.source);
|
|
58
|
+
|
|
59
|
+
const result = await context.client.rename(context.source, 1, 6, "after");
|
|
60
|
+
|
|
61
|
+
expect(result.apply.success).toBe(true);
|
|
62
|
+
expect(existsSync(context.source)).toBe(false);
|
|
63
|
+
expect(context.client.getOpenDocumentVersion(context.source)).toBeUndefined();
|
|
64
|
+
expect(readEvents(context.events).some((event) => event.method === "textDocument/didClose")).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("#given a closed edited file #when applyEdit commits #then one changed watched-file event is emitted", async () => {
|
|
68
|
+
const edit = renameTextEdit("closed", "after_", { version: null, uri: "file:///closed" });
|
|
69
|
+
const context = await harness.makeClient({ renameSteps: [{ applyEdit: edit, renameResult: "null" }] });
|
|
70
|
+
await context.client.openFile(context.source);
|
|
71
|
+
|
|
72
|
+
const result = await context.client.rename(context.source, 1, 6, "after");
|
|
73
|
+
|
|
74
|
+
expect(result.apply.success).toBe(true);
|
|
75
|
+
expect(readFileSync(context.closed, "utf-8")).toBe("const after_ = 1;\n");
|
|
76
|
+
const watched = readEvents(context.events).find(
|
|
77
|
+
(event) => event.type === "clientNotification" && event.method === "workspace/didChangeWatchedFiles",
|
|
78
|
+
);
|
|
79
|
+
expect(watched?.params).toEqual({ changes: [{ uri: pathToFileURL(realpathSync(context.closed)).href, type: 2 }] });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("#given a new closed file #when applyEdit creates it #then one created watched-file event is emitted", async () => {
|
|
83
|
+
const edit: WorkspaceEdit = { documentChanges: [{ kind: "create", uri: "file:///created" }] };
|
|
84
|
+
const context = await harness.makeClient({ renameSteps: [{ applyEdit: edit, renameResult: "null" }] });
|
|
85
|
+
await context.client.openFile(context.source);
|
|
86
|
+
|
|
87
|
+
const result = await context.client.rename(context.source, 1, 6, "after");
|
|
88
|
+
|
|
89
|
+
expect(result.apply.success).toBe(true);
|
|
90
|
+
expect(readFileSync(context.created, "utf-8")).toBe("");
|
|
91
|
+
const watched = readEvents(context.events).find(
|
|
92
|
+
(event) => event.type === "clientNotification" && event.method === "workspace/didChangeWatchedFiles",
|
|
93
|
+
);
|
|
94
|
+
expect(watched?.params).toEqual({ changes: [{ uri: pathToFileURL(realpathSync(context.created)).href, type: 1 }] });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("#given an open file overwritten by create #when applyEdit commits #then didClose and didOpen reset its state", async () => {
|
|
98
|
+
const edit: WorkspaceEdit = {
|
|
99
|
+
documentChanges: [{ kind: "create", uri: "file:///closed", options: { overwrite: true } }],
|
|
100
|
+
};
|
|
101
|
+
const context = await harness.makeClient({ renameSteps: [{ applyEdit: edit, renameResult: "null" }] });
|
|
102
|
+
await context.client.openFile(context.source);
|
|
103
|
+
await context.client.openFile(context.closed);
|
|
104
|
+
|
|
105
|
+
const result = await context.client.rename(context.source, 1, 6, "after");
|
|
106
|
+
|
|
107
|
+
expect(result.apply.success).toBe(true);
|
|
108
|
+
expect(readFileSync(context.closed, "utf-8")).toBe("");
|
|
109
|
+
expect(context.client.getOpenDocumentVersion(context.closed)).toBe(1);
|
|
110
|
+
const methods = readEvents(context.events).map((event) => event.method);
|
|
111
|
+
expect(methods.indexOf("textDocument/didClose")).toBeLessThan(methods.lastIndexOf("textDocument/didOpen"));
|
|
112
|
+
});
|
|
113
|
+
});
|