@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 +6 -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 +78 -16
- package/src/prompt.md +23 -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,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
|
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,7 +254,9 @@ 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;
|
|
@@ -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 =
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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 } =
|
|
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 (
|
|
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. */
|