@oh-my-pi/hashline 16.1.23 → 16.2.0

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/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.2.0] - 2026-06-27
6
+
7
+ ### Added
8
+
9
+ - Added `REM` (remove) and `MV` (move/rename) section operations to hashline patches, allowing files to be deleted or relocated (with snapshot history migration) directly within the edit tool.
10
+
5
11
  ## [16.1.23] - 2026-06-26
6
12
 
7
13
  ### Added
package/README.md CHANGED
@@ -52,6 +52,8 @@ Inside a section:
52
52
  - `DEL A.=B` / `DEL.BLK A` — delete concrete lines or a resolved block.
53
53
  - `INS.PRE A:` / `INS.POST A:` / `INS.HEAD:` / `INS.TAIL:` — insert following body rows.
54
54
  - `INS.BLK.POST A:` — insert following body rows after the resolved block's last line.
55
+ - `REM` — delete the whole file named by the section header.
56
+ - `MV DEST` — move/rename the section file to `DEST` (optionally after line edits).
55
57
  - `+TEXT` — literal body row (use `+` alone for a blank line).
56
58
 
57
59
  ## Abstractions
@@ -29,6 +29,10 @@ export declare const HL_REPLACE_BLOCK_KEYWORD = "SWAP.BLK";
29
29
  export declare const HL_DELETE_BLOCK_KEYWORD = "DEL.BLK";
30
30
  /** Hunk-header keyword: `INS.BLK.POST N:` inserts after the last line of the tree-sitter block at N. */
31
31
  export declare const HL_INSERT_AFTER_BLOCK_KEYWORD = "INS.BLK.POST";
32
+ /** File-level keyword: `REM` deletes the whole file named by the section header. */
33
+ export declare const HL_REM_KEYWORD = "REM";
34
+ /** File-level keyword: `MV DEST` renames/moves the section file to `DEST`. */
35
+ export declare const HL_MOVE_KEYWORD = "MV";
32
36
  export declare const HL_HEADER_COLON = ":";
33
37
  /** Separator between a hashline file path and its opaque snapshot tag. */
34
38
  export declare const HL_FILE_HASH_SEP = "#";
@@ -7,6 +7,11 @@ export interface WriteResult {
7
7
  /** Final text that was persisted. May differ from the input if the FS transformed it. */
8
8
  text: string;
9
9
  }
10
+ import type { FileOp } from "./types";
11
+ /** Optional hints for {@link Filesystem.preflightWrite}. */
12
+ export interface PreflightWriteOptions {
13
+ fileOp?: FileOp;
14
+ }
10
15
  /**
11
16
  * ENOENT-like error thrown by {@link Filesystem.readText} when a path is
12
17
  * missing. Carrying a `code` property keeps the contract compatible with
@@ -33,9 +38,16 @@ export declare abstract class Filesystem {
33
38
  /** Read the file's full text content. Throw on missing file. */
34
39
  abstract readText(path: string): Promise<string>;
35
40
  /** Validate that `path` is writable before a prepared batch starts committing. */
36
- preflightWrite(_path: string): Promise<void>;
41
+ preflightWrite(_path: string, _options?: PreflightWriteOptions): Promise<void>;
37
42
  /** Persist `content` at `path`. Returns the actual final text that was written. */
38
43
  abstract writeText(path: string, content: string): Promise<WriteResult>;
44
+ /** Delete the file at `path`. Default: not supported. */
45
+ delete(path: string): Promise<void>;
46
+ /**
47
+ * Move/rename `from` to `to`. When `content` is provided the destination
48
+ * receives that text; otherwise implementations may preserve the source bytes.
49
+ */
50
+ move(from: string, to: string, content?: string): Promise<void>;
39
51
  /** Return true when the path exists and can be read. Default: probe via {@link readText}. */
40
52
  exists(path: string): Promise<boolean>;
41
53
  /**
@@ -67,13 +79,13 @@ export declare class InMemoryFilesystem extends Filesystem {
67
79
  constructor(initial?: Iterable<readonly [string, string]>);
68
80
  readText(path: string): Promise<string>;
69
81
  writeText(path: string, content: string): Promise<WriteResult>;
82
+ delete(path: string): Promise<void>;
83
+ move(from: string, to: string, content?: string): Promise<void>;
70
84
  exists(path: string): Promise<boolean>;
71
85
  /** Synchronous helper for setting up fixtures without awaiting. */
72
86
  set(path: string, content: string): void;
73
87
  /** Synchronous helper for inspecting state without awaiting. */
74
88
  get(path: string): string | undefined;
75
- /** Remove a single entry. Returns true when something was removed. */
76
- delete(path: string): boolean;
77
89
  /** Wipe all entries. */
78
90
  clear(): void;
79
91
  /** Iterate `[path, content]` pairs. */
