@oh-my-pi/hashline 16.1.22 → 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 +16 -0
- package/README.md +2 -0
- package/dist/types/format.d.ts +4 -0
- package/dist/types/fs.d.ts +17 -3
- package/dist/types/input.d.ts +5 -2
- package/dist/types/messages.d.ts +4 -0
- package/dist/types/parser.d.ts +5 -1
- package/dist/types/patcher.d.ts +8 -5
- package/dist/types/snapshots.d.ts +7 -0
- package/dist/types/tokenizer.d.ts +5 -0
- package/dist/types/types.d.ts +7 -0
- package/package.json +1 -1
- package/src/format.ts +4 -0
- package/src/fs.ts +58 -6
- package/src/grammar.lark +3 -1
- package/src/input.ts +19 -5
- package/src/messages.ts +8 -0
- package/src/parser.ts +53 -8
- package/src/patcher.ts +87 -18
- package/src/prompt.md +24 -0
- package/src/snapshots.ts +27 -0
- package/src/tokenizer.ts +52 -1
- package/src/types.ts +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
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
|
+
|
|
11
|
+
## [16.1.23] - 2026-06-26
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Updated prompt documentation to include support for Markdown section operations
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Improved file path recovery to correctly handle read-only or incorrectly typed paths
|
|
20
|
+
|
|
5
21
|
## [16.1.14] - 2026-06-22
|
|
6
22
|
|
|
7
23
|
### Fixed
|
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
|
package/dist/types/format.d.ts
CHANGED
|
@@ -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 = "#";
|
package/dist/types/fs.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/types/input.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/types/messages.d.ts
CHANGED
|
@@ -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. */
|
package/dist/types/parser.d.ts
CHANGED
|
@@ -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
|
};
|
package/dist/types/patcher.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/types/types.d.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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,18 +254,22 @@ export class Patcher {
|
|
|
251
254
|
* tag mismatch ({@link MismatchError}).
|
|
252
255
|
*/
|
|
253
256
|
async prepare(section: PatchSection): Promise<PreparedSection> {
|
|
254
|
-
const
|
|
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;
|
|
258
263
|
let canonicalPath = this.fs.canonicalPath(target.path);
|
|
259
|
-
await this.fs.preflightWrite(target.path);
|
|
260
264
|
let read = await this.#tryRead(target.path);
|
|
261
265
|
|
|
262
266
|
// Path recovery: the authored path doesn't exist on disk, but its
|
|
263
267
|
// filename + snapshot tag may name a file the model read this session
|
|
264
268
|
// (it supplied a bare filename, or the wrong directory). Rebind to that
|
|
265
|
-
// file so the edit lands where the tag points, and warn.
|
|
269
|
+
// file so the edit lands where the tag points, and warn. This runs
|
|
270
|
+
// before the write gate so a recoverable bare/mis-typed path is rebound
|
|
271
|
+
// to its real (writable) location instead of being rejected against the
|
|
272
|
+
// literal — possibly read-only — path it was authored as.
|
|
266
273
|
if (!read.exists) {
|
|
267
274
|
const recovered = this.#recoverSectionPathFromTag(target, canonicalPath);
|
|
268
275
|
if (recovered && this.fs.allowTagPathRecovery(target.path, recovered.section.path)) {
|
|
@@ -271,25 +278,43 @@ export class Patcher {
|
|
|
271
278
|
);
|
|
272
279
|
target = recovered.section;
|
|
273
280
|
canonicalPath = recovered.canonicalPath;
|
|
274
|
-
await this.fs.preflightWrite(target.path);
|
|
275
281
|
read = await this.#tryRead(target.path);
|
|
276
282
|
}
|
|
277
283
|
}
|
|
284
|
+
|
|
285
|
+
// Gate the final (possibly recovered) target before any write work, so
|
|
286
|
+
// an unrecoverable read-only target (e.g. a plan-mode working-tree path)
|
|
287
|
+
// fails with the write guard rather than a misleading "file not found".
|
|
288
|
+
await this.fs.preflightWrite(target.path, { fileOp });
|
|
289
|
+
|
|
278
290
|
if (!read.exists) {
|
|
279
291
|
throw new Error(`File not found: ${target.path}. Use the write tool to create new files.`);
|
|
280
292
|
}
|
|
281
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
|
+
|
|
282
298
|
const { bom, text } = stripBom(read.rawContent);
|
|
283
299
|
const lineEnding = detectLineEnding(text);
|
|
284
300
|
const normalized = normalizeToLF(text);
|
|
285
301
|
|
|
286
|
-
const applyResult =
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
+
});
|
|
293
318
|
|
|
294
319
|
return new PreparedSection(
|
|
295
320
|
target,
|
|
@@ -301,6 +326,7 @@ export class Patcher {
|
|
|
301
326
|
normalized,
|
|
302
327
|
applyResult,
|
|
303
328
|
parseWarnings,
|
|
329
|
+
fileOp,
|
|
304
330
|
);
|
|
305
331
|
}
|
|
306
332
|
|
|
@@ -343,11 +369,31 @@ export class Patcher {
|
|
|
343
369
|
* filesystem-canonical path.
|
|
344
370
|
*/
|
|
345
371
|
async commit(prepared: PreparedSection): Promise<PatchSectionResult> {
|
|
346
|
-
const { section, normalized, bom, lineEnding, parseWarnings, exists, applyResult, canonicalPath } =
|
|
372
|
+
const { section, normalized, bom, lineEnding, parseWarnings, exists, applyResult, canonicalPath, fileOp } =
|
|
373
|
+
prepared;
|
|
347
374
|
const after = applyResult.text;
|
|
348
375
|
const warnings = mergeWarnings(parseWarnings, applyResult.warnings);
|
|
376
|
+
const moveDest = fileOp?.kind === "move" ? fileOp.dest : undefined;
|
|
377
|
+
const resultPath = moveDest ?? section.path;
|
|
349
378
|
|
|
350
|
-
if (
|
|
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) {
|
|
351
397
|
const hash = this.#recordFullSnapshot(canonicalPath, normalized);
|
|
352
398
|
return {
|
|
353
399
|
path: section.path,
|
|
@@ -364,6 +410,29 @@ export class Patcher {
|
|
|
364
410
|
}
|
|
365
411
|
|
|
366
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
|
+
|
|
367
436
|
const write: WriteResult = await this.fs.writeText(section.path, persisted);
|
|
368
437
|
const fileHash = this.#recordFullSnapshot(canonicalPath, after);
|
|
369
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
|
|
|
@@ -34,6 +36,7 @@ Body rows appear only under a `:` header. Every body row is `+TEXT` — add a li
|
|
|
34
36
|
- Whole construct → `SWAP.BLK N` (tree-sitter resolves the end); lines inside it → `SWAP N.=M`.
|
|
35
37
|
- `SWAP.BLK N` resolves EXACTLY the node at N. Leading decorators/attributes/doc-comments are separate nodes: point N at the FIRST decorator to sweep both; standalone line-comments are never swept — use `SWAP N.=M`.
|
|
36
38
|
- Block ops (`SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST`) anchor the OPENING line of a MULTI-LINE construct — never its closer, last line, or a bare inner statement. Anchoring one statement resolves to ONE line and is REJECTED: use the plain op (`SWAP N.=N` / `DEL N` / `INS.POST N`), or point N at the real opener. Saw the closer? Use plain `INS.POST M:`.
|
|
39
|
+
- Markdown: a heading line IS a block opener — `SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST` on a `##`/`###` heading resolves its WHOLE section (heading through every nested deeper heading, up to the next same-or-higher heading). So `DEL.BLK` drops the section, `SWAP.BLK` rewrites it, `INS.BLK.POST` lands after it (end the inserted body with a blank line to keep the next heading separated).
|
|
37
40
|
- Non-adjacent changes = separate hunks; untouched lines stay out of every range.
|
|
38
41
|
- Pure additions use `INS.PRE` / `INS.POST` / `INS.HEAD` / `INS.TAIL`, never a widened `SWAP` — retyped keepers are exactly what gets dropped. (A multi-line `SWAP` whose body restates the line just past the range is auto-dropped as an off-by-one keeper with a warning — issue the payload for the range only; never lean on the repair.)
|
|
39
42
|
- NEVER format/restyle code with this tool; run the project formatter instead.
|
|
@@ -70,6 +73,27 @@ Delete line 3:
|
|
|
70
73
|
DEL 3
|
|
71
74
|
```
|
|
72
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
|
+
|
|
73
97
|
Add a header and trailer:
|
|
74
98
|
```
|
|
75
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. */
|