@d3ara1n/pi-hashline-edit 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core/apply.ts CHANGED
@@ -1,22 +1,37 @@
1
1
  /**
2
- * Pure-function applicator: applies edits to the file backing a snapshot.
2
+ * Pure-function applicator: applies edits to a file's current text.
3
3
  *
4
- * Strict semantics:
5
- * - Requires the current `text === snapshot.text` (stale check); drift is
6
- * handled by the `transforms/relocate` middleware before calling apply pure
7
- * apply does not guess.
8
- * - Each anchor's hash must match its corresponding line in the snapshot
9
- * (guards against the model misremembering line numbers / hashes).
4
+ * Verification is live and surgical: each anchor's hash is recomputed from the
5
+ * CURRENT line content at the cited line number and compared to the cited hash.
6
+ * No snapshot, no global stale checka line that changed (or was
7
+ * misremembered) fails its own anchor; unchanged lines elsewhere never block
8
+ * the edit.
9
+ *
10
+ * Shifted-anchor recovery: when a cited anchor no longer matches, we rescan
11
+ * ±radius lines for the original content, holding the ORIGINAL line number fixed
12
+ * and re-hashing each candidate's content. On a unique hit the new anchor (with
13
+ * its freshly computed hash) is returned so the caller can retry without a
14
+ * re-read; on several hits they are reported as ambiguous; on none the live
15
+ * content at the cited line is returned to steer a re-read.
16
+ *
17
+ * Batch semantics: all ops are verified against the same current snapshot. If
18
+ * ANY anchor fails, EVERY failure (with recovery) is collected and returned
19
+ * together — nothing is written. This keeps the rescue report and the on-disk
20
+ * file in sync: a partial write would shift lines and invalidate the very
21
+ * recovery info we just returned. Range issues among the surviving ops are
22
+ * deferred until anchors are corrected.
23
+ *
24
+ * Other strict semantics:
10
25
  * - Operation ranges must not overlap (including the same insertion point).
11
- * - body byte-identical to the target → `noop` error (guides the model to
12
- * investigate the bug rather than blindly retry).
26
+ * - body byte-identical to the whole-file result → `noop` error (guides the
27
+ * model to investigate rather than blindly retry).
13
28
  *
14
29
  * @module pi-hashline-edit/core
15
30
  */
16
31
 
17
- import { buildDiff } from "./diff.ts";
18
- import { createSnapshot, joinLines, splitLines } from "./snapshot.ts";
19
- import type { ApplyResult, Edit, FileSnapshot, PatchError } from "./types.ts";
32
+ import { computeLineHash } from "./hash.ts";
33
+ import { detectLineEnding, joinLines, splitLines } from "./lines.ts";
34
+ import type { Anchor, AnchorFailure, AnchorRecovery, ApplyResult, Edit } from "./types.ts";
20
35
 
21
36
  /** Line-level operation: replace the raw lines in the `[lo, hi)` range (0-based, hi exclusive) with newLines. */
