oh-my-opencode 4.18.0 → 4.18.1
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 +3654 -0
- package/.agents/skills/opencode-qa/scripts/lsp-e2e.sh +3071 -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/dist/cli/index.js +457 -154
- package/dist/cli-node/index.js +457 -154
- package/dist/index.js +415 -388
- package/package.json +16 -16
- 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 +104 -0
- package/packages/lsp-core/src/lsp/directory-diagnostics.ts +60 -27
- 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 +3114 -747
- package/packages/lsp-daemon/dist/client.d.ts +105 -0
- package/packages/lsp-daemon/dist/client.js +5851 -0
- package/packages/lsp-daemon/dist/daemon-client.d.ts +11 -6
- package/packages/lsp-daemon/dist/daemon-client.js +113 -30
- 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 +8 -7
- package/packages/lsp-daemon/dist/ensure-daemon.js +67 -44
- package/packages/lsp-daemon/dist/index.d.ts +2 -2
- package/packages/lsp-daemon/dist/index.js +2862 -754
- 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 +3 -0
- package/packages/lsp-daemon/dist/proxy.js +54 -3
- 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 +2115 -442
- package/packages/lsp-tools-mcp/dist/lsp/manager.js +1741 -148
- package/packages/lsp-tools-mcp/dist/mcp.js +2127 -454
- package/packages/lsp-tools-mcp/dist/request-context.js +176 -6
- package/packages/lsp-tools-mcp/dist/tools.js +2118 -446
- package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -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/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 +55 -0
- package/packages/omo-codex/plugin/components/lsp/dist/cli.js +2959 -944
- 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 +19 -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 +1 -1
- 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/directive.md +1 -1
- 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/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/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
- 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/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 +1 -1
- package/packages/omo-codex/plugin/skills/start-work/SKILL.md +1 -1
- 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/sync-skills-test-support.mjs +1 -1
- package/packages/omo-codex/scripts/install-dist/install-local.mjs +328 -63
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import type { WorkspaceSnapshotEntry } from "./workspace-edit-types.js";
|
|
6
|
+
|
|
7
|
+
export type WorkspacePathResult =
|
|
8
|
+
| {
|
|
9
|
+
readonly success: true;
|
|
10
|
+
readonly path: string;
|
|
11
|
+
readonly requestedPath: string;
|
|
12
|
+
readonly followedSymbolicLink: boolean;
|
|
13
|
+
}
|
|
14
|
+
| { readonly success: false; readonly error: string };
|
|
15
|
+
|
|
16
|
+
class WorkspaceEditPathError extends Error {
|
|
17
|
+
override readonly name = "WorkspaceEditPathError";
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
readonly path: string,
|
|
21
|
+
readonly detail: string,
|
|
22
|
+
) {
|
|
23
|
+
super(`${detail}: ${path}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isPathInsideWorkspace(filePath: string, workspaceRoot: string): boolean {
|
|
28
|
+
const relativePath = relative(workspaceRoot, filePath);
|
|
29
|
+
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function canonicalizeMissingPath(filePath: string): string {
|
|
33
|
+
let ancestor = filePath;
|
|
34
|
+
while (!existsSync(ancestor)) {
|
|
35
|
+
const parent = dirname(ancestor);
|
|
36
|
+
if (parent === ancestor) throw new WorkspaceEditPathError(filePath, "no existing ancestor");
|
|
37
|
+
ancestor = parent;
|
|
38
|
+
}
|
|
39
|
+
return resolve(realpathSync(ancestor), relative(ancestor, filePath));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function canonicalWorkspaceRoot(workspaceRoot: string): WorkspacePathResult {
|
|
43
|
+
try {
|
|
44
|
+
const canonical = realpathSync(resolve(workspaceRoot));
|
|
45
|
+
if (!lstatSync(canonical).isDirectory()) {
|
|
46
|
+
return { success: false, error: `workspace root is not a directory: ${workspaceRoot}` };
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
success: true,
|
|
50
|
+
path: canonical,
|
|
51
|
+
requestedPath: resolve(workspaceRoot),
|
|
52
|
+
followedSymbolicLink: existsSync(resolve(workspaceRoot)) && lstatSync(resolve(workspaceRoot)).isSymbolicLink(),
|
|
53
|
+
};
|
|
54
|
+
} catch (error) {
|
|
55
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
56
|
+
return { success: false, error: `workspace root ${workspaceRoot}: ${detail}` };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function uriToCanonicalWorkspacePath(uri: string, workspaceRoot: string): WorkspacePathResult {
|
|
61
|
+
let requestedPath: string;
|
|
62
|
+
try {
|
|
63
|
+
const parsed = new URL(uri);
|
|
64
|
+
if (parsed.protocol !== "file:" || parsed.search !== "" || parsed.hash !== "") {
|
|
65
|
+
return { success: false, error: `non-file URI ${uri}` };
|
|
66
|
+
}
|
|
67
|
+
requestedPath = resolve(fileURLToPath(parsed));
|
|
68
|
+
} catch (error) {
|
|
69
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
70
|
+
return { success: false, error: `non-file URI ${uri}: ${detail}` };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const canonical = existsSync(requestedPath) ? realpathSync(requestedPath) : canonicalizeMissingPath(requestedPath);
|
|
75
|
+
if (!isPathInsideWorkspace(canonical, workspaceRoot)) {
|
|
76
|
+
return { success: false, error: `${requestedPath}: outside workspace ${workspaceRoot}` };
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
success: true,
|
|
80
|
+
path: canonical,
|
|
81
|
+
requestedPath,
|
|
82
|
+
followedSymbolicLink: existsSync(requestedPath) && lstatSync(requestedPath).isSymbolicLink(),
|
|
83
|
+
};
|
|
84
|
+
} catch (error) {
|
|
85
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
86
|
+
return { success: false, error: `${requestedPath}: ${detail}` };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function snapshotPath(path: string, includeChildren: boolean): WorkspaceSnapshotEntry {
|
|
91
|
+
if (!existsSync(path)) return { kind: "missing" };
|
|
92
|
+
const stats = lstatSync(path);
|
|
93
|
+
if (stats.isFile()) return { kind: "file", content: readFileSync(path, "utf-8") };
|
|
94
|
+
if (stats.isDirectory()) {
|
|
95
|
+
return includeChildren ? { kind: "directory", children: readdirSync(path).sort() } : { kind: "directory" };
|
|
96
|
+
}
|
|
97
|
+
throw new WorkspaceEditPathError(path, "unsupported filesystem entry");
|
|
98
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { TextEdit } from "./types.js";
|
|
2
|
+
import type { ApplyResult, PlannedWorkspaceOperation } from "./workspace-edit-types.js";
|
|
3
|
+
|
|
4
|
+
export type ParsedWorkspaceOperation =
|
|
5
|
+
| {
|
|
6
|
+
readonly kind: "text";
|
|
7
|
+
readonly changeIndex: number;
|
|
8
|
+
readonly path: string;
|
|
9
|
+
readonly reportedPath: string;
|
|
10
|
+
readonly edits: readonly TextEdit[];
|
|
11
|
+
readonly version: number | null;
|
|
12
|
+
}
|
|
13
|
+
| {
|
|
14
|
+
readonly kind: "create";
|
|
15
|
+
readonly changeIndex: number;
|
|
16
|
+
readonly path: string;
|
|
17
|
+
readonly reportedPath: string;
|
|
18
|
+
readonly overwrite: boolean;
|
|
19
|
+
readonly ignoreIfExists: boolean;
|
|
20
|
+
readonly followedSymbolicLink: boolean;
|
|
21
|
+
}
|
|
22
|
+
| {
|
|
23
|
+
readonly kind: "rename";
|
|
24
|
+
readonly changeIndex: number;
|
|
25
|
+
readonly oldPath: string;
|
|
26
|
+
readonly newPath: string;
|
|
27
|
+
readonly reportedOldPath: string;
|
|
28
|
+
readonly reportedNewPath: string;
|
|
29
|
+
readonly overwrite: boolean;
|
|
30
|
+
readonly ignoreIfExists: boolean;
|
|
31
|
+
readonly followedSymbolicLink: boolean;
|
|
32
|
+
}
|
|
33
|
+
| {
|
|
34
|
+
readonly kind: "delete";
|
|
35
|
+
readonly changeIndex: number;
|
|
36
|
+
readonly path: string;
|
|
37
|
+
readonly reportedPath: string;
|
|
38
|
+
readonly recursive: boolean;
|
|
39
|
+
readonly ignoreIfNotExists: boolean;
|
|
40
|
+
readonly followedSymbolicLink: boolean;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export interface ParseFailure {
|
|
44
|
+
readonly changeIndex: number;
|
|
45
|
+
readonly message: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ParsedWorkspaceEdit {
|
|
49
|
+
readonly operations: readonly ParsedWorkspaceOperation[];
|
|
50
|
+
readonly failures: readonly ParseFailure[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SimulatedWorkspaceEdit {
|
|
54
|
+
readonly operations: readonly PlannedWorkspaceOperation[];
|
|
55
|
+
readonly failures: readonly ParseFailure[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type WorkspaceEditFingerprintResult =
|
|
59
|
+
| { readonly success: true; readonly fingerprint: string }
|
|
60
|
+
| { readonly success: false; readonly result: ApplyResult };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { canonicalFingerprint } from "./workspace-edit-fingerprint.js";
|
|
2
|
+
import { canonicalWorkspaceRoot } from "./workspace-edit-path.js";
|
|
3
|
+
import { failureResult, parseWorkspaceEdit } from "./workspace-edit-parser.js";
|
|
4
|
+
import type { ParsedWorkspaceOperation, WorkspaceEditFingerprintResult } from "./workspace-edit-plan-types.js";
|
|
5
|
+
import { simulateOperations } from "./workspace-edit-simulation.js";
|
|
6
|
+
import { snapshotOperations } from "./workspace-edit-snapshot.js";
|
|
7
|
+
import type { WorkspaceEditPlan, WorkspaceEditPlanResult } from "./workspace-edit-types.js";
|
|
8
|
+
|
|
9
|
+
export type { WorkspaceEditFingerprintResult } from "./workspace-edit-plan-types.js";
|
|
10
|
+
|
|
11
|
+
class PlanPathIndex {
|
|
12
|
+
readonly firstChangeByPath = new Map<string, number>();
|
|
13
|
+
readonly reportedPathByCanonical = new Map<string, string>();
|
|
14
|
+
|
|
15
|
+
build(operations: readonly ParsedWorkspaceOperation[]): void {
|
|
16
|
+
for (const operation of operations) {
|
|
17
|
+
switch (operation.kind) {
|
|
18
|
+
case "rename":
|
|
19
|
+
this.add(operation.oldPath, operation.reportedOldPath, operation.changeIndex);
|
|
20
|
+
this.add(operation.newPath, operation.reportedNewPath, operation.changeIndex);
|
|
21
|
+
break;
|
|
22
|
+
case "text":
|
|
23
|
+
case "create":
|
|
24
|
+
case "delete":
|
|
25
|
+
this.add(operation.path, operation.reportedPath, operation.changeIndex);
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private add(path: string, reportedPath: string, changeIndex: number): void {
|
|
32
|
+
if (!this.firstChangeByPath.has(path)) this.firstChangeByPath.set(path, changeIndex);
|
|
33
|
+
if (!this.reportedPathByCanonical.has(path)) this.reportedPathByCanonical.set(path, reportedPath);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function fingerprintWorkspaceEdit(edit: unknown, workspaceRoot: string): WorkspaceEditFingerprintResult {
|
|
38
|
+
const root = canonicalWorkspaceRoot(workspaceRoot);
|
|
39
|
+
if (!root.success) return { success: false, result: failureResult([{ changeIndex: 0, message: root.error }]) };
|
|
40
|
+
const parsed = parseWorkspaceEdit(edit, root.path);
|
|
41
|
+
if (parsed.failures.length > 0) return { success: false, result: failureResult(parsed.failures) };
|
|
42
|
+
return { success: true, fingerprint: canonicalFingerprint(parsed.operations) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function planWorkspaceEdit(edit: unknown, workspaceRoot: string): WorkspaceEditPlanResult {
|
|
46
|
+
const root = canonicalWorkspaceRoot(workspaceRoot);
|
|
47
|
+
if (!root.success) return { success: false, result: failureResult([{ changeIndex: 0, message: root.error }]) };
|
|
48
|
+
const parsed = parseWorkspaceEdit(edit, root.path);
|
|
49
|
+
if (parsed.failures.length > 0) return { success: false, result: failureResult(parsed.failures) };
|
|
50
|
+
|
|
51
|
+
let snapshots;
|
|
52
|
+
try {
|
|
53
|
+
snapshots = snapshotOperations(parsed.operations, root.path);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
return {
|
|
56
|
+
success: false,
|
|
57
|
+
result: failureResult([{ changeIndex: 0, message: error instanceof Error ? error.message : String(error) }]),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const simulated = simulateOperations(parsed.operations, snapshots);
|
|
61
|
+
if (simulated.failures.length > 0) return { success: false, result: failureResult(simulated.failures) };
|
|
62
|
+
const paths = new PlanPathIndex();
|
|
63
|
+
paths.build(parsed.operations);
|
|
64
|
+
const plan: WorkspaceEditPlan = {
|
|
65
|
+
workspaceRoot: root.path,
|
|
66
|
+
operations: simulated.operations,
|
|
67
|
+
snapshots,
|
|
68
|
+
firstChangeByPath: paths.firstChangeByPath,
|
|
69
|
+
reportedPathByCanonical: paths.reportedPathByCanonical,
|
|
70
|
+
fingerprint: canonicalFingerprint(parsed.operations),
|
|
71
|
+
};
|
|
72
|
+
return { success: true, plan };
|
|
73
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
7
|
+
|
|
8
|
+
import { applyWorkspaceEdit } from "./workspace-edit.js";
|
|
9
|
+
|
|
10
|
+
const tempDirectories: string[] = [];
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
for (const directory of tempDirectories.splice(0)) {
|
|
14
|
+
rmSync(directory, { recursive: true, force: true });
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("applyWorkspaceEdit prevalidation", () => {
|
|
19
|
+
it("#given a valid edit before an overlapping edit #when validated #then neither file is mutated", () => {
|
|
20
|
+
// given
|
|
21
|
+
const workspace = makeTempDirectory();
|
|
22
|
+
const validFile = join(workspace, "valid.ts");
|
|
23
|
+
const invalidFile = join(workspace, "invalid.ts");
|
|
24
|
+
writeFileSync(validFile, "const valid = 1;\n", "utf-8");
|
|
25
|
+
writeFileSync(invalidFile, "const invalid = 2;\n", "utf-8");
|
|
26
|
+
|
|
27
|
+
// when
|
|
28
|
+
const result = applyWorkspaceEdit(
|
|
29
|
+
{
|
|
30
|
+
changes: {
|
|
31
|
+
[pathToFileURL(validFile).href]: [edit(0, [6, 11], "changed")],
|
|
32
|
+
[pathToFileURL(invalidFile).href]: [edit(0, [0, 10], "first"), edit(0, [5, 13], "second")],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{ workspaceRoot: workspace },
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// then
|
|
39
|
+
expect(result.success).toBe(false);
|
|
40
|
+
expect(result.filesModified).toEqual([]);
|
|
41
|
+
expect(readFileSync(validFile, "utf-8")).toBe("const valid = 1;\n");
|
|
42
|
+
expect(readFileSync(invalidFile, "utf-8")).toBe("const invalid = 2;\n");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("#given byte-identical non-empty edits #when applied #then one edit is committed", () => {
|
|
46
|
+
// given
|
|
47
|
+
const workspace = makeTempDirectory();
|
|
48
|
+
const source = join(workspace, "source.ts");
|
|
49
|
+
writeFileSync(source, "const before = 1;\n", "utf-8");
|
|
50
|
+
const replacement = edit(0, [6, 12], "after");
|
|
51
|
+
|
|
52
|
+
// when
|
|
53
|
+
const result = applyWorkspaceEdit(
|
|
54
|
+
{ changes: { [pathToFileURL(source).href]: [replacement, replacement] } },
|
|
55
|
+
{ workspaceRoot: workspace },
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// then
|
|
59
|
+
expect(result.success).toBe(true);
|
|
60
|
+
expect(result.totalEdits).toBe(1);
|
|
61
|
+
expect(readFileSync(source, "utf-8")).toBe("const after = 1;\n");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("#given equal-position insertions #when applied #then all insertions retain declared order", () => {
|
|
65
|
+
// given
|
|
66
|
+
const workspace = makeTempDirectory();
|
|
67
|
+
const source = join(workspace, "source.ts");
|
|
68
|
+
writeFileSync(source, "abc", "utf-8");
|
|
69
|
+
|
|
70
|
+
// when
|
|
71
|
+
const result = applyWorkspaceEdit(
|
|
72
|
+
{
|
|
73
|
+
changes: {
|
|
74
|
+
[pathToFileURL(source).href]: [edit(0, [1, 1], "X"), edit(0, [1, 1], "X"), edit(0, [1, 1], "Y")],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{ workspaceRoot: workspace },
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// then
|
|
81
|
+
expect(result.success).toBe(true);
|
|
82
|
+
expect(result.totalEdits).toBe(3);
|
|
83
|
+
expect(readFileSync(source, "utf-8")).toBe("aXXYbc");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("#given a range beyond the snapshot #when validated #then the edit fails without mutation", () => {
|
|
87
|
+
// given
|
|
88
|
+
const workspace = makeTempDirectory();
|
|
89
|
+
const source = join(workspace, "source.ts");
|
|
90
|
+
writeFileSync(source, "short\n", "utf-8");
|
|
91
|
+
|
|
92
|
+
// when
|
|
93
|
+
const result = applyWorkspaceEdit(
|
|
94
|
+
{ changes: { [pathToFileURL(source).href]: [edit(4, [0, 1], "invalid")] } },
|
|
95
|
+
{ workspaceRoot: workspace },
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// then
|
|
99
|
+
expect(result.success).toBe(false);
|
|
100
|
+
expect(result.filesModified).toEqual([]);
|
|
101
|
+
expect(readFileSync(source, "utf-8")).toBe("short\n");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("#given a create followed by an invalid edit #when the sequence is validated #then no file is created", () => {
|
|
105
|
+
// given
|
|
106
|
+
const workspace = makeTempDirectory();
|
|
107
|
+
const source = join(workspace, "created.ts");
|
|
108
|
+
const uri = pathToFileURL(source).href;
|
|
109
|
+
|
|
110
|
+
// when
|
|
111
|
+
const result = applyWorkspaceEdit(
|
|
112
|
+
{
|
|
113
|
+
documentChanges: [
|
|
114
|
+
{ kind: "create", uri },
|
|
115
|
+
{ textDocument: { uri, version: null }, edits: [edit(2, [0, 1], "invalid")] },
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
{ workspaceRoot: workspace },
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// then
|
|
122
|
+
expect(result.success).toBe(false);
|
|
123
|
+
expect(result.filesModified).toEqual([]);
|
|
124
|
+
expect(result).toMatchObject({ failedChange: 1 });
|
|
125
|
+
expect(result.errors.join("\n")).toContain("change 1");
|
|
126
|
+
expect(existsSync(source)).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("#given rename then text edit of the destination #when applied #then declared order uses virtual state", () => {
|
|
130
|
+
// given
|
|
131
|
+
const workspace = makeTempDirectory();
|
|
132
|
+
const source = join(workspace, "before.ts");
|
|
133
|
+
const destination = join(workspace, "after.ts");
|
|
134
|
+
writeFileSync(source, "const before = 1;\n", "utf-8");
|
|
135
|
+
const sourceUri = pathToFileURL(source).href;
|
|
136
|
+
const destinationUri = pathToFileURL(destination).href;
|
|
137
|
+
|
|
138
|
+
// when
|
|
139
|
+
const result = applyWorkspaceEdit(
|
|
140
|
+
{
|
|
141
|
+
documentChanges: [
|
|
142
|
+
{ kind: "rename", oldUri: sourceUri, newUri: destinationUri },
|
|
143
|
+
{
|
|
144
|
+
textDocument: { uri: destinationUri, version: null },
|
|
145
|
+
edits: [edit(0, [6, 12], "after")],
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
{ workspaceRoot: workspace },
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// then
|
|
153
|
+
expect(result.success).toBe(true);
|
|
154
|
+
expect(existsSync(source)).toBe(false);
|
|
155
|
+
expect(readFileSync(destination, "utf-8")).toBe("const after = 1;\n");
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
function edit(line: number, characters: readonly [start: number, end: number], newText: string) {
|
|
160
|
+
const [startCharacter, endCharacter] = characters;
|
|
161
|
+
return {
|
|
162
|
+
range: {
|
|
163
|
+
start: { line, character: startCharacter },
|
|
164
|
+
end: { line, character: endCharacter },
|
|
165
|
+
},
|
|
166
|
+
newText,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function makeTempDirectory(): string {
|
|
171
|
+
const directory = mkdtempSync(join(tmpdir(), "lsp-workspace-prevalidation-"));
|
|
172
|
+
tempDirectories.push(directory);
|
|
173
|
+
return directory;
|
|
174
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { uriToCanonicalWorkspacePath } from "./workspace-edit-path.js";
|
|
2
|
+
import { parseOptions } from "./workspace-edit-parse-helpers.js";
|
|
3
|
+
import type { ParsedWorkspaceOperation, ParseFailure } from "./workspace-edit-plan-types.js";
|
|
4
|
+
import { WorkspaceEditValidationError } from "./workspace-edit-types.js";
|
|
5
|
+
|
|
6
|
+
interface ResourceParseTarget {
|
|
7
|
+
readonly operations: ParsedWorkspaceOperation[];
|
|
8
|
+
readonly failures: ParseFailure[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ResourceChangeInput {
|
|
12
|
+
readonly change: Record<string, unknown>;
|
|
13
|
+
readonly changeIndex: number;
|
|
14
|
+
readonly workspaceRoot: string;
|
|
15
|
+
readonly target: ResourceParseTarget;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseResourceChange(input: ResourceChangeInput): void {
|
|
19
|
+
const kind = input.change["kind"];
|
|
20
|
+
if (kind === "create" || kind === "delete") {
|
|
21
|
+
parseSinglePathResource(input, kind);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (kind !== "rename") {
|
|
25
|
+
throw new WorkspaceEditValidationError(input.changeIndex, `unsupported resource operation ${String(kind)}`);
|
|
26
|
+
}
|
|
27
|
+
parseRename(input);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseSinglePathResource(input: ResourceChangeInput, kind: "create" | "delete"): void {
|
|
31
|
+
const { change, changeIndex, workspaceRoot, target } = input;
|
|
32
|
+
if (typeof change["uri"] !== "string") throw new WorkspaceEditValidationError(changeIndex, `${kind}.uri is required`);
|
|
33
|
+
const resolvedPath = uriToCanonicalWorkspacePath(change["uri"], workspaceRoot);
|
|
34
|
+
if (!resolvedPath.success) {
|
|
35
|
+
target.failures.push({ changeIndex, message: resolvedPath.error });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (kind === "create") {
|
|
39
|
+
const options = parseOptions(change["options"], ["overwrite", "ignoreIfExists"], changeIndex);
|
|
40
|
+
target.operations.push({
|
|
41
|
+
kind,
|
|
42
|
+
changeIndex,
|
|
43
|
+
path: resolvedPath.path,
|
|
44
|
+
reportedPath: resolvedPath.requestedPath,
|
|
45
|
+
overwrite: options["overwrite"] ?? false,
|
|
46
|
+
ignoreIfExists: options["ignoreIfExists"] ?? false,
|
|
47
|
+
followedSymbolicLink: resolvedPath.followedSymbolicLink,
|
|
48
|
+
});
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const options = parseOptions(change["options"], ["recursive", "ignoreIfNotExists"], changeIndex);
|
|
52
|
+
target.operations.push({
|
|
53
|
+
kind,
|
|
54
|
+
changeIndex,
|
|
55
|
+
path: resolvedPath.path,
|
|
56
|
+
reportedPath: resolvedPath.requestedPath,
|
|
57
|
+
recursive: options["recursive"] ?? false,
|
|
58
|
+
ignoreIfNotExists: options["ignoreIfNotExists"] ?? false,
|
|
59
|
+
followedSymbolicLink: resolvedPath.followedSymbolicLink,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseRename(input: ResourceChangeInput): void {
|
|
64
|
+
const { change, changeIndex, workspaceRoot, target } = input;
|
|
65
|
+
if (typeof change["oldUri"] !== "string" || typeof change["newUri"] !== "string") {
|
|
66
|
+
throw new WorkspaceEditValidationError(changeIndex, "rename requires oldUri and newUri");
|
|
67
|
+
}
|
|
68
|
+
const oldPath = uriToCanonicalWorkspacePath(change["oldUri"], workspaceRoot);
|
|
69
|
+
const newPath = uriToCanonicalWorkspacePath(change["newUri"], workspaceRoot);
|
|
70
|
+
if (!oldPath.success || !newPath.success) {
|
|
71
|
+
target.failures.push({
|
|
72
|
+
changeIndex,
|
|
73
|
+
message: !oldPath.success ? oldPath.error : !newPath.success ? newPath.error : "invalid rename path",
|
|
74
|
+
});
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const options = parseOptions(change["options"], ["overwrite", "ignoreIfExists"], changeIndex);
|
|
78
|
+
target.operations.push({
|
|
79
|
+
kind: "rename",
|
|
80
|
+
changeIndex,
|
|
81
|
+
oldPath: oldPath.path,
|
|
82
|
+
newPath: newPath.path,
|
|
83
|
+
reportedOldPath: oldPath.requestedPath,
|
|
84
|
+
reportedNewPath: newPath.requestedPath,
|
|
85
|
+
overwrite: options["overwrite"] ?? false,
|
|
86
|
+
ignoreIfExists: options["ignoreIfExists"] ?? false,
|
|
87
|
+
followedSymbolicLink: oldPath.followedSymbolicLink || newPath.followedSymbolicLink,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { dirname, relative, resolve } from "node:path";
|
|
2
|
+
|
|
3
|
+
import { normalizeTextEdits } from "./workspace-edit-text.js";
|
|
4
|
+
import type {
|
|
5
|
+
ParsedWorkspaceOperation,
|
|
6
|
+
ParseFailure,
|
|
7
|
+
SimulatedWorkspaceEdit,
|
|
8
|
+
} from "./workspace-edit-plan-types.js";
|
|
9
|
+
import type { PlannedWorkspaceOperation, WorkspaceSnapshotEntry } from "./workspace-edit-types.js";
|
|
10
|
+
import { WorkspaceEditValidationError } from "./workspace-edit-types.js";
|
|
11
|
+
|
|
12
|
+
function isSameOrDescendant(candidate: string, parent: string): boolean {
|
|
13
|
+
const relativePath = relative(parent, candidate);
|
|
14
|
+
return relativePath === "" || (!relativePath.startsWith("..") && relativePath !== "..");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function removeVirtualSubtree(virtual: Map<string, WorkspaceSnapshotEntry>, path: string): void {
|
|
18
|
+
for (const candidate of [...virtual.keys()]) {
|
|
19
|
+
if (isSameOrDescendant(candidate, path)) virtual.delete(candidate);
|
|
20
|
+
}
|
|
21
|
+
virtual.set(path, { kind: "missing" });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function moveVirtualSubtree(virtual: Map<string, WorkspaceSnapshotEntry>, oldPath: string, newPath: string): void {
|
|
25
|
+
const moved = [...virtual.entries()].filter(([candidate]) => isSameOrDescendant(candidate, oldPath));
|
|
26
|
+
removeVirtualSubtree(virtual, oldPath);
|
|
27
|
+
removeVirtualSubtree(virtual, newPath);
|
|
28
|
+
for (const [candidate, entry] of moved) {
|
|
29
|
+
const suffix = relative(oldPath, candidate);
|
|
30
|
+
virtual.set(suffix === "" ? newPath : resolve(newPath, suffix), entry);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function virtualDirectoryHasChildren(virtual: ReadonlyMap<string, WorkspaceSnapshotEntry>, path: string): boolean {
|
|
35
|
+
for (const [candidate, entry] of virtual) {
|
|
36
|
+
if (candidate !== path && entry.kind !== "missing" && isSameOrDescendant(candidate, path)) return true;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function requireVirtualParent(
|
|
42
|
+
virtual: ReadonlyMap<string, WorkspaceSnapshotEntry>,
|
|
43
|
+
path: string,
|
|
44
|
+
changeIndex: number,
|
|
45
|
+
): void {
|
|
46
|
+
if (virtual.get(dirname(path))?.kind !== "directory") {
|
|
47
|
+
throw new WorkspaceEditValidationError(changeIndex, `parent directory does not exist for ${path}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function simulateOperations(
|
|
52
|
+
parsed: readonly ParsedWorkspaceOperation[],
|
|
53
|
+
snapshots: ReadonlyMap<string, WorkspaceSnapshotEntry>,
|
|
54
|
+
): SimulatedWorkspaceEdit {
|
|
55
|
+
const virtual = new Map(snapshots);
|
|
56
|
+
const planned: PlannedWorkspaceOperation[] = [];
|
|
57
|
+
const failures: ParseFailure[] = [];
|
|
58
|
+
for (const operation of parsed) {
|
|
59
|
+
try {
|
|
60
|
+
planned.push(simulateOperation(operation, virtual));
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error instanceof WorkspaceEditValidationError) {
|
|
63
|
+
failures.push({ changeIndex: operation.changeIndex, message: error.detail });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { operations: planned, failures };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function simulateOperation(
|
|
73
|
+
operation: ParsedWorkspaceOperation,
|
|
74
|
+
virtual: Map<string, WorkspaceSnapshotEntry>,
|
|
75
|
+
): PlannedWorkspaceOperation {
|
|
76
|
+
switch (operation.kind) {
|
|
77
|
+
case "text":
|
|
78
|
+
return simulateText(operation, virtual);
|
|
79
|
+
case "create":
|
|
80
|
+
return simulateCreate(operation, virtual);
|
|
81
|
+
case "rename":
|
|
82
|
+
return simulateRename(operation, virtual);
|
|
83
|
+
case "delete":
|
|
84
|
+
return simulateDelete(operation, virtual);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function rejectSymbolicLink(operation: Exclude<ParsedWorkspaceOperation, { readonly kind: "text" }>): void {
|
|
89
|
+
if (operation.followedSymbolicLink) {
|
|
90
|
+
throw new WorkspaceEditValidationError(operation.changeIndex, "resource operations through symbolic links are unsupported");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function simulateText(
|
|
95
|
+
operation: Extract<ParsedWorkspaceOperation, { readonly kind: "text" }>,
|
|
96
|
+
virtual: Map<string, WorkspaceSnapshotEntry>,
|
|
97
|
+
): PlannedWorkspaceOperation {
|
|
98
|
+
const entry = virtual.get(operation.path);
|
|
99
|
+
if (entry?.kind !== "file") throw new WorkspaceEditValidationError(operation.changeIndex, `${operation.path} is not a file`);
|
|
100
|
+
const normalized = normalizeTextEdits(entry.content, operation.edits, operation.changeIndex);
|
|
101
|
+
virtual.set(operation.path, { kind: "file", content: normalized.text });
|
|
102
|
+
return {
|
|
103
|
+
kind: "text",
|
|
104
|
+
changeIndex: operation.changeIndex,
|
|
105
|
+
path: operation.path,
|
|
106
|
+
beforeText: entry.content,
|
|
107
|
+
afterText: normalized.text,
|
|
108
|
+
editCount: normalized.edits.length,
|
|
109
|
+
documentVersion: operation.version,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function simulateCreate(
|
|
114
|
+
operation: Extract<ParsedWorkspaceOperation, { readonly kind: "create" }>,
|
|
115
|
+
virtual: Map<string, WorkspaceSnapshotEntry>,
|
|
116
|
+
): PlannedWorkspaceOperation {
|
|
117
|
+
rejectSymbolicLink(operation);
|
|
118
|
+
requireVirtualParent(virtual, operation.path, operation.changeIndex);
|
|
119
|
+
const target = virtual.get(operation.path) ?? { kind: "missing" };
|
|
120
|
+
if (target.kind !== "missing") {
|
|
121
|
+
if (operation.overwrite && target.kind === "file") {
|
|
122
|
+
virtual.set(operation.path, { kind: "file", content: "" });
|
|
123
|
+
return { kind: "create", changeIndex: operation.changeIndex, path: operation.path, replaced: true };
|
|
124
|
+
}
|
|
125
|
+
if (operation.ignoreIfExists) return { kind: "noop", changeIndex: operation.changeIndex };
|
|
126
|
+
throw new WorkspaceEditValidationError(operation.changeIndex, `create target already exists: ${operation.path}`);
|
|
127
|
+
}
|
|
128
|
+
virtual.set(operation.path, { kind: "file", content: "" });
|
|
129
|
+
return { kind: "create", changeIndex: operation.changeIndex, path: operation.path, replaced: false };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function simulateRename(
|
|
133
|
+
operation: Extract<ParsedWorkspaceOperation, { readonly kind: "rename" }>,
|
|
134
|
+
virtual: Map<string, WorkspaceSnapshotEntry>,
|
|
135
|
+
): PlannedWorkspaceOperation {
|
|
136
|
+
rejectSymbolicLink(operation);
|
|
137
|
+
const source = virtual.get(operation.oldPath) ?? { kind: "missing" };
|
|
138
|
+
if (source.kind === "missing") {
|
|
139
|
+
throw new WorkspaceEditValidationError(operation.changeIndex, `rename source does not exist: ${operation.oldPath}`);
|
|
140
|
+
}
|
|
141
|
+
if (operation.oldPath === operation.newPath) return { kind: "noop", changeIndex: operation.changeIndex };
|
|
142
|
+
if (isSameOrDescendant(operation.newPath, operation.oldPath)) {
|
|
143
|
+
throw new WorkspaceEditValidationError(operation.changeIndex, "cannot rename a path into its own subtree");
|
|
144
|
+
}
|
|
145
|
+
requireVirtualParent(virtual, operation.newPath, operation.changeIndex);
|
|
146
|
+
const destination = virtual.get(operation.newPath) ?? { kind: "missing" };
|
|
147
|
+
if (destination.kind !== "missing" && !operation.overwrite) {
|
|
148
|
+
if (operation.ignoreIfExists) return { kind: "noop", changeIndex: operation.changeIndex };
|
|
149
|
+
throw new WorkspaceEditValidationError(operation.changeIndex, `rename target already exists: ${operation.newPath}`);
|
|
150
|
+
}
|
|
151
|
+
moveVirtualSubtree(virtual, operation.oldPath, operation.newPath);
|
|
152
|
+
return {
|
|
153
|
+
kind: "rename",
|
|
154
|
+
changeIndex: operation.changeIndex,
|
|
155
|
+
oldPath: operation.oldPath,
|
|
156
|
+
newPath: operation.newPath,
|
|
157
|
+
sourceKind: source.kind,
|
|
158
|
+
replaceDestination: destination.kind !== "missing",
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function simulateDelete(
|
|
163
|
+
operation: Extract<ParsedWorkspaceOperation, { readonly kind: "delete" }>,
|
|
164
|
+
virtual: Map<string, WorkspaceSnapshotEntry>,
|
|
165
|
+
): PlannedWorkspaceOperation {
|
|
166
|
+
rejectSymbolicLink(operation);
|
|
167
|
+
const target = virtual.get(operation.path) ?? { kind: "missing" };
|
|
168
|
+
if (target.kind === "missing") {
|
|
169
|
+
if (operation.ignoreIfNotExists) return { kind: "noop", changeIndex: operation.changeIndex };
|
|
170
|
+
throw new WorkspaceEditValidationError(operation.changeIndex, `delete target does not exist: ${operation.path}`);
|
|
171
|
+
}
|
|
172
|
+
if (target.kind === "directory" && !operation.recursive && virtualDirectoryHasChildren(virtual, operation.path)) {
|
|
173
|
+
throw new WorkspaceEditValidationError(operation.changeIndex, `directory is not empty: ${operation.path}`);
|
|
174
|
+
}
|
|
175
|
+
removeVirtualSubtree(virtual, operation.path);
|
|
176
|
+
return {
|
|
177
|
+
kind: "delete",
|
|
178
|
+
changeIndex: operation.changeIndex,
|
|
179
|
+
path: operation.path,
|
|
180
|
+
targetKind: target.kind,
|
|
181
|
+
recursive: operation.recursive,
|
|
182
|
+
};
|
|
183
|
+
}
|