@oh-my-pi/hashline 15.5.4 → 15.5.7

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 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\n1:\n+const greeting = "hello";`);
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
- |Op|Meaning|
49
- |---|---|
50
- |`LINE↑`|Insert before LINE (or `BOF↑` for the beginning of file)|
51
- |`LINE↓`|Insert after LINE (or `EOF↓` for the end of file)|
52
- |`A-B:`|Replace lines A..B (single-anchor `A:` is sugar for `A-A:`)|
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
 
@@ -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 or a
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;
@@ -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
- /** Op sigil used immediately after a line-number anchor to insert before it. */
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
- /** Op sigil used after a range (or single anchor) to delete its lines. */
13
- export declare const HL_OP_DELETE = "!";
14
- /** All hashline edit op sigils, concatenated for fast membership tests. */
15
- export declare const HL_OP_CHARS = "\u2191\u2193:!";
16
- /** Prefix for payload continuation lines. The prefix itself is not written. */
17
- export declare const HL_PAYLOAD_PREFIX = "+";
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. */
@@ -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
@@ -20,37 +20,25 @@ 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 `A-B:` ops on the exact same
24
- * range get coalesced (model painted a before/after pair). The second op wins;
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 an identical-range before/after replace pair; kept only the second block's payload. Issue ONE op per range \u2014 the payload is the final desired content, never both old and new.";
28
- /**
29
- * Warning text appended when un-prefixed continuation lines are accepted as
30
- * implicit payload (lenient legacy behavior). The author wrote a multi-line
31
- * replace without `+` prefixes; the parser accepted it because the lines did
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. */
54
34
  export declare const RECOVERY_SESSION_CHAIN_WARNING = "Recovered from a stale file hash using an earlier in-session snapshot (the file hash advanced after a prior edit in this session).";
55
- /** Warning text emitted by `Recovery` when the session-chain fast-path was taken. */
35
+ /**
36
+ * Warning text emitted by `Recovery` when the session-chain replay
37
+ * fast-path was taken. Distinct from {@link RECOVERY_SESSION_CHAIN_WARNING}
38
+ * because replay is the less-certain mode: the structured-patch 3-way
39
+ * merge refused, the anchor-content gate passed, but a coincidental
40
+ * insert+delete pair earlier in the chain could still leave an anchor's
41
+ * line number pointing at a duplicated row. Surface the hedge so the
42
+ * model verifies before continuing.
43
+ */
56
44
  export declare const RECOVERY_SESSION_REPLAY_WARNING = "Recovered by replaying your edits onto the current file content \u2014 your previous edit in this session changed line(s) you re-targeted with a stale hash. Verify the diff matches your intent before continuing.";
@@ -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; multi-line payloads accumulate
8
- * until the next op or {@link end} flushes them. After `terminated` flips
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 their tokenizer without
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 op (with its full accumulated payload, including
24
- * explicit `+` blank lines) and return the accumulated edits and
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 replace/delete ops target the same line with non-identical
29
- * shapes (different ranges, replace+delete, delete+delete). Identical-range
30
- * `A-B:` pairs in the same hunk are coalesced last-wins by `feed()` with a
31
- * warning, so they never reach the validator.
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
+ };
@@ -22,8 +22,12 @@ export interface RecoveryResult {
22
22
  *
23
23
  * 1. Apply on the cached `fullText` snapshot, then 3-way-merge onto current.
24
24
  * 2. (Session chain) If the snapshot wasn't the head, retry on current text
25
- * when line counts match the user's previous edit advanced the hash but
26
- * didn't shift line numbers.
25
+ * when line counts match AND every edit's anchor line content is unchanged
26
+ * between snapshot and current — the previous in-session edit advanced
27
+ * the hash and the model's anchors still name the same logical rows. Emits
28
+ * a dedicated {@link RECOVERY_SESSION_REPLAY_WARNING} because even with
29
+ * both guards a coincidental insert+delete pair on duplicate rows can
30
+ * still land the edit on the wrong row; see {@link replaySessionChainOnCurrent}.
27
31
  * 3. Reconstruct from a sparse snapshot (lines map only), verify the rebuilt
28
32
  * text hashes to the expected value, then 3-way-merge.
29
33
  */
@@ -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 (used by insert ops). Throws on malformed input. */
27
+ /** Parse a bare line-number anchor. Throws on malformed input. */
28
28
  export declare function parseLid(raw: string, lineNum: number): Anchor;
29
- /**
30
- * Returns true when the line scans as `LINE!payload` (delete sigil followed
31
- * by additional content). The parser uses this for the dedicated "deletes
32
- * only" diagnostic, separate from the standard "unrecognized op" path.
33
- */
34
- export declare function isDeleteOpWithPayload(line: string): boolean;
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-insert";
53
- cursor: Cursor;
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";
@@ -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",
4
+ "version": "15.5.7",
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 is conservative about edits that look like authoring mistakes:
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,11 +33,24 @@ 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
- if (edit.cursor.kind === "before_anchor") return [edit.cursor.anchor];
41
- if (edit.cursor.kind === "after_anchor") return [edit.cursor.anchor];
42
- return [];
42
+ switch (edit.cursor.kind) {
43
+ case "before_anchor":
44
+ case "after_anchor":
45
+ return [edit.cursor.anchor];
46
+ case "bof":
47
+ case "eof":
48
+ return [];
49
+ default: {
50
+ const _exhaustive: never = edit.cursor;
51
+ return _exhaustive;
52
+ }
53
+ }
43
54
  }
44
55
 
45
56
  /**
@@ -56,57 +67,6 @@ function validateLineBounds(edits: Edit[], fileLines: string[]): void {
56
67
  }
57
68
  }
58
69
 
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
70
  function insertAtStart(fileLines: string[], lineOrigins: LineOrigin[], lines: string[]): void {
111
71
  if (lines.length === 0) return;
112
72
  const origins = lines.map((): LineOrigin => "insert");
@@ -152,14 +112,14 @@ function collectAnchorTargetLines(edits: Edit[]): Set<number> {
152
112
 
153
113
  function findReplacementGroup(edits: Edit[], startIndex: number): ReplacementGroup | undefined {
154
114
  const first = edits[startIndex];
155
- if (first?.kind !== "insert" || first.cursor.kind !== "before_anchor") return undefined;
115
+ if (!isReplacementInsert(first) || first.cursor.kind !== "before_anchor") return undefined;
156
116
 
157
117
  const sourceLineNum = first.lineNum;
158
118
  const replacement: string[] = [];
159
119
  let index = startIndex;
160
120
  while (index < edits.length) {
161
121
  const edit = edits[index];
162
- if (edit.kind !== "insert" || edit.lineNum !== sourceLineNum || edit.cursor.kind !== "before_anchor") break;
122
+ if (!isReplacementInsert(edit) || edit.lineNum !== sourceLineNum || edit.cursor.kind !== "before_anchor") break;
163
123
  replacement.push(edit.text);
164
124
  index++;
165
125
  }
@@ -318,10 +278,9 @@ function countMatchingSingleStructuralSuffixBoundary(
318
278
  /**
319
279
  * Single-line non-structural boundary duplicate detector for replacement
320
280
  * groups. Mirrors the same boundary check the pure-insert absorber uses for
321
- * `ANCHOR↓` (leading) / `ANCHOR↑` (trailing) inserts, but applied to the
322
- * top/bottom edges of an `A-B:payload` range. Catches mistakes like
323
- * `103-138:const X = …` where line 102 already reads `const X = …` and the
324
- * user really meant `103-138!` (delete only).
281
+ * `A:` + `↓` (leading) / `A:` + `↑` (trailing) inserts, but applied to the
282
+ * top/bottom edges of an `A-B:` replacement payload. Catches mistakes like
283
+ * `103-138:` + `|const X = …` where line 102 already reads `const X = …`.
325
284
  *
326
285
  * Gated by `options.autoDropPureInsertDuplicates`: the existing 2+-line block
327
286
  * absorb already runs unconditionally, and the structural single-line
@@ -400,7 +359,7 @@ function cursorMatches(a: Cursor, b: Cursor): boolean {
400
359
  */
401
360
  function findPureInsertGroup(edits: Edit[], startIndex: number): PureInsertGroup | undefined {
402
361
  const first = edits[startIndex];
403
- if (first?.kind !== "insert") return undefined;
362
+ if (first?.kind !== "insert" || isReplacementInsert(first)) return undefined;
404
363
 
405
364
  const sourceLineNum = first.lineNum;
406
365
  const cursor = first.cursor;
@@ -408,7 +367,7 @@ function findPureInsertGroup(edits: Edit[], startIndex: number): PureInsertGroup
408
367
  let index = startIndex;
409
368
  while (index < edits.length) {
410
369
  const edit = edits[index];
411
- if (edit.kind !== "insert" || edit.lineNum !== sourceLineNum) break;
370
+ if (edit.kind !== "insert" || isReplacementInsert(edit) || edit.lineNum !== sourceLineNum) break;
412
371
  if (!cursorMatches(edit.cursor, cursor)) break;
413
372
  payload.push(edit.text);
414
373
  index++;
@@ -676,8 +635,7 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[
676
635
  *
677
636
  * Returns the post-edit text, the first changed line number (1-indexed), and
678
637
  * 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 or a
680
- * blank-target replace is detected.
638
+ * structural-boundary delete check. Throws if an anchor is out of bounds.
681
639
  */
682
640
  export function applyEdits(text: string, edits: Edit[], options: ApplyOptions = {}): ApplyResult {
683
641
  if (edits.length === 0) return { text, firstChangedLine: undefined };
@@ -693,9 +651,6 @@ export function applyEdits(text: string, edits: Edit[], options: ApplyOptions =
693
651
 
694
652
  validateLineBounds(edits, fileLines);
695
653
 
696
- const blankTargetError = detectReplaceOnBlankTarget(edits, fileLines);
697
- if (blankTargetError !== null) throw new Error(blankTargetError);
698
-
699
654
  const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings, options);
700
655
  const targetEdits: Edit[] = [];
701
656
 
@@ -744,20 +699,23 @@ export function applyEdits(text: string, edits: Edit[], options: ApplyOptions =
744
699
 
745
700
  const idx = line - 1;
746
701
  const currentLine = fileLines[idx] ?? "";
747
- const beforeLines: string[] = [];
702
+ const insertLines: string[] = [];
703
+ const replacementLines: string[] = [];
748
704
  let deleteLine = false;
749
705
 
750
706
  for (const { edit } of bucket) {
751
- if (edit.kind === "insert") {
752
- beforeLines.push(edit.text);
707
+ if (isReplacementInsert(edit)) {
708
+ replacementLines.push(edit.text);
709
+ } else if (edit.kind === "insert") {
710
+ insertLines.push(edit.text);
753
711
  } else if (edit.kind === "delete") {
754
712
  deleteLine = true;
755
713
  }
756
714
  }
757
- if (beforeLines.length === 0 && !deleteLine) continue;
715
+ if (insertLines.length === 0 && replacementLines.length === 0 && !deleteLine) continue;
758
716
 
759
- const replaceMode = beforeLines.length > 0;
760
- if (deleteLine && !replaceMode) {
717
+ const hasReplacementPayload = replacementLines.length > 0;
718
+ if (deleteLine && !hasReplacementPayload) {
761
719
  const balance = computeDelimiterBalance([currentLine]);
762
720
  const trimmedCurrentLine = currentLine.trim();
763
721
  const touchesStructuralBoundary =
@@ -769,15 +727,17 @@ export function applyEdits(text: string, edits: Edit[], options: ApplyOptions =
769
727
  trimmedCurrentLine.endsWith("{");
770
728
  if (balance.paren !== 0 || balance.bracket !== 0 || balance.brace !== 0 || touchesStructuralBoundary) {
771
729
  warnings.push(
772
- `Deleted line ${line} contains a structural bracket/brace boundary (${JSON.stringify(trimmedCurrentLine)}); verify the file is still balanced or use 'A:<replacement>' to keep the boundary intact.`,
730
+ `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
731
  );
