@oh-my-pi/hashline 17.2.13 → 17.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.2.15] - 2026-08-12
6
+
7
+ ### Added
8
+
9
+ - Added a post-apply parse advisory warning that alerts users when an applied edit fails to parse (despite the pre-edit content parsing successfully), helping catch balance-neutral misplacements that previously failed silently.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed a bug where Rust lifetimes (e.g., `'static`) were incorrectly parsed as starting a string literal, which blinded the delimiter-balance scanner and could lead to silent signature deletions. Single-quote lexing on `.rs` files is now language-aware and correctly distinguishes lifetimes from character literals.
14
+ - Fixed an issue where terminal newlines in files were incorrectly exposed as editable blank rows.
15
+
5
16
  ## [17.2.12] - 2026-08-08
6
17
 
7
18
  ### Breaking Changes
@@ -77,5 +77,10 @@ export declare function describeAnchorExamples(linePrefix?: string): string;
77
77
  export declare function formatHashlineHeader(filePath: string, fileHash: string): string;
78
78
  /** Formats a single numbered line as `LINE:TEXT`. */
79
79
  export declare function formatNumberedLine(lineNumber: number, line: string): string;
80
+ /**
81
+ * Split LF-delimited file text into lines hashline anchors can address.
82
+ * A terminal newline terminates the preceding line; it is not content.
83
+ */
84
+ export declare function splitAddressableFileLines(text: string): string[];
80
85
  /** Format file text with hashline-mode line-number prefixes for display. */
81
86
  export declare function formatNumberedLines(text: string, startLine?: number): string;
@@ -121,6 +121,17 @@ export declare function ambiguousLeadingCloserSpareMessage(startLine: number, en
121
121
  * be literal prose), so the edit applies as authored and the author decides.
122
122
  */
123
123
  export declare function midBlockRangeWarning(startLine: number, endLine: number, orphaned: number): string;
124
+ /**
125
+ * The applied result no longer parses while the pre-edit content did: the
126
+ * patch introduced a syntax error. Advisory, never a rejection — the applier
127
+ * honors the authored edit — but the breakage is machine-confirmed (the
128
+ * tree-sitter probe parsed the original and rejects the result), so it is
129
+ * surfaced in the same response instead of waiting for a compiler pass. The
130
+ * classic trigger is a balance-neutral misplacement: a statement landed on
131
+ * the wrong line number with no delimiter anomaly for the repair heuristics
132
+ * to notice.
133
+ */
134
+ export declare function editBrokeParseWarning(firstChangedLine: number | undefined): string;
124
135
  /**
125
136
  * Internal invariant: `applyEdits` received an unresolved block edit;
126
137
  * `resolveBlockEdits` must run first.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/hashline",
4
- "version": "17.2.13",
4
+ "version": "17.2.15",
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",
@@ -33,8 +33,8 @@
33
33
  "fmt": "biome format --write ."
34
34
  },
35
35
  "dependencies": {
36
- "@oh-my-pi/pi-natives": "17.2.13",
37
- "@oh-my-pi/pi-utils": "17.2.13"
36
+ "@oh-my-pi/pi-natives": "17.2.15",
37
+ "@oh-my-pi/pi-utils": "17.2.15"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/bun": "^1.3.14"
package/src/apply.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  ambiguousCloserSpareMessage,
16
16
  ambiguousLeadingCloserSpareMessage,
17
17
  blockInsertLandingShiftWarning,
18
+ editBrokeParseWarning,
18
19
  midBlockRangeWarning,
19
20
  REPLACEMENT_INDENT_AUTO_SHIFT_WARNING,
20
21
  UNRESOLVED_BLOCK_INTERNAL,
@@ -257,13 +258,38 @@ interface DelimiterBalance {
257
258
  brace: number;
258
259
  }
259
260
 
261
+ /**
262
+ * Single-quote lexing mode for {@link computeDelimiterBalance}. `"literal"`:
263
+ * `'…'` is a same-line string (JS/Python/C-like default, the original
264
+ * behavior). `"rust"`: `'` opens a literal only when it lexes as a Rust char
265
+ * literal (`'a'`, `'\n'`, `'\u{7FFF}'`); any other `'` is a lifetime or
266
+ * apostrophe and stays an ordinary character — pairing arbitrary apostrophes
267
+ * would swallow real delimiters between two lifetimes (`<'a>(x: &'a str)`
268
+ * loses the `(`), and quote-state-to-EOL hides the opener on signature lines
269
+ * (`&'static str {` — the `extension()` incident).
270
+ *
271
+ * Module-scoped rather than threaded: the scan helpers are a dozen pure free
272
+ * functions all rooted in the synchronous `applyEdits` call, which sets the
273
+ * mode from its target path on every entry.
274
+ */
275
+ let singleQuoteMode: "literal" | "rust" = "literal";
276
+
277
+ /** Set {@link singleQuoteMode} from the target file's extension. */
278
+ function setDelimiterScanLanguage(path: string | undefined): void {
279
+ singleQuoteMode = path?.endsWith(".rs") ? "rust" : "literal";
280
+ }
281
+
282
+ /** Rust char literal at one position: `'a'`, `'\n'`, `'\x41'`, `'\u{7FFF}'`. */
283
+ const RUST_CHAR_LITERAL_RE = /^'(?:\\u\{[0-9a-fA-F_]{1,6}\}|\\x[0-9a-fA-F]{2}|\\.|[^\\'])'/;
284
+
260
285
  /**
261
286
  * Net `()` / `[]` / `{}` delta across `lines`, skipping delimiters inside line
262
287
  * comments (`//`), block comments, and string/template literals. Block-comment
263
288
  * and backtick-template state carry across lines; `"` / `'` reset at EOL since
264
- * they cannot span lines. Deliberately language-light: constructs it cannot
265
- * classify (e.g. regex literals) are counted naively, which can only suppress a
266
- * repair (the safe direction), never force one.
289
+ * they cannot span lines. Single-quote handling follows the target language
290
+ * (see {@link singleQuoteMode}). Deliberately language-light otherwise:
291
+ * constructs it cannot classify (e.g. regex literals) are counted naively,
292
+ * which can only suppress a repair (the safe direction), never force one.
267
293
  */