@@ -87,6 +99,8 @@ export declare class InMemoryFilesystem extends Filesystem {
87
99
  export declare class NodeFilesystem extends Filesystem {
88
100
  readText(path: string): Promise<string>;
89
101
  writeText(path: string, content: string): Promise<WriteResult>;
102
+ delete(path: string): Promise<void>;
103
+ move(from: string, to: string, content?: string): Promise<void>;
90
104
  canonicalPath(path: string): string;
91
105
  exists(path: string): Promise<boolean>;
92
106
  }
@@ -1,4 +1,4 @@
1
- import type { ApplyResult, BlockResolver, Edit, SplitOptions } from "./types";
1
+ import type { ApplyResult, BlockResolver, Edit, FileOp, SplitOptions } from "./types";
2
2
  interface RawSection {
3
3
  path: string;
4
4
  fileHash?: string;
@@ -24,15 +24,18 @@ export declare class PatchSection {
24
24
  constructor(raw: RawSection);
25
25
  /**
26
26
  * Parse this section's diff body. Cached: subsequent calls return the
27
- * same `{ edits, warnings }` object so callers can safely call this from
27
+ * same `{ edits, fileOp?, warnings }` object so callers can safely call this from
28
28
  * multiple paths (preflight, apply, diff-preview).
29
29
  */
30
30
  parse(): {
31
31
  edits: Edit[];
32
+ fileOp?: FileOp;
32
33
  warnings: readonly string[];
33
34
  };
34
35
  /** Parsed edits for this section. */
35
36
  get edits(): readonly Edit[];
37
+ /** Optional whole-file operation (`REM` / `MV`). */
38
+ get fileOp(): FileOp | undefined;
36
39
  /** Warnings emitted during parsing of this section. */
37
40
  get warnings(): readonly string[];
38
41
  /**
@@ -56,6 +56,10 @@ export declare function insertAfterBlockUnresolvedLoweredWarning(line: number):
56
56
  export declare const UNRESOLVED_BLOCK_INTERNAL = "internal error: unresolved `SWAP.BLK` edit reached the applier (resolveBlockEdits was not run).";
57
57
  /** Delete hunk received a body row. */
58
58
  export declare const DELETE_TAKES_NO_BODY = "`DEL N.=M` does not take body rows. Remove the body, or use `SWAP N.=M:`.";
59
+ /** `REM` received a body row or coexists with line edits. */
60
+ export declare const REM_TAKES_NO_BODY = "`REM` deletes the whole file and takes no body rows or line ops. Issue it alone under the header.";
61
+ /** `MV` received a body row. */
62
+ export declare 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.";
59
63
  /** `delete_block N` hunk received a body row. */
60
64
  export declare const DELETE_BLOCK_TAKES_NO_BODY = "`DEL.BLK N` does not take body rows. Remove the body, or use `SWAP.BLK N:`.";
61
65
  /** Insert hunk with no body. */
@@ -1,23 +1,27 @@
1
1
  import { type Token } from "./tokenizer";
2
- import type { Edit } from "./types";
2
+ import type { Edit, FileOp } from "./types";
3
3
  export declare class Executor {
4
4
  #private;
5
5
  feed(token: Token): void;
6
6
  end(): {
7
7
  edits: Edit[];
8
+ fileOp?: FileOp;
8
9
  warnings: string[];
9
10
  };
10
11
  endStreaming(): {
11
12
  edits: Edit[];
13
+ fileOp?: FileOp;
12
14
  warnings: string[];
13
15
  };
14
16
  reset(): void;
15
17
  }
16
18
  export declare function parsePatch(diff: string): {
17
19
  edits: Edit[];
20
+ fileOp?: FileOp;
18
21
  warnings: string[];
19
22
  };
20
23
  export declare function parsePatchStreaming(diff: string): {
21
24
  edits: Edit[];
25
+ fileOp?: FileOp;
22
26
  warnings: string[];
23
27
  };
@@ -3,7 +3,7 @@ import type { Patch, PatchSection } from "./input";
3
3
  import { type LineEnding } from "./normalize";
4
4
  import { Recovery } from "./recovery";
5
5
  import type { SnapshotStore } from "./snapshots";
6
- import type { ApplyResult, BlockResolution, BlockResolver } from "./types";
6
+ import type { ApplyResult, BlockResolution, BlockResolver, FileOp } from "./types";
7
7
  export interface PatcherOptions {
8
8
  /** Storage backend used for all reads and writes. */
9
9
  fs: Filesystem;
@@ -22,8 +22,8 @@ export interface PatchSectionResult {
22
22
  path: string;
23
23
  /** Filesystem-canonical key for this section (e.g. absolute path). */
24
24
  canonicalPath: string;
25
- /** `"noop"` when the apply produced no change; otherwise `"create"` / `"update"`. */
26
- op: "create" | "update" | "noop";
25
+ /** `"noop"` when the apply produced no change; `"delete"` removes the file; otherwise `"create"` / `"update"`. */
26
+ op: "create" | "update" | "delete" | "noop";
27
27
  /** Pre-edit text (LF-normalized, BOM-stripped). */
28
28
  before: string;
29
29
  /** Post-edit text (LF-normalized, BOM-stripped). For `"noop"` equals `before`. */
@@ -40,6 +40,8 @@ export interface PatchSectionResult {
40
40
  firstChangedLine?: number;
41
41
  /** Warnings collected by the parser, applier, and (optionally) recovery. */
42
42
  warnings: string[];
43
+ /** Destination path when this section includes `MV DEST`. */
44
+ moveDest?: string;
43
45
  /**
44
46
  * Resolved spans for any `replace_block`/`delete_block` ops, present when the
45
47
  * apply matched the tagged content. Undefined for patches with no block ops
@@ -65,9 +67,10 @@ export declare class PreparedSection {
65
67
  readonly normalized: string;
66
68
  readonly applyResult: ApplyResult;
67
69
  readonly parseWarnings: readonly string[];
70
+ readonly fileOp: FileOp | undefined;
68
71
  /** @internal */
69
- constructor(section: PatchSection, canonicalPath: string, exists: boolean, rawContent: string, bom: string, lineEnding: LineEnding, normalized: string, applyResult: ApplyResult, parseWarnings: readonly string[]);
70
- /** Convenience: returns true when the apply produced no change. */
72
+ constructor(section: PatchSection, canonicalPath: string, exists: boolean, rawContent: string, bom: string, lineEnding: LineEnding, normalized: string, applyResult: ApplyResult, parseWarnings: readonly string[], fileOp: FileOp | undefined);
73
+ /** Convenience: returns true when the apply produced no change and no file op. */
71
74
  get isNoop(): boolean;
72
75
  }
73
76
  /**
@@ -56,6 +56,12 @@ export declare abstract class SnapshotStore {
56
56
  abstract recordSeenLines(path: string, hash: string, lines: Iterable<number>): void;
57
57
  /** Drop the version history for a single path. */
58
58
  abstract invalidate(path: string): void;
59
+ /**
60
+ * Move retained version history (and read provenance) from `from` to `to`.
61
+ * No-op when `from` has no history. Used by file moves so tags minted from
62
+ * reads of the source path stay valid at the destination.
63
+ */
64
+ abstract relocate(from: string, to: string): void;
59
65
  /** Drop every version history. */
60
66
  abstract clear(): void;
61
67
  }
@@ -89,5 +95,6 @@ export declare class InMemorySnapshotStore extends SnapshotStore {
89
95
  record(path: string, fullText: string, seenLines?: Iterable<number>): string;
90
96
  recordSeenLines(path: string, hash: string, lines: Iterable<number>): void;
91
97
  invalidate(path: string): void;
98
+ relocate(from: string, to: string): void;
92
99
  clear(): void;
93
100
  }
@@ -24,6 +24,11 @@ export type BlockTarget = {
24
24
  } | {
25
25
  kind: "insert_after_block";
26
26
  anchor: Anchor;
27
+ } | {
28
+ kind: "rem";
29
+ } | {
30
+ kind: "move";
31
+ dest: string;
27
32
  } | {
28
33
  kind: "bof";
29
34
  } | {
@@ -66,6 +66,13 @@ export type Edit = {
66
66
  lineNum: number;
67
67
  index: number;
68
68
  };
69
+ /** File-level operation parsed from a section body (`REM` / `MV`). */
70
+ export type FileOp = {
71
+ kind: "rem";
72
+ } | {
73
+ kind: "move";
74
+ dest: string;
75
+ };
69
76
  /** Result of applying a parsed set of edits to a text body. */
70
77
  export interface ApplyResult {
71
78
  /** Post-edit text body. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/hashline",
4
- "version": "16.1.23",
4
+ "version": "16.2.0",
5
5
  "description": "Hashline: a compact, line-anchored patch language and applier. Pluggable FS/IO so it works over disk, in-memory, or any custom backend.",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
package/src/format.ts CHANGED
@@ -33,6 +33,10 @@ export const HL_REPLACE_BLOCK_KEYWORD = "SWAP.BLK";
33
33
  export const HL_DELETE_BLOCK_KEYWORD = "DEL.BLK";
34
34
  /** Hunk-header keyword: `INS.BLK.POST N:` inserts after the last line of the tree-sitter block at N. */
35
35
  export const HL_INSERT_AFTER_BLOCK_KEYWORD = "INS.BLK.POST";
36
+ /** File-level keyword: `REM` deletes the whole file named by the section header. */
37
+ export const HL_REM_KEYWORD = "REM";
38
+ /** File-level keyword: `MV DEST` renames/moves the section file to `DEST`. */
39
+ export const HL_MOVE_KEYWORD = "MV";
36
40
  export const HL_HEADER_COLON = ":";
37
41
 
38
42
  /** Separator between a hashline file path and its opaque snapshot tag. */
package/src/fs.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * {@link Filesystem.readText} and {@link Filesystem.writeText}; the FS deals
9
9
  * only in raw text strings.
10
10
  */
11
+ import * as fs from "node:fs/promises";
11
12
  import * as pathModule from "node:path";
12
13
 
13
14
  /**
@@ -20,6 +21,13 @@ export interface WriteResult {
20
21
  text: string;
21
22
  }
22
23
 
24
+ import type { FileOp } from "./types";
25
+
26
+ /** Optional hints for {@link Filesystem.preflightWrite}. */
27
+ export interface PreflightWriteOptions {
28
+ fileOp?: FileOp;
29
+ }
30
+
23
31
  /**
24
32
  * ENOENT-like error thrown by {@link Filesystem.readText} when a path is
25
33
  * missing. Carrying a `code` property keeps the contract compatible with
@@ -58,11 +66,25 @@ export abstract class Filesystem {
58
66
  abstract readText(path: string): Promise<string>;
59
67
 
60
68
  /** Validate that `path` is writable before a prepared batch starts committing. */
61
- async preflightWrite(_path: string): Promise<void> {}
69
+ async preflightWrite(_path: string, _options?: PreflightWriteOptions): Promise<void> {}
62
70
 
63
71
  /** Persist `content` at `path`. Returns the actual final text that was written. */
64
72
  abstract writeText(path: string, content: string): Promise<WriteResult>;
65
73
 
74
+ /** Delete the file at `path`. Default: not supported. */
75
+ async delete(path: string): Promise<void> {
76
+ throw new Error(`Filesystem does not support delete: ${path}`);
77
+ }
78
+
79
+ /**
80
+ * Move/rename `from` to `to`. When `content` is provided the destination
81
+ * receives that text; otherwise implementations may preserve the source bytes.
82
+ */
83
+ async move(from: string, to: string, content?: string): Promise<void> {
84
+ void content;
85
+ throw new Error(`Filesystem does not support move: ${from} -> ${to}`);
86
+ }
87
+
66
88
  /** Return true when the path exists and can be read. Default: probe via {@link readText}. */
67
89
  async exists(path: string): Promise<boolean> {
68
90
  try {
@@ -125,6 +147,18 @@ export class InMemoryFilesystem extends Filesystem {
125
147
  return { text: content };
126
148
  }
127
149
 
150
+ async delete(path: string): Promise<void> {
151
+ if (!this.#files.delete(path)) throw new NotFoundError(path);
152
+ }
153
+
154
+ async move(from: string, to: string, content?: string): Promise<void> {
155
+ const existing = this.#files.get(from);
156
+ if (existing === undefined) throw new NotFoundError(from);
157
+ const finalContent = content ?? existing;
158
+ this.#files.set(to, finalContent);
159
+ this.#files.delete(from);
160
+ }
161
+
128
162
  async exists(path: string): Promise<boolean> {
129
163
  return this.#files.has(path);
130
164
  }
@@ -139,11 +173,6 @@ export class InMemoryFilesystem extends Filesystem {
139
173
  return this.#files.get(path);
140
174
  }
141
175
 
142
- /** Remove a single entry. Returns true when something was removed. */
143
- delete(path: string): boolean {
144
- return this.#files.delete(path);
145
- }
146
-
147
176
  /** Wipe all entries. */
148
177
  clear(): void {
149
178
  this.#files.clear();
@@ -172,6 +201,29 @@ export class NodeFilesystem extends Filesystem {
172
201
  return { text: content };
173
202
  }
174
203
 
204
+ async delete(path: string): Promise<void> {
205
+ try {
206
+ await fs.rm(path);
207
+ } catch (error) {
208
+ if (isNotFound(error)) throw new NotFoundError(path, error);
209
+ throw error;
210
+ }
211
+ }
212
+
213
+ async move(from: string, to: string, content?: string): Promise<void> {
214
+ if (content !== undefined) {
215
+ await Bun.write(to, content);
216
+ await this.delete(from);
217
+ return;
218
+ }
219
+ try {
220
+ await fs.rename(from, to);
221
+ } catch (error) {
222
+ if (isNotFound(error)) throw new NotFoundError(from, error);
223
+ throw error;
224
+ }
225
+ }
226
+
175
227
  canonicalPath(path: string): string {
176
228
  return pathModule.resolve(path);
177
229
  }
package/src/grammar.lark CHANGED
@@ -7,13 +7,15 @@ file_header: "[" filename "#" file_hash "]" LF
7
7
  file_hash: /[0-9A-F]{4}/
8
8
  filename: /[^#\r\n]+/
9
9
 
10
- hunk: replace_hunk | replace_block_hunk | insert_hunk | insert_block_hunk | delete_hunk | delete_block_hunk
10
+ hunk: replace_hunk | replace_block_hunk | insert_hunk | insert_block_hunk | delete_hunk | delete_block_hunk | remove_hunk | move_hunk
11
11
  replace_hunk: replace_anchor LF emit_op*
12
12
  replace_block_hunk: replace_block_anchor LF emit_op+
13
13
  insert_hunk: insert_anchor LF emit_op+
14
14
  insert_block_hunk: insert_block_anchor LF emit_op+
15
15
  delete_hunk: "DEL " header_range LF
16
16
  delete_block_hunk: "DEL.BLK " LID LF
17
+ remove_hunk: "REM" LF
18
+ move_hunk: "MV " filename LF emit_op*
17
19
  replace_anchor: "SWAP " header_range ":"
18
20
  replace_block_anchor: "SWAP.BLK " LID ":"
19
21
  insert_anchor: "INS." insert_pos ":"
package/src/input.ts CHANGED
@@ -13,7 +13,7 @@ import { resolveBlockEdits } from "./block";
13
13
  import { HL_FILE_HASH_EXAMPLES, HL_FILE_HASH_LENGTH, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format";
14
14
  import { parsePatch, parsePatchStreaming } from "./parser";
15
15
  import { Tokenizer } from "./tokenizer";
16
- import type { ApplyResult, BlockResolver, Edit, SplitOptions } from "./types";
16
+ import type { ApplyResult, BlockResolver, Edit, FileOp, SplitOptions } from "./types";
17
17
 
18
18
  // Pure classification — single shared tokenizer is safe.
19
19
  const TOKENIZER = new Tokenizer();
@@ -237,7 +237,7 @@ export class PatchSection {
237
237
  readonly path: string;
238
238
  readonly fileHash: string | undefined;
239
239
  readonly diff: string;
240
- #parsed: { edits: Edit[]; warnings: string[] } | undefined;
240
+ #parsed: { edits: Edit[]; fileOp?: FileOp; warnings: string[] } | undefined;
241
241
 
242
242
  constructor(raw: RawSection) {
243
243
  this.path = raw.path;
@@ -247,12 +247,21 @@ export class PatchSection {
247
247
 
248
248
  /**
249
249
  * Parse this section's diff body. Cached: subsequent calls return the
250
- * same `{ edits, warnings }` object so callers can safely call this from
250
+ * same `{ edits, fileOp?, warnings }` object so callers can safely call this from
251
251
  * multiple paths (preflight, apply, diff-preview).
252
252
  */
253
- parse(): { edits: Edit[]; warnings: readonly string[] } {
253
+ parse(): { edits: Edit[]; fileOp?: FileOp; warnings: readonly string[] } {
254
254
  this.#parsed ??= parsePatch(this.diff);
255
- return this.#parsed;
255
+ const parsed = this.#parsed;
256
+ const fileOp =
257
+ parsed.fileOp === undefined
258
+ ? undefined
259
+ : parsed.fileOp.kind === "move"
260
+ ? { kind: "move" as const, dest: normalizeHashlinePath(parsed.fileOp.dest) }
261
+ : parsed.fileOp;
262
+ return fileOp === parsed.fileOp
263
+ ? parsed
264
+ : { edits: parsed.edits, ...(fileOp === undefined ? {} : { fileOp }), warnings: parsed.warnings };
256
265
  }
257
266
 
258
267
  /** Parsed edits for this section. */
@@ -260,6 +269,11 @@ export class PatchSection {
260
269
  return this.parse().edits;
261
270
  }
262
271
 
272
+ /** Optional whole-file operation (`REM` / `MV`). */
273
+ get fileOp(): FileOp | undefined {
274
+ return this.parse().fileOp;
275
+ }
276
+
263
277
  /** Warnings emitted during parsing of this section. */
264
278
  get warnings(): readonly string[] {
265
279
  return this.parse().warnings;
package/src/messages.ts CHANGED
@@ -119,6 +119,14 @@ export const UNRESOLVED_BLOCK_INTERNAL =
119
119
  /** Delete hunk received a body row. */
120
120
  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:\`.`;
121
121
 
122
+ /** `REM` received a body row or coexists with line edits. */
123
+ export const REM_TAKES_NO_BODY =
124
+ "`REM` deletes the whole file and takes no body rows or line ops. Issue it alone under the header.";
125
+
126
+ /** `MV` received a body row. */
127
+ export const MOVE_TAKES_NO_BODY =
128
+ "`MV DEST` does not take body rows. Put line edits above the `MV` row; the destination path follows `MV` on the same line.";
129
+
122
130
  /** `delete_block N` hunk received a body row. */
123
131
  export const DELETE_BLOCK_TAKES_NO_BODY = "`DEL.BLK N` does not take body rows. Remove the body, or use `SWAP.BLK N:`.";
124
132
 
package/src/parser.ts CHANGED
@@ -11,10 +11,12 @@ import {
11
11
  EMPTY_BLOCK,
12
12
  EMPTY_INSERT,
13
13
  MINUS_ROW_REJECTED,
14
+ MOVE_TAKES_NO_BODY,
15
+ REM_TAKES_NO_BODY,
14
16
  } from "./messages";
15
17
  import { stripOneLeadingHashlinePrefix } from "./prefixes";
16
18
  import { type BlockTarget, cloneCursor, type ParsedRange, type Token, Tokenizer } from "./tokenizer";
17
- import type { Anchor, Cursor, Edit } from "./types";
19
+ import type { Anchor, Cursor, Edit, FileOp } from "./types";
18
20
 
19
21
  function validateRangeOrder(range: ParsedRange, lineNum: number): void {
20
22
  if (range.end.line < range.start.line) {
@@ -110,6 +112,7 @@ export class Executor {
110
112
  #warnings: string[] = [];
111
113
  #editIndex = 0;
112
114
  #pending: Pending | undefined;
115
+ #fileOp: FileOp | undefined;
113
116
  #terminated = false;
114
117
  #skippableComments: PendingComment[] = [];
115
118
 
@@ -161,27 +164,47 @@ export class Executor {
161
164
  if (token.target.kind === "replace" || token.target.kind === "delete") {
162
165
  validateRangeOrder(token.target.range, token.lineNum);
163
166
  }
167
+ if (token.target.kind === "rem") {
168
+ this.#flushPending();
169
+ this.#setFileOp({ kind: "rem" }, token.lineNum);
170
+ return;
171
+ }
172
+ if (token.target.kind === "move") {
173
+ this.#flushPending();
174
+ this.#setFileOp({ kind: "move", dest: token.target.dest }, token.lineNum);
175
+ return;
176
+ }
164
177
  this.#flushPending();
165
178
  this.#pending = { target: token.target, lineNum: token.lineNum, payloads: [], deferredBlanks: [] };
166
179
  return;
167
180
  }
168
181
  }
169
182
 
170
- end(): { edits: Edit[]; warnings: string[] } {
183
+ end(): { edits: Edit[]; fileOp?: FileOp; warnings: string[] } {
171
184
  this.#consumePendingSkippableComments();
172
185
  this.#flushPending();
186
+ this.#validateFileOp();
173
187
  this.#validateNoOverlappingDeletes();
174
- return { edits: this.#edits, warnings: this.#warnings };
188
+ return {
189
+ edits: this.#edits,
190
+ ...(this.#fileOp === undefined ? {} : { fileOp: this.#fileOp }),
191
+ warnings: this.#warnings,
192
+ };
175
193
  }
176
194
 
177
- endStreaming(): { edits: Edit[]; warnings: string[] } {
195
+ endStreaming(): { edits: Edit[]; fileOp?: FileOp; warnings: string[] } {
178
196
  this.#consumePendingSkippableComments();
179
197
  if (this.#pending && this.#pending.payloads.length > 0) this.#flushPending();
180
198
  else if (this.#pending?.target.kind === "delete" || this.#pending?.target.kind === "delete_block")
181
199
  this.#flushPending();
182
200
  else this.#pending = undefined;
201
+ this.#validateFileOp();
183
202
  this.#validateNoOverlappingDeletes();
184
- return { edits: this.#edits, warnings: this.#warnings };
203
+ return {
204
+ edits: this.#edits,
205
+ ...(this.#fileOp === undefined ? {} : { fileOp: this.#fileOp }),
206
+ warnings: this.#warnings,
207
+ };
185
208
  }
186
209
 
187
210
  reset(): void {
@@ -189,10 +212,30 @@ export class Executor {
189
212
  this.#warnings = [];
190
213
  this.#editIndex = 0;
191
214
  this.#pending = undefined;
215
+ this.#fileOp = undefined;
192
216
  this.#skippableComments = [];
193
217
  this.#terminated = false;
194
218
  }
195
219
 
220
+ #setFileOp(fileOp: FileOp, lineNum: number): void {
221
+ if (this.#fileOp !== undefined) {
222
+ throw new Error(
223
+ `line ${lineNum}: only one file-level op (\`REM\` or \`MV\`) per section. Merge them under one header.`,
224
+ );
225
+ }
226
+ if (fileOp.kind === "rem" && this.#edits.length > 0) {
227
+ throw new Error(`line ${lineNum}: ${REM_TAKES_NO_BODY}`);
228
+ }
229
+ this.#fileOp = fileOp;
230
+ }
231
+
232
+ #validateFileOp(): void {
233
+ if (this.#fileOp?.kind !== "rem") return;
234
+ if (this.#edits.length > 0) {
235
+ throw new Error("`REM` deletes the whole file and cannot be combined with line ops.");
236
+ }
237
+ }
238
+
196
239
  #validateNoOverlappingDeletes(): void {
197
240
  const sourceLinesByAnchor = new Map<number, number[]>();
198
241
  for (const edit of this.#edits) {
@@ -217,6 +260,7 @@ export class Executor {
217
260
  #handleLiteralPayload(text: string, lineNum: number): void {
218
261
  const pending = this.#pending;
219
262
  if (!pending) {
263
+ if (this.#fileOp !== undefined) throw new Error(`line ${lineNum}: ${MOVE_TAKES_NO_BODY}`);
220
264
  throw new Error(
221
265
  `line ${lineNum}: payload line has no preceding hunk header. ` +
222
266
  `Got ${JSON.stringify(`${HL_PAYLOAD_REPLACE}${text}`)}.`,
@@ -231,6 +275,7 @@ export class Executor {
231
275
  #handleRaw(text: string, lineNum: number): void {
232
276
  const contamination = detectApplyPatchContamination(text, this.#pending !== undefined);
233
277
  if (contamination !== null) throw new Error(`line ${lineNum}: ${contamination}`);
278
+ if (this.#fileOp !== undefined) throw new Error(`line ${lineNum}: ${MOVE_TAKES_NO_BODY}`);
234
279
  if (this.#pending) {
235
280
  if (text.trim().length === 0) {
236
281
  this.#handleBlank(text, lineNum);
@@ -390,19 +435,19 @@ export class Executor {
390
435
  }
391
436
  }
392
437
 
393
- function drain(executor: Executor, tokenizer: Tokenizer): { edits: Edit[]; warnings: string[] } {
438
+ function drain(executor: Executor, tokenizer: Tokenizer): { edits: Edit[]; fileOp?: FileOp; warnings: string[] } {
394
439
  for (const token of tokenizer.end()) executor.feed(token);
395
440
  return executor.end();
396
441
  }
397
442
 
398
- export function parsePatch(diff: string): { edits: Edit[]; warnings: string[] } {
443
+ export function parsePatch(diff: string): { edits: Edit[]; fileOp?: FileOp; warnings: string[] } {
399
444
  const tokenizer = new Tokenizer();
400
445
  const executor = new Executor();
401
446
  for (const token of tokenizer.feed(diff)) executor.feed(token);
402
447
  return drain(executor, tokenizer);
403
448
  }
404
449
 
405
- export function parsePatchStreaming(diff: string): { edits: Edit[]; warnings: string[] } {
450
+ export function parsePatchStreaming(diff: string): { edits: Edit[]; fileOp?: FileOp; warnings: string[] } {
406
451
  const tokenizer = new Tokenizer();
407
452
  const executor = new Executor();
408
453
  for (const token of tokenizer.feed(diff)) executor.feed(token);
package/src/patcher.ts CHANGED
@@ -39,7 +39,7 @@ import { MismatchError } from "./mismatch";
39
39
  import { detectLineEnding, type LineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
40
40
  import { Recovery, type RecoveryResult } from "./recovery";
41
41
  import type { SnapshotStore } from "./snapshots";
42
- import type { ApplyResult, BlockResolution, BlockResolver, Edit } from "./types";
42
+ import type { ApplyResult, BlockResolution, BlockResolver, Edit, FileOp } from "./types";
43
43
 
44
44
  export interface PatcherOptions {
45
45
  /** Storage backend used for all reads and writes. */
@@ -60,8 +60,8 @@ export interface PatchSectionResult {
60
60
  path: string;
61
61
  /** Filesystem-canonical key for this section (e.g. absolute path). */
62
62
  canonicalPath: string;
63
- /** `"noop"` when the apply produced no change; otherwise `"create"` / `"update"`. */
64
- op: "create" | "update" | "noop";
63
+ /** `"noop"` when the apply produced no change; `"delete"` removes the file; otherwise `"create"` / `"update"`. */
64
+ op: "create" | "update" | "delete" | "noop";
65
65
  /** Pre-edit text (LF-normalized, BOM-stripped). */
66
66
  before: string;
67
67
  /** Post-edit text (LF-normalized, BOM-stripped). For `"noop"` equals `before`. */
@@ -78,6 +78,8 @@ export interface PatchSectionResult {
78
78
  firstChangedLine?: number;
79
79
  /** Warnings collected by the parser, applier, and (optionally) recovery. */
80
80
  warnings: string[];
81
+ /** Destination path when this section includes `MV DEST`. */
82
+ moveDest?: string;
81
83
  /**
82
84
  * Resolved spans for any `replace_block`/`delete_block` ops, present when the
83
85
  * apply matched the tagged content. Undefined for patches with no block ops
@@ -107,11 +109,12 @@ export class PreparedSection {
107
109
  readonly normalized: string,
108
110
  readonly applyResult: ApplyResult,
109
111
  readonly parseWarnings: readonly string[],
112
+ readonly fileOp: FileOp | undefined,
110
113
  ) {}
111
114
 
112
- /** Convenience: returns true when the apply produced no change. */
115
+ /** Convenience: returns true when the apply produced no change and no file op. */
113
116
  get isNoop(): boolean {
114
- return this.applyResult.text === this.normalized;
117
+ return this.fileOp === undefined && this.applyResult.text === this.normalized;
115
118
  }
116
119
  }
117
120
 
@@ -251,7 +254,9 @@ export class Patcher {
251
254
  * tag mismatch ({@link MismatchError}).
252
255
  */
253
256
  async prepare(section: PatchSection): Promise<PreparedSection> {
254
- const parseWarnings = [...section.parse().warnings];
257
+ const parsed = section.parse();
258
+ const parseWarnings = [...parsed.warnings];
259
+ const fileOp = parsed.fileOp;
255
260
  assertSectionHashPresent(section.path, section.fileHash);
256
261
 
257
262
  let target = section;
@@ -280,23 +285,36 @@ export class Patcher {
280
285
  // Gate the final (possibly recovered) target before any write work, so
281
286
  // an unrecoverable read-only target (e.g. a plan-mode working-tree path)
282
287
  // fails with the write guard rather than a misleading "file not found".
283
- await this.fs.preflightWrite(target.path);
288
+ await this.fs.preflightWrite(target.path, { fileOp });
284
289
 
285
290
  if (!read.exists) {
286
291
  throw new Error(`File not found: ${target.path}. Use the write tool to create new files.`);
287
292
  }
288
293
 
294
+ if (fileOp?.kind === "move" && this.fs.canonicalPath(fileOp.dest) === canonicalPath) {
295
+ throw new Error(`MV destination is the same as ${target.path}.`);
296
+ }
297
+
289
298
  const { bom, text } = stripBom(read.rawContent);
290
299
  const lineEnding = detectLineEnding(text);
291
300
  const normalized = normalizeToLF(text);
292
301
 
293
- const applyResult = this.#applyWithRecovery({
294
- section: target,
295
- canonicalPath,
296
- exists: read.exists,
297
- normalized,
298
- edits: target.parse().edits,
299
- });
302
+ const applyResult =
303
+ fileOp?.kind === "rem"
304
+ ? this.#applyWithRecovery({
305
+ section: target,
306
+ canonicalPath,
307
+ exists: read.exists,
308
+ normalized,
309
+ edits: [],
310
+ })
311
+ : this.#applyWithRecovery({
312
+ section: target,
313
+ canonicalPath,
314
+ exists: read.exists,
315
+ normalized,
316
+ edits: parsed.edits,
317
+ });
300
318
 
301
319
  return new PreparedSection(
302
320
  target,
@@ -308,6 +326,7 @@ export class Patcher {
308
326
  normalized,
309
327
  applyResult,
310
328
  parseWarnings,
329
+ fileOp,
311
330
  );
312
331
  }
313
332
 
@@ -350,11 +369,31 @@ export class Patcher {
350
369
  * filesystem-canonical path.
351
370
  */
352
371
  async commit(prepared: PreparedSection): Promise<PatchSectionResult> {
353
- const { section, normalized, bom, lineEnding, parseWarnings, exists, applyResult, canonicalPath } = prepared;
372
+ const { section, normalized, bom, lineEnding, parseWarnings, exists, applyResult, canonicalPath, fileOp } =
373
+ prepared;
354
374
  const after = applyResult.text;
355
375
  const warnings = mergeWarnings(parseWarnings, applyResult.warnings);
376
+ const moveDest = fileOp?.kind === "move" ? fileOp.dest : undefined;
377
+ const resultPath = moveDest ?? section.path;
356
378
 
357
- if (after === normalized) {
379
+ if (fileOp?.kind === "rem") {
380
+ await this.fs.delete(section.path);
381
+ this.snapshots.invalidate(canonicalPath);
382
+ return {
383
+ path: section.path,
384
+ canonicalPath,
385
+ op: "delete",
386
+ before: normalized,
387
+ after: normalized,
388
+ persisted: prepared.rawContent,
389
+ written: prepared.rawContent,
390
+ fileHash: computeFileHash(normalized),
391
+ header: formatHashlineHeader(section.path, computeFileHash(normalized)),
392
+ warnings,
393
+ };
394
+ }
395
+
396
+ if (after === normalized && moveDest === undefined) {
358
397
  const hash = this.#recordFullSnapshot(canonicalPath, normalized);
359
398
  return {
360
399
  path: section.path,
@@ -371,6 +410,29 @@ export class Patcher {
371
410
  }
372
411
 
373
412
  const persisted = bom + restoreLineEndings(after, lineEnding);
413
+
414
+ if (moveDest !== undefined) {
415
+ const destCanonical = this.fs.canonicalPath(moveDest);
416
+ this.snapshots.relocate(canonicalPath, destCanonical);
417
+ await this.fs.move(section.path, moveDest, persisted);
418
+ const fileHash = this.#recordFullSnapshot(destCanonical, after);
419
+ return {
420
+ path: resultPath,
421
+ canonicalPath: destCanonical,
422
+ op: "update",
423
+ before: normalized,
424
+ after,
425
+ persisted,
426
+ written: persisted,
427
+ fileHash,
428
+ header: formatHashlineHeader(moveDest, fileHash),
429
+ firstChangedLine: applyResult.firstChangedLine,
430
+ blockResolutions: applyResult.blockResolutions,
431
+ moveDest,
432
+ warnings,
433
+ };
434
+ }
435
+
374
436
  const write: WriteResult = await this.fs.writeText(section.path, persisted);
375
437
  const fileHash = this.#recordFullSnapshot(canonicalPath, after);
376
438
  const op = exists ? "update" : "create";
package/src/prompt.md CHANGED
@@ -13,6 +13,8 @@ Every file section starts with `[PATH#TAG]`. `TAG` = 4-hex snapshot tag from you
13
13
  `INS.POST N:` — insert the body rows immediately after line N.
14
14
  `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`.
15
15
  `INS.HEAD:` / `INS.TAIL:` — insert the body rows at the very start / end of the file.
16
+ `REM` — delete the whole file named by the section header. No body, no line ops.
17
+ `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`.
16
18
  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:`).
17
19
  </ops>
18
20
 
@@ -71,6 +73,27 @@ Delete line 3:
71
73
  DEL 3
72
74
  ```
73
75
 
76
+ Delete the whole file:
77
+ ```
78
+ [greet.py#A1B2]
79
+ REM
80
+ ```
81
+
82
+ Rename or move the file:
83
+ ```
84
+ [greet.py#A1B2]
85
+ MV greet_v2.py
86
+ ```
87
+
88
+ Move after editing:
89
+ ```
90
+ [greet.py#A1B2]
91
+ SWAP 1.=3:
92
+ +def greet(name):
93
+ + print(f"Hi, {name}")
94
+ MV lib/greet.py
95
+ ```
96
+
74
97
  Add a header and trailer:
75
98
  ```
76
99
  [greet.py#A1B2]
package/src/snapshots.ts CHANGED
@@ -90,6 +90,13 @@ export abstract class SnapshotStore {
90
90
  /** Drop the version history for a single path. */
91
91
  abstract invalidate(path: string): void;
92
92
 
93
+ /**
94
+ * Move retained version history (and read provenance) from `from` to `to`.
95
+ * No-op when `from` has no history. Used by file moves so tags minted from
96
+ * reads of the source path stay valid at the destination.
97
+ */
98
+ abstract relocate(from: string, to: string): void;
99
+
93
100
  /** Drop every version history. */
94
101
  abstract clear(): void;
95
102
  }
@@ -197,6 +204,26 @@ export class InMemorySnapshotStore extends SnapshotStore {
197
204
  this.#versions.delete(path);
198
205
  }
199
206
 
207
+ relocate(from: string, to: string): void {
208
+ const sourceHistory = this.#versions.get(from);
209
+ if (sourceHistory === undefined || sourceHistory.length === 0) return;
210
+ const relocated = sourceHistory.map(version => ({ ...version, path: to }));
211
+ const destHistory = this.#versions.get(to);
212
+ if (destHistory === undefined) {
213
+ this.#versions.set(to, relocated);
214
+ } else {
215
+ const seen = new Set<string>();
216
+ const merged: Snapshot[] = [];
217
+ for (const version of [...relocated, ...destHistory]) {
218
+ if (seen.has(version.hash)) continue;
219
+ seen.add(version.hash);
220
+ merged.push(version);
221
+ }
222
+ this.#versions.set(to, merged.slice(0, this.#maxVersionsPerPath));
223
+ }
224
+ this.#versions.delete(from);
225
+ }
226
+
200
227
  clear(): void {
201
228
  this.#versions.clear();
202
229
  }
package/src/tokenizer.ts CHANGED
@@ -23,7 +23,9 @@ import {
23
23
  HL_INSERT_HEAD,
24
24
  HL_INSERT_KEYWORD,
25
25
  HL_INSERT_TAIL,
26
+ HL_MOVE_KEYWORD,
26
27
  HL_PAYLOAD_REPLACE,
28
+ HL_REM_KEYWORD,
27
29
  HL_REPLACE_BLOCK_KEYWORD,
28
30
  HL_REPLACE_KEYWORD,
29
31
  } from "./format";
@@ -212,6 +214,8 @@ export type BlockTarget =
212
214
  | { kind: "insert_before"; anchor: Anchor }
213
215
  | { kind: "insert_after"; anchor: Anchor }
214
216
  | { kind: "insert_after_block"; anchor: Anchor }
217
+ | { kind: "rem" }
218
+ | { kind: "move"; dest: string }
215
219
  | { kind: "bof" }
216
220
  | { kind: "eof" };
217
221
 
@@ -259,9 +263,54 @@ function scanInsertTarget(line: string, index: number, end: number): TargetScan
259
263
  return null;
260
264
  }
261
265
 
266
+ function unquotePath(pathText: string): string {
267
+ if (pathText.length < 2) return pathText;
268
+ const first = pathText[0];
269
+ const last = pathText[pathText.length - 1];
270
+ if ((first === '"' || first === "'") && first === last) return pathText.slice(1, -1);
271
+ return pathText;
272
+ }
273
+
274
+ function scanMoveDest(line: string, index: number, end: number): string | null {
275
+ const cursor = skipWhitespace(line, index, end);
276
+ if (cursor >= end) return null;
277
+ const first = line.charCodeAt(cursor);
278
+ if (first === 34 /* " */ || first === 39 /* ' */) {
279
+ const quote = line[cursor];
280
+ let next = cursor + 1;
281
+ while (next < end) {
282
+ const ch = line[next];
283
+ if (ch === "\\" && next + 1 < end) {
284
+ next += 2;
285
+ continue;
286
+ }
287
+ if (ch === quote) {
288
+ const after = skipWhitespace(line, next + 1, end);
289
+ return after === end ? unquotePath(line.slice(cursor, next + 1)) : null;
290
+ }
291
+ next++;
292
+ }
293
+ return null;
294
+ }
295
+ return unquotePath(line.slice(cursor, end).trim());
296
+ }
297
+
262
298
  function scanHunkAnchor(line: string, start: number, end: number): TargetScan | null {
263
299
  const cursor = skipWhitespace(line, start, end);
264
300
 
301
+ const remEnd = scanKeyword(line, cursor, end, HL_REM_KEYWORD);
302
+ if (remEnd !== null) {
303
+ const next = skipWhitespace(line, remEnd, end);
304
+ if (next !== end) return null;
305
+ return { target: { kind: "rem" }, nextIndex: next };
306
+ }
307
+ const moveEnd = scanKeyword(line, cursor, end, HL_MOVE_KEYWORD);
308
+ if (moveEnd !== null) {
309
+ const dest = scanMoveDest(line, moveEnd, end);
310
+ if (dest === null || dest.length === 0) return null;
311
+ return { target: { kind: "move", dest }, nextIndex: end };
312
+ }
313
+
265
314
  // `replace_block N:` — resolve N to a tree-sitter block range at apply time.
266
315
  const replaceBlockEnd = scanKeyword(line, cursor, end, HL_REPLACE_BLOCK_KEYWORD);
267
316
  if (replaceBlockEnd !== null) {
@@ -406,7 +455,9 @@ function classifyLine(line: string, lineNum: number): Token {
406
455
  const isHunkLead =
407
456
  line.startsWith(HL_REPLACE_KEYWORD, lead) ||
408
457
  line.startsWith(HL_DELETE_KEYWORD, lead) ||
409
- line.startsWith(HL_INSERT_KEYWORD, lead);
458
+ line.startsWith(HL_INSERT_KEYWORD, lead) ||
459
+ line.startsWith(HL_REM_KEYWORD, lead) ||
460
+ line.startsWith(HL_MOVE_KEYWORD, lead);
410
461
  if (isHunkLead) {
411
462
  const hunk = tryParseHunkHeader(line);
412
463
  if (hunk !== null) return { kind: "op-block", lineNum, target: hunk.target };
package/src/types.ts CHANGED
@@ -61,6 +61,9 @@ export type Edit =
61
61
  index: number;
62
62
  };
63
63
 
64
+ /** File-level operation parsed from a section body (`REM` / `MV`). */
65
+ export type FileOp = { kind: "rem" } | { kind: "move"; dest: string };
66
+
64
67
  /** Result of applying a parsed set of edits to a text body. */
65
68
  export interface ApplyResult {
66
69
  /** Post-edit text body. */