test(coding-agent/lsp): cover $-prefixed identifiers, create→edit ordering, folder-delete subtree flush

Adds the three regression tests the PR body claimed but #1389 had not actually shipped:

- $-prefixed identifier resolution: asserts resolveSymbolColumn("$store")
  on a line with bar$store + $store returns the standalone column (16),
  not the substring inside the compound identifier (7).
- create→edit ordering on the same URI: the motivating LSP §3.16.2 case
  ("Extract to new file" code actions emitting [CreateFile, TextDocumentEdit]
  for the same URI). Pre-fix this threw ENOENT.
- Folder-delete subtree flush: mirror of the existing folder-rename test
  for the delete arm — child-file edits must land before the parent folder
  goes away.

Also adds a CHANGELOG sentence noting that when a WorkspaceEdit supplies
both `changes` and `documentChanges`, the new code uses documentChanges
exclusively per LSP §3.16.2 (previously the two were merged).
This commit is contained in:
oldschoola
2026-05-25 23:36:08 -07:00
parent 4be57358dd
commit 5cdc450fa6
2 changed files with 126 additions and 1 deletions
+1 -1
View File
@@ -28,7 +28,7 @@
- Updated Tavily missing-credential feedback to prompt users to configure an API-key provider setting instead of referencing `agent.db` directly
- Refreshed expired OpenAI Codex OAuth tokens during `web_search` execution and persisted the updated credentials so searches continue working after token expiry
- Fixed the LSP symbol-resolver `BARE_IDENTIFIER_RE` (`/^[A-Za-z_][\w]*$/`) rejecting `$`-prefixed identifiers (`$store`, `$count`, RxJS observables, Svelte stores, Angular signals). Without the word-boundary check, searching for `$store` on a line containing `bar$store` returned the offset inside the compound identifier rather than the standalone occurrence, feeding a wrong column to the LSP server. Pattern now `/^[$A-Za-z_][\w$]*$/`; the companion `IDENTIFIER_CHAR_RE` already contained `$`.
- Fixed `applyWorkspaceEdit` writing all text edits before walking `documentChanges` for resource operations. LSP §3.16.2 requires clients to apply `documentChanges` in declared order, so any server emitting `{kind: "create", uri: X}` followed by a `TextDocumentEdit` for `X` (e.g. "Extract to new file" code actions, some rename responses) broke: the edit ran against a non-existent file, then the create happened. `applyWorkspaceEdit` now walks `documentChanges` once in declared order; per-URI text edits are coalesced into a pending Map and flushed immediately before any subsequent resource op for the same URI. Legacy `changes`-map-only payloads are unchanged. Folder-level `rename`/`delete` ops now flush every pending URI under the affected subtree (not just the exact target) so child-file edits queued before a parent-folder move land at the original location instead of dangling against a non-existent path on the final flush. Rename ops additionally flush pending edits queued against `renameOp.newUri` (and its descendants) **before** `fs.rename` runs, so edits intended for the pre-rename target file are applied before the rename clobbers or replaces it (relevant under `options.overwrite`/`options.ignoreIfExists`).
- Fixed `applyWorkspaceEdit` writing all text edits before walking `documentChanges` for resource operations. LSP §3.16.2 requires clients to apply `documentChanges` in declared order, so any server emitting `{kind: "create", uri: X}` followed by a `TextDocumentEdit` for `X` (e.g. "Extract to new file" code actions, some rename responses) broke: the edit ran against a non-existent file, then the create happened. `applyWorkspaceEdit` now walks `documentChanges` once in declared order; per-URI text edits are coalesced into a pending Map and flushed immediately before any subsequent resource op for the same URI. Legacy `changes`-map-only payloads are unchanged. Folder-level `rename`/`delete` ops now flush every pending URI under the affected subtree (not just the exact target) so child-file edits queued before a parent-folder move land at the original location instead of dangling against a non-existent path on the final flush. Rename ops additionally flush pending edits queued against `renameOp.newUri` (and its descendants) **before** `fs.rename` runs, so edits intended for the pre-rename target file are applied before the rename clobbers or replaces it (relevant under `options.overwrite`/`options.ignoreIfExists`). When a `WorkspaceEdit` payload supplies both `changes` and `documentChanges`, the `documentChanges` arm is now used exclusively per LSP §3.16.2 ("if documentChanges are supplied … servers should use them in preference to changes"); previously the two were merged.
### Fixed
@@ -11,6 +11,8 @@ import { applyWorkspaceEdit } from "@oh-my-pi/pi-coding-agent/lsp/edits";
import { renderCall, renderResult } from "@oh-my-pi/pi-coding-agent/lsp/render";
import type {
CodeAction,
CreateFile,
DeleteFile,
Diagnostic,
LspClient,
RenameFile,
@@ -1044,4 +1046,127 @@ describe("lsp regressions", () => {
tempDir.removeSync();
}
});
it("resolves $-prefixed identifiers past compound matches", async () => {
// Pre-fix, BARE_IDENTIFIER_RE rejected leading `$`, so requireWordBoundary
// was false and `resolveSymbolColumn(_, _, "$store")` returned the column
// inside `bar$store` rather than the standalone occurrence, feeding the
// LSP server the wrong column. The new regex `/^[$A-Za-z_][\w$]*$/` plus
// IDENTIFIER_CHAR_RE's existing `$` membership enforces the boundary.
const tempDir = TempDir.createSync("@omp-lsp-dollar-identifier-");
try {
const filePath = path.join(tempDir.path(), "store.ts");
// Standalone `$store` starts at column 16; compound `bar$store`
// contains the substring at column 7. Old code returned 7; new code
// returns 16.
await Bun.write(filePath, "let bar$store = $store + 1;\n");
const column = await resolveSymbolColumn(filePath, 1, "$store");
expect(column).toBe(16);
// `bar$store` is itself a valid `$`-bearing identifier and resolves
// to its own start, not into either fragment.
const compoundColumn = await resolveSymbolColumn(filePath, 1, "bar$store");
expect(compoundColumn).toBe(4);
} finally {
tempDir.removeSync();
}
});
it("applies a create op followed by a text edit for the same URI in declared order", async () => {
// LSP §3.16.2 motivating case for the rewrite: "Extract to new file"
// code actions emit `[CreateFile(newUri), TextDocumentEdit(newUri, ...)]`.
// Pre-fix, all text edits flushed first → applyTextEdits opened a
// not-yet-created file → ENOENT. The new walk processes each entry in
// order, so the create lands first and the edit reads the empty file
// the create just wrote.
const tempDir = TempDir.createSync("@omp-lsp-create-then-edit-");
try {
const newFilePath = path.join(tempDir.path(), "extracted.ts");
expect(fs.existsSync(newFilePath)).toBe(false);
const newUri = fileToUri(newFilePath);
const createOp: CreateFile = {
kind: "create",
uri: newUri,
};
const textEdit: TextDocumentEdit = {
textDocument: { uri: newUri, version: null },
edits: [
{
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 0 },
},
newText: "export const extracted = 42;\n",
},
],
};
const workspaceEdit: WorkspaceEdit = {
documentChanges: [createOp, textEdit],
};
const applied = await applyWorkspaceEdit(workspaceEdit, tempDir.path());
expect(fs.existsSync(newFilePath)).toBe(true);
expect(fs.readFileSync(newFilePath, "utf8")).toBe("export const extracted = 42;\n");
// Declared order observable in the applied log: create first, then edit.
expect(applied).toHaveLength(2);
expect(applied[0]).toContain("Created");
expect(applied[0]).toContain("extracted.ts");
expect(applied[1]).toContain("Applied 1 edit(s)");
expect(applied[1]).toContain("extracted.ts");
} finally {
tempDir.removeSync();
}
});
it("flushes pending descendant text edits before a folder delete", async () => {
// Mirror of the folder-rename subtree-flush test for the `delete` arm:
// edits queued against a child URI must land at the original path
// BEFORE the parent folder is removed, otherwise the flush at end of
// walk would target a non-existent path and throw.
const tempDir = TempDir.createSync("@omp-lsp-folder-delete-");
try {
const srcDir = path.join(tempDir.path(), "src");
fs.mkdirSync(srcDir, { recursive: true });
const childPath = path.join(srcDir, "a.ts");
await Bun.write(childPath, "export const a = 1;\n");
const childUri = fileToUri(childPath);
const folderUri = fileToUri(srcDir);
const childEdit: TextDocumentEdit = {
textDocument: { uri: childUri, version: null },
edits: [
{
range: {
start: { line: 0, character: 18 },
end: { line: 0, character: 19 },
},
newText: "999",
},
],
};
const folderDelete: DeleteFile = {
kind: "delete",
uri: folderUri,
};
const workspaceEdit: WorkspaceEdit = {
documentChanges: [childEdit, folderDelete],
};
const applied = await applyWorkspaceEdit(workspaceEdit, tempDir.path());
// Folder is gone; "Applied" message proves the flush ran before delete.
expect(fs.existsSync(srcDir)).toBe(false);
expect(applied).toHaveLength(2);
expect(applied[0]).toContain("Applied 1 edit(s)");
expect(applied[0]).toContain("src/a.ts");
expect(applied[1]).toContain("Deleted");
expect(applied[1]).toContain("src");
} finally {
tempDir.removeSync();
}
});
});