268
294
  function computeDelimiterBalance(lines: readonly string[]): DelimiterBalance {
269
295
  const balance: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 };
@@ -284,6 +310,13 @@ function computeDelimiterBalance(lines: readonly string[]): DelimiterBalance {
284
310
  else if (ch === quote) quote = "";
285
311
  continue;
286
312
  }
313
+ if (ch === "'" && singleQuoteMode === "rust") {
314
+ // A real char literal is skipped whole; a lifetime (`'static`,
315
+ // `<'a>`) or apostrophe is an ordinary character.
316
+ const literal = RUST_CHAR_LITERAL_RE.exec(line.slice(i));
317
+ if (literal) i += literal[0].length - 1;
318
+ continue;
319
+ }
287
320
  if (ch === '"' || ch === "'" || ch === "`") {
288
321
  quote = ch;
289
322
  continue;
@@ -1600,6 +1633,7 @@ function materializeEdits(originalLines: readonly string[], edits: readonly Appl
1600
1633
  */
1601
1634
  export function applyEdits(text: string, edits: readonly Edit[], options: ApplyEditsOptions = {}): ApplyResult {
1602
1635
  if (edits.length === 0) return { text, firstChangedLine: undefined };
1636
+ setDelimiterScanLanguage(options.path);
1603
1637
 
1604
1638
  const fileLines = text.split("\n");
1605
1639
 
@@ -1633,6 +1667,14 @@ export function applyEdits(text: string, edits: readonly Edit[], options: ApplyE
1633
1667
  const authored = repairReplacementBoundaries(targetEdits, fileLines, false);
1634
1668
  const finish = (result: Materialized, warnings: string[]): ApplyResult => {
1635
1669
  const merged = [...warnings, ...result.warnings];
1670
+ // Post-apply syntax advisory: the result stopped parsing while the
1671
+ // pre-edit text parsed, so this patch demonstrably introduced the
1672
+ // error. Catches balance-neutral misplacements (a statement swapped
1673
+ // onto the wrong line) that no delimiter heuristic can see. Both
1674
+ // probes are content-cached; a pathless call short-circuits to false.
1675
+ if (!parsesCleanly(options.path, result.text) && parsesCleanly(options.path, text)) {
1676
+ merged.push(editBrokeParseWarning(result.firstChangedLine));
1677
+ }
1636
1678
  return {
1637
1679
  text: result.text,
1638
1680
  firstChangedLine: result.firstChangedLine,
package/src/format.ts CHANGED
@@ -139,8 +139,20 @@ export function formatNumberedLine(lineNumber: number, line: string): string {
139
139
  return `${lineNumber}${HL_LINE_BODY_SEP}${line}`;
140
140
  }
141
141
 
142
+ /**
143
+ * Split LF-delimited file text into lines hashline anchors can address.
144
+ * A terminal newline terminates the preceding line; it is not content.
145
+ */
146
+ export function splitAddressableFileLines(text: string): string[] {
147
+ const lines = text.split("\n");
148
+ if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
149
+ return lines;
150
+ }
151
+
142
152
  /** Format file text with hashline-mode line-number prefixes for display. */
143
153
  export function formatNumberedLines(text: string, startLine = 1): string {
144
- const lines = text.split("\n");
145
- return lines.map((line, i) => formatNumberedLine(startLine + i, line)).join("\n");
154
+ return text
155
+ .split("\n")
156
+ .map((line, i) => formatNumberedLine(startLine + i, line))
157
+ .join("\n");
146
158
  }
package/src/messages.ts CHANGED
@@ -366,6 +366,25 @@ export function midBlockRangeWarning(startLine: number, endLine: number, orphane
366
366
  );
367
367
  }
368
368
 
369
+ /**
370
+ * The applied result no longer parses while the pre-edit content did: the
371
+ * patch introduced a syntax error. Advisory, never a rejection — the applier
372
+ * honors the authored edit — but the breakage is machine-confirmed (the
373
+ * tree-sitter probe parsed the original and rejects the result), so it is
374
+ * surfaced in the same response instead of waiting for a compiler pass. The
375
+ * classic trigger is a balance-neutral misplacement: a statement landed on
376
+ * the wrong line number with no delimiter anomaly for the repair heuristics
377
+ * to notice.
378
+ */
379
+ export function editBrokeParseWarning(firstChangedLine: number | undefined): string {
380
+ const at = firstChangedLine === undefined ? "" : ` near line ${firstChangedLine}`;
381
+ return (
382
+ `This edit introduced a syntax error${at}: the file parsed before the patch and no longer does. ` +
383
+ `It was applied exactly as written, so a line number or range endpoint is likely wrong — ` +
384
+ `re-read the touched region and re-issue a correcting edit.`
385
+ );
386
+ }
387
+
369
388
  /**
370
389
  * Internal invariant: `applyEdits` received an unresolved block edit;
371
390
  * `resolveBlockEdits` must run first.
package/src/prompt.md CHANGED
@@ -1,38 +1,38 @@
1
- Line-anchored patch language: name original lines/gaps to replace, insert, cut, or paste, then list new content. A header ending in `:` takes `+` body rows; colonless `PUT` (paste), `CUT`, `REM`, `MV` take none.
1
+ Line-anchored patch language: name original lines/gaps to replace, insert, cut, or paste; then give new content. `:` headers take `+` body rows; colonless paste `PUT`, `CUT`, `REM`, `MV` take none.
2
2
 
3
3
  <headers>
4
- Every file section starts `[PATH#TAG]`. `TAG` = 4-hex snapshot tag from your latest `read`/`search` REQUIRED on every section. Create new files with `write`; hashline only edits existing files.
4
+ Section: `[PATH#TAG]`; `TAG`: 4-hex snapshot from latest `read`/`search`, REQUIRED each section. New files: `write`; hashline edits existing files only.
5
5
  </headers>
6
6
 
7
7
  <ops>
8
- `PUT N.=M:` replace original lines N through M (INCLUSIVE) with body rows.
9
- `PUT N*:` replace the syntactic block BEGINNING on line N; its closing line is resolved for you.
10
- `PUT <N:` / `PUT >N:` insert body rows before / after line N (`PUT <1:` = file head, `PUT >$:` = file tail).
11
- `PUT >N*:` insert body rows after the END of the block beginning at N (at sibling depth). Append inside a block `PUT >M:`.
12
- `PUT <N` / `PUT >N` / `PUT N.=M @name` / `PUT N* @name` paste a captured register at a gap, over a range, or over a resolved block (no `:` header, no body rows). Unlabeled gap `PUT` pastes the anonymous register; span/block paste requires `@name`.
13
- `CUT N.=M` / `CUT N*` delete lines N through M / block N and capture them (anonymous, or `@name` when given).
14
- `REM` delete the whole section file. `MV DEST` move/rename to `DEST` (quote paths with spaces); edits above `MV` land on the source first, final content written at `DEST`.
15
- Single line: `PUT N.=N:` / `CUT N.=N`. Range = ORIGINAL lines touched (`N.=M`, inclusive); body length irrelevant.
8
+ `PUT N.=M:`: replace original inclusive lines NM with body.
9
+ `PUT N*:`: replace syntactic block beginning N; closing line resolved.
10
+ `PUT <N:` / `PUT >N:`: insert before/after N; `<1` head, `>$` tail.
11
+ `PUT >N*:`: insert after block N's end, at sibling depth. Append inside block: `PUT >M:`.
12
+ `PUT <N` / `PUT >N` / `PUT N.=M @name` / `PUT N* @name`: paste captured register at gap/range/resolved block; no `:` or body. Unlabeled gap paste: anonymous register; range/block paste: `@name` required.
13
+ `CUT N.=M` / `CUT N*`: delete and capture inclusive lines NM / block N; anonymous or given `@name`.
14
+ `REM`: delete section file. `MV DEST`: move/rename (quote paths with spaces); prior edits apply to source, final content to `DEST`.
15
+ Single line: `PUT N.=N:` / `CUT N.=N`. Ranges name original inclusive touched lines; body length irrelevant.
16
16
  </ops>
17
17
 
18
18
  <body-rows>
19
- Only under a `:` header. Every row is `+TEXT`, verbatim (leading whitespace kept); `+` alone = blank line. NEVER `-old` or bare/context rows — the range deletes; the body is only the final content. Keep a line: leave it out of every range. Literal leading `-`/`+` keeps the prefix: `- item` → `+- item`, `+ item` → `++ item`.
19
+ Only below `:` headers. Row: verbatim `+TEXT` (leading whitespace preserved); `+`: blank. NEVER `-old`, bare, or context rows: range deletes; body is final content. Keep line: exclude it from every range. Literal initial `-`/`+`: `- item` → `+- item`; `+ item` → `++ item`.
20
20
  </body-rows>
21
21
 
22
22
  <rules>
23
- - Line numbers + `#TAG` come from your latest `read`/`search` (`LINE:TEXT` rows); numbers name ORIGINAL lines, never shifted by applied hunks.
24
- - Applied edits renumber the file and change the `#TAG` take the next edit's numbers from the edit response or a fresh `read`.
25
- - Touch only displayed lines hunks on undisplayed lines are REJECTED. Far from your read window? Re-`read`; confirm numbers map to the intended construct.
26
- - Elided regions are UNSEEN (`…`/`..` markers, collapsed `N-M:` summary rows) NEVER place or span a hunk inside one; `read` the range first.
27
- - NEVER start or end a range mid-expression or mid-block.
28
- - Ranges cover ONLY changed lines never widen over keepers. Non-adjacent changes = separate hunks.
29
- - Whole construct `PUT N*:`; lines inside one → `PUT N.=M:`.
30
- - `PUT N*:` resolves EXACTLY the node at N: leading decorators/attributes/doc-comments are separate nodes point N at the FIRST decorator to sweep both; standalone line-comments are never swept (use `PUT N.=M:`).
31
- - Block ops anchor the OPENING line of a MULTI-LINE construct never the closer, last line, or a bare inner statement; one statement plain op (`PUT N.=N:` / `CUT N.=N` / `PUT >N:`). Saw the closer? `PUT >M:`.
32
- - Markdown: a heading IS a block opener block ops on `##`/`###` resolve the WHOLE section (through deeper nested headings, up to the next same-or-higher heading). `PUT >N*:` after a section: end the body with a blank line to keep the next heading separated.
33
- - Pure additions `PUT <N:` / `PUT >N:`, never a widened `PUT N.=M:`.
34
- - Move code with `CUT`+`PUT`: `CUT 5.=9 @fn` captures into `@fn`; `PUT >40 @fn` pastes it. Unlabeled `CUT` + `PUT >40` works for a single call-local move. Named registers persist across edit calls.
35
- - NEVER format/restyle code with this tool; run the project formatter.
23
+ - Numbers and `#TAG`: latest `read`/`search` `LINE:TEXT`; numbers are original, never shifted by hunks.
24
+ - Each edit renumbers and changes `#TAG` next numbers from edit response or fresh `read`.
25
+ - Touch displayed lines only; undisplayed hunks REJECTED. Far from read window: re-`read`; confirm construct.
26
+ - Elisions UNSEEN: `…`, `..`, collapsed `N-M:` rows. NEVER hunk in/across one; `read` first.
27
+ - NEVER start/end range mid-expression or mid-block.
28
+ - Ranges: changed lines only; NEVER widen over keepers. Non-adjacent changes: separate hunks.
29
+ - Whole construct: `PUT N*:`; internal lines: `PUT N.=M:`.
30
+ - `PUT N*:` resolves exactly node N. Leading decorators/attributes/doc-comments are separate nodes: point N at first decorator to include both. Standalone line-comments never swept: use `PUT N.=M:`.
31
+ - Block ops: opening line of multi-line construct, NEVER closer, last line, bare inner statement. One statement: plain `PUT N.=N:` / `CUT N.=N` / `PUT >N:`. At closer: `PUT >M:`.
32
+ - Markdown headings are block openers. Block op on `##`/`###`: whole section through deeper headings to next same/higher heading. After section `PUT >N*:`: end body with blank line to separate next heading.
33
+ - Pure addition: `PUT <N:` / `PUT >N:`, NEVER widened `PUT N.=M:`.
34
+ - Move: `CUT`+`PUT`; `CUT 5.=9 @fn` `@fn`, `PUT >40 @fn` pastes. Single call-local move: unlabeled `CUT` + `PUT >40`. Named registers persist across edit calls.
35
+ - NEVER format/restyle with this tool; run project formatter.
36
36
  </rules>
37
37
 
38
38
  <example>
@@ -54,7 +54,7 @@ PUT 1.=3:
54
54
  MV lib/greet.py
55
55
  ```
56
56
 
57
- Markdown bullets — the file receives `- task`:
57
+ Markdown bullets — file receives `- task`:
58
58
  ```
59
59
  [PLAN.md#A1B2]
60
60
  PUT >2:
@@ -62,7 +62,7 @@ PUT >2:
62
62
  + - nested task
63
63
  ```
64
64
 
65
- Move `greet` to a sibling file using a named register flows across sections:
65
+ Move `greet` to sibling file via named register; flows across sections:
66
66
  ```
67
67
  [greet.py#A1B2]
68
68
  CUT 1* @fn
@@ -70,7 +70,7 @@ CUT 1* @fn
70
70
  PUT <1 @fn
71
71
  ```
72
72
 
73
- `PUT 1*:` resolves lines 1–3 (`def` header through `print(msg)`); line 4 is a separate statement and stays:
73
+ `PUT 1*:` resolves lines 1–3 (`def` through `print(msg)`); line 4 separate, remains:
74
74
  ```
75
75
  [greet.py#A1B2]
76
76
  PUT 1*:
@@ -78,7 +78,7 @@ PUT 1*:
78
78
  + print(f"Hello, {name}")
79
79
  ```
80
80
 
81
- Decorator/doc-comment = SEPARATE block point N at the decorator to take both; anchoring the `def` (line 2) would orphan `@cache`:
81
+ Decorator/doc-comment separate block: point N at decorator to include both; anchoring `def` line 2 orphans `@cache`:
82
82
  ```
83
83
  [svc.py#C3D4]
84
84
  PUT 1*:
@@ -127,7 +127,7 @@ PUT >20 @fn:
127
127
  </anti-patterns>
128
128
 
129
129
  <critical>
130
- 1. RE-GROUND AFTER EVERY EDIT — applied edits renumber the file and change the `#TAG`; take next numbers from the edit response or a fresh `read`. Stale tag or surprise? STOP, re-`read`.
131
- 2. RANGES ARE TIGHT cover only lines that change. Whole construct `PUT N*:`.
132
- 3. BODY = FINAL CONTENT every body row starts with `+`; Markdown bullets use `+- item`, not `- item`.
130
+ 1. RE-GROUND AFTER EVERY EDIT: edits renumber and change `#TAG`; take next numbers from edit response or fresh `read`. Stale tag/surprise: STOP; re-`read`.
131
+ 2. RANGES TIGHT: changed lines only. Whole construct: `PUT N*:`.
132
+ 3. BODY FINAL CONTENT: every row starts `+`; Markdown bullet: `+- item`, not `- item`.
133
133
  </critical>