@oh-my-pi/hashline 15.6.0 → 15.7.1

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.
@@ -5,4 +5,4 @@ import type { ApplyResult, Edit } from "./types";
5
5
  * Returns the post-edit text and the first changed line number (1-indexed).
6
6
  * Throws if an anchor is out of bounds.
7
7
  */
8
- export declare function applyEdits(text: string, edits: Edit[]): ApplyResult;
8
+ export declare function applyEdits(text: string, edits: readonly Edit[]): ApplyResult;
@@ -0,0 +1,24 @@
1
+ import type { BlockResolver, Edit } from "./types";
2
+ export interface ResolveBlockEditsOptions {
3
+ /**
4
+ * How to handle a block edit that cannot be resolved (missing resolver or a
5
+ * `null` span). `"throw"` (default) raises a `blockUnresolvedMessage` error —
6
+ * used by the authoritative apply + final preview paths. `"drop"` silently
7
+ * skips the edit — used by the streaming preview, where a half-written file
8
+ * or transient parse error must not throw.
9
+ */
10
+ onUnresolved?: "throw" | "drop";
11
+ }
12
+ /** True when at least one edit is an unresolved `replace block N:` edit. */
13
+ export declare function hasBlockEdit(edits: readonly Edit[]): boolean;
14
+ /**
15
+ * Resolve every `replace block N:` edit in `edits` against `text` (parsed as
16
+ * the language inferred from `path`). Non-block edits pass through untouched.
17
+ * Returns a fresh edit list with no `block` variants. The fast path returns the
18
+ * input unchanged when there is nothing to resolve.
19
+ *
20
+ * Synthesized inserts/deletes carry sequential `index` values for readability
21
+ * only — {@link applyEdits} re-derives every edit's index from array order, so
22
+ * the passthrough edits keeping their original indices is harmless.
23
+ */
24
+ export declare function resolveBlockEdits(edits: readonly Edit[], text: string, path: string, resolver: BlockResolver | undefined, options?: ResolveBlockEditsOptions): readonly Edit[];
@@ -10,6 +10,8 @@ export declare const HL_FILE_PREFIX = "\u00B6";
10
10
  export declare const HL_PAYLOAD_REPLACE = "+";
11
11
  /** Hunk-header keyword for concrete line replacement. */
12
12
  export declare const HL_REPLACE_KEYWORD = "replace";
13
+ /** Hunk-header sub-keyword: `replace block N:` resolves N to a tree-sitter block range. */
14
+ export declare const HL_BLOCK_KEYWORD = "block";
13
15
  /** Hunk-header keyword for concrete line deletion. */
14
16
  export declare const HL_DELETE_KEYWORD = "delete";
15
17
  /** Hunk-header keyword for insertion operations. */
@@ -1,4 +1,5 @@
1
1
  export * from "./apply";
2
+ export * from "./block";
2
3
  export * from "./diff-preview";
3
4
  export * from "./format";
4
5
  export * from "./fs";
@@ -1,4 +1,4 @@
1
- import type { ApplyResult, Edit, SplitOptions } from "./types";
1
+ import type { ApplyResult, BlockResolver, Edit, SplitOptions } from "./types";
2
2
  interface RawSection {
3
3
  path: string;
4
4
  fileHash?: string;
@@ -49,16 +49,23 @@ export declare class PatchSection {
49
49
  * {@link Patcher} owns tag validation and recovery; reach for this
50
50
  * method directly when you've already validated the file content and
51
51
  * just want the result.
52
+ *
53
+ * `blockResolver` resolves any `replace block N:` edits against `text`; an
54
+ * unresolvable block throws (this is the final, authoritative preview path).
52
55
  */
53
- applyTo(text: string): ApplyResult;
56
+ applyTo(text: string, blockResolver?: BlockResolver): ApplyResult;
54
57
  /**
55
58
  * Streaming-tolerant counterpart to {@link applyTo}. Uses
56
59
  * {@link parsePatchStreaming} so a trailing in-flight op (no payload yet,
57
60
  * or a per-token parse error mid-stream) does not throw or emit a phantom
58
61
  * empty-payload edit. Intended for incremental diff previews; the writer
59
62
  * path should always use {@link applyTo}.
63
+ *
64
+ * `blockResolver` resolves any `replace block N:` edits against `text`; an
65
+ * unresolvable block is silently dropped so a half-written file does not
66
+ * throw mid-stream.
60
67
  */
61
- applyPartialTo(text: string): ApplyResult;
68
+ applyPartialTo(text: string, blockResolver?: BlockResolver): ApplyResult;
62
69
  }
63
70
  /**
64
71
  * A parsed hashline patch — zero or more {@link PatchSection}s, each rooted
@@ -26,8 +26,32 @@ export declare const BARE_BODY_AUTO_PIPED_WARNING = "Auto-prefixed bare body row
26
26
  export declare const MINUS_ROW_REJECTED = "`-` rows are not valid; hashline ranges already name the lines being changed. To insert a literal line starting with `-`, write `+-\u2026`.";
27
27
  /** Error text emitted when a replace hunk has no body. */
28
28
  export declare const EMPTY_REPLACE = "`replace N..M:` needs at least one `+TEXT` body row. To delete lines, use `delete N..M`.";
29
+ /** Error text emitted when a `replace block N:` hunk has no body. */
30
+ export declare const EMPTY_BLOCK = "`replace block N:` needs at least one `+TEXT` body row. To delete a block, use `delete N..M` with the block's line range.";
31
+ /**
32
+ * Error text emitted when a `replace block N:` anchor cannot be resolved to a
33
+ * syntactic block (unrecognized language, blank/out-of-range line, no node
34
+ * begins on line N such as a lone closing delimiter, or the resolved block has
35
+ * a syntax error). Names the offending line and steers back to an explicit
36
+ * `replace N..M:` range.
37
+ */
38
+ export declare function blockUnresolvedMessage(line: number): string;
39
+ /**
40
+ * Error text emitted when a `replace block N:` edit reaches a code path that
41
+ * has no {@link BlockResolver} wired in. Indicates a host-configuration bug
42
+ * rather than authored-input error.
43
+ */
44
+ export declare const BLOCK_RESOLVER_UNAVAILABLE = "`replace block N:` is not available here (no tree-sitter block resolver is configured). Use `replace N..M:` with an explicit range.";
45
+ /**
46
+ * Internal invariant error: `applyEdits` received an unresolved `replace block
47
+ * N:` edit. Block edits must be expanded by `resolveBlockEdits` before reaching
48
+ * the applier; hitting this is a wiring bug, not authored-input error.
49
+ */
50
+ export declare const UNRESOLVED_BLOCK_INTERNAL = "internal error: unresolved `replace block` edit reached the applier (resolveBlockEdits was not run).";
29
51
  /** Error text emitted when a delete hunk receives a body row. */
30
52
  export declare const DELETE_TAKES_NO_BODY = "`delete N..M` does not take body rows. Remove the body, or use `replace N..M:`.";
53
+ /** Error text emitted when a `delete block N` hunk receives a body row. */
54
+ export declare const DELETE_BLOCK_TAKES_NO_BODY = "`delete block N` does not take body rows. Remove the body, or use `replace block N:` to replace the block.";
31
55
  /** Error text emitted when an insert hunk has no body. */
32
56
  export declare const EMPTY_INSERT = "`insert` needs at least one `+TEXT` body row.";
33
57
  /** Warning text emitted by `Recovery` when an external write fits a cached snapshot. */
@@ -3,12 +3,18 @@ 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 } from "./types";
6
+ import type { ApplyResult, BlockResolver } from "./types";
7
7
  export interface PatcherOptions {
8
8
  /** Storage backend used for all reads and writes. */
9
9
  fs: Filesystem;
10
10
  /** Snapshot store that minted and resolves hashline section tags. Required. */
11
11
  snapshots: SnapshotStore;
12
+ /**
13
+ * Resolves `replace block N:` anchors to concrete line spans via tree-sitter.
14
+ * Optional: when omitted, any `replace block N:` edit throws on apply (the
15
+ * host did not wire a resolver). Plain line-range ops never need it.
16
+ */
17
+ blockResolver?: BlockResolver;
12
18
  }