22
37
  interface SpanOp {
@@ -25,40 +40,94 @@ interface SpanOp {
25
40
  newLines: string[];
26
41
  }
27
42
 
28
- /** Verify the anchor matches the snapshot (pure apply: text already equals snapshot.text, so this only guards against misremembering). */
29
- function checkAnchor(snapshot: FileSnapshot, line: number, hash: string): PatchError | null {
30
- if (line < 1 || line > snapshot.lineHashes.length) {
31
- return {
32
- kind: "anchor",
33
- message: `line ${line} does not exist (file has ${snapshot.lineHashes.length} lines)`,
34
- };
43
+ /** Default ±line radius for shifted-anchor recovery. */
44
+ const DEFAULT_SHIFT_RADIUS = 15;
45
+
46
+ /**
47
+ * Verify an anchor against the live content; on mismatch, attempt shifted
48
+ * recovery. Returns null when the anchor matches, otherwise an
49
+ * {@link AnchorFailure} carrying the recovery outcome and the cited line's
50
+ * current snapshot.
51
+ *
52
+ * Recovery holds the ORIGINAL line number fixed and re-hashes each candidate's
53
+ * content: `computeLineHash(citedLine, candidateContent) === citedHash` holds
54
+ * iff the candidate IS the original content (modulo negligible hash collision).
55
+ * A returned candidate's anchor uses the candidate's real line number with a
56
+ * hash computed for that line, so it verifies on retry.
57
+ */
58
+ function verifyAnchor(
59
+ lines: readonly string[],
60
+ cited: Anchor,
61
+ which: "anchor" | "end",
62
+ opIndex: number,
63
+ op: Edit["op"],
64
+ hashLen: number,
65
+ radius: number,
66
+ ): AnchorFailure | null {
67
+ const { line, hash } = cited;
68
+ if (line >= 1 && line <= lines.length && computeLineHash(line, lines[line - 1], hashLen) === hash) {
69
+ return null;
35
70
  }
36
- if (snapshot.lineHashes[line - 1] !== hash) {
37
- return {
38
- kind: "anchor",
39
- message: `hash mismatch at line ${line}: file has #${snapshot.lineHashes[line - 1]}, edit says #${hash}`,
40
- };
71
+
72
+ // Shifted recovery: scan ±radius (excluding the already-failed cited line).
73
+ const candidates: { line: number; hash: string }[] = [];
74
+ const lo = Math.max(1, line - radius);
75
+ const hi = Math.min(lines.length, line + radius);
76
+ for (let c = lo; c <= hi; c++) {
77
+ if (c === line) continue;
78
+ if (computeLineHash(line, lines[c - 1], hashLen) === hash) {
79
+ candidates.push({ line: c, hash: computeLineHash(c, lines[c - 1], hashLen) });
80
+ }
81
+ }
82
+
83
+ let recovery: AnchorRecovery;
84
+ if (candidates.length === 1) {
85
+ recovery = { kind: "found", newLine: candidates[0].line, newHash: candidates[0].hash };
86
+ } else if (candidates.length > 1) {
87
+ recovery = { kind: "ambiguous", candidates };
88
+ } else {
89
+ recovery = { kind: "none" };
41
90
  }
42
- return null;
91
+
92
+ const current =
93
+ line >= 1 && line <= lines.length
94
+ ? { hash: computeLineHash(line, lines[line - 1], hashLen), content: lines[line - 1] }
95
+ : null;
96
+
97
+ return { opIndex, which, op, cited, recovery, current };
43
98
  }
44
99
 
45
- /** Translate an Edit into a SpanOp, while verifying anchors and ranges. */
46
- function translateEdit(edit: Edit, snapshot: FileSnapshot): { op: SpanOp } | { error: PatchError } {
100
+ type TranslateResult =
101
+ | { readonly ok: true; readonly op: SpanOp }
102
+ | { readonly ok: false; readonly anchorFailures: AnchorFailure[] }
103
+ | { readonly ok: false; readonly rangeError: string };
104
+
105
+ /** Translate an Edit into a SpanOp, verifying anchors and ranges against the current lines. */
106
+ function translateEdit(
107
+ edit: Edit,
108
+ opIndex: number,
109
+ lines: readonly string[],
110
+ hashLen: number,
111
+ radius: number,
112
+ ): TranslateResult {
47
113
  switch (edit.op) {
48
114
  case "replace":
49
115
  case "delete": {
50
- const startErr = checkAnchor(snapshot, edit.start.line, edit.start.hash);
51
- if (startErr) return { error: startErr };
116
+ const failures: AnchorFailure[] = [];
117
+ const startF = verifyAnchor(lines, edit.start, "anchor", opIndex, edit.op, hashLen, radius);
118
+ if (startF) failures.push(startF);
52
119
  let endLine = edit.start.line;
53
120
  if (edit.end) {
54
- const endErr = checkAnchor(snapshot, edit.end.line, edit.end.hash);
55
- if (endErr) return { error: endErr };
121
+ const endF = verifyAnchor(lines, edit.end, "end", opIndex, edit.op, hashLen, radius);
122
+ if (endF) failures.push(endF);
56
123
  endLine = edit.end.line;
57
124
  }
125
+ if (failures.length > 0) return { ok: false, anchorFailures: failures };
58
126
  if (endLine < edit.start.line) {
59
- return { error: { kind: "range", message: `range ${edit.start.line}..${endLine} ends before it starts` } };
127
+ return { ok: false, rangeError: `range ${edit.start.line}..${endLine} ends before it starts` };
60
128
  }
61
129
  return {
130
+ ok: true,
62
131
  op: {
63
132
  lo: edit.start.line - 1,
64
133
  hi: endLine,
@@ -67,20 +136,20 @@ function translateEdit(edit: Edit, snapshot: FileSnapshot): { op: SpanOp } | { e
67
136
  };
68
137
  }
69
138
  case "insert_after": {
70
- const err = checkAnchor(snapshot, edit.anchor.line, edit.anchor.hash);
71
- if (err) return { error: err };
72
- return { op: { lo: edit.anchor.line, hi: edit.anchor.line, newLines: edit.body } };
139
+ const f = verifyAnchor(lines, edit.anchor, "anchor", opIndex, edit.op, hashLen, radius);
140
+ if (f) return { ok: false, anchorFailures: [f] };
141
+ return { ok: true, op: { lo: edit.anchor.line, hi: edit.anchor.line, newLines: edit.body } };
73
142
  }
74
143
  case "insert_before": {
75
- const err = checkAnchor(snapshot, edit.anchor.line, edit.anchor.hash);
76
- if (err) return { error: err };
77
- return { op: { lo: edit.anchor.line - 1, hi: edit.anchor.line - 1, newLines: edit.body } };
144
+ const f = verifyAnchor(lines, edit.anchor, "anchor", opIndex, edit.op, hashLen, radius);
145
+ if (f) return { ok: false, anchorFailures: [f] };
146
+ return { ok: true, op: { lo: edit.anchor.line - 1, hi: edit.anchor.line - 1, newLines: edit.body } };
78
147
  }
79
148
  case "append": {
80
- return { op: { lo: snapshot.lineHashes.length, hi: snapshot.lineHashes.length, newLines: edit.body } };
149
+ return { ok: true, op: { lo: lines.length, hi: lines.length, newLines: edit.body } };
81
150
  }
82
151
  case "prepend": {
83
- return { op: { lo: 0, hi: 0, newLines: edit.body } };
152
+ return { ok: true, op: { lo: 0, hi: 0, newLines: edit.body } };
84
153
  }
85
154
  }
86
155
  }
@@ -91,28 +160,42 @@ function maxAffected(op: SpanOp): number {
91
160
  }
92
161
 
93
162
  /**
94
- * Apply edits to the file backing a snapshot.
163
+ * Apply edits to `text`. Anchors are verified against the current content; on
164
+ * success `touchedLines` gives the 0-based indices of the new-file lines this
165
+ * edit produced. On any anchor mismatch, all failures (with shifted recovery)
166
+ * are collected and returned together — nothing is written.
95
167
  *
96
- * @param text current full file text
97
- * @param edits parsed edit operations
98
- * @param snapshot snapshot recorded at read time (text must equal current text)
99
- * @returns apply result; on failure returns a structured error
168
+ * @param text current full file text
169
+ * @param edits parsed edit operations
170
+ * @param hashLen hash length used to verify anchors (default 4)
171
+ * @param shiftRadius ±line radius for shifted-anchor recovery (default 15; 0 disables rescue)
100
172
  */
101
- export function applyEdits(text: string, edits: Edit[], snapshot: FileSnapshot): ApplyResult {
102
- if (text !== snapshot.text) {
103
- return {
104
- ok: false,
105
- error: { kind: "stale", message: "file changed since last read; re-read before editing" },
106
- };
107
- }
108
-
173
+ export function applyEdits(text: string, edits: Edit[], hashLen = 4, shiftRadius = DEFAULT_SHIFT_RADIUS): ApplyResult {
109
174
  const lines = splitLines(text);
175
+ const ending = detectLineEnding(text);
110
176
 
111
177
  const ops: SpanOp[] = [];
112
- for (const edit of edits) {
113
- const t = translateEdit(edit, snapshot);
114
- if ("error" in t) return { ok: false, error: t.error };
115
- ops.push(t.op);
178
+ const anchorFailures: AnchorFailure[] = [];
179
+ let rangeError: string | null = null;
180
+
181
+ for (let i = 0; i < edits.length; i++) {
182
+ const t = translateEdit(edits[i], i, lines, hashLen, shiftRadius);
183
+ if (t.ok) {
184
+ ops.push(t.op);
185
+ } else if ("anchorFailures" in t) {
186
+ anchorFailures.push(...t.anchorFailures);
187
+ } else if (rangeError === null) {
188
+ rangeError = t.rangeError;
189
+ }
190
+ }
191
+
192
+ // Anchor failures take priority: the model must fix anchors first; range
193
+ // issues among surviving ops are premature until anchors are corrected.
194
+ if (anchorFailures.length > 0) {
195
+ return { ok: false, failure: { kind: "anchor", failures: anchorFailures } };
196
+ }
197
+ if (rangeError !== null) {
198
+ return { ok: false, failure: { kind: "range", message: rangeError } };
116
199
  }
117
200
 
118
201
  // Overlap check: sort ascending by lo; the next op's start must not fall inside the previous op's affected range
@@ -121,7 +204,7 @@ export function applyEdits(text: string, edits: Edit[], snapshot: FileSnapshot):
121
204
  if (sorted[k].lo <= maxAffected(sorted[k - 1])) {
122
205
  return {
123
206
  ok: false,
124
- error: {
207
+ failure: {
125
208
  kind: "range",
126
209
  message: `overlapping edits near line ${sorted[k].lo + 1}; issue one edit per range`,
127
210
  },
@@ -129,25 +212,37 @@ export function applyEdits(text: string, edits: Edit[], snapshot: FileSnapshot):
129
212
  }
130
213
  }
131
214
 
132
- // Apply back-to-front (lo descending) to avoid line-number shifts
215
+ // Apply back-to-front (lo descending) so original lo/hi stay valid
133
216
  let result = [...lines];
134
217
  for (const op of [...sorted].sort((a, b) => b.lo - a.lo)) {
135
218
  result = [...result.slice(0, op.lo), ...op.newLines, ...result.slice(op.hi)];
136
219
  }
137
220
 
138
- const newText = joinLines(result, snapshot.lineEnding);
221
+ const newText = joinLines(result, ending);
139
222
  if (newText === text) {
140
223
  return {
141
224
  ok: false,
142
- error: {
225
+ failure: {
143
226
  kind: "noop",
144
- message:
145
- "edit parsed and applied cleanly but produced no change; body is byte-identical to the target — the bug is elsewhere, re-read first",
227
+ message: "edit parsed and applied cleanly but produced no change; body is byte-identical — the bug is elsewhere, re-read first",
146
228
  },
147
229
  };
148
230
  }
149
231
 
150
- const newSnapshot = createSnapshot(snapshot.path, newText, snapshot.hashLen);
151
- const diff = buildDiff(snapshot.path, lines, sorted);
152
- return { ok: true, text: newText, newSnapshot, changed: true, diff };
232
+ // touchedLines: new-file indices worth re-anchoring — each produced line,
233
+ // and for a pure delete the line that shifted into the gap (so the model
234
+ // gets a fresh anchor for the shifted region).
235
+ const touched: number[] = [];
236
+ let delta = 0;
237
+ for (const op of sorted) {
238
+ const newLo = op.lo + delta;
239
+ if (op.newLines.length > 0) {
240
+ for (let i = 0; i < op.newLines.length; i++) touched.push(newLo + i);
241
+ } else if (newLo < result.length) {
242
+ touched.push(newLo);
243
+ }
244
+ delta += op.newLines.length - (op.hi - op.lo);
245
+ }
246
+
247
+ return { ok: true, text: newText, changed: true, touchedLines: touched };
153
248
  }
@@ -5,26 +5,29 @@ import { computeLineHash, hashFileLines } from "./hash.ts";
5
5
  const ALLOWED = new Set("0123456789ABCDEFGHJKMNPQRSTVWXYZ");
6
6
 
7
7
  test("computeLineHash is stable and base32", () => {
8
- const a = computeLineHash("p", "c", "n", 4);
9
- const b = computeLineHash("p", "c", "n", 4);
8
+ const a = computeLineHash(3, "code", 4);
9
+ const b = computeLineHash(3, "code", 4);
10
10
  assert.equal(a, b);
11
11
  assert.equal(a.length, 4);
12
12
  for (const ch of a) assert.ok(ALLOWED.has(ch), `bad char ${ch}`);
13
13
  });
14
14
 
15
- test("context-aware: same line, different neighbors → different hash", () => {
16
- const h1 = computeLineHash("a", "x", "b");
17
- const h2 = computeLineHash("c", "x", "d");
18
- assert.notEqual(h1, h2);
15
+ test("different line number → different hash (even for identical content)", () => {
16
+ assert.notEqual(computeLineHash(2, ""), computeLineHash(5, ""));
17
+ assert.notEqual(computeLineHash(1, "}"), computeLineHash(2, "}"));
19
18
  });
20
19
 
21
- test("context-aware: same triple same hash", () => {
22
- assert.equal(computeLineHash("a", "x", "b"), computeLineHash("a", "x", "b"));
20
+ test("different contentdifferent hash", () => {
21
+ assert.notEqual(computeLineHash(1, "a"), computeLineHash(1, "b"));
22
+ });
23
+
24
+ test("same (line, content) → same hash", () => {
25
+ assert.equal(computeLineHash(7, "x"), computeLineHash(7, "x"));
23
26
  });
24
27
 
25
28
  test("base32 alphabet (without I/L/O/U) in bulk", () => {
26
29
  for (let i = 0; i < 2000; i++) {
27
- const h = computeLineHash("", `line ${i}`, "", 4);
30
+ const h = computeLineHash(i + 1, `line ${i}`, 4);
28
31
  for (const ch of h) assert.ok(ALLOWED.has(ch), `bad char ${ch} in ${h}`);
29
32
  }
30
33
  });
@@ -37,10 +40,12 @@ test("hashFileLines empty file", () => {
37
40
  assert.deepEqual(hashFileLines([]), []);
38
41
  });
39
42
 
40
- test("hashFileLines has no in-file collisions (many duplicate lines)", () => {
43
+ test("hashFileLines: identical content lines get distinct hashes (no collision, no length bloat)", () => {
44
+ // runs of identical lines — the case neighbor-aware hashing explodes on
41
45
  const lines = ["", "", "", "", "", "}", "}", "}", "return", "return", ",", ","];
42
46
  const hashes = hashFileLines(lines);
43
- assert.equal(new Set(hashes).size, hashes.length, "collision not resolved");
47
+ assert.equal(new Set(hashes).size, hashes.length, "duplicate hashes");
48
+ for (const h of hashes) assert.equal(h.length, 4, `hash ${h} is not 4 chars`);
44
49
  });
45
50
 
46
51
  test("hashFileLines respects the length parameter", () => {
package/src/core/hash.ts CHANGED
@@ -1,11 +1,22 @@
1
1
  /**
2
- * Per-line context-aware hash.
2
+ * Per-line content hash.
3
3
  *
4
- * Each line's hash is computed by concatenating "previous line + this line +
5
- * next line", so identical content lines (blank lines, `}`, `return`) get
6
- * different hashes due to different neighbors, making in-file collisions
7
- * effectively zero. Residual collisions are resolved by {@link hashFileLines}
8
- * via automatic length extension (per-file zero-collision guarantee).
4
+ * The hash mixes the 1-based line number into the line content, so every line
5
+ * gets a unique hash by construction line numbers are unique, therefore no
6
+ * in-file collision is possible, and no length extension / fallback is ever
7
+ * needed.
8
+ *
9
+ * Why line + content (not content alone, not content + neighbors):
10
+ *
11
+ * - The line number is the address; the hash is a checksum that the line at
12
+ * that address is still what was read. Mixing the number in makes the hash a
13
+ * pure fingerprint of (position, content): it changes only when the line's
14
+ * own content changes, never when a neighbor changes. (A neighbor-aware hash
15
+ * would change an unchanged line's hash when an adjacent line is edited — a
16
+ * spurious dependency with no benefit under this design's position-fixed
17
+ * apply.)
18
+ * - Content alone would leave identical lines (blank lines, `}`) sharing a
19
+ * hash; mixing the line number disambiguates them for free.
9
20
  *
10
21
  * @module pi-hashline-edit/core
11
22
  */
@@ -38,62 +49,20 @@ function toBase32(n: number, len: number): string {
38
49
  }
39
50
 
40
51
  /**
41
- * Compute the context-aware hash of a single line.
52
+ * Compute the hash of a single line from its 1-based line number and content.
42
53
  *
43
- * @param prev previous line content (`""` for the first line)
44
- * @param cur this line's content
45
- * @param next next line content (`""` for the last line)
46
- * @param len hash length (default 4, 20 bits ≈ 1M values)
54
+ * @param line 1-based line number
55
+ * @param content the line's content (no line terminator)
56
+ * @param len hash length (default 4, 20 bits 1M values)
47
57
  */
48
- export function computeLineHash(prev: string, cur: string, next: string, len = 4): string {
49
- const h = fnv1a32(`${prev}\n${cur}\n${next}`);
50
- return toBase32(h, len);
51
- }
52
-
53
- /** Compute raw per-line hashes (no collision handling). */
54
- function rawHashes(lines: readonly string[], len: number): string[] {
55
- return lines.map((line, i) =>
56
- computeLineHash(lines[i - 1] ?? "", line, lines[i + 1] ?? "", len),
57
- );
58
- }
59
-
60
- /** Find hash values that appear more than once. */
61
- function duplicatedHashes(hashes: readonly string[]): Set<string> {
62
- const counts = new Map<string, number>();
63
- for (const h of hashes) counts.set(h, (counts.get(h) ?? 0) + 1);
64
- return new Set([...counts.entries()].filter(([, c]) => c > 1).map(([h]) => h));
65
- }
66
-
67
- /** Fallback: force uniqueness with an index suffix (theoretically unreachable under context-aware hashing). */
68
- function forceUnique(hashes: string[]): string[] {
69
- const seen = new Map<string, number>();
70
- return hashes.map((h) => {
71
- const c = seen.get(h) ?? 0;
72
- seen.set(h, c + 1);
73
- return c === 0 ? h : `${h}${c}`;
74
- });
58
+ export function computeLineHash(line: number, content: string, len = 4): string {
59
+ return toBase32(fnv1a32(`${line}\n${content}`), len);
75
60
  }
76
61
 
77
62
  /**
78
- * Compute per-line hashes for the whole file and resolve in-file collisions:
79
- * colliding lines are recomputed with a longer len until unique within the file
80
- * (per-file zero-collision guarantee).
81
- *
82
- * Design rationale: context-aware hashing already drives the collision
83
- * probability near zero; this extension is a defensive fallback guaranteeing
84
- * apply never mislocates due to hash ambiguity.
63
+ * Compute per-line hashes for a file. Unique by construction — the 1-based line
64
+ * number is part of each hash, so two identical content lines always differ.
85
65
  */
86
66
  export function hashFileLines(lines: readonly string[], len = 4): string[] {
87
- if (lines.length === 0) return [];
88
- let hashes = rawHashes(lines, len);
89
- for (let curLen = len; curLen <= len + 4; curLen++) {
90
- const dups = duplicatedHashes(hashes);
91
- if (dups.size === 0) return hashes;
92
- hashes = hashes.map((h, i) =>
93
- dups.has(h)
94
- ? computeLineHash(lines[i - 1] ?? "", lines[i] ?? "", lines[i + 1] ?? "", curLen + 1)
95
- : h,
96
- );
97
- }
98
- return forceUnique(hashes);
67
+ return lines.map((content, i) => computeLineHash(i + 1, content, len));
99
68
  }
package/src/core/index.ts CHANGED
@@ -9,9 +9,5 @@
9
9
 
10
10
  export * from "./types.ts";
11
11
  export { computeLineHash, hashFileLines } from "./hash.ts";
12
- export { splitLines, joinLines, createSnapshot, verifyAnchor } from "./snapshot.ts";
13
- export type { AnchorVerifyResult } from "./snapshot.ts";
14
- export { parsePatch } from "./parse.ts";
15
- export type { ParseResult } from "./parse.ts";
12
+ export { splitLines, joinLines, detectLineEnding } from "./lines.ts";
16
13
  export { applyEdits } from "./apply.ts";
17
- export { buildDiff } from "./diff.ts";
@@ -0,0 +1,30 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { splitLines, joinLines, detectLineEnding } from "./lines.ts";
4
+
5
+ test("splitLines edge cases", () => {
6
+ assert.deepEqual(splitLines(""), []);
7
+ assert.deepEqual(splitLines("a"), ["a"]);
8
+ assert.deepEqual(splitLines("a\nb"), ["a", "b"]);
9
+ assert.deepEqual(splitLines("a\nb\n"), ["a", "b"]);
10
+ assert.deepEqual(splitLines("a\n\n"), ["a", ""]);
11
+ assert.deepEqual(splitLines("\n"), [""]);
12
+ });
13
+
14
+ test("splitLines strips CRLF \\r", () => {
15
+ assert.deepEqual(splitLines("a\r\nb\r\n"), ["a", "b"]);
16
+ assert.deepEqual(splitLines("a\r\nb"), ["a", "b"]);
17
+ });
18
+
19
+ test("joinLines restores line endings", () => {
20
+ assert.equal(joinLines(["a", "b"]), "a\nb\n");
21
+ assert.equal(joinLines([]), "");
22
+ assert.equal(joinLines(["a", "b"], "crlf"), "a\r\nb\r\n");
23
+ assert.equal(joinLines(["a", "b"], "lf"), "a\nb\n");
24
+ });
25
+
26
+ test("detectLineEnding", () => {
27
+ assert.equal(detectLineEnding("a\nb\n"), "lf");
28
+ assert.equal(detectLineEnding("a\r\nb\r\n"), "crlf");
29
+ assert.equal(detectLineEnding("a\nb\r\nc\n"), "crlf");
30
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Line text helpers: split/join with CRLF normalization and line-ending
3
+ * detection.
4
+ *
5
+ * CRLF: splitLines strips the trailing `\r` from each line (hashes are based on
6
+ * clean lines, matching the `\r`-free content the model copies from the
7
+ * display); detectLineEnding records the original ending so joinLines can
8
+ * restore it — guaranteeing a CRLF file keeps its endings after edit.
9
+ *
10
+ * @module pi-hashline-edit/core
11
+ */
12
+
13
+ import type { LineEnding } from "./types.ts";
14
+
15
+ /**
16
+ * Split text into lines, stripping the trailing `\r` of each line (CRLF
17
+ * normalization, so hashes are based on clean lines).
18
+ *
19
+ * Convention: a trailing newline is treated as the terminator of the last line,
20
+ * not as producing an extra empty trailing line.
21
+ * - `"a\nb\n"` → `["a", "b"]`
22
+ * - `"a\r\nb\r\n"` → `["a", "b"]` (`\r` stripped)
23
+ * - `"a\n\n"` → `["a", ""]`
24
+ * - `""` → `[]`
25
+ */
26
+ export function splitLines(text: string): string[] {
27
+ if (text === "") return [];
28
+ const normalized = text.endsWith("\n") ? text.slice(0, -1) : text;
29
+ return normalized.split("\n").map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
30
+ }
31
+
32
+ /** Detect the dominant line ending of the text (any `\r\n` counts as CRLF). */
33
+ export function detectLineEnding(text: string): LineEnding {
34
+ return text.includes("\r\n") ? "crlf" : "lf";
35
+ }
36
+
37
+ /** Join a line array back into text, restoring the given line ending (default LF). Non-empty files end with a newline. */
38
+ export function joinLines(lines: readonly string[], ending: LineEnding = "lf"): string {
39
+ if (lines.length === 0) return "";
40
+ const sep = ending === "crlf" ? "\r\n" : "\n";
41
+ return lines.join(sep) + sep;
42
+ }