From b612e8c7929098d0b4554a192d55a37a72f86a28 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sat, 27 Jun 2026 00:33:31 +0200 Subject: [PATCH] feat(hashline): allowed file deletion and movement operations - Implemented file deletion (`REM`) and movement (`MV`) operations within the hashline grammar and parser. - Added filesystem support for executing delete and move commands while maintaining snapshot history migration. - Integrated file operation detection and parsing logic into the coding agent and patcher components. - Added comprehensive integration tests and documentation for the new file-level operation syntax. --- packages/coding-agent/CHANGELOG.md | 5 +- .../coding-agent/src/edit/hashline/execute.ts | 34 ++++++- .../src/edit/hashline/filesystem.ts | 40 +++++++- .../coding-agent/src/session/agent-session.ts | 10 ++ packages/hashline/CHANGELOG.md | 7 +- packages/hashline/README.md | 2 + packages/hashline/src/format.ts | 4 + packages/hashline/src/fs.ts | 59 +++++++++++- packages/hashline/src/grammar.lark | 4 +- packages/hashline/src/input.ts | 24 ++++- packages/hashline/src/messages.ts | 8 ++ packages/hashline/src/parser.ts | 61 ++++++++++-- packages/hashline/src/patcher.ts | 94 +++++++++++++++---- packages/hashline/src/prompt.md | 23 +++++ packages/hashline/src/snapshots.ts | 28 ++++++ packages/hashline/src/tokenizer.ts | 53 ++++++++++- packages/hashline/src/types.ts | 3 + packages/hashline/test/file-ops.test.ts | 70 ++++++++++++++ packages/hashline/test/snapshots.test.ts | 11 +++ 19 files changed, 502 insertions(+), 38 deletions(-) create mode 100644 packages/hashline/test/file-ops.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index abfdcc734..13d21db40 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Added + +- Added support for file deletion and moving within file editing operations ### Changed @@ -12529,4 +12532,4 @@ Initial public release. ## [0.7.6] - 2025-11-13 -Previous releases did not maintain a changelog. +Previous releases did not maintain a changelog. \ No newline at end of file diff --git a/packages/coding-agent/src/edit/hashline/execute.ts b/packages/coding-agent/src/edit/hashline/execute.ts index 3eac7d257..b879821ed 100644 --- a/packages/coding-agent/src/edit/hashline/execute.ts +++ b/packages/coding-agent/src/edit/hashline/execute.ts @@ -107,6 +107,23 @@ function formatBlockResolution(resolution: BlockResolution): string { } function renderSection(result: PatchSectionResult, diagnostics: FileDiagnosticsResult | undefined): RenderedSection { + if (result.op === "delete") { + const toolResult: AgentToolResult = { + content: [{ type: "text", text: `Deleted ${result.path}` }], + details: { + diff: "", + op: "delete", + path: result.path, + oldText: result.before, + meta: outputMeta().get(), + }, + }; + return { + toolResult, + perFileResult: { path: result.path, diff: "", op: "delete", oldText: result.before }, + }; + } + if (result.op === "noop") { const toolResult: AgentToolResult = { content: [{ type: "text", text: noChangeDiagnostic(result.path) }], @@ -130,24 +147,37 @@ function renderSection(result: PatchSectionResult, diagnostics: FileDiagnosticsR result.blockResolutions && result.blockResolutions.length > 0 ? `\n${result.blockResolutions.map(formatBlockResolution).join("\n")}` : ""; + const moveBlock = result.moveDest ? `\nMoved to ${result.moveDest}` : ""; const firstChangedLine = result.firstChangedLine ?? diff.firstChangedLine; return { toolResult: { - content: [{ type: "text", text: `${result.header}${blockBlock}${previewBlock}${warningsBlock}` }], + content: [ + { + type: "text", + text: `${result.header}${blockBlock}${moveBlock}${previewBlock}${warningsBlock}`, + }, + ], details: { diff: diff.diff, firstChangedLine, diagnostics, op: result.op, + move: result.moveDest, + path: result.moveDest ?? result.path, + oldText: result.before, + newText: result.after, meta, }, }, perFileResult: { - path: result.path, + path: result.moveDest ?? result.path, diff: diff.diff, firstChangedLine, diagnostics, op: result.op, + move: result.moveDest, + oldText: result.before, + newText: result.after, }, }; } diff --git a/packages/coding-agent/src/edit/hashline/filesystem.ts b/packages/coding-agent/src/edit/hashline/filesystem.ts index 10b76378b..63b9ffd17 100644 --- a/packages/coding-agent/src/edit/hashline/filesystem.ts +++ b/packages/coding-agent/src/edit/hashline/filesystem.ts @@ -16,8 +16,9 @@ * (batch request, diagnostics) lives on the instance and isn't safe to * share across concurrent edit tools. */ +import * as fs from "node:fs/promises"; import * as path from "node:path"; -import { Filesystem, NotFoundError, type WriteResult } from "@oh-my-pi/hashline"; +import { Filesystem, NotFoundError, type PreflightWriteOptions, type WriteResult } from "@oh-my-pi/hashline"; import { isEnoent } from "@oh-my-pi/pi-utils"; import type { FileDiagnosticsResult, WritethroughCallback, WritethroughDeferredHandle } from "../../lsp"; import type { ToolSession } from "../../tools"; @@ -122,10 +123,45 @@ export class HashlineFilesystem extends Filesystem { return content; } - async preflightWrite(relativePath: string): Promise { + async preflightWrite(relativePath: string, options?: PreflightWriteOptions): Promise { + const fileOp = options?.fileOp; + if (fileOp?.kind === "rem") { + enforcePlanModeWrite(this.session, relativePath, { op: "delete" }); + return; + } + if (fileOp?.kind === "move") { + enforcePlanModeWrite(this.session, relativePath, { op: "update", move: fileOp.dest }); + return; + } enforcePlanModeWrite(this.session, relativePath, { op: "update" }); } + async delete(relativePath: string): Promise { + enforcePlanModeWrite(this.session, relativePath, { op: "delete" }); + const absolutePath = this.resolveAbsolute(relativePath); + try { + await fs.rm(absolutePath); + } catch (error) { + if (isEnoent(error)) throw new NotFoundError(relativePath, error); + throw error; + } + invalidateFsScanAfterWrite(absolutePath); + } + + async move(fromRelative: string, toRelative: string, content?: string): Promise { + enforcePlanModeWrite(this.session, fromRelative, { op: "update", move: toRelative }); + const fromAbsolute = this.resolveAbsolute(fromRelative); + const toAbsolute = this.resolveAbsolute(toRelative); + if (content !== undefined) { + await Bun.write(toAbsolute, content); + await fs.rm(fromAbsolute); + } else { + await fs.rename(fromAbsolute, toAbsolute); + } + invalidateFsScanAfterWrite(fromAbsolute); + invalidateFsScanAfterWrite(toAbsolute); + } + async writeText(relativePath: string, content: string): Promise { await this.preflightWrite(relativePath); const absolutePath = this.resolveAbsolute(relativePath); diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 8716e3fd9..cd0a03cab 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -20,6 +20,7 @@ import { scheduler } from "node:timers/promises"; import { isPromise } from "node:util/types"; import type { InMemorySnapshotStore } from "@oh-my-pi/hashline"; +import { Patch } from "@oh-my-pi/hashline"; import { type AfterToolCallContext, type AfterToolCallResult, @@ -939,6 +940,15 @@ function getEditDestructiveIntent(args: unknown): { kind: "delete" | "move"; pat const input = getStringProperty(a, "input"); if (input) { + try { + const patch = Patch.parse(input); + for (const section of patch.sections) { + if (section.fileOp?.kind === "rem") return { kind: "delete", paths: [section.path] }; + if (section.fileOp?.kind === "move") return { kind: "move", paths: [section.path, section.fileOp.dest] }; + } + } catch { + // Not a hashline patch — fall through to apply_patch parsing. + } try { const entries = expandApplyPatchToEntries({ input }); const deleteEntry = entries.find(entry => entry.op === "delete"); diff --git a/packages/hashline/CHANGELOG.md b/packages/hashline/CHANGELOG.md index 52ee5cd7d..f678c84d7 100644 --- a/packages/hashline/CHANGELOG.md +++ b/packages/hashline/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog ## [Unreleased] +### Added + +- Added `REM` section op to delete files +- Added `MV` section op to rename or move files, including snapshot history migration +- Added whole-file `REM` and `MV DEST` section ops so a hashline patch can delete or rename/move files (optionally after line edits) without leaving the edit tool. `MV` relocates read/snapshot history to the destination path. ## [16.1.23] - 2026-06-26 @@ -312,4 +317,4 @@ All notable changes to this package will be documented in this file. - Fixed repeated patch application mutating cached `after_anchor` edits between target snapshots - Fixed multi-section patching to preflight write policies and reject duplicate canonical targets before any section is committed -- Fixed mixed line-ending restoration to preserve the first newline style instead of rewriting ties to LF +- Fixed mixed line-ending restoration to preserve the first newline style instead of rewriting ties to LF \ No newline at end of file diff --git a/packages/hashline/README.md b/packages/hashline/README.md index f31741cd2..934030f6d 100644 --- a/packages/hashline/README.md +++ b/packages/hashline/README.md @@ -52,6 +52,8 @@ Inside a section: - `DEL A.=B` / `DEL.BLK A` — delete concrete lines or a resolved block. - `INS.PRE A:` / `INS.POST A:` / `INS.HEAD:` / `INS.TAIL:` — insert following body rows. - `INS.BLK.POST A:` — insert following body rows after the resolved block's last line. +- `REM` — delete the whole file named by the section header. +- `MV DEST` — move/rename the section file to `DEST` (optionally after line edits). - `+TEXT` — literal body row (use `+` alone for a blank line). ## Abstractions diff --git a/packages/hashline/src/format.ts b/packages/hashline/src/format.ts index 106e38991..ff782ac68 100644 --- a/packages/hashline/src/format.ts +++ b/packages/hashline/src/format.ts @@ -33,6 +33,10 @@ export const HL_REPLACE_BLOCK_KEYWORD = "SWAP.BLK"; export const HL_DELETE_BLOCK_KEYWORD = "DEL.BLK"; /** Hunk-header keyword: `INS.BLK.POST N:` inserts after the last line of the tree-sitter block at N. */ export const HL_INSERT_AFTER_BLOCK_KEYWORD = "INS.BLK.POST"; +/** File-level keyword: `REM` deletes the whole file named by the section header. */ +export const HL_REM_KEYWORD = "REM"; +/** File-level keyword: `MV DEST` renames/moves the section file to `DEST`. */ +export const HL_MOVE_KEYWORD = "MV"; export const HL_HEADER_COLON = ":"; /** Separator between a hashline file path and its opaque snapshot tag. */ diff --git a/packages/hashline/src/fs.ts b/packages/hashline/src/fs.ts index ab0a17cf3..766442531 100644 --- a/packages/hashline/src/fs.ts +++ b/packages/hashline/src/fs.ts @@ -8,6 +8,7 @@ * {@link Filesystem.readText} and {@link Filesystem.writeText}; the FS deals * only in raw text strings. */ +import * as fs from "node:fs/promises"; import * as pathModule from "node:path"; /** @@ -20,6 +21,13 @@ export interface WriteResult { text: string; } +import type { FileOp } from "./types"; + +/** Optional hints for {@link Filesystem.preflightWrite}. */ +export interface PreflightWriteOptions { + fileOp?: FileOp; +} + /** * ENOENT-like error thrown by {@link Filesystem.readText} when a path is * missing. Carrying a `code` property keeps the contract compatible with @@ -58,11 +66,25 @@ export abstract class Filesystem { abstract readText(path: string): Promise; /** Validate that `path` is writable before a prepared batch starts committing. */ - async preflightWrite(_path: string): Promise {} + async preflightWrite(_path: string, _options?: PreflightWriteOptions): Promise {} /** Persist `content` at `path`. Returns the actual final text that was written. */ abstract writeText(path: string, content: string): Promise; + /** Delete the file at `path`. Default: not supported. */ + async delete(path: string): Promise { + throw new Error(`Filesystem does not support delete: ${path}`); + } + + /** + * Move/rename `from` to `to`. When `content` is provided the destination + * receives that text; otherwise implementations may preserve the source bytes. + */ + async move(from: string, to: string, content?: string): Promise { + void content; + throw new Error(`Filesystem does not support move: ${from} -> ${to}`); + } + /** Return true when the path exists and can be read. Default: probe via {@link readText}. */ async exists(path: string): Promise { try { @@ -125,6 +147,18 @@ export class InMemoryFilesystem extends Filesystem { return { text: content }; } + async delete(path: string): Promise { + if (!this.#files.delete(path)) throw new NotFoundError(path); + } + + async move(from: string, to: string, content?: string): Promise { + const existing = this.#files.get(from); + if (existing === undefined) throw new NotFoundError(from); + const finalContent = content ?? existing; + this.#files.set(to, finalContent); + this.#files.delete(from); + } + async exists(path: string): Promise { return this.#files.has(path); } @@ -172,6 +206,29 @@ export class NodeFilesystem extends Filesystem { return { text: content }; } + async delete(path: string): Promise { + try { + await fs.rm(path); + } catch (error) { + if (isNotFound(error)) throw new NotFoundError(path, error); + throw error; + } + } + + async move(from: string, to: string, content?: string): Promise { + if (content !== undefined) { + await Bun.write(to, content); + await this.delete(from); + return; + } + try { + await fs.rename(from, to); + } catch (error) { + if (isNotFound(error)) throw new NotFoundError(from, error); + throw error; + } + } + canonicalPath(path: string): string { return pathModule.resolve(path); } diff --git a/packages/hashline/src/grammar.lark b/packages/hashline/src/grammar.lark index 2e5cd7ac1..41c7a2a32 100644 --- a/packages/hashline/src/grammar.lark +++ b/packages/hashline/src/grammar.lark @@ -7,13 +7,15 @@ file_header: "[" filename "#" file_hash "]" LF file_hash: /[0-9A-F]{4}/ filename: /[^#\r\n]+/ -hunk: replace_hunk | replace_block_hunk | insert_hunk | insert_block_hunk | delete_hunk | delete_block_hunk +hunk: replace_hunk | replace_block_hunk | insert_hunk | insert_block_hunk | delete_hunk | delete_block_hunk | remove_hunk | move_hunk replace_hunk: replace_anchor LF emit_op* replace_block_hunk: replace_block_anchor LF emit_op+ insert_hunk: insert_anchor LF emit_op+ insert_block_hunk: insert_block_anchor LF emit_op+ delete_hunk: "DEL " header_range LF delete_block_hunk: "DEL.BLK " LID LF +remove_hunk: "REM" LF +move_hunk: "MV " filename LF emit_op* replace_anchor: "SWAP " header_range ":" replace_block_anchor: "SWAP.BLK " LID ":" insert_anchor: "INS." insert_pos ":" diff --git a/packages/hashline/src/input.ts b/packages/hashline/src/input.ts index a520a6fb3..e2b3f9455 100644 --- a/packages/hashline/src/input.ts +++ b/packages/hashline/src/input.ts @@ -13,7 +13,7 @@ import { resolveBlockEdits } from "./block"; import { HL_FILE_HASH_EXAMPLES, HL_FILE_HASH_LENGTH, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format"; import { parsePatch, parsePatchStreaming } from "./parser"; import { Tokenizer } from "./tokenizer"; -import type { ApplyResult, BlockResolver, Edit, SplitOptions } from "./types"; +import type { ApplyResult, BlockResolver, Edit, FileOp, SplitOptions } from "./types"; // Pure classification — single shared tokenizer is safe. const TOKENIZER = new Tokenizer(); @@ -237,7 +237,7 @@ export class PatchSection { readonly path: string; readonly fileHash: string | undefined; readonly diff: string; - #parsed: { edits: Edit[]; warnings: string[] } | undefined; + #parsed: { edits: Edit[]; fileOp?: FileOp; warnings: string[] } | undefined; constructor(raw: RawSection) { this.path = raw.path; @@ -247,12 +247,21 @@ export class PatchSection { /** * Parse this section's diff body. Cached: subsequent calls return the - * same `{ edits, warnings }` object so callers can safely call this from + * same `{ edits, fileOp?, warnings }` object so callers can safely call this from * multiple paths (preflight, apply, diff-preview). */ - parse(): { edits: Edit[]; warnings: readonly string[] } { + parse(): { edits: Edit[]; fileOp?: FileOp; warnings: readonly string[] } { this.#parsed ??= parsePatch(this.diff); - return this.#parsed; + const parsed = this.#parsed; + const fileOp = + parsed.fileOp === undefined + ? undefined + : parsed.fileOp.kind === "move" + ? { kind: "move" as const, dest: normalizeHashlinePath(parsed.fileOp.dest) } + : parsed.fileOp; + return fileOp === parsed.fileOp + ? parsed + : { edits: parsed.edits, ...(fileOp === undefined ? {} : { fileOp }), warnings: parsed.warnings }; } /** Parsed edits for this section. */ @@ -260,6 +269,11 @@ export class PatchSection { return this.parse().edits; } + /** Optional whole-file operation (`REM` / `MV`). */ + get fileOp(): FileOp | undefined { + return this.parse().fileOp; + } + /** Warnings emitted during parsing of this section. */ get warnings(): readonly string[] { return this.parse().warnings; diff --git a/packages/hashline/src/messages.ts b/packages/hashline/src/messages.ts index 5527cf678..db8c5a0c0 100644 --- a/packages/hashline/src/messages.ts +++ b/packages/hashline/src/messages.ts @@ -119,6 +119,14 @@ export const UNRESOLVED_BLOCK_INTERNAL = /** Delete hunk received a body row. */ export const DELETE_TAKES_NO_BODY = `\`DEL N${HL_RANGE_SEP}M\` does not take body rows. Remove the body, or use \`SWAP N${HL_RANGE_SEP}M:\`.`; +/** `REM` received a body row or coexists with line edits. */ +export const REM_TAKES_NO_BODY = + "`REM` deletes the whole file and takes no body rows or line ops. Issue it alone under the header."; + +/** `MV` received a body row. */ +export const MOVE_TAKES_NO_BODY = + "`MV DEST` does not take body rows. Put line edits above the `MV` row; the destination path follows `MV` on the same line."; + /** `delete_block N` hunk received a body row. */ export const DELETE_BLOCK_TAKES_NO_BODY = "`DEL.BLK N` does not take body rows. Remove the body, or use `SWAP.BLK N:`."; diff --git a/packages/hashline/src/parser.ts b/packages/hashline/src/parser.ts index b71ff3d8c..27ba9951d 100644 --- a/packages/hashline/src/parser.ts +++ b/packages/hashline/src/parser.ts @@ -11,10 +11,12 @@ import { EMPTY_BLOCK, EMPTY_INSERT, MINUS_ROW_REJECTED, + MOVE_TAKES_NO_BODY, + REM_TAKES_NO_BODY, } from "./messages"; import { stripOneLeadingHashlinePrefix } from "./prefixes"; import { type BlockTarget, cloneCursor, type ParsedRange, type Token, Tokenizer } from "./tokenizer"; -import type { Anchor, Cursor, Edit } from "./types"; +import type { Anchor, Cursor, Edit, FileOp } from "./types"; function validateRangeOrder(range: ParsedRange, lineNum: number): void { if (range.end.line < range.start.line) { @@ -110,6 +112,7 @@ export class Executor { #warnings: string[] = []; #editIndex = 0; #pending: Pending | undefined; + #fileOp: FileOp | undefined; #terminated = false; #skippableComments: PendingComment[] = []; @@ -161,27 +164,47 @@ export class Executor { if (token.target.kind === "replace" || token.target.kind === "delete") { validateRangeOrder(token.target.range, token.lineNum); } + if (token.target.kind === "rem") { + this.#flushPending(); + this.#setFileOp({ kind: "rem" }, token.lineNum); + return; + } + if (token.target.kind === "move") { + this.#flushPending(); + this.#setFileOp({ kind: "move", dest: token.target.dest }, token.lineNum); + return; + } this.#flushPending(); this.#pending = { target: token.target, lineNum: token.lineNum, payloads: [], deferredBlanks: [] }; return; } } - end(): { edits: Edit[]; warnings: string[] } { + end(): { edits: Edit[]; fileOp?: FileOp; warnings: string[] } { this.#consumePendingSkippableComments(); this.#flushPending(); + this.#validateFileOp(); this.#validateNoOverlappingDeletes(); - return { edits: this.#edits, warnings: this.#warnings }; + return { + edits: this.#edits, + ...(this.#fileOp === undefined ? {} : { fileOp: this.#fileOp }), + warnings: this.#warnings, + }; } - endStreaming(): { edits: Edit[]; warnings: string[] } { + endStreaming(): { edits: Edit[]; fileOp?: FileOp; warnings: string[] } { this.#consumePendingSkippableComments(); if (this.#pending && this.#pending.payloads.length > 0) this.#flushPending(); else if (this.#pending?.target.kind === "delete" || this.#pending?.target.kind === "delete_block") this.#flushPending(); else this.#pending = undefined; + this.#validateFileOp(); this.#validateNoOverlappingDeletes(); - return { edits: this.#edits, warnings: this.#warnings }; + return { + edits: this.#edits, + ...(this.#fileOp === undefined ? {} : { fileOp: this.#fileOp }), + warnings: this.#warnings, + }; } reset(): void { @@ -189,10 +212,30 @@ export class Executor { this.#warnings = []; this.#editIndex = 0; this.#pending = undefined; + this.#fileOp = undefined; this.#skippableComments = []; this.#terminated = false; } + #setFileOp(fileOp: FileOp, lineNum: number): void { + if (this.#fileOp !== undefined) { + throw new Error( + `line ${lineNum}: only one file-level op (\`REM\` or \`MV\`) per section. Merge them under one header.`, + ); + } + if (fileOp.kind === "rem" && this.#edits.length > 0) { + throw new Error(`line ${lineNum}: ${REM_TAKES_NO_BODY}`); + } + this.#fileOp = fileOp; + } + + #validateFileOp(): void { + if (this.#fileOp?.kind !== "rem") return; + if (this.#edits.length > 0) { + throw new Error("`REM` deletes the whole file and cannot be combined with line ops."); + } + } + #validateNoOverlappingDeletes(): void { const sourceLinesByAnchor = new Map(); for (const edit of this.#edits) { @@ -217,6 +260,7 @@ export class Executor { #handleLiteralPayload(text: string, lineNum: number): void { const pending = this.#pending; if (!pending) { + if (this.#fileOp !== undefined) throw new Error(`line ${lineNum}: ${MOVE_TAKES_NO_BODY}`); throw new Error( `line ${lineNum}: payload line has no preceding hunk header. ` + `Got ${JSON.stringify(`${HL_PAYLOAD_REPLACE}${text}`)}.`, @@ -231,6 +275,7 @@ export class Executor { #handleRaw(text: string, lineNum: number): void { const contamination = detectApplyPatchContamination(text, this.#pending !== undefined); if (contamination !== null) throw new Error(`line ${lineNum}: ${contamination}`); + if (this.#fileOp !== undefined) throw new Error(`line ${lineNum}: ${MOVE_TAKES_NO_BODY}`); if (this.#pending) { if (text.trim().length === 0) { this.#handleBlank(text, lineNum); @@ -390,19 +435,19 @@ export class Executor { } } -function drain(executor: Executor, tokenizer: Tokenizer): { edits: Edit[]; warnings: string[] } { +function drain(executor: Executor, tokenizer: Tokenizer): { edits: Edit[]; fileOp?: FileOp; warnings: string[] } { for (const token of tokenizer.end()) executor.feed(token); return executor.end(); } -export function parsePatch(diff: string): { edits: Edit[]; warnings: string[] } { +export function parsePatch(diff: string): { edits: Edit[]; fileOp?: FileOp; warnings: string[] } { const tokenizer = new Tokenizer(); const executor = new Executor(); for (const token of tokenizer.feed(diff)) executor.feed(token); return drain(executor, tokenizer); } -export function parsePatchStreaming(diff: string): { edits: Edit[]; warnings: string[] } { +export function parsePatchStreaming(diff: string): { edits: Edit[]; fileOp?: FileOp; warnings: string[] } { const tokenizer = new Tokenizer(); const executor = new Executor(); for (const token of tokenizer.feed(diff)) executor.feed(token); diff --git a/packages/hashline/src/patcher.ts b/packages/hashline/src/patcher.ts index ebb1b155b..ba5cc9a75 100644 --- a/packages/hashline/src/patcher.ts +++ b/packages/hashline/src/patcher.ts @@ -39,7 +39,7 @@ import { MismatchError } from "./mismatch"; import { detectLineEnding, type LineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize"; import { Recovery, type RecoveryResult } from "./recovery"; import type { SnapshotStore } from "./snapshots"; -import type { ApplyResult, BlockResolution, BlockResolver, Edit } from "./types"; +import type { ApplyResult, BlockResolution, BlockResolver, Edit, FileOp } from "./types"; export interface PatcherOptions { /** Storage backend used for all reads and writes. */ @@ -60,8 +60,8 @@ export interface PatchSectionResult { path: string; /** Filesystem-canonical key for this section (e.g. absolute path). */ canonicalPath: string; - /** `"noop"` when the apply produced no change; otherwise `"create"` / `"update"`. */ - op: "create" | "update" | "noop"; + /** `"noop"` when the apply produced no change; `"delete"` removes the file; otherwise `"create"` / `"update"`. */ + op: "create" | "update" | "delete" | "noop"; /** Pre-edit text (LF-normalized, BOM-stripped). */ before: string; /** Post-edit text (LF-normalized, BOM-stripped). For `"noop"` equals `before`. */ @@ -78,6 +78,8 @@ export interface PatchSectionResult { firstChangedLine?: number; /** Warnings collected by the parser, applier, and (optionally) recovery. */ warnings: string[]; + /** Destination path when this section includes `MV DEST`. */ + moveDest?: string; /** * Resolved spans for any `replace_block`/`delete_block` ops, present when the * apply matched the tagged content. Undefined for patches with no block ops @@ -107,11 +109,12 @@ export class PreparedSection { readonly normalized: string, readonly applyResult: ApplyResult, readonly parseWarnings: readonly string[], + readonly fileOp: FileOp | undefined, ) {} - /** Convenience: returns true when the apply produced no change. */ + /** Convenience: returns true when the apply produced no change and no file op. */ get isNoop(): boolean { - return this.applyResult.text === this.normalized; + return this.fileOp === undefined && this.applyResult.text === this.normalized; } } @@ -251,7 +254,9 @@ export class Patcher { * tag mismatch ({@link MismatchError}). */ async prepare(section: PatchSection): Promise { - const parseWarnings = [...section.parse().warnings]; + const parsed = section.parse(); + const parseWarnings = [...parsed.warnings]; + const fileOp = parsed.fileOp; assertSectionHashPresent(section.path, section.fileHash); let target = section; @@ -280,23 +285,36 @@ export class Patcher { // Gate the final (possibly recovered) target before any write work, so // an unrecoverable read-only target (e.g. a plan-mode working-tree path) // fails with the write guard rather than a misleading "file not found". - await this.fs.preflightWrite(target.path); + await this.fs.preflightWrite(target.path, { fileOp }); if (!read.exists) { throw new Error(`File not found: ${target.path}. Use the write tool to create new files.`); } + if (fileOp?.kind === "move" && this.fs.canonicalPath(fileOp.dest) === canonicalPath) { + throw new Error(`MV destination is the same as ${target.path}.`); + } + const { bom, text } = stripBom(read.rawContent); const lineEnding = detectLineEnding(text); const normalized = normalizeToLF(text); - const applyResult = this.#applyWithRecovery({ - section: target, - canonicalPath, - exists: read.exists, - normalized, - edits: target.parse().edits, - }); + const applyResult = + fileOp?.kind === "rem" + ? this.#applyWithRecovery({ + section: target, + canonicalPath, + exists: read.exists, + normalized, + edits: [], + }) + : this.#applyWithRecovery({ + section: target, + canonicalPath, + exists: read.exists, + normalized, + edits: parsed.edits, + }); return new PreparedSection( target, @@ -308,6 +326,7 @@ export class Patcher { normalized, applyResult, parseWarnings, + fileOp, ); } @@ -350,11 +369,31 @@ export class Patcher { * filesystem-canonical path. */ async commit(prepared: PreparedSection): Promise { - const { section, normalized, bom, lineEnding, parseWarnings, exists, applyResult, canonicalPath } = prepared; + const { section, normalized, bom, lineEnding, parseWarnings, exists, applyResult, canonicalPath, fileOp } = + prepared; const after = applyResult.text; const warnings = mergeWarnings(parseWarnings, applyResult.warnings); + const moveDest = fileOp?.kind === "move" ? fileOp.dest : undefined; + const resultPath = moveDest ?? section.path; - if (after === normalized) { + if (fileOp?.kind === "rem") { + await this.fs.delete(section.path); + this.snapshots.invalidate(canonicalPath); + return { + path: section.path, + canonicalPath, + op: "delete", + before: normalized, + after: normalized, + persisted: prepared.rawContent, + written: prepared.rawContent, + fileHash: computeFileHash(normalized), + header: formatHashlineHeader(section.path, computeFileHash(normalized)), + warnings, + }; + } + + if (after === normalized && moveDest === undefined) { const hash = this.#recordFullSnapshot(canonicalPath, normalized); return { path: section.path, @@ -371,6 +410,29 @@ export class Patcher { } const persisted = bom + restoreLineEndings(after, lineEnding); + + if (moveDest !== undefined) { + const destCanonical = this.fs.canonicalPath(moveDest); + this.snapshots.relocate(canonicalPath, destCanonical); + await this.fs.move(section.path, moveDest, persisted); + const fileHash = this.#recordFullSnapshot(destCanonical, after); + return { + path: resultPath, + canonicalPath: destCanonical, + op: "update", + before: normalized, + after, + persisted, + written: persisted, + fileHash, + header: formatHashlineHeader(moveDest, fileHash), + firstChangedLine: applyResult.firstChangedLine, + blockResolutions: applyResult.blockResolutions, + moveDest, + warnings, + }; + } + const write: WriteResult = await this.fs.writeText(section.path, persisted); const fileHash = this.#recordFullSnapshot(canonicalPath, after); const op = exists ? "update" : "create"; diff --git a/packages/hashline/src/prompt.md b/packages/hashline/src/prompt.md index 17a6756e3..355e1f828 100644 --- a/packages/hashline/src/prompt.md +++ b/packages/hashline/src/prompt.md @@ -13,6 +13,8 @@ Every file section starts with `[PATH#TAG]`. `TAG` = 4-hex snapshot tag from you `INS.POST N:` — insert the body rows immediately after line N. `INS.BLK.POST N:` — insert the body rows after the END of the block that BEGINS on line N — outside it, at sibling depth. To append inside a block, use `INS.POST`. `INS.HEAD:` / `INS.TAIL:` — insert the body rows at the very start / end of the file. +`REM` — delete the whole file named by the section header. No body, no line ops. +`MV DEST` — move/rename the section file to `DEST` (a path, quoted when it contains spaces). Line edits above `MV` land on the source first, then the final content is written at `DEST`. Single line: `SWAP N.=N:` / `DEL N`. The range is the ORIGINAL lines you touch; body length is irrelevant (replacing 1 line with 10 is still `SWAP N.=N:`). @@ -71,6 +73,27 @@ Delete line 3: DEL 3 ``` +Delete the whole file: +``` +[greet.py#A1B2] +REM +``` + +Rename or move the file: +``` +[greet.py#A1B2] +MV greet_v2.py +``` + +Move after editing: +``` +[greet.py#A1B2] +SWAP 1.=3: ++def greet(name): ++ print(f"Hi, {name}") +MV lib/greet.py +``` + Add a header and trailer: ``` [greet.py#A1B2] diff --git a/packages/hashline/src/snapshots.ts b/packages/hashline/src/snapshots.ts index d87cb1a8e..6f59a4545 100644 --- a/packages/hashline/src/snapshots.ts +++ b/packages/hashline/src/snapshots.ts @@ -90,6 +90,13 @@ export abstract class SnapshotStore { /** Drop the version history for a single path. */ abstract invalidate(path: string): void; + /** + * Move retained version history (and read provenance) from `from` to `to`. + * No-op when `from` has no history. Used by file moves so tags minted from + * reads of the source path stay valid at the destination. + */ + abstract relocate(from: string, to: string): void; + /** Drop every version history. */ abstract clear(): void; } @@ -197,6 +204,27 @@ export class InMemorySnapshotStore extends SnapshotStore { this.#versions.delete(path); } + relocate(from: string, to: string): void { + const sourceHistory = this.#versions.get(from); + if (sourceHistory === undefined || sourceHistory.length === 0) return; + for (const version of sourceHistory) version.path = to; + const destHistory = this.#versions.get(to); + if (destHistory === undefined) { + this.#versions.set(to, sourceHistory); + } else { + const seen = new Set(); + const merged: Snapshot[] = []; + for (const version of [...sourceHistory, ...destHistory]) { + if (seen.has(version.hash)) continue; + seen.add(version.hash); + version.path = to; + merged.push(version); + } + this.#versions.set(to, merged.slice(0, this.#maxVersionsPerPath)); + } + this.#versions.delete(from); + } + clear(): void { this.#versions.clear(); } diff --git a/packages/hashline/src/tokenizer.ts b/packages/hashline/src/tokenizer.ts index 93f05901d..7da74a23c 100644 --- a/packages/hashline/src/tokenizer.ts +++ b/packages/hashline/src/tokenizer.ts @@ -23,7 +23,9 @@ import { HL_INSERT_HEAD, HL_INSERT_KEYWORD, HL_INSERT_TAIL, + HL_MOVE_KEYWORD, HL_PAYLOAD_REPLACE, + HL_REM_KEYWORD, HL_REPLACE_BLOCK_KEYWORD, HL_REPLACE_KEYWORD, } from "./format"; @@ -212,6 +214,8 @@ export type BlockTarget = | { kind: "insert_before"; anchor: Anchor } | { kind: "insert_after"; anchor: Anchor } | { kind: "insert_after_block"; anchor: Anchor } + | { kind: "rem" } + | { kind: "move"; dest: string } | { kind: "bof" } | { kind: "eof" }; @@ -259,9 +263,54 @@ function scanInsertTarget(line: string, index: number, end: number): TargetScan return null; } +function unquotePath(pathText: string): string { + if (pathText.length < 2) return pathText; + const first = pathText[0]; + const last = pathText[pathText.length - 1]; + if ((first === '"' || first === "'") && first === last) return pathText.slice(1, -1); + return pathText; +} + +function scanMoveDest(line: string, index: number, end: number): string | null { + const cursor = skipWhitespace(line, index, end); + if (cursor >= end) return null; + const first = line.charCodeAt(cursor); + if (first === 34 /* " */ || first === 39 /* ' */) { + const quote = line[cursor]; + let next = cursor + 1; + while (next < end) { + const ch = line[next]; + if (ch === "\\" && next + 1 < end) { + next += 2; + continue; + } + if (ch === quote) { + const after = skipWhitespace(line, next + 1, end); + return after === end ? unquotePath(line.slice(cursor, next + 1)) : null; + } + next++; + } + return null; + } + return unquotePath(line.slice(cursor, end).trim()); +} + function scanHunkAnchor(line: string, start: number, end: number): TargetScan | null { const cursor = skipWhitespace(line, start, end); + const remEnd = scanKeyword(line, cursor, end, HL_REM_KEYWORD); + if (remEnd !== null) { + const next = skipWhitespace(line, remEnd, end); + if (next !== end) return null; + return { target: { kind: "rem" }, nextIndex: next }; + } + const moveEnd = scanKeyword(line, cursor, end, HL_MOVE_KEYWORD); + if (moveEnd !== null) { + const dest = scanMoveDest(line, moveEnd, end); + if (dest === null || dest.length === 0) return null; + return { target: { kind: "move", dest }, nextIndex: end }; + } + // `replace_block N:` — resolve N to a tree-sitter block range at apply time. const replaceBlockEnd = scanKeyword(line, cursor, end, HL_REPLACE_BLOCK_KEYWORD); if (replaceBlockEnd !== null) { @@ -406,7 +455,9 @@ function classifyLine(line: string, lineNum: number): Token { const isHunkLead = line.startsWith(HL_REPLACE_KEYWORD, lead) || line.startsWith(HL_DELETE_KEYWORD, lead) || - line.startsWith(HL_INSERT_KEYWORD, lead); + line.startsWith(HL_INSERT_KEYWORD, lead) || + line.startsWith(HL_REM_KEYWORD, lead) || + line.startsWith(HL_MOVE_KEYWORD, lead); if (isHunkLead) { const hunk = tryParseHunkHeader(line); if (hunk !== null) return { kind: "op-block", lineNum, target: hunk.target }; diff --git a/packages/hashline/src/types.ts b/packages/hashline/src/types.ts index d23f94150..72d7e66fe 100644 --- a/packages/hashline/src/types.ts +++ b/packages/hashline/src/types.ts @@ -61,6 +61,9 @@ export type Edit = index: number; }; +/** File-level operation parsed from a section body (`REM` / `MV`). */ +export type FileOp = { kind: "rem" } | { kind: "move"; dest: string }; + /** Result of applying a parsed set of edits to a text body. */ export interface ApplyResult { /** Post-edit text body. */ diff --git a/packages/hashline/test/file-ops.test.ts b/packages/hashline/test/file-ops.test.ts new file mode 100644 index 000000000..174ee211b --- /dev/null +++ b/packages/hashline/test/file-ops.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "bun:test"; +import { + computeFileHash, + InMemoryFilesystem, + InMemorySnapshotStore, + Patch, + Patcher, + parsePatch, +} from "@oh-my-pi/hashline"; + +const PATH = "src/old.ts"; +const DEST = "src/new.ts"; +const CONTENT = "one\ntwo\nthree\n"; + +describe("hashline file ops", () => { + it("parses REM and rejects line ops in the same section", () => { + expect(parsePatch("REM").fileOp).toEqual({ kind: "rem" }); + expect(() => parsePatch(`SWAP 1.=1:\n+one\nREM`)).toThrow(/REM.*line ops/); + }); + + it("parses MV with a normalized destination path", () => { + const section = Patch.parseSingle(`[${PATH}#AB12]\nMV ${DEST}`); + expect(section.fileOp).toEqual({ kind: "move", dest: DEST }); + }); + + it("deletes a tagged file with REM", async () => { + const fs = new InMemoryFilesystem([[PATH, CONTENT]]); + const snapshots = new InMemorySnapshotStore(); + const tag = snapshots.record(PATH, CONTENT); + const patcher = new Patcher({ fs, snapshots }); + + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nREM`)); + + expect(result.sections[0]?.op).toBe("delete"); + expect(fs.get(PATH)).toBeUndefined(); + expect(snapshots.byHash(PATH, tag)).toBeNull(); + }); + + it("moves a file without content edits", async () => { + const fs = new InMemoryFilesystem([[PATH, CONTENT]]); + const snapshots = new InMemorySnapshotStore(); + const tag = snapshots.record(PATH, CONTENT, [1, 2]); + const patcher = new Patcher({ fs, snapshots }); + + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nMV ${DEST}`)); + + expect(result.sections[0]?.op).toBe("update"); + expect(result.sections[0]?.moveDest).toBe(DEST); + expect(fs.get(PATH)).toBeUndefined(); + expect(fs.get(DEST)).toBe(CONTENT); + expect(snapshots.byHash(DEST, tag)?.text).toBe(CONTENT); + expect(snapshots.byHash(DEST, tag)?.seenLines).toEqual(new Set([1, 2])); + expect(snapshots.byHash(PATH, tag)).toBeNull(); + }); + + it("applies line edits then moves the updated content", async () => { + const fs = new InMemoryFilesystem([[PATH, CONTENT]]); + const snapshots = new InMemorySnapshotStore(); + const tag = snapshots.record(PATH, CONTENT); + const patcher = new Patcher({ fs, snapshots }); + + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 2.=2:\n+TWO\nMV ${DEST}`)); + + expect(result.sections[0]?.moveDest).toBe(DEST); + expect(fs.get(PATH)).toBeUndefined(); + expect(fs.get(DEST)).toBe("one\nTWO\nthree\n"); + expect(result.sections[0]?.fileHash).toBe(computeFileHash("one\nTWO\nthree\n")); + expect(snapshots.head(DEST)?.hash).toBe(result.sections[0]?.fileHash); + }); +}); diff --git a/packages/hashline/test/snapshots.test.ts b/packages/hashline/test/snapshots.test.ts index 6103533fd..e92974eb0 100644 --- a/packages/hashline/test/snapshots.test.ts +++ b/packages/hashline/test/snapshots.test.ts @@ -86,6 +86,17 @@ describe("InMemorySnapshotStore", () => { expect(store.byHash(OTHER, tagB)).toBeNull(); }); + it("relocate moves version history and read provenance to a new path", () => { + const store = new InMemorySnapshotStore(); + const dest = "/tmp/__hashline-dest__.ts"; + const tag = store.record(PATH, "A\n", [1]); + store.relocate(PATH, dest); + expect(store.byHash(PATH, tag)).toBeNull(); + expect(store.byHash(dest, tag)?.text).toBe("A\n"); + expect(store.byHash(dest, tag)?.seenLines).toEqual(new Set([1])); + expect(store.head(dest)?.hash).toBe(tag); + }); + it("findByHash returns every retained version with that tag across paths", () => { const store = new InMemorySnapshotStore(); const text = "shared\n";