13
19
  /** Per-section result returned by {@link Patcher.apply} / {@link Patcher.commit}. */
14
20
  export interface PatchSectionResult {
@@ -69,6 +75,7 @@ export declare class Patcher {
69
75
  readonly fs: Filesystem;
70
76
  readonly snapshots: SnapshotStore;
71
77
  readonly recovery: Recovery;
78
+ readonly blockResolver: BlockResolver | undefined;
72
79
  constructor(options: PatcherOptions);
73
80
  /**
74
81
  * Apply every section in `patch`. `prepare` runs the full apply for each
@@ -6,9 +6,15 @@ export declare function parseLid(raw: string, lineNum: number): Anchor;
6
6
  export type BlockTarget = {
7
7
  kind: "replace";
8
8
  range: ParsedRange;
9
+ } | {
10
+ kind: "block";
11
+ anchor: Anchor;
9
12
  } | {
10
13
  kind: "delete";
11
14
  range: ParsedRange;
15
+ } | {
16
+ kind: "delete_block";
17
+ anchor: Anchor;
12
18
  } | {
13
19
  kind: "insert_before";
14
20
  anchor: Anchor;
@@ -39,6 +39,22 @@ export type Edit = {
39
39
  lineNum: number;
40
40
  index: number;
41
41
  oldAssertion?: string;
42
+ } | {
43
+ /**
44
+ * Deferred block edit (`replace block N:` / `delete block N`). The exact
45
+ * line span is unknown at parse time — it is computed by
46
+ * {@link resolveBlockEdits} once file text + path (→ language) are
47
+ * available, then expanded into concrete edits: a non-empty `payloads`
48
+ * (from `replace block`) becomes the same `replacement` inserts + deletes
49
+ * that `replace start..end:` produces; an empty `payloads` (from `delete
50
+ * block`) becomes a pure range deletion. `applyEdits` never sees this
51
+ * variant.
52
+ */
53
+ kind: "block";
54
+ anchor: Anchor;
55
+ payloads: string[];
56
+ lineNum: number;
57
+ index: number;
42
58
  };
43
59
  /** Result of applying a parsed set of edits to a text body. */
44
60
  export interface ApplyResult {
@@ -85,3 +101,29 @@ export interface CompactDiffOptions {
85
101
  /** Maximum entries kept on each side of an unchanged-context truncation (default 2). */
86
102
  maxUnchangedRun?: number;
87
103
  }
104
+ /**
105
+ * Resolved 1-indexed inclusive line span of a `replace block N:` target.
106
+ */
107
+ export interface BlockSpan {
108
+ /** First line of the block (1-indexed, inclusive). */
109
+ start: number;
110
+ /** Last line of the block (1-indexed, inclusive). */
111
+ end: number;
112
+ }
113
+ /** Request handed to a {@link BlockResolver} to resolve one `replace block N:` anchor. */
114
+ export interface BlockResolverRequest {
115
+ /** Target file path (used to infer language by extension). */
116
+ path: string;
117
+ /** Full text the block must be resolved against (the snapshot the tag names). */
118
+ text: string;
119
+ /** 1-indexed line the block must begin on. */
120
+ line: number;
121
+ }
122
+ /**
123
+ * Resolves a `replace block N:` anchor to the line span of the syntactic block
124
+ * that begins on line N. Returns `null` when no block can be resolved
125
+ * (unrecognized language, blank/out-of-range line, no node begins there, or the
126
+ * resolved subtree has a syntax error). Pure seam: the hashline core declares
127
+ * the contract; the host injects a tree-sitter-backed implementation.
128
+ */
129
+ export type BlockResolver = (request: BlockResolverRequest) => BlockSpan | null;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/hashline",
4
- "version": "15.6.0",
4
+ "version": "15.7.1",
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
@@ -7,6 +7,7 @@
7
7
  * which fixes the common model mistake of a payload that duplicates or drops
8
8
  * the closing delimiter bordering the range (balance-validated; see below).
9
9
  */
10
+ import { UNRESOLVED_BLOCK_INTERNAL } from "./messages";
10
11
  import { cloneCursor } from "./tokenizer";
11
12
  import type { Anchor, ApplyResult, Cursor, Edit } from "./types";
12
13
 
@@ -29,7 +30,7 @@ function getCursorAnchors(cursor: Cursor): Anchor[] {
29
30
  return cursor.kind === "before_anchor" || cursor.kind === "after_anchor" ? [cursor.anchor] : [];
30
31
  }
31
32
 
32
- function getEditAnchors(edit: Edit): Anchor[] {
33
+ function getEditAnchors(edit: AppliedEdit): Anchor[] {
33
34
  if (edit.kind === "delete") return [edit.anchor];
34
35
  return getCursorAnchors(edit.cursor);
35
36
  }
@@ -407,9 +408,17 @@ function repairBoundaryBalance(
407
408
  * Returns the post-edit text and the first changed line number (1-indexed).
408
409
  * Throws if an anchor is out of bounds.
409
410
  */
410
- export function applyEdits(text: string, edits: Edit[]): ApplyResult {
411
+ export function applyEdits(text: string, edits: readonly Edit[]): ApplyResult {
411
412
  if (edits.length === 0) return { text, firstChangedLine: undefined };
412
413
 
414
+ // Block edits are deferred until `resolveBlockEdits` expands them into
415
+ // concrete inserts + deletes. Reaching the applier with one still present
416
+ // is an internal wiring bug, not authored-input error.
417
+ for (const edit of edits) {
418
+ if (edit.kind === "block") throw new Error(UNRESOLVED_BLOCK_INTERNAL);
419
+ }
420
+ const appliedEdits = edits as readonly AppliedEdit[];
421
+
413
422
  const fileLines = text.split("\n");
414
423
  const lineOrigins: LineOrigin[] = fileLines.map(() => "original");
415
424
 
@@ -418,7 +427,7 @@ export function applyEdits(text: string, edits: Edit[]): ApplyResult {
418
427
  if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
419
428
  };
420
429
 
421
- const targetEdits = edits.map((edit, index) => cloneAppliedEdit(edit, index));
430
+ const targetEdits = appliedEdits.map((edit, index) => cloneAppliedEdit(edit, index));
422
431
  validateLineBounds(targetEdits, fileLines);
423
432
  const { edits: repaired, warnings } = repairBoundaryBalance(targetEdits, fileLines);
424
433
 
package/src/block.ts ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Expand deferred `replace block N:` edits into concrete inserts + deletes.
3
+ *
4
+ * The hashline parser cannot expand a block edit on its own — the line span is
5
+ * unknown until file text + path (→ language) are available. This transform
6
+ * runs at every apply/preview boundary that has text: it calls the injected
7
+ * {@link BlockResolver} to resolve each block's `[start, end]` span, then emits
8
+ * the exact same `before_anchor` replacement inserts + range deletes that
9
+ * `replace start..end:` produces in the parser. After it runs, no `block` edits
10
+ * remain, so {@link applyEdits} (and recovery) only ever see resolved edits.
11
+ */
12
+ import { BLOCK_RESOLVER_UNAVAILABLE, blockUnresolvedMessage } from "./messages";
13
+ import type { BlockResolver, Cursor, Edit } from "./types";
14
+
15
+ export interface ResolveBlockEditsOptions {
16
+ /**
17
+ * How to handle a block edit that cannot be resolved (missing resolver or a
18
+ * `null` span). `"throw"` (default) raises a `blockUnresolvedMessage` error —
19
+ * used by the authoritative apply + final preview paths. `"drop"` silently
20
+ * skips the edit — used by the streaming preview, where a half-written file
21
+ * or transient parse error must not throw.
22
+ */
23
+ onUnresolved?: "throw" | "drop";
24
+ }
25
+
26
+ /** True when at least one edit is an unresolved `replace block N:` edit. */
27
+ export function hasBlockEdit(edits: readonly Edit[]): boolean {
28
+ return edits.some(edit => edit.kind === "block");
29
+ }
30
+
31
+ /**
32
+ * Resolve every `replace block N:` edit in `edits` against `text` (parsed as
33
+ * the language inferred from `path`). Non-block edits pass through untouched.
34
+ * Returns a fresh edit list with no `block` variants. The fast path returns the
35
+ * input unchanged when there is nothing to resolve.
36
+ *
37
+ * Synthesized inserts/deletes carry sequential `index` values for readability
38
+ * only — {@link applyEdits} re-derives every edit's index from array order, so
39
+ * the passthrough edits keeping their original indices is harmless.
40
+ */
41
+ export function resolveBlockEdits(
42
+ edits: readonly Edit[],
43
+ text: string,
44
+ path: string,
45
+ resolver: BlockResolver | undefined,
46
+ options: ResolveBlockEditsOptions = {},
47
+ ): readonly Edit[] {
48
+ if (!hasBlockEdit(edits)) return edits;
49
+ const onUnresolved = options.onUnresolved ?? "throw";
50
+ const resolved: Edit[] = [];
51
+ let synthIndex = 0;
52
+ for (const edit of edits) {
53
+ if (edit.kind !== "block") {
54
+ resolved.push(edit);
55
+ continue;
56
+ }
57
+ const span = resolver ? resolver({ path, text, line: edit.anchor.line }) : null;
58
+ if (span === null) {
59
+ if (onUnresolved === "drop") continue;
60
+ throw new Error(
61
+ `line ${edit.lineNum}: ${resolver ? blockUnresolvedMessage(edit.anchor.line) : BLOCK_RESOLVER_UNAVAILABLE}`,
62
+ );
63
+ }
64
+ // Mirror the parser's `replace start..end:` expansion exactly: one
65
+ // `before_anchor` replacement insert per payload row at `span.start`,
66
+ // then one delete per line across `[span.start, span.end]`. An empty
67
+ // `payloads` (from `delete block N`) emits no inserts — a pure deletion.
68
+ for (const payload of edit.payloads) {
69
+ const cursor: Cursor = { kind: "before_anchor", anchor: { line: span.start } };
70
+ resolved.push({
71
+ kind: "insert",
72
+ cursor,
73
+ text: payload,
74
+ lineNum: edit.lineNum,
75
+ index: synthIndex++,
76
+ mode: "replacement",
77
+ });
78
+ }
79
+ for (let line = span.start; line <= span.end; line++) {
80
+ resolved.push({ kind: "delete", anchor: { line }, lineNum: edit.lineNum, index: synthIndex++ });
81
+ }
82
+ }
83
+ return resolved;
84
+ }
package/src/format.ts CHANGED
@@ -14,6 +14,8 @@ export const HL_PAYLOAD_REPLACE = "+";
14
14
 
15
15
  /** Hunk-header keyword for concrete line replacement. */
16
16
  export const HL_REPLACE_KEYWORD = "replace";
17
+ /** Hunk-header sub-keyword: `replace block N:` resolves N to a tree-sitter block range. */
18
+ export const HL_BLOCK_KEYWORD = "block";
17
19
  /** Hunk-header keyword for concrete line deletion. */
18
20
  export const HL_DELETE_KEYWORD = "delete";
19
21
  /** Hunk-header keyword for insertion operations. */
package/src/grammar.lark CHANGED
@@ -7,11 +7,13 @@ file_header: "¶" filename "#" file_hash LF
7
7
  file_hash: /[0-9A-F]{4}/
8
8
  filename: /[^\s#]+/
9
9
 
10
- hunk: body_hunk | delete_hunk
10
+ hunk: body_hunk | delete_hunk | delete_block_hunk
11
11
  body_hunk: body_header emit_op+
12
12
  delete_hunk: "delete " header_range LF
13
- body_header: (replace_anchor | insert_anchor) LF
13
+ delete_block_hunk: "delete block " LID LF
14
+ body_header: (replace_anchor | replace_block_anchor | insert_anchor) LF
14
15
  replace_anchor: "replace " header_range ":"
16
+ replace_block_anchor: "replace block " LID ":"
15
17
  insert_anchor: "insert " insert_pos ":"
16
18
  insert_pos: "before " LID | "after " LID | "head" | "tail"
17
19
  emit_op: "+" /(.*)/ LF
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./apply";
2
+ export * from "./block";
2
3
  export * from "./diff-preview";
3
4
  export * from "./format";
4
5
  export * from "./fs";
package/src/input.ts CHANGED
@@ -9,10 +9,11 @@
9
9
  */
10
10
  import * as path from "node:path";
11
11
  import { applyEdits } from "./apply";
12
+ import { resolveBlockEdits } from "./block";
12
13
  import { HL_FILE_HASH_LENGTH, HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format";
13
14
  import { parsePatch, parsePatchStreaming } from "./parser";
14
15
  import { Tokenizer } from "./tokenizer";
15
- import type { ApplyResult, Edit, SplitOptions } from "./types";
16
+ import type { ApplyResult, BlockResolver, Edit, SplitOptions } from "./types";
16
17
 
17
18
  // Pure classification — single shared tokenizer is safe.
18
19
  const TOKENIZER = new Tokenizer();
@@ -251,6 +252,8 @@ export class PatchSection {
251
252
  get hasAnchorScopedEdit(): boolean {
252
253
  return this.edits.some(edit => {
253
254
  if (edit.kind === "delete") return true;
255
+ // A `replace block N:` edit is anchored to concrete content on line N.
256
+ if (edit.kind === "block") return true;
254
257
  return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
255
258
  });
256
259
  }
@@ -263,6 +266,10 @@ export class PatchSection {
263
266
  lines.add(edit.anchor.line);
264
267
  continue;
265
268
  }
269
+ if (edit.kind === "block") {
270
+ lines.add(edit.anchor.line);
271
+ continue;
272
+ }
266
273
  if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") {
267
274
  lines.add(edit.cursor.anchor.line);
268
275
  }
@@ -276,10 +283,14 @@ export class PatchSection {
276
283
  * {@link Patcher} owns tag validation and recovery; reach for this
277
284
  * method directly when you've already validated the file content and
278
285
  * just want the result.
286
+ *
287
+ * `blockResolver` resolves any `replace block N:` edits against `text`; an
288
+ * unresolvable block throws (this is the final, authoritative preview path).
279
289
  */
280
- applyTo(text: string): ApplyResult {
290
+ applyTo(text: string, blockResolver?: BlockResolver): ApplyResult {
281
291
  const { edits, warnings } = this.parse();
282
- const result = applyEdits(text, [...edits]);
292
+ const resolved = resolveBlockEdits(edits, text, this.path, blockResolver, { onUnresolved: "throw" });
293
+ const result = applyEdits(text, resolved);
283
294
  // Preserve parse warnings so consumers don't need to call `parse()`
284
295
  // separately.
285
296
  const merged = warnings.length === 0 ? result.warnings : [...warnings, ...(result.warnings ?? [])];
@@ -294,10 +305,15 @@ export class PatchSection {
294
305
  * or a per-token parse error mid-stream) does not throw or emit a phantom
295
306
  * empty-payload edit. Intended for incremental diff previews; the writer
296
307
  * path should always use {@link applyTo}.
308
+ *
309
+ * `blockResolver` resolves any `replace block N:` edits against `text`; an
310
+ * unresolvable block is silently dropped so a half-written file does not
311
+ * throw mid-stream.
297
312
  */
298
- applyPartialTo(text: string): ApplyResult {
313
+ applyPartialTo(text: string, blockResolver?: BlockResolver): ApplyResult {
299
314
  const { edits, warnings } = parsePatchStreaming(this.diff);
300
- const result = applyEdits(text, [...edits]);
315
+ const resolved = resolveBlockEdits(edits, text, this.path, blockResolver, { onUnresolved: "drop" });
316
+ const result = applyEdits(text, resolved);
301
317
  const merged = warnings.length === 0 ? result.warnings : [...warnings, ...(result.warnings ?? [])];
302
318
  return merged && merged.length > 0
303
319
  ? { ...result, warnings: merged }
package/src/messages.ts CHANGED
@@ -42,9 +42,48 @@ export const MINUS_ROW_REJECTED =
42
42
  /** Error text emitted when a replace hunk has no body. */
43
43
  export const EMPTY_REPLACE = "`replace N..M:` needs at least one `+TEXT` body row. To delete lines, use `delete N..M`.";
44
44
 
45
+ /** Error text emitted when a `replace block N:` hunk has no body. */
46
+ export const EMPTY_BLOCK =
47
+ "`replace block N:` needs at least one `+TEXT` body row. To delete a block, use `delete N..M` with the block's line range.";
48
+
49
+ /**
50
+ * Error text emitted when a `replace block N:` anchor cannot be resolved to a
51
+ * syntactic block (unrecognized language, blank/out-of-range line, no node
52
+ * begins on line N such as a lone closing delimiter, or the resolved block has
53
+ * a syntax error). Names the offending line and steers back to an explicit
54
+ * `replace N..M:` range.
55
+ */
56
+ export function blockUnresolvedMessage(line: number): string {
57
+ return (
58
+ `\`replace block ${line}:\` could not resolve a syntactic block beginning on line ${line}. ` +
59
+ `The language may be unsupported, the line may be blank or a closing delimiter, or the block may not parse. ` +
60
+ `Use \`replace ${line}..M:\` with the block's explicit end line instead.`
61
+ );
62
+ }
63
+
64
+ /**
65
+ * Error text emitted when a `replace block N:` edit reaches a code path that
66
+ * has no {@link BlockResolver} wired in. Indicates a host-configuration bug
67
+ * rather than authored-input error.
68
+ */
69
+ export const BLOCK_RESOLVER_UNAVAILABLE =
70
+ "`replace block N:` is not available here (no tree-sitter block resolver is configured). Use `replace N..M:` with an explicit range.";
71
+
72
+ /**
73
+ * Internal invariant error: `applyEdits` received an unresolved `replace block
74
+ * N:` edit. Block edits must be expanded by `resolveBlockEdits` before reaching
75
+ * the applier; hitting this is a wiring bug, not authored-input error.
76
+ */
77
+ export const UNRESOLVED_BLOCK_INTERNAL =
78
+ "internal error: unresolved `replace block` edit reached the applier (resolveBlockEdits was not run).";
79
+
45
80
  /** Error text emitted when a delete hunk receives a body row. */
46
81
  export const DELETE_TAKES_NO_BODY = "`delete N..M` does not take body rows. Remove the body, or use `replace N..M:`.";
47
82
 
83
+ /** Error text emitted when a `delete block N` hunk receives a body row. */
84
+ export const DELETE_BLOCK_TAKES_NO_BODY =
85
+ "`delete block N` does not take body rows. Remove the body, or use `replace block N:` to replace the block.";
86
+
48
87
  /** Error text emitted when an insert hunk has no body. */
49
88
  export const EMPTY_INSERT = "`insert` needs at least one `+TEXT` body row.";
50
89
 
package/src/parser.ts CHANGED
@@ -6,7 +6,9 @@
6
6
  import { HL_PAYLOAD_REPLACE } from "./format";
7
7
  import {
8
8
  BARE_BODY_AUTO_PIPED_WARNING,
9
+ DELETE_BLOCK_TAKES_NO_BODY,
9
10
  DELETE_TAKES_NO_BODY,
11
+ EMPTY_BLOCK,
10
12
  EMPTY_INSERT,
11
13
  EMPTY_REPLACE,
12
14
  MINUS_ROW_REJECTED,
@@ -159,7 +161,8 @@ export class Executor {
159
161
  endStreaming(): { edits: Edit[]; warnings: string[] } {
160
162
  this.#consumePendingSkippableComments();
161
163
  if (this.#pending && this.#pending.payloads.length > 0) this.#flushPending();
162
- else if (this.#pending?.target.kind === "delete") this.#flushPending();
164
+ else if (this.#pending?.target.kind === "delete" || this.#pending?.target.kind === "delete_block")
165
+ this.#flushPending();
163
166
  else this.#pending = undefined;
164
167
  this.#validateNoOverlappingDeletes();
165
168
  return { edits: this.#edits, warnings: this.#warnings };
@@ -204,6 +207,7 @@ export class Executor {
204
207
  );
205
208
  }
206
209
  if (pending.target.kind === "delete") throw new Error(`line ${lineNum}: ${DELETE_TAKES_NO_BODY}`);
210
+ if (pending.target.kind === "delete_block") throw new Error(`line ${lineNum}: ${DELETE_BLOCK_TAKES_NO_BODY}`);
207
211
  pending.payloads.push({ kind: "literal", text, lineNum });
208
212
  }
209
213
 
@@ -213,6 +217,8 @@ export class Executor {
213
217
  if (this.#pending) {
214
218
  if (text.trim().length === 0) return;
215
219
  if (this.#pending.target.kind === "delete") throw new Error(`line ${lineNum}: ${DELETE_TAKES_NO_BODY}`);
220
+ if (this.#pending.target.kind === "delete_block")
221
+ throw new Error(`line ${lineNum}: ${DELETE_BLOCK_TAKES_NO_BODY}`);
216
222
  if (text.trimStart().charCodeAt(0) === 45 /* - */) throw new Error(`line ${lineNum}: ${MINUS_ROW_REJECTED}`);
217
223
  if (!this.#warnings.includes(BARE_BODY_AUTO_PIPED_WARNING)) this.#warnings.push(BARE_BODY_AUTO_PIPED_WARNING);
218
224
  this.#pending.payloads.push({ kind: "literal", text, lineNum });
@@ -240,6 +246,16 @@ export class Executor {
240
246
  this.#edits.push({ kind: "delete", anchor: { ...anchor }, lineNum, index: this.#editIndex++ });
241
247
  }
242
248
 
249
+ #pushBlock(anchor: Anchor, payloads: readonly PayloadRow[], lineNum: number): void {
250
+ this.#edits.push({
251
+ kind: "block",
252
+ anchor: { ...anchor },
253
+ payloads: payloads.map(payload => payload.text),
254
+ lineNum,
255
+ index: this.#editIndex++,
256
+ });
257
+ }
258
+
243
259
  #emitPayloadRows(cursor: Cursor, payloads: readonly PayloadRow[], lineNum: number, mode?: "replacement"): void {
244
260
  for (const payload of payloads) this.#pushInsert(cursor, payload.text, lineNum, mode);
245
261
  }
@@ -253,6 +269,16 @@ export class Executor {
253
269
  for (const anchor of expandRange(target.range)) this.#pushDelete(anchor, lineNum);
254
270
  return;
255
271
  }
272
+ if (target.kind === "delete_block") {
273
+ // A block edit with no payloads resolves to a pure block deletion.
274
+ this.#pushBlock(target.anchor, [], lineNum);
275
+ return;
276
+ }
277
+ if (target.kind === "block") {
278
+ if (payloads.length === 0) throw new Error(`line ${lineNum}: ${EMPTY_BLOCK}`);
279
+ this.#pushBlock(target.anchor, payloads, lineNum);
280
+ return;
281
+ }
256
282
  if (payloads.length === 0) {
257
283
  if (target.kind === "replace") throw new Error(`line ${lineNum}: ${EMPTY_REPLACE}`);
258
284
  throw new Error(`line ${lineNum}: ${EMPTY_INSERT}`);
package/src/patcher.ts CHANGED
@@ -23,6 +23,7 @@
23
23
  * filesystem configuration.
24
24
  */
25
25
  import { applyEdits } from "./apply";
26
+ import { hasBlockEdit, resolveBlockEdits } from "./block";
26
27
  import { computeFileHash, formatHashlineHeader } from "./format";
27
28
  import type { Filesystem, WriteResult } from "./fs";
28
29
  import { isNotFound } from "./fs";
@@ -32,13 +33,19 @@ import { MismatchError } from "./mismatch";
32
33
  import { detectLineEnding, type LineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
33
34
  import { Recovery, type RecoveryResult } from "./recovery";
34
35
  import type { SnapshotStore } from "./snapshots";
35
- import type { ApplyResult, Edit } from "./types";
36
+ import type { ApplyResult, BlockResolver, Edit } from "./types";
36
37
 
37
38
  export interface PatcherOptions {
38
39
  /** Storage backend used for all reads and writes. */
39
40
  fs: Filesystem;
40
41
  /** Snapshot store that minted and resolves hashline section tags. Required. */
41
42
  snapshots: SnapshotStore;
43
+ /**
44
+ * Resolves `replace block N:` anchors to concrete line spans via tree-sitter.
45
+ * Optional: when omitted, any `replace block N:` edit throws on apply (the
46
+ * host did not wire a resolver). Plain line-range ops never need it.
47
+ */
48
+ blockResolver?: BlockResolver;
42
49
  }
43
50
 
44
51
  /** Per-section result returned by {@link Patcher.apply} / {@link Patcher.commit}. */
@@ -99,6 +106,8 @@ export class PreparedSection {
99
106
  function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
100
107
  return edits.some(edit => {
101
108
  if (edit.kind === "delete") return true;
109
+ // A `replace block N:` edit anchors to concrete content on line N.
110
+ if (edit.kind === "block") return true;
102
111
  return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
103
112
  });
104
113
  }
@@ -147,6 +156,7 @@ export class Patcher {
147
156
  readonly fs: Filesystem;
148
157
  readonly snapshots: SnapshotStore;
149
158
  readonly recovery: Recovery;
159
+ readonly blockResolver: BlockResolver | undefined;
150
160
 
151
161
  constructor(options: PatcherOptions) {
152
162
  if (!options.snapshots) {
@@ -155,6 +165,7 @@ export class Patcher {
155
165
  this.fs = options.fs;
156
166
  this.snapshots = options.snapshots;
157
167
  this.recovery = new Recovery(options.snapshots);
168
+ this.blockResolver = options.blockResolver;
158
169
  }
159
170
 
160
171
  /**
@@ -306,6 +317,24 @@ export class Patcher {
306
317
  #recordFullSnapshot(canonicalPath: string, normalized: string): string {
307
318
  return this.snapshots.record(canonicalPath, normalized);
308
319
  }
320
+ #mismatchError(
321
+ section: PatchSection,
322
+ canonicalPath: string,
323
+ normalized: string,
324
+ expected: string,
325
+ hashRecognized: boolean,
326
+ ): MismatchError {
327
+ const actualFileHash = this.#recordFullSnapshot(canonicalPath, normalized);
328
+ return new MismatchError({
329
+ path: section.path,
330
+ expectedFileHash: expected,
331
+ actualFileHash,
332
+ fileLines: normalized.split("\n"),
333
+ anchorLines: section.collectAnchorLines(),
334
+ hashRecognized,
335
+ });
336
+ }
337
+
309
338
  #applyWithRecovery(args: {
310
339
  section: PatchSection;
311
340
  canonicalPath: string;
@@ -315,16 +344,37 @@ export class Patcher {
315
344
  }): ApplyResult {
316
345
  const { section, canonicalPath, exists, normalized, edits } = args;
317
346
  const expected = exists ? section.fileHash : undefined;
318
- if (expected === undefined) return applyEdits(normalized, [...edits]);
347
+ const liveMatches = expected !== undefined && computeFileHash(normalized) === expected;
348
+
349
+ // Resolve `replace block N:` edits to concrete ranges before recovery
350
+ // runs. Block anchors are expressed against the snapshot the section tag
351
+ // names, so resolve against that exact text:
352
+ // - live content matches the tag (or there is no tag) → resolve against
353
+ // the live, normalized content;
354
+ // - the file drifted → resolve against the tagged snapshot's text so the
355
+ // resulting ranges flow through the 3-way-merge recovery below.
356
+ // When a block edit needs the tagged snapshot but it is unavailable, the
357
+ // range cannot be placed safely — reject with a MismatchError (re-read).
358
+ let resolved: readonly Edit[] = edits;
359
+ if (hasBlockEdit(edits)) {
360
+ const baseText =
361
+ expected === undefined || liveMatches ? normalized : this.snapshots.byHash(canonicalPath, expected)?.text;
362
+ if (baseText === undefined) {
363
+ throw this.#mismatchError(section, canonicalPath, normalized, expected ?? "", false);
364
+ }
365
+ resolved = resolveBlockEdits(edits, baseText, section.path, this.blockResolver, { onUnresolved: "throw" });
366
+ }
367
+
368
+ if (expected === undefined) return applyEdits(normalized, resolved);
319
369
  // Whole-file unchanged → the tag still names the live content, so an
320
370
  // edit anchored at ANY line (displayed or not) is safe to apply.
321
- if (computeFileHash(normalized) === expected) return applyEdits(normalized, [...edits]);
371
+ if (liveMatches) return applyEdits(normalized, resolved);
322
372
  // Head/tail-only inserts are position-stable: "start"/"end" cannot move
323
373
  // with content drift, so a stale tag is non-fatal. Apply onto the live
324
374
  // content and warn instead of hard-failing — unlike an anchored
325
375
  // mismatch, which cannot be safely relocated and must reject.
326
- if (!hasAnchorScopedEdit(edits)) {
327
- const result = applyEdits(normalized, [...edits]);
376
+ if (!hasAnchorScopedEdit(resolved)) {
377
+ const result = applyEdits(normalized, resolved);
328
378
  return { ...result, warnings: [HEADTAIL_DRIFT_WARNING, ...(result.warnings ?? [])] };
329
379
  }
330
380
  // File drifted: try to replay the edit against the version the tag
@@ -333,18 +383,10 @@ export class Patcher {
333
383
  path: canonicalPath,
334
384
  currentText: normalized,
335
385
  fileHash: expected,
336
- edits,
386
+ edits: resolved,
337
387
  });
338
388
  if (recovered) return recoveryToApplyResult(recovered);
339
389
  const hashRecognized = this.snapshots.byHash(canonicalPath, expected) !== null;
340
- const actualFileHash = this.#recordFullSnapshot(canonicalPath, normalized);
341
- throw new MismatchError({
342
- path: section.path,
343
- expectedFileHash: expected,
344
- actualFileHash,
345
- fileLines: normalized.split("\n"),
346
- anchorLines: section.collectAnchorLines(),
347
- hashRecognized,
348
- });
390
+ throw this.#mismatchError(section, canonicalPath, normalized, expected, hashRecognized);
349
391
  }
350
392
  }
package/src/prompt.md CHANGED
@@ -6,7 +6,9 @@ Every file section starts with `¶PATH#TAG`. `TAG` is the 4-hex snapshot tag fro
6
6
 
7
7
  <ops>
8
8
  replace N..M: replace original lines N..M with the body rows below.
9
+ replace block N: replace the whole syntactic block that BEGINS on line N — its header line through its closing line — resolved with tree-sitter. Body rows below. Point N at the line that OPENS the construct (the `if`/`function`/`def`/`{`-bearing line), not a closing `}` or a blank line.
9
10
  delete N..M delete original lines N..M. No body.
11
+ delete block N delete the whole syntactic block that BEGINS on line N.
10
12
  insert before N: insert the body rows immediately before line N.
11
13
  insert after N: insert the body rows immediately after line N.
12
14
  insert head: insert the body rows at the very start of the file.
@@ -27,6 +29,7 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k
27
29
  - A line number is an offset, not a structural boundary: never `insert after N` into a construct you have not read, and never start or end a `replace`/`delete` range mid-expression or mid-block. If unsure what is on those lines, `read` them first.
28
30
  - On a stale-tag rejection — or any result you cannot fully account for — STOP and re-`read`. Never stack more line-numbered edits onto output you have not re-grounded; that compounds corruption.
29
31
  - One hunk per range; the body is the final content, never an old/new pair.
32
+ - Keep every range as tight as the change: a range must cover ONLY lines whose content actually changes. Never widen it to swallow an unchanged signature, brace, or neighboring statement just to rewrite a few lines inside — change one line with `replace N..N`, not the whole block around it. (A range where every line genuinely changes is correctly long; tightness is about excluding unchanged lines, not about being short.) This bounds the blast radius if a number is off: a stale single-line replace corrupts one line, while a stale block replace shreds the whole block and its structure.
30
33
  - To change lines 2 and 5 while keeping 3–4, issue two hunks (`replace 2..2:` and `replace 5..5:`). Untouched lines are simply absent from every range.
31
34
  </rules>
32
35
 
@@ -69,6 +72,14 @@ insert head:
69
72
  insert tail:
70
73
  +greet("everyone")
71
74
  ```
75
+
76
+ Replace the whole `greet` function block — `replace block 1:` resolves lines 1–3 (the `def` header through `print(msg)`); line 4 is a separate statement and stays:
77
+ ```
78
+ ¶greet.py#A1B2
79
+ replace block 1:
80
+ +def greet(name):
81
+ + print(f"Hello, {name}")
82
+ ```
72
83
  </example>
73
84
 
74
85
  <anti-patterns>
@@ -88,3 +99,10 @@ replace 3..3:
88
99
  replace 3..3:
89
100
  + return msg
90
101
  </anti-patterns>
102
+
103
+ <critical>
104
+ If you remember nothing else:
105
+ 1. RE-GROUND AFTER EVERY EDIT. Each applied edit mints a fresh `#TAG` and renumbers the file — the tag and line numbers you just used are now dead. Take the next edit's numbers from the edit response or a fresh `read`, never from pre-edit memory. On a stale-tag rejection or any unexpected result, STOP and re-`read`.
106
+ 2. RANGES ARE TIGHT AND IN-BOUNDS. Cover only lines whose content actually changes; never widen a range to swallow an unchanged signature, brace, or statement, and never start or end a range mid-expression or mid-block. A stale single-line replace corrupts one line; a stale block replace shreds the whole block.
107
+ 3. THE BODY IS THE FINAL CONTENT. Only `+TEXT` rows under a `:` header — never `-old`/bare context lines, never an old/new pair. The range does the deleting.
108
+ </critical>
package/src/recovery.ts CHANGED
@@ -70,6 +70,9 @@ function collectAnchorLines(edits: readonly Edit[]): number[] {
70
70
 
71
71
  function getEditAnchors(edit: Edit): Anchor[] {
72
72
  if (edit.kind === "delete") return [edit.anchor];
73
+ // Recovery only ever receives already-resolved edits (no `block`); this arm
74
+ // exists for type-exhaustiveness over the full `Edit` union.
75
+ if (edit.kind === "block") return [edit.anchor];
73
76
  return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor" ? [edit.cursor.anchor] : [];
74
77
  }
75
78
 
package/src/tokenizer.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import {
12
12
  describeAnchorExamples,
13
+ HL_BLOCK_KEYWORD,
13
14
  HL_DELETE_KEYWORD,
14
15
  HL_FILE_HASH_LENGTH,
15
16
  HL_FILE_HASH_SEP,
@@ -196,7 +197,9 @@ function scanHeaderRange(line: string, index = 0, end = trimEndIndex(line), allo
196
197
 
197
198
  export type BlockTarget =
198
199
  | { kind: "replace"; range: ParsedRange }
200
+ | { kind: "block"; anchor: Anchor }
199
201
  | { kind: "delete"; range: ParsedRange }
202
+ | { kind: "delete_block"; anchor: Anchor }
200
203
  | { kind: "insert_before"; anchor: Anchor }
201
204
  | { kind: "insert_after"; anchor: Anchor }
202
205
  | { kind: "bof" }
@@ -249,6 +252,18 @@ function scanHunkAnchor(line: string, start: number, end: number): TargetScan |
249
252
  const cursor = skipWhitespace(line, start, end);
250
253
  const replaceEnd = scanKeyword(line, cursor, end, HL_REPLACE_KEYWORD);
251
254
  if (replaceEnd !== null) {
255
+ // `replace block N:` — resolve N to a tree-sitter block range at apply
256
+ // time. Try the `block` sub-keyword before falling back to a literal
257
+ // `replace N..M:` range.
258
+ const blockEnd = scanKeyword(line, skipWhitespace(line, replaceEnd, end), end, HL_BLOCK_KEYWORD);
259
+ if (blockEnd !== null) {
260
+ const anchor = scanLineNumber(line, skipWhitespace(line, blockEnd, end), end);
261
+ if (anchor === null) return null;
262
+ return {
263
+ target: { kind: "block", anchor: { line: anchor.line } },
264
+ nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
265
+ };
266
+ }
252
267
  const range = scanHeaderRange(line, replaceEnd, end, true);
253
268
  if (range === null) return null;
254
269
  return {
@@ -258,6 +273,17 @@ function scanHunkAnchor(line: string, start: number, end: number): TargetScan |
258
273
  }
259
274
  const deleteEnd = scanKeyword(line, cursor, end, HL_DELETE_KEYWORD);
260
275
  if (deleteEnd !== null) {
276
+ // `delete block N` — resolve N to a tree-sitter block range at apply
277
+ // time and delete its whole span. Like `delete N..M`, it takes no body
278
+ // and no trailing colon.
279
+ const blockEnd = scanKeyword(line, skipWhitespace(line, deleteEnd, end), end, HL_BLOCK_KEYWORD);
280
+ if (blockEnd !== null) {
281
+ const anchor = scanLineNumber(line, skipWhitespace(line, blockEnd, end), end);
282
+ if (anchor === null) return null;
283
+ const next = skipWhitespace(line, anchor.nextIndex, end);
284
+ if (next < end && line.charCodeAt(next) === CHAR_COLON) return null;
285
+ return { target: { kind: "delete_block", anchor: { line: anchor.line } }, nextIndex: next };
286
+ }
261
287
  const range = scanHeaderRange(line, deleteEnd, end, true);
262
288
  if (range === null) return null;
263
289
  const next = skipWhitespace(line, range.nextIndex, end);
package/src/types.ts CHANGED
@@ -32,7 +32,24 @@ export type Edit =
32
32
  index: number;
33
33
  mode?: "replacement";
34
34
  }
35
- | { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string };
35
+ | { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string }
36
+ | {
37
+ /**
38
+ * Deferred block edit (`replace block N:` / `delete block N`). The exact
39
+ * line span is unknown at parse time — it is computed by
40
+ * {@link resolveBlockEdits} once file text + path (→ language) are
41
+ * available, then expanded into concrete edits: a non-empty `payloads`
42
+ * (from `replace block`) becomes the same `replacement` inserts + deletes
43
+ * that `replace start..end:` produces; an empty `payloads` (from `delete
44
+ * block`) becomes a pure range deletion. `applyEdits` never sees this
45
+ * variant.
46
+ */
47
+ kind: "block";
48
+ anchor: Anchor;
49
+ payloads: string[];
50
+ lineNum: number;
51
+ index: number;
52
+ };
36
53
 
37
54
  /** Result of applying a parsed set of edits to a text body. */
38
55
  export interface ApplyResult {
@@ -84,3 +101,32 @@ export interface CompactDiffOptions {
84
101
  /** Maximum entries kept on each side of an unchanged-context truncation (default 2). */
85
102
  maxUnchangedRun?: number;
86
103
  }
104
+
105
+ /**
106
+ * Resolved 1-indexed inclusive line span of a `replace block N:` target.
107
+ */
108
+ export interface BlockSpan {
109
+ /** First line of the block (1-indexed, inclusive). */
110
+ start: number;
111
+ /** Last line of the block (1-indexed, inclusive). */
112
+ end: number;
113
+ }
114
+
115
+ /** Request handed to a {@link BlockResolver} to resolve one `replace block N:` anchor. */
116
+ export interface BlockResolverRequest {
117
+ /** Target file path (used to infer language by extension). */
118
+ path: string;
119
+ /** Full text the block must be resolved against (the snapshot the tag names). */
120
+ text: string;
121
+ /** 1-indexed line the block must begin on. */
122
+ line: number;
123
+ }
124
+
125
+ /**
126
+ * Resolves a `replace block N:` anchor to the line span of the syntactic block
127
+ * that begins on line N. Returns `null` when no block can be resolved
128
+ * (unrecognized language, blank/out-of-range line, no node begins there, or the
129
+ * resolved subtree has a syntax error). Pure seam: the hashline core declares
130
+ * the contract; the host injects a tree-sitter-backed implementation.
131
+ */
132
+ export type BlockResolver = (request: BlockResolverRequest) => BlockSpan | null;