@oh-my-pi/hashline 15.5.4 → 15.5.6
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/README.md +8 -8
- package/dist/types/apply.d.ts +1 -2
- package/dist/types/format.d.ts +9 -11
- package/dist/types/input.d.ts +8 -0
- package/dist/types/messages.d.ts +7 -27
- package/dist/types/parser.d.ts +37 -11
- package/dist/types/tokenizer.d.ts +14 -18
- package/dist/types/types.d.ts +4 -1
- package/package.json +1 -1
- package/src/apply.ts +30 -79
- package/src/format.ts +9 -11
- package/src/grammar.lark +7 -8
- package/src/input.ts +17 -1
- package/src/messages.ts +8 -31
- package/src/parser.ts +225 -161
- package/src/prompt.md +106 -65
- package/src/tokenizer.ts +54 -122
- package/src/types.ts +11 -2
package/README.md
CHANGED
|
@@ -25,7 +25,9 @@ await fs.writeText(
|
|
|
25
25
|
);
|
|
26
26
|
|
|
27
27
|
const patcher = new Patcher({ fs });
|
|
28
|
-
const patch = Patch.parse(`¶hello.ts
|
|
28
|
+
const patch = Patch.parse(String.raw`¶hello.ts
|
|
29
|
+
1:
|
|
30
|
+
|const greeting = "hello";`);
|
|
29
31
|
const result = await patcher.apply(patch);
|
|
30
32
|
|
|
31
33
|
console.log(result.sections[0].op); // "update"
|
|
@@ -45,13 +47,11 @@ session-aware recovery).
|
|
|
45
47
|
|
|
46
48
|
Inside a hunk:
|
|
47
49
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|`A-B!`|Delete lines A..B (single-anchor `A!` is sugar for `A-A!`)|
|
|
54
|
-
|`+TEXT`|Payload continuation. The `+` prefix is stripped|
|
|
50
|
+
- `A-B:` — anchor lines A..B (single-anchor `A:` is sugar for `A-A:`).
|
|
51
|
+
- `BOF:` / `EOF:` — virtual anchors at the beginning/end of file.
|
|
52
|
+
- `|TEXT` — replace-bucket payload. A non-empty replace bucket replaces A..B.
|
|
53
|
+
- `↑TEXT` — insert before A (`BOF:` treats `↑`/`↓` equivalently).
|
|
54
|
+
- `↓TEXT` — insert after B (`EOF:` treats `↑`/`↓` equivalently).
|
|
55
55
|
|
|
56
56
|
## Abstractions
|
|
57
57
|
|
package/dist/types/apply.d.ts
CHANGED
|
@@ -4,7 +4,6 @@ import type { ApplyOptions, ApplyResult, Edit } from "./types";
|
|
|
4
4
|
*
|
|
5
5
|
* Returns the post-edit text, the first changed line number (1-indexed), and
|
|
6
6
|
* any diagnostic warnings produced by the auto-absorb heuristics or by the
|
|
7
|
-
* structural-boundary delete check. Throws if an anchor is out of bounds
|
|
8
|
-
* blank-target replace is detected.
|
|
7
|
+
* structural-boundary delete check. Throws if an anchor is out of bounds.
|
|
9
8
|
*/
|
|
10
9
|
export declare function applyEdits(text: string, edits: Edit[], options?: ApplyOptions): ApplyResult;
|
package/dist/types/format.d.ts
CHANGED
|
@@ -3,18 +3,16 @@
|
|
|
3
3
|
* file-hash computation. These are the single source of truth for the
|
|
4
4
|
* parser, the tokenizer, the prompt, and the formal grammar.
|
|
5
5
|
*/
|
|
6
|
-
/**
|
|
7
|
-
export declare const HL_OP_INSERT_BEFORE = "\u2191";
|
|
8
|
-
/** Op sigil used immediately after a line-number anchor to insert after it. */
|
|
9
|
-
export declare const HL_OP_INSERT_AFTER = "\u2193";
|
|
10
|
-
/** Op sigil used after a range (or single anchor) to replace its lines. */
|
|
6
|
+
/** Anchor terminator for every hashline operation block. */
|
|
11
7
|
export declare const HL_OP_REPLACE = ":";
|
|
12
|
-
/**
|
|
13
|
-
export declare const
|
|
14
|
-
/**
|
|
15
|
-
export declare const
|
|
16
|
-
/**
|
|
17
|
-
export declare const
|
|
8
|
+
/** Payload sigil for lines that replace the anchored range in place. */
|
|
9
|
+
export declare const HL_PAYLOAD_REPLACE = "|";
|
|
10
|
+
/** Payload sigil for lines inserted before the anchored range. */
|
|
11
|
+
export declare const HL_PAYLOAD_ABOVE = "\u2191";
|
|
12
|
+
/** Payload sigil for lines inserted after the anchored range. */
|
|
13
|
+
export declare const HL_PAYLOAD_BELOW = "\u2193";
|
|
14
|
+
/** All hashline payload sigils, concatenated for fast membership tests. */
|
|
15
|
+
export declare const HL_PAYLOAD_CHARS = "|\u2191\u2193";
|
|
18
16
|
/** Hashline edit file-section header marker. */
|
|
19
17
|
export declare const HL_FILE_PREFIX = "\u00B6";
|
|
20
18
|
/** Separator between a hashline file path and its file hash. */
|
package/dist/types/input.d.ts
CHANGED
|
@@ -51,6 +51,14 @@ export declare class PatchSection {
|
|
|
51
51
|
* just want the result.
|
|
52
52
|
*/
|
|
53
53
|
applyTo(text: string, options?: ApplyOptions): ApplyResult;
|
|
54
|
+
/**
|
|
55
|
+
* Streaming-tolerant counterpart to {@link applyTo}. Uses
|
|
56
|
+
* {@link parsePatchStreaming} so a trailing in-flight op (no payload yet,
|
|
57
|
+
* or a per-token parse error mid-stream) does not throw or emit a phantom
|
|
58
|
+
* empty-payload edit. Intended for incremental diff previews; the writer
|
|
59
|
+
* path should always use {@link applyTo}.
|
|
60
|
+
*/
|
|
61
|
+
applyPartialTo(text: string, options?: ApplyOptions): ApplyResult;
|
|
54
62
|
}
|
|
55
63
|
/**
|
|
56
64
|
* A parsed hashline patch — zero or more {@link PatchSection}s, each rooted
|
package/dist/types/messages.d.ts
CHANGED
|
@@ -20,34 +20,14 @@ export declare const ABORT_MARKER = "*** Abort";
|
|
|
20
20
|
/** Warning text appended to the tool result when {@link ABORT_MARKER} terminates parsing. */
|
|
21
21
|
export declare const ABORT_WARNING = "Tool stream truncated mid-call due to detected output corruption. Applied ops above are valid. Re-issue any remaining edits.";
|
|
22
22
|
/**
|
|
23
|
-
* Warning text appended when two consecutive
|
|
24
|
-
* range
|
|
25
|
-
* the first op's payload is silently discarded.
|
|
23
|
+
* Warning text appended when two consecutive blocks target the exact same
|
|
24
|
+
* concrete range. The second block wins; the first block is discarded.
|
|
26
25
|
*/
|
|
27
|
-
export declare const REPLACE_PAIR_COALESCED_WARNING = "Detected
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
* not classify as ops/headers/payloads, but the canonical syntax requires `+`
|
|
33
|
-
* on every continuation line after the op.
|
|
34
|
-
*/
|
|
35
|
-
export declare const IMPLICIT_CONTINUATION_WARNING = "Accepted continuation line(s) without the `+` prefix as implicit payload. Canonical syntax is `A-B:` followed by `+` on every continuation row; without `+`, lines that look like ops will be parsed as new ops instead of payload. Prefer the explicit form.";
|
|
36
|
-
/**
|
|
37
|
-
* Warning text appended when an inner `LINE:TEXT` (or sub-range `A-B:TEXT`)
|
|
38
|
-
* op arrives while an outer `A-B:` replace is still pending and the inner
|
|
39
|
-
* anchor falls inside the outer range. The author used the read-output
|
|
40
|
-
* `LINE:TEXT` format as if it were a payload-continuation line; we strip the
|
|
41
|
-
* `LINE:` prefix and append the body to the pending payload, but warn so the
|
|
42
|
-
* canonical `+`-continuation form remains preferred.
|
|
43
|
-
*/
|
|
44
|
-
export declare const PAYLOAD_LINE_PREFIX_DEMOTED_WARNING = "Detected one or more `LINE:TEXT` lines whose anchors fell inside a pending replace range; treated them as payload-continuation lines and stripped the `LINE:` prefix. Inside an `A-B:` block, every payload line must be on its own row prefixed with `+` \u2014 never reuse the read-output gutter format.";
|
|
45
|
-
/**
|
|
46
|
-
* Warning text appended when an op carries an inline payload (`LINE:TEXT`,
|
|
47
|
-
* `LINE↑CONTENT`, `LINE↓CONTENT`). Canonical syntax is the bare op followed
|
|
48
|
-
* by `+`-prefixed payload rows on the next line(s).
|
|
49
|
-
*/
|
|
50
|
-
export declare const INLINE_PAYLOAD_ACCEPTED_WARNING = "Accepted inline payload on the op line (e.g. `LINE:CONTENT`, `LINE\u2191CONTENT`). Canonical syntax is the bare op followed by `+`-prefixed payload rows on the next line(s). Prefer the explicit form.";
|
|
26
|
+
export declare const REPLACE_PAIR_COALESCED_WARNING = "Detected two identical-range hashline blocks; kept only the second block. Issue ONE block per range \u2014 payload is the final desired content, never both old and new.";
|
|
27
|
+
/** Error text prefix emitted when an anchor line carries inline payload. */
|
|
28
|
+
export declare const INLINE_PAYLOAD_REJECTED_PREFIX = "Inline payload on the anchor line is rejected.";
|
|
29
|
+
/** Error text emitted when `|` replacement payload targets BOF/EOF. */
|
|
30
|
+
export declare const VIRTUAL_REPLACE_REJECTED_MESSAGE = "BOF:/EOF: anchors are virtual positions and cannot use `|` replacement payload. Use `\u2191` or `\u2193` payload lines.";
|
|
51
31
|
/** Warning text emitted by `Recovery` when an external write fits a cached snapshot. */
|
|
52
32
|
export declare const RECOVERY_EXTERNAL_WARNING = "Recovered from a stale file hash using a previous read snapshot (file changed externally between read and edit).";
|
|
53
33
|
/** Warning text emitted by `Recovery` when a prior in-session edit advanced the hash. */
|
package/dist/types/parser.d.ts
CHANGED
|
@@ -4,8 +4,8 @@ import type { Edit } from "./types";
|
|
|
4
4
|
* Token-driven state machine that turns a stream of {@link Token}s into a
|
|
5
5
|
* flat list of {@link Edit}s.
|
|
6
6
|
*
|
|
7
|
-
* `feed()` accepts tokens one at a time;
|
|
8
|
-
*
|
|
7
|
+
* `feed()` accepts tokens one at a time; block payload rows accumulate until
|
|
8
|
+
* the next anchor block or {@link end} flushes them. After `terminated` flips
|
|
9
9
|
* true (on `envelope-end` or `abort`) subsequent feeds are silently ignored
|
|
10
10
|
* so callers can keep draining their tokenizer.
|
|
11
11
|
*/
|
|
@@ -15,25 +15,33 @@ export declare class Executor {
|
|
|
15
15
|
get terminated(): boolean;
|
|
16
16
|
/**
|
|
17
17
|
* Consume one token. After `terminated` flips true subsequent feeds are
|
|
18
|
-
* silently ignored so callers can keep draining
|
|
18
|
+
* silently ignored so callers can keep draining the tokenizer without
|
|
19
19
|
* explicit early-exit guards.
|
|
20
20
|
*/
|
|
21
21
|
feed(token: Token): void;
|
|
22
22
|
/**
|
|
23
|
-
* Flush any open pending
|
|
24
|
-
*
|
|
25
|
-
* warnings. The executor is single-use; {@link reset} is required for
|
|
26
|
-
* reuse.
|
|
23
|
+
* Flush any open pending block and return the accumulated edits and
|
|
24
|
+
* warnings. The executor is single-use; {@link reset} is required for reuse.
|
|
27
25
|
*
|
|
28
|
-
* Throws if two
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
26
|
+
* Throws if two replacement/delete blocks target the same line with
|
|
27
|
+
* non-identical ranges. Identical-range blocks in the same hunk are
|
|
28
|
+
* coalesced last-wins by `feed()` with a warning, so they never reach the
|
|
29
|
+
* validator.
|
|
32
30
|
*/
|
|
33
31
|
end(): {
|
|
34
32
|
edits: Edit[];
|
|
35
33
|
warnings: string[];
|
|
36
34
|
};
|
|
35
|
+
/**
|
|
36
|
+
* Streaming-tolerant variant of {@link end}. Identical, except a pending
|
|
37
|
+
* block whose payload has not yet accumulated any rows is treated as still
|
|
38
|
+
* in flight and dropped instead of flushed (which would otherwise preview a
|
|
39
|
+
* destructive bare delete while the model may still be typing payload).
|
|
40
|
+
*/
|
|
41
|
+
endStreaming(): {
|
|
42
|
+
edits: Edit[];
|
|
43
|
+
warnings: string[];
|
|
44
|
+
};
|
|
37
45
|
/** Reset to a fresh state so the same instance can drive another parse. */
|
|
38
46
|
reset(): void;
|
|
39
47
|
}
|
|
@@ -48,3 +56,21 @@ export declare function parsePatch(diff: string): {
|
|
|
48
56
|
edits: Edit[];
|
|
49
57
|
warnings: string[];
|
|
50
58
|
};
|
|
59
|
+
/**
|
|
60
|
+
* Streaming-tolerant variant of {@link parsePatch}. Returns whatever edits
|
|
61
|
+
* parsed successfully when the diff is still being typed:
|
|
62
|
+
*
|
|
63
|
+
* - per-token feed errors stop the drain but preserve the edits already
|
|
64
|
+
* collected (the trailing block is malformed mid-stream — wait for the next
|
|
65
|
+
* chunk),
|
|
66
|
+
* - the trailing pending block is dropped if it has no payload yet (avoids a
|
|
67
|
+
* destructive bare-delete preview while payload may still be coming).
|
|
68
|
+
*
|
|
69
|
+
* Throws only on the cross-block overlap validator, which catches conflicting
|
|
70
|
+
* shapes (two replacements/deletes hitting the same anchor). Streaming preview
|
|
71
|
+
* callers should treat any throw here as "no preview this tick".
|
|
72
|
+
*/
|
|
73
|
+
export declare function parsePatchStreaming(diff: string): {
|
|
74
|
+
edits: Edit[];
|
|
75
|
+
warnings: string[];
|
|
76
|
+
};
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* The tokenizer is intentionally permissive about decorations and prefixes
|
|
11
11
|
* the model may echo back from `read`/`search` output — leading `*`/`>`/`-`
|
|
12
12
|
* markers, CR-terminated lines, leading whitespace before line numbers, and
|
|
13
|
-
* so on are all stripped before classification.
|
|
13
|
+
* so on are all stripped before anchor classification.
|
|
14
14
|
*/
|
|
15
15
|
import type { Anchor, Cursor, ParsedRange } from "./types";
|
|
16
16
|
/**
|
|
@@ -24,14 +24,17 @@ import type { Anchor, Cursor, ParsedRange } from "./types";
|
|
|
24
24
|
*/
|
|
25
25
|
export declare function splitHashlineLines(text: string): string[];
|
|
26
26
|
export declare function cloneCursor(cursor: Cursor): Cursor;
|
|
27
|
-
/** Parse a bare line-number anchor
|
|
27
|
+
/** Parse a bare line-number anchor. Throws on malformed input. */
|
|
28
28
|
export declare function parseLid(raw: string, lineNum: number): Anchor;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
export type BlockTarget = {
|
|
30
|
+
kind: "range";
|
|
31
|
+
range: ParsedRange;
|
|
32
|
+
} | {
|
|
33
|
+
kind: "bof";
|
|
34
|
+
} | {
|
|
35
|
+
kind: "eof";
|
|
36
|
+
};
|
|
37
|
+
export type PayloadBucket = "above" | "replace" | "below";
|
|
35
38
|
interface TokenBase {
|
|
36
39
|
/** 1-indexed line number in the original input stream. */
|
|
37
40
|
lineNum: number;
|
|
@@ -49,19 +52,12 @@ export type Token = (TokenBase & {
|
|
|
49
52
|
path: string;
|
|
50
53
|
fileHash?: string;
|
|
51
54
|
}) | (TokenBase & {
|
|
52
|
-
kind: "op-
|
|
53
|
-
|
|
54
|
-
inlineBody: string | undefined;
|
|
55
|
-
}) | (TokenBase & {
|
|
56
|
-
kind: "op-replace";
|
|
57
|
-
range: ParsedRange;
|
|
55
|
+
kind: "op-block";
|
|
56
|
+
target: BlockTarget;
|
|
58
57
|
inlineBody: string | undefined;
|
|
59
|
-
}) | (TokenBase & {
|
|
60
|
-
kind: "op-delete";
|
|
61
|
-
range: ParsedRange;
|
|
62
|
-
trailingPayload: boolean;
|
|
63
58
|
}) | (TokenBase & {
|
|
64
59
|
kind: "payload";
|
|
60
|
+
bucket: PayloadBucket;
|
|
65
61
|
text: string;
|
|
66
62
|
}) | (TokenBase & {
|
|
67
63
|
kind: "raw";
|
package/dist/types/types.d.ts
CHANGED
|
@@ -22,7 +22,9 @@ export type Cursor = {
|
|
|
22
22
|
/**
|
|
23
23
|
* A single low-level edit produced by the parser and consumed by the applier.
|
|
24
24
|
* Multi-line replacements decompose to one `insert` per replacement line plus
|
|
25
|
-
* one `delete` per consumed line.
|
|
25
|
+
* one `delete` per consumed line. Replacement inserts are tagged so the applier
|
|
26
|
+
* can distinguish "insert above this deleted line" from "new content for this
|
|
27
|
+
* deleted line" when a source block carries both buckets.
|
|
26
28
|
*/
|
|
27
29
|
export type Edit = {
|
|
28
30
|
kind: "insert";
|
|
@@ -30,6 +32,7 @@ export type Edit = {
|
|
|
30
32
|
text: string;
|
|
31
33
|
lineNum: number;
|
|
32
34
|
index: number;
|
|
35
|
+
mode?: "replacement";
|
|
33
36
|
} | {
|
|
34
37
|
kind: "delete";
|
|
35
38
|
anchor: Anchor;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/hashline",
|
|
4
|
-
"version": "15.5.
|
|
4
|
+
"version": "15.5.6",
|
|
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/apply.ts
CHANGED
|
@@ -3,10 +3,8 @@
|
|
|
3
3
|
* post-edit lines plus any diagnostic warnings. Pure function: no FS, no
|
|
4
4
|
* mutation of the input.
|
|
5
5
|
*
|
|
6
|
-
* The applier
|
|
6
|
+
* The applier normalizes common model boundary mistakes:
|
|
7
7
|
*
|
|
8
|
-
* - Replace ops on a blank line with non-empty payload are rejected outright
|
|
9
|
-
* (the model almost certainly miscounted; recommend `↑`/`↓` instead).
|
|
10
8
|
* - Multi-line replacement-boundary duplicates are auto-absorbed (model
|
|
11
9
|
* echoed surrounding context as if it were payload).
|
|
12
10
|
* - Single-line structural-boundary duplicates (`}`, `)`, `];`, …) are
|
|
@@ -35,6 +33,10 @@ interface ReplacementGroup {
|
|
|
35
33
|
deletes: DeleteEdit[];
|
|
36
34
|
}
|
|
37
35
|
|
|
36
|
+
function isReplacementInsert(edit: Edit): edit is Extract<Edit, { kind: "insert" }> & { mode: "replacement" } {
|
|
37
|
+
return edit.kind === "insert" && edit.mode === "replacement";
|
|
38
|
+
}
|
|
39
|
+
|
|
38
40
|
function getEditAnchors(edit: Edit): Anchor[] {
|
|
39
41
|
if (edit.kind === "delete") return [edit.anchor];
|
|
40
42
|
if (edit.cursor.kind === "before_anchor") return [edit.cursor.anchor];
|
|
@@ -56,57 +58,6 @@ function validateLineBounds(edits: Edit[], fileLines: string[]): void {
|
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
/**
|
|
60
|
-
* Refuse a single-line replace whose target line is blank and whose payload is
|
|
61
|
-
* non-empty. The author is almost certainly miscounting: `A:CONTENT` overwrites
|
|
62
|
-
* the existing line, so applying it to a blank target deletes the blank cadence
|
|
63
|
-
* and inserts content in its place. To insert content at a blank line, use
|
|
64
|
-
* `A↑` (insert before) or `A↓` (insert after) instead.
|
|
65
|
-
*
|
|
66
|
-
* Only fires for the simple shape: exactly one `insert(before_anchor A)` + one
|
|
67
|
-
* `delete(A)` sharing the same source op line, no other inserts/deletes from
|
|
68
|
-
* that op.
|
|
69
|
-
*/
|
|
70
|
-
function detectReplaceOnBlankTarget(edits: Edit[], fileLines: string[]): string | null {
|
|
71
|
-
interface Pair {
|
|
72
|
-
insert?: Extract<Edit, { kind: "insert" }>;
|
|
73
|
-
delete?: Extract<Edit, { kind: "delete" }>;
|
|
74
|
-
multi?: boolean;
|
|
75
|
-
}
|
|
76
|
-
const byOpLine = new Map<number, Pair>();
|
|
77
|
-
for (const edit of edits) {
|
|
78
|
-
const pair = byOpLine.get(edit.lineNum) ?? {};
|
|
79
|
-
if (pair.multi) continue;
|
|
80
|
-
if (edit.kind === "insert") {
|
|
81
|
-
if (pair.insert) pair.multi = true;
|
|
82
|
-
else pair.insert = edit;
|
|
83
|
-
} else {
|
|
84
|
-
if (pair.delete) pair.multi = true;
|
|
85
|
-
else pair.delete = edit;
|
|
86
|
-
}
|
|
87
|
-
byOpLine.set(edit.lineNum, pair);
|
|
88
|
-
}
|
|
89
|
-
for (const pair of byOpLine.values()) {
|
|
90
|
-
if (pair.multi || !pair.insert || !pair.delete) continue;
|
|
91
|
-
const insert = pair.insert;
|
|
92
|
-
const del = pair.delete;
|
|
93
|
-
if (insert.cursor.kind !== "before_anchor") continue;
|
|
94
|
-
if (insert.cursor.anchor.line !== del.anchor.line) continue;
|
|
95
|
-
if (insert.text.includes("\n")) continue;
|
|
96
|
-
if (insert.text.trim().length === 0) continue;
|
|
97
|
-
const targetLine = del.anchor.line;
|
|
98
|
-
const oldLine = fileLines[targetLine - 1];
|
|
99
|
-
if (oldLine === undefined || oldLine.trim().length !== 0) continue;
|
|
100
|
-
return (
|
|
101
|
-
`Edit rejected: replace at line ${targetLine} targets a blank line but the payload is non-empty. ` +
|
|
102
|
-
`'A:CONTENT' overwrites the line at A; to insert content next to a blank line, use 'A${"\u2191"}' (insert before) ` +
|
|
103
|
-
`or 'A${"\u2193"}' (insert after) instead. If you really meant to replace this blank with content, ` +
|
|
104
|
-
`widen the range to include surrounding non-blank lines so the intent is explicit.`
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
return null;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
61
|
function insertAtStart(fileLines: string[], lineOrigins: LineOrigin[], lines: string[]): void {
|
|
111
62
|
if (lines.length === 0) return;
|
|
112
63
|
const origins = lines.map((): LineOrigin => "insert");
|
|
@@ -152,14 +103,14 @@ function collectAnchorTargetLines(edits: Edit[]): Set<number> {
|
|
|
152
103
|
|
|
153
104
|
function findReplacementGroup(edits: Edit[], startIndex: number): ReplacementGroup | undefined {
|
|
154
105
|
const first = edits[startIndex];
|
|
155
|
-
if (first
|
|
106
|
+
if (!isReplacementInsert(first) || first.cursor.kind !== "before_anchor") return undefined;
|
|
156
107
|
|
|
157
108
|
const sourceLineNum = first.lineNum;
|
|
158
109
|
const replacement: string[] = [];
|
|
159
110
|
let index = startIndex;
|
|
160
111
|
while (index < edits.length) {
|
|
161
112
|
const edit = edits[index];
|
|
162
|
-
if (edit
|
|
113
|
+
if (!isReplacementInsert(edit) || edit.lineNum !== sourceLineNum || edit.cursor.kind !== "before_anchor") break;
|
|
163
114
|
replacement.push(edit.text);
|
|
164
115
|
index++;
|
|
165
116
|
}
|
|
@@ -318,10 +269,9 @@ function countMatchingSingleStructuralSuffixBoundary(
|
|
|
318
269
|
/**
|
|
319
270
|
* Single-line non-structural boundary duplicate detector for replacement
|
|
320
271
|
* groups. Mirrors the same boundary check the pure-insert absorber uses for
|
|
321
|
-
* `
|
|
322
|
-
* top/bottom edges of an `A-B
|
|
323
|
-
* `103-138
|
|
324
|
-
* user really meant `103-138!` (delete only).
|
|
272
|
+
* `A:` + `↓` (leading) / `A:` + `↑` (trailing) inserts, but applied to the
|
|
273
|
+
* top/bottom edges of an `A-B:` replacement payload. Catches mistakes like
|
|
274
|
+
* `103-138:` + `|const X = …` where line 102 already reads `const X = …`.
|
|
325
275
|
*
|
|
326
276
|
* Gated by `options.autoDropPureInsertDuplicates`: the existing 2+-line block
|
|
327
277
|
* absorb already runs unconditionally, and the structural single-line
|
|
@@ -400,7 +350,7 @@ function cursorMatches(a: Cursor, b: Cursor): boolean {
|
|
|
400
350
|
*/
|
|
401
351
|
function findPureInsertGroup(edits: Edit[], startIndex: number): PureInsertGroup | undefined {
|
|
402
352
|
const first = edits[startIndex];
|
|
403
|
-
if (first?.kind !== "insert") return undefined;
|
|
353
|
+
if (first?.kind !== "insert" || isReplacementInsert(first)) return undefined;
|
|
404
354
|
|
|
405
355
|
const sourceLineNum = first.lineNum;
|
|
406
356
|
const cursor = first.cursor;
|
|
@@ -408,7 +358,7 @@ function findPureInsertGroup(edits: Edit[], startIndex: number): PureInsertGroup
|
|
|
408
358
|
let index = startIndex;
|
|
409
359
|
while (index < edits.length) {
|
|
410
360
|
const edit = edits[index];
|
|
411
|
-
if (edit.kind !== "insert" || edit.lineNum !== sourceLineNum) break;
|
|
361
|
+
if (edit.kind !== "insert" || isReplacementInsert(edit) || edit.lineNum !== sourceLineNum) break;
|
|
412
362
|
if (!cursorMatches(edit.cursor, cursor)) break;
|
|
413
363
|
payload.push(edit.text);
|
|
414
364
|
index++;
|
|
@@ -676,8 +626,7 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[
|
|
|
676
626
|
*
|
|
677
627
|
* Returns the post-edit text, the first changed line number (1-indexed), and
|
|
678
628
|
* any diagnostic warnings produced by the auto-absorb heuristics or by the
|
|
679
|
-
* structural-boundary delete check. Throws if an anchor is out of bounds
|
|
680
|
-
* blank-target replace is detected.
|
|
629
|
+
* structural-boundary delete check. Throws if an anchor is out of bounds.
|
|
681
630
|
*/
|
|
682
631
|
export function applyEdits(text: string, edits: Edit[], options: ApplyOptions = {}): ApplyResult {
|
|
683
632
|
if (edits.length === 0) return { text, firstChangedLine: undefined };
|
|
@@ -693,9 +642,6 @@ export function applyEdits(text: string, edits: Edit[], options: ApplyOptions =
|
|
|
693
642
|
|
|
694
643
|
validateLineBounds(edits, fileLines);
|
|
695
644
|
|
|
696
|
-
const blankTargetError = detectReplaceOnBlankTarget(edits, fileLines);
|
|
697
|
-
if (blankTargetError !== null) throw new Error(blankTargetError);
|
|
698
|
-
|
|
699
645
|
const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings, options);
|
|
700
646
|
const targetEdits: Edit[] = [];
|
|
701
647
|
|
|
@@ -744,20 +690,23 @@ export function applyEdits(text: string, edits: Edit[], options: ApplyOptions =
|
|
|
744
690
|
|
|
745
691
|
const idx = line - 1;
|
|
746
692
|
const currentLine = fileLines[idx] ?? "";
|
|
747
|
-
const
|
|
693
|
+
const insertLines: string[] = [];
|
|
694
|
+
const replacementLines: string[] = [];
|
|
748
695
|
let deleteLine = false;
|
|
749
696
|
|
|
750
697
|
for (const { edit } of bucket) {
|
|
751
|
-
if (edit
|
|
752
|
-
|
|
698
|
+
if (isReplacementInsert(edit)) {
|
|
699
|
+
replacementLines.push(edit.text);
|
|
700
|
+
} else if (edit.kind === "insert") {
|
|
701
|
+
insertLines.push(edit.text);
|
|
753
702
|
} else if (edit.kind === "delete") {
|
|
754
703
|
deleteLine = true;
|
|
755
704
|
}
|
|
756
705
|
}
|
|
757
|
-
if (
|
|
706
|
+
if (insertLines.length === 0 && replacementLines.length === 0 && !deleteLine) continue;
|
|
758
707
|
|
|
759
|
-
const
|
|
760
|
-
if (deleteLine && !
|
|
708
|
+
const hasReplacementPayload = replacementLines.length > 0;
|
|
709
|
+
if (deleteLine && !hasReplacementPayload) {
|
|
761
710
|
const balance = computeDelimiterBalance([currentLine]);
|
|
762
711
|
const trimmedCurrentLine = currentLine.trim();
|
|
763
712
|
const touchesStructuralBoundary =
|
|
@@ -769,15 +718,17 @@ export function applyEdits(text: string, edits: Edit[], options: ApplyOptions =
|
|
|
769
718
|
trimmedCurrentLine.endsWith("{");
|
|
770
719
|
if (balance.paren !== 0 || balance.bracket !== 0 || balance.brace !== 0 || touchesStructuralBoundary) {
|
|
771
720
|
warnings.push(
|
|
772
|
-
`Deleted line ${line} contains a structural bracket/brace boundary (${JSON.stringify(trimmedCurrentLine)}); verify the file is still balanced or use '
|
|
721
|
+
`Deleted line ${line} contains a structural bracket/brace boundary (${JSON.stringify(trimmedCurrentLine)}); verify the file is still balanced or use '|replacement' payload to keep the boundary intact.`,
|
|
773
722
|
);
|
|
774
723
|
}
|
|
775
724
|
}
|
|
776
|
-
const replacement = deleteLine
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
725
|
+
const replacement = deleteLine
|
|
726
|
+
? [...insertLines, ...replacementLines]
|
|
727
|
+
: [...insertLines, ...replacementLines, currentLine];
|
|
728
|
+
const origins: LineOrigin[] = [];
|
|
729
|
+
for (let i = 0; i < insertLines.length; i++) origins.push("insert");
|
|
730
|
+
for (let i = 0; i < replacementLines.length; i++) origins.push(deleteLine ? "replacement" : "insert");
|
|
731
|
+
if (!deleteLine) origins.push(lineOrigins[idx] ?? "original");
|
|
781
732
|
|
|
782
733
|
fileLines.splice(idx, 1, ...replacement);
|
|
783
734
|
lineOrigins.splice(idx, 1, ...origins);
|
package/src/format.ts
CHANGED
|
@@ -4,20 +4,18 @@
|
|
|
4
4
|
* parser, the tokenizer, the prompt, and the formal grammar.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
export const HL_OP_INSERT_BEFORE = "↑";
|
|
9
|
-
/** Op sigil used immediately after a line-number anchor to insert after it. */
|
|
10
|
-
export const HL_OP_INSERT_AFTER = "↓";
|
|
11
|
-
/** Op sigil used after a range (or single anchor) to replace its lines. */
|
|
7
|
+
/** Anchor terminator for every hashline operation block. */
|
|
12
8
|
export const HL_OP_REPLACE = ":";
|
|
13
|
-
/** Op sigil used after a range (or single anchor) to delete its lines. */
|
|
14
|
-
export const HL_OP_DELETE = "!";
|
|
15
9
|
|
|
16
|
-
/**
|
|
17
|
-
export const
|
|
10
|
+
/** Payload sigil for lines that replace the anchored range in place. */
|
|
11
|
+
export const HL_PAYLOAD_REPLACE = "|";
|
|
12
|
+
/** Payload sigil for lines inserted before the anchored range. */
|
|
13
|
+
export const HL_PAYLOAD_ABOVE = "↑";
|
|
14
|
+
/** Payload sigil for lines inserted after the anchored range. */
|
|
15
|
+
export const HL_PAYLOAD_BELOW = "↓";
|
|
18
16
|
|
|
19
|
-
/**
|
|
20
|
-
export const
|
|
17
|
+
/** All hashline payload sigils, concatenated for fast membership tests. */
|
|
18
|
+
export const HL_PAYLOAD_CHARS = `${HL_PAYLOAD_REPLACE}${HL_PAYLOAD_ABOVE}${HL_PAYLOAD_BELOW}`;
|
|
21
19
|
|
|
22
20
|
/** Hashline edit file-section header marker. */
|
|
23
21
|
export const HL_FILE_PREFIX = "¶";
|
package/src/grammar.lark
CHANGED
|
@@ -3,19 +3,18 @@ begin_patch: "*** Begin Patch" LF
|
|
|
3
3
|
end_patch: "*** End Patch" LF?
|
|
4
4
|
|
|
5
5
|
hunk: update_hunk
|
|
6
|
-
update_hunk: "¶" filename ("#" file_hash)? LF
|
|
6
|
+
update_hunk: "¶" filename ("#" file_hash)? LF block*
|
|
7
7
|
|
|
8
8
|
filename: /([^\s#]+)/
|
|
9
9
|
file_hash: /[0-9a-f]{4}/
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
payload: "+" /[^\n]*/ LF
|
|
11
|
+
block: anchor ":" LF payload*
|
|
12
|
+
payload: above_payload | replace_payload | below_payload
|
|
13
|
+
above_payload: "↑" /[^\n]*/ LF
|
|
14
|
+
replace_payload: "|" /[^\n]*/ LF
|
|
15
|
+
below_payload: "↓" /[^\n]*/ LF
|
|
17
16
|
|
|
18
|
-
anchor:
|
|
17
|
+
anchor: range | "BOF" | "EOF"
|
|
19
18
|
range: LID ("-" LID)?
|
|
20
19
|
LID: /[1-9]\d*/
|
|
21
20
|
|
package/src/input.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import * as path from "node:path";
|
|
11
11
|
import { applyEdits } from "./apply";
|
|
12
12
|
import { HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format";
|
|
13
|
-
import { parsePatch } from "./parser";
|
|
13
|
+
import { parsePatch, parsePatchStreaming } from "./parser";
|
|
14
14
|
import { Tokenizer } from "./tokenizer";
|
|
15
15
|
import type { ApplyOptions, ApplyResult, Edit, SplitOptions } from "./types";
|
|
16
16
|
|
|
@@ -235,6 +235,22 @@ export class PatchSection {
|
|
|
235
235
|
? { ...result, warnings: merged }
|
|
236
236
|
: { text: result.text, firstChangedLine: result.firstChangedLine };
|
|
237
237
|
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Streaming-tolerant counterpart to {@link applyTo}. Uses
|
|
241
|
+
* {@link parsePatchStreaming} so a trailing in-flight op (no payload yet,
|
|
242
|
+
* or a per-token parse error mid-stream) does not throw or emit a phantom
|
|
243
|
+
* empty-payload edit. Intended for incremental diff previews; the writer
|
|
244
|
+
* path should always use {@link applyTo}.
|
|
245
|
+
*/
|
|
246
|
+
applyPartialTo(text: string, options: ApplyOptions = {}): ApplyResult {
|
|
247
|
+
const { edits, warnings } = parsePatchStreaming(this.diff);
|
|
248
|
+
const result = applyEdits(text, [...edits], options);
|
|
249
|
+
const merged = warnings.length === 0 ? result.warnings : [...warnings, ...(result.warnings ?? [])];
|
|
250
|
+
return merged && merged.length > 0
|
|
251
|
+
? { ...result, warnings: merged }
|
|
252
|
+
: { text: result.text, firstChangedLine: result.firstChangedLine };
|
|
253
|
+
}
|
|
238
254
|
}
|
|
239
255
|
|
|
240
256
|
/**
|
package/src/messages.ts
CHANGED
|
@@ -27,41 +27,18 @@ export const ABORT_WARNING =
|
|
|
27
27
|
"Tool stream truncated mid-call due to detected output corruption. Applied ops above are valid. Re-issue any remaining edits.";
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
* Warning text appended when two consecutive
|
|
31
|
-
* range
|
|
32
|
-
* the first op's payload is silently discarded.
|
|
30
|
+
* Warning text appended when two consecutive blocks target the exact same
|
|
31
|
+
* concrete range. The second block wins; the first block is discarded.
|
|
33
32
|
*/
|
|
34
33
|
export const REPLACE_PAIR_COALESCED_WARNING =
|
|
35
|
-
"Detected
|
|
34
|
+
"Detected two identical-range hashline blocks; kept only the second block. Issue ONE block per range — payload is the final desired content, never both old and new.";
|
|
36
35
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
* implicit payload (lenient legacy behavior). The author wrote a multi-line
|
|
40
|
-
* replace without `+` prefixes; the parser accepted it because the lines did
|
|
41
|
-
* not classify as ops/headers/payloads, but the canonical syntax requires `+`
|
|
42
|
-
* on every continuation line after the op.
|
|
43
|
-
*/
|
|
44
|
-
export const IMPLICIT_CONTINUATION_WARNING =
|
|
45
|
-
"Accepted continuation line(s) without the `+` prefix as implicit payload. Canonical syntax is `A-B:` followed by `+` on every continuation row; without `+`, lines that look like ops will be parsed as new ops instead of payload. Prefer the explicit form.";
|
|
36
|
+
/** Error text prefix emitted when an anchor line carries inline payload. */
|
|
37
|
+
export const INLINE_PAYLOAD_REJECTED_PREFIX = "Inline payload on the anchor line is rejected.";
|
|
46
38
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
* anchor falls inside the outer range. The author used the read-output
|
|
51
|
-
* `LINE:TEXT` format as if it were a payload-continuation line; we strip the
|
|
52
|
-
* `LINE:` prefix and append the body to the pending payload, but warn so the
|
|
53
|
-
* canonical `+`-continuation form remains preferred.
|
|
54
|
-
*/
|
|
55
|
-
export const PAYLOAD_LINE_PREFIX_DEMOTED_WARNING =
|
|
56
|
-
"Detected one or more `LINE:TEXT` lines whose anchors fell inside a pending replace range; treated them as payload-continuation lines and stripped the `LINE:` prefix. Inside an `A-B:` block, every payload line must be on its own row prefixed with `+` — never reuse the read-output gutter format.";
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Warning text appended when an op carries an inline payload (`LINE:TEXT`,
|
|
60
|
-
* `LINE↑CONTENT`, `LINE↓CONTENT`). Canonical syntax is the bare op followed
|
|
61
|
-
* by `+`-prefixed payload rows on the next line(s).
|
|
62
|
-
*/
|
|
63
|
-
export const INLINE_PAYLOAD_ACCEPTED_WARNING =
|
|
64
|
-
"Accepted inline payload on the op line (e.g. `LINE:CONTENT`, `LINE↑CONTENT`). Canonical syntax is the bare op followed by `+`-prefixed payload rows on the next line(s). Prefer the explicit form.";
|
|
39
|
+
/** Error text emitted when `|` replacement payload targets BOF/EOF. */
|
|
40
|
+
export const VIRTUAL_REPLACE_REJECTED_MESSAGE =
|
|
41
|
+
"BOF:/EOF: anchors are virtual positions and cannot use `|` replacement payload. Use `↑` or `↓` payload lines.";
|
|
65
42
|
|
|
66
43
|
/** Warning text emitted by `Recovery` when an external write fits a cached snapshot. */
|
|
67
44
|
export const RECOVERY_EXTERNAL_WARNING =
|