feat: added block replace syntax with block edits in hashline parser
- Added `replace block N:` syntax in grammar/parser/tokenizer/types, emitting `kind: "block"` edits with empty-body checks. - Added block-resolution in hashline apply/recovery, wiring `BlockResolver` to resolve edits with throw/drop unresolved behavior. - Added native `blockRangeAt` support and exported block range/options types through pi-ast, pi-natives, and JS bindings. - Added coding-agent resolver wiring plus internal visibility/exports updates and new parser, patcher, and setup-wizard tests.
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import { beforeAll, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { formatHashlineHeader } from "@oh-my-pi/hashline";
|
||||
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import {
|
||||
type ExecuteHashlineSingleOptions,
|
||||
executeHashlineSingle,
|
||||
getFileSnapshotStore,
|
||||
} from "@oh-my-pi/pi-coding-agent/edit";
|
||||
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
|
||||
beforeAll(async () => {
|
||||
resetSettingsForTest();
|
||||
await Settings.init({ inMemory: true, cwd: process.cwd() });
|
||||
});
|
||||
|
||||
async function withTempDir(fn: (tempDir: string) => Promise<void>): Promise<void> {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "block-replace-"));
|
||||
try {
|
||||
await fn(tempDir);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function makeSession(tempDir: string): ToolSession {
|
||||
return { cwd: tempDir, settings: Settings.isolated() } as ToolSession;
|
||||
}
|
||||
|
||||
function executeOptions(_tempDir: string, input: string, session: ToolSession): ExecuteHashlineSingleOptions {
|
||||
return {
|
||||
session,
|
||||
input,
|
||||
writethrough: async (targetPath, content) => {
|
||||
await Bun.write(targetPath, content);
|
||||
return undefined;
|
||||
},
|
||||
beginDeferredDiagnosticsForPath: () => ({
|
||||
onDeferredDiagnostics: () => {},
|
||||
signal: new AbortController().signal,
|
||||
finalize: () => {},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a file on disk + a recorded snapshot tag, returning the hashline
|
||||
* section header bound to the current content.
|
||||
*/
|
||||
async function seedFile(
|
||||
tempDir: string,
|
||||
session: ToolSession,
|
||||
name: string,
|
||||
source: string,
|
||||
): Promise<{ filePath: string; header: string }> {
|
||||
const filePath = path.join(tempDir, name);
|
||||
await Bun.write(filePath, source);
|
||||
const tag = getFileSnapshotStore(session).record(filePath, source);
|
||||
return { filePath, header: formatHashlineHeader(name, tag) };
|
||||
}
|
||||
|
||||
const TS_SOURCE = "function x() {\n if (y) {\n }\n}\n";
|
||||
|
||||
describe("replace block — native tree-sitter resolution end-to-end", () => {
|
||||
it("resolves the inner `if` block (line 2) and replaces its full span", async () => {
|
||||
await withTempDir(async tempDir => {
|
||||
const session = makeSession(tempDir);
|
||||
const { filePath, header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE);
|
||||
const input = `${header}\nreplace block 2:\n+ if (y || z) {\n+ }`;
|
||||
|
||||
await executeHashlineSingle(executeOptions(tempDir, input, session));
|
||||
|
||||
expect(await Bun.file(filePath).text()).toBe("function x() {\n if (y || z) {\n }\n}\n");
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves the enclosing function block (line 1) and replaces the whole construct", async () => {
|
||||
await withTempDir(async tempDir => {
|
||||
const session = makeSession(tempDir);
|
||||
const { filePath, header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE);
|
||||
const input = `${header}\nreplace block 1:\n+function x() {\n+ return 42;\n+}`;
|
||||
|
||||
await executeHashlineSingle(executeOptions(tempDir, input, session));
|
||||
|
||||
expect(await Bun.file(filePath).text()).toBe("function x() {\n return 42;\n}\n");
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes the resolved `if` block (line 2) end-to-end via `delete block`", async () => {
|
||||
await withTempDir(async tempDir => {
|
||||
const session = makeSession(tempDir);
|
||||
const { filePath, header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE);
|
||||
const input = `${header}\ndelete block 2`;
|
||||
|
||||
await executeHashlineSingle(executeOptions(tempDir, input, session));
|
||||
|
||||
expect(await Bun.file(filePath).text()).toBe("function x() {\n}\n");
|
||||
});
|
||||
});
|
||||
|
||||
it("reports the diff for a resolved block edit", async () => {
|
||||
await withTempDir(async tempDir => {
|
||||
const session = makeSession(tempDir);
|
||||
const { header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE);
|
||||
const input = `${header}\nreplace block 2:\n+ if (y || z) {\n+ }`;
|
||||
|
||||
const result = await executeHashlineSingle(executeOptions(tempDir, input, session));
|
||||
|
||||
const diff = result.details?.diff ?? "";
|
||||
expect(diff).toContain("if (y || z)");
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a lone closing delimiter (no block begins there) and steers to `replace N..M:`", async () => {
|
||||
await withTempDir(async tempDir => {
|
||||
const session = makeSession(tempDir);
|
||||
const { filePath, header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE);
|
||||
// Line 3 is ` }` — a closing delimiter, not a block opener.
|
||||
const input = `${header}\nreplace block 3:\n+ }`;
|
||||
|
||||
await expect(executeHashlineSingle(executeOptions(tempDir, input, session))).rejects.toThrow(
|
||||
/could not resolve a syntactic block beginning on line 3.*replace 3\.\.M:/s,
|
||||
);
|
||||
// Disk untouched — refusal never leaves a partial write.
|
||||
expect(await Bun.file(filePath).text()).toBe(TS_SOURCE);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a block edit on an unrecognized language", async () => {
|
||||
await withTempDir(async tempDir => {
|
||||
const session = makeSession(tempDir);
|
||||
const source = "alpha\nbeta\ngamma\n";
|
||||
const { filePath, header } = await seedFile(tempDir, session, "data.unknownext", source);
|
||||
const input = `${header}\nreplace block 1:\n+ALPHA`;
|
||||
|
||||
await expect(executeHashlineSingle(executeOptions(tempDir, input, session))).rejects.toThrow(
|
||||
/could not resolve a syntactic block/,
|
||||
);
|
||||
expect(await Bun.file(filePath).text()).toBe(source);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user