774
732
  }
775
733
  }
776
- const replacement = deleteLine ? beforeLines : [...beforeLines, currentLine];
777
- const origins = replacement.map((): LineOrigin => (deleteLine ? "replacement" : "insert"));
778
- if (!deleteLine) {
779
- origins[origins.length - 1] = lineOrigins[idx] ?? "original";
780
- }
734
+ const replacement = deleteLine
735
+ ? [...insertLines, ...replacementLines]
736
+ : [...insertLines, ...replacementLines, currentLine];
737
+ const origins: LineOrigin[] = [];
738
+ for (let i = 0; i < insertLines.length; i++) origins.push("insert");
739
+ for (let i = 0; i < replacementLines.length; i++) origins.push(deleteLine ? "replacement" : "insert");
740
+ if (!deleteLine) origins.push(lineOrigins[idx] ?? "original");
781
741
 
782
742
  fileLines.splice(idx, 1, ...replacement);
783
743
  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
- /** Op sigil used immediately after a line-number anchor to insert before it. */
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
- /** All hashline edit op sigils, concatenated for fast membership tests. */
17
- export const HL_OP_CHARS = `${HL_OP_INSERT_BEFORE}${HL_OP_INSERT_AFTER}${HL_OP_REPLACE}${HL_OP_DELETE}`;
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
- /** Prefix for payload continuation lines. The prefix itself is not written. */
20
- export const HL_PAYLOAD_PREFIX = "+";
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 line_op*
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
- line_op: insert_before | insert_after | replace | delete
12
- insert_before: anchor "↑" LF payload*
13
- insert_after: anchor "" LF payload*
14
- replace: range ":" LF payload*
15
- delete: range "!" LF
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: LID | "EOF" | "BOF"
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
  /**