@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/pi/pi.test.ts CHANGED
@@ -1,28 +1,16 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { canonicalPath } from "./read-tool.ts";
4
- import { missingInputError } from "./edit-tool.ts";
4
+ import { homedir } from "node:os";
5
5
 
6
- test("canonicalPath resolves relative/absolute", () => {
6
+ test("canonicalPath resolves relative and absolute", () => {
7
7
  assert.equal(canonicalPath("/cwd", "foo.ts"), "/cwd/foo.ts");
8
8
  assert.equal(canonicalPath("/cwd", "./foo.ts"), "/cwd/foo.ts");
9
9
  assert.equal(canonicalPath("/cwd", "/abs/x.ts"), "/abs/x.ts");
10
10
  });
11
11
 
12
- test("missingInputError: edits array explicit no-degradation message", () => {
13
- const msg = missingInputError("f.ts", { edits: [{ oldText: "a", newText: "b" }] });
14
- assert.ok(msg.includes("legacy"), msg);
15
- assert.ok(msg.includes("ONLY"), msg);
16
- assert.ok(msg.includes("f.ts"), msg);
17
- });
18
-
19
- test("missingInputError: top-level oldText/newText also recognized as legacy", () => {
20
- const msg = missingInputError("f.ts", { oldText: "a", newText: "b" });
21
- assert.ok(msg.includes("legacy"));
22
- });
23
-
24
- test("missingInputError: only input missing (not legacy)", () => {
25
- const msg = missingInputError("f.ts", {});
26
- assert.ok(msg.includes("missing"));
27
- assert.ok(!msg.includes("legacy"));
12
+ test("canonicalPath expands ~ to home directory", () => {
13
+ const home = homedir();
14
+ assert.equal(canonicalPath("/cwd", "~"), home);
15
+ assert.equal(canonicalPath("/cwd", "~/foo.ts"), `${home}/foo.ts`);
28
16
  });
@@ -1,23 +1,40 @@
1
1
  /**
2
- * Override read: text files output "lineNo#hash│content" and record a snapshot;
3
- * non-text (images/binary) and read errors delegate to the built-in read, whose
4
- * renderer is inherited automatically.
2
+ * Override read: text files output "lineNo#hash│content"; non-text (images /
3
+ * binary) and read errors delegate to the built-in read.
4
+ *
5
+ * Hashes are computed from the current content on the fly — nothing is stored.
6
+ * The hash is `(line number, content)`, recomputed and checked at edit time, so
7
+ * no snapshot is needed to verify an anchor later.
5
8
  *
6
9
  * @module pi-hashline-edit/pi
7
10
  */
8
11
 
9
12
  import { createReadTool } from "@earendil-works/pi-coding-agent";
10
13
  import { readFile } from "node:fs/promises";
11
- import { resolve } from "node:path";
12
- import { splitLines } from "../core/snapshot.ts";
13
- import { getState, getSnapshot, recordSnapshot } from "./state.ts";
14
+ import { join, resolve } from "node:path";
15
+ import { homedir } from "node:os";
16
+ import { hashFileLines } from "../core/hash.ts";
17
+ import { splitLines } from "../core/lines.ts";
18
+ import { getState } from "./state.ts";
14
19
 
15
20
  const MAX_LINES = 2000;
16
21
  const MAX_BYTES = 256 * 1024;
17
22
 
18
- /** canonical path: shared by read/edit to keep the snapshot key consistent. */
23
+ /**
24
+ * Canonical absolute path: shared by read/edit/grep to resolve a file consistently.
25
+ * Expands a leading `~` / `~/` to the user's home directory. (`~user` is not supported.)
26
+ */
19
27
  export function canonicalPath(cwd: string, p: string): string {
20
- return resolve(cwd, p);
28
+ return resolve(cwd, expandTilde(p));
29
+ }
30
+
31
+ /** Mirrors pi core's `normalizePath` tilde handling: expands `~` / `~/` (and `~\` on Windows), leaves `~user` untouched. */
32
+ function expandTilde(p: string): string {
33
+ if (p === "~") return homedir();
34
+ if (p.startsWith("~/") || (process.platform === "win32" && p.startsWith("~\\"))) {
35
+ return join(homedir(), p.slice(2));
36
+ }
37
+ return p;
21
38
  }
22
39
 
23
40
  /** Build the read override (a ToolDefinition fragment for registerTool). */
@@ -55,9 +72,7 @@ export function makeReadOverride(cwd: string) {
55
72
  const text = buf.toString("utf-8");
56
73
  const allLines = splitLines(text);
57
74
  const totalLines = allLines.length;
58
-
59
- // record a full-file snapshot (edit anchors are based on full-file line numbers)
60
- const snap = recordSnapshot(absPath, text);
75
+ const hashes = hashFileLines(allLines, getState().config.hashLen);
61
76
 
62
77
  // offset/limit
63
78
  const offset = (params.offset as number | undefined) ?? 1;
@@ -70,7 +85,7 @@ export function makeReadOverride(cwd: string) {
70
85
  let truncated = false;
71
86
  for (let i = startIdx; i < endIdx; i++) {
72
87
  const lineNo = i + 1;
73
- const row = `${lineNo}#${snap.lineHashes[i]}│${allLines[i]}`;
88
+ const row = `${lineNo}#${hashes[i]}│${allLines[i]}`;
74
89
  bytes += Buffer.byteLength(row, "utf-8");
75
90
  if (bytes > MAX_BYTES) {
76
91
  truncated = true;
@@ -91,8 +106,3 @@ export function makeReadOverride(cwd: string) {
91
106
  },
92
107
  };
93
108
  }
94
-
95
- /** Reused by the edit override: look up a recorded snapshot for a path (by canonical path). */
96
- export function lookupSnapshot(cwd: string, p: string) {
97
- return getSnapshot(canonicalPath(cwd, p));
98
- }
package/src/pi/state.ts CHANGED
@@ -1,28 +1,18 @@
1
1
  /**
2
- * Session-level state: file snapshot store (LRU) + config.
2
+ * Session-level config holder.
3
3
  *
4
- * globalThis singleton (avoids Bun module-identity issues; see the repo AGENTS).
5
- * Snapshot LRU eviction (default 64 files) prevents unbounded memory growth in
6
- * long sessions — cold files are pushed out by new ones. read records; edit
7
- * verifies; key is the canonical absolute path.
8
- * config lives in state and is reloaded on session_start.
4
+ * globalThis singleton (consistent with the repo's module-identity guidance).
5
+ * Config is loaded on session_start and read by the read/edit overrides.
9
6
  *
10
7
  * @module pi-hashline-edit/pi
11
8
  */
12
9
 
13
- import { createSnapshot } from "../core/snapshot.ts";
14
- import type { FileSnapshot } from "../core/types.ts";
15
10
  import type { HashlineEditConfig } from "./config.ts";
16
11
 
17
12
  const GLOBAL_KEY = "__piHashlineEdit";
18
- const DEFAULT_CONFIG: HashlineEditConfig = { enabled: true, hashLen: 4 };
19
- /** Maximum number of cached file snapshots; beyond this the least-recently-accessed is evicted. */
20
- const MAX_SNAPSHOTS = 64;
13
+ const DEFAULT_CONFIG: HashlineEditConfig = { enabled: true, hashLen: 4, shiftRadius: 15 };
21
14
 
22
15
  export interface HashlineEditState {
23
- /** canonical path → snapshot. LRU order: Map insertion order, most recently accessed at the end. */
24
- readonly snapshots: Map<string, FileSnapshot>;
25
- hashLen: number;
26
16
  config: HashlineEditConfig;
27
17
  }
28
18
 
@@ -30,51 +20,7 @@ export function getState(): HashlineEditState {
30
20
  const g = globalThis as Record<string, unknown>;
31
21
  const existing = g[GLOBAL_KEY];
32
22
  if (existing) return existing as HashlineEditState;
33
- const state: HashlineEditState = {
34
- snapshots: new Map(),
35
- hashLen: DEFAULT_CONFIG.hashLen,
36
- config: DEFAULT_CONFIG,
37
- };
23
+ const state: HashlineEditState = { config: DEFAULT_CONFIG };
38
24
  g[GLOBAL_KEY] = state;
39
25
  return state;
40
26
  }
41
-
42
- /** Write a snapshot and maintain LRU: move to the end (most recently used); evict the oldest (Map's first entry) when over the limit. */
43
- function touchAndEvict(map: Map<string, FileSnapshot>, path: string, snap: FileSnapshot): void {
44
- if (map.has(path)) map.delete(path);
45
- map.set(path, snap);
46
- while (map.size > MAX_SNAPSHOTS) {
47
- const oldest = map.keys().next().value;
48
- if (oldest === undefined) break;
49
- map.delete(oldest);
50
- }
51
- }
52
-
53
- /** Record a file snapshot (called on read): compute line hashes + LRU bookkeeping. */
54
- export function recordSnapshot(canonicalPath: string, text: string): FileSnapshot {
55
- const state = getState();
56
- const snap = createSnapshot(canonicalPath, text, state.hashLen);
57
- touchAndEvict(state.snapshots, canonicalPath, snap);
58
- return snap;
59
- }
60
-
61
- /** Store an already-computed snapshot (updated after a successful edit), via LRU. */
62
- export function putSnapshot(canonicalPath: string, snap: FileSnapshot): void {
63
- touchAndEvict(getState().snapshots, canonicalPath, snap);
64
- }
65
-
66
- /** Get a file snapshot (for edit verification); on a hit move it to the end (LRU touch). */
67
- export function getSnapshot(canonicalPath: string): FileSnapshot | undefined {
68
- const map = getState().snapshots;
69
- const snap = map.get(canonicalPath);
70
- if (snap) {
71
- map.delete(canonicalPath);
72
- map.set(canonicalPath, snap);
73
- }
74
- return snap;
75
- }
76
-
77
- /** Clear all snapshots (on session restart). */
78
- export function clearSnapshots(): void {
79
- getState().snapshots.clear();
80
- }
@@ -1,21 +0,0 @@
1
- import { test } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { buildDiff } from "./diff.ts";
4
-
5
- test("single hunk", () => {
6
- const d = buildDiff("f.ts", ["a", "b", "c"], [{ lo: 1, hi: 2, newLines: ["X"] }]);
7
- assert.ok(d.startsWith("--- a/f.ts\n"));
8
- assert.ok(d.includes("+++ b/f.ts"));
9
- assert.ok(d.includes("@@ -2 +2 @@"));
10
- assert.ok(d.includes("-b"));
11
- assert.ok(d.includes("+X"));
12
- });
13
-
14
- test("multi-line range hunk carries counts", () => {
15
- const d = buildDiff("f", ["a", "b", "c", "d"], [{ lo: 0, hi: 3, newLines: ["X"] }]);
16
- assert.ok(d.includes("@@ -1,3 +1 @@")); // newCount=1 omits the count (git convention)
17
- });
18
-
19
- test("empty ops returns empty string", () => {
20
- assert.equal(buildDiff("f", ["a"], []), "");
21
- });
package/src/core/diff.ts DELETED
@@ -1,41 +0,0 @@
1
- /**
2
- * Ops-based unified diff preview.
3
- *
4
- * Each SpanOp produces one hunk; the `@@` line numbers are based on the
5
- * original file (each op's own original position), so the content is accurate.
6
- * This is a Phase 1 approximation; if precise multi-op line numbers are needed,
7
- * LCS can replace it later.
8
- *
9
- * @module pi-hashline-edit/core
10
- */
11
-
12
- interface SpanOpLike {
13
- lo: number;
14
- hi: number;
15
- newLines: string[];
16
- }
17
-
18
- /**
19
- * Build a unified diff.
20
- *
21
- * @param path file path (for the diff header)
22
- * @param oldLines original line array before applying
23
- * @param ops line-level operations applied
24
- */
25
- export function buildDiff(path: string, oldLines: readonly string[], ops: readonly SpanOpLike[]): string {
26
- if (ops.length === 0) return "";
27
- const out: string[] = [`--- a/${path}`, `+++ b/${path}`];
28
- for (const op of ops) {
29
- const oldCount = op.hi - op.lo;
30
- const oldStart = oldCount === 0 ? op.lo : op.lo + 1; // zero-width (insertion point) uses lo, following the unified-diff "after line N" convention
31
- const newCount = op.newLines.length;
32
- const newStart = op.lo + 1;
33
- // single-line hunks omit the count, following the unified-diff convention
34
- const oldRange = oldCount === 1 ? `${oldStart}` : `${oldStart},${oldCount}`;
35
- const newRange = newCount === 1 ? `${newStart}` : `${newStart},${newCount}`;
36
- out.push(`@@ -${oldRange} +${newRange} @@`);
37
- for (let i = op.lo; i < op.hi; i++) out.push(`-${oldLines[i]}`);
38
- for (const nl of op.newLines) out.push(`+${nl}`);
39
- }
40
- return out.join("\n") + "\n";
41
- }
@@ -1,117 +0,0 @@
1
- import { test } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { parsePatch } from "./parse.ts";
4
-
5
- test("parse replace single line", () => {
6
- const r = parsePatch("file: f.ts\n\nreplace 4#ABCD:\n+new line\n");
7
- assert.equal(r.ok, true);
8
- if (r.ok) {
9
- assert.equal(r.patch.path, "f.ts");
10
- assert.equal(r.patch.edits.length, 1);
11
- assert.equal(r.patch.edits[0].op, "replace");
12
- }
13
- });
14
-
15
- test("parse replace range", () => {
16
- const r = parsePatch("file: f.ts\nreplace 3#AAA..5#BBB:\n+a\n+b\n");
17
- assert.equal(r.ok, true);
18
- if (r.ok) {
19
- const e = r.patch.edits[0];
20
- if (e.op === "replace") {
21
- assert.equal(e.start.line, 3);
22
- assert.equal(e.end?.line, 5);
23
- assert.deepEqual(e.body, ["a", "b"]);
24
- }
25
- }
26
- });
27
-
28
- test("parse delete (no body)", () => {
29
- const r = parsePatch("file: f.ts\ndelete 2#XYZ\n");
30
- assert.equal(r.ok, true);
31
- if (r.ok) assert.equal(r.patch.edits[0].op, "delete");
32
- });
33
-
34
- test("parse insert_after / insert_before", () => {
35
- const r = parsePatch("file: f.ts\ninsert_after 3#ABC:\n+x\n\ninsert_before 5#DEF:\n+y\n");
36
- assert.equal(r.ok, true);
37
- if (r.ok) assert.equal(r.patch.edits.length, 2);
38
- });
39
-
40
- test("parse append / prepend", () => {
41
- const r = parsePatch("file: f.ts\nappend:\n+z\n\nprepend:\n+w\n");
42
- assert.equal(r.ok, true);
43
- if (r.ok) {
44
- assert.equal(r.patch.edits[0].op, "append");
45
- assert.equal(r.patch.edits[1].op, "prepend");
46
- }
47
- });
48
-
49
- test("multiple mixed operations", () => {
50
- const r = parsePatch("file: f.ts\n\nreplace 1#A:\n+x\n\ndelete 3#C\n\ninsert_after 5#E:\n+y\n");
51
- assert.equal(r.ok, true);
52
- if (r.ok) assert.equal(r.patch.edits.length, 3);
53
- });
54
-
55
- test("body kept literally (with + prefix, markdown)", () => {
56
- const r = parsePatch("file: f.ts\nreplace 1#A:\n++i\n+- item\n+\n");
57
- assert.equal(r.ok, true);
58
- if (r.ok) {
59
- const e = r.patch.edits[0];
60
- if (e.op === "replace") assert.deepEqual(e.body, ["+i", "- item", ""]);
61
- }
62
- });
63
-
64
- test("colon is optional", () => {
65
- const r = parsePatch("file: f.ts\nreplace 1#A\n+x\n");
66
- assert.equal(r.ok, true);
67
- });
68
-
69
- test("CRLF normalization", () => {
70
- const r = parsePatch("file: f.ts\r\nreplace 1#A:\r\n+x\r\n");
71
- assert.equal(r.ok, true);
72
- if (r.ok) {
73
- const e = r.patch.edits[0];
74
- if (e.op === "replace") assert.deepEqual(e.body, ["x"]); // no \r
75
- }
76
- });
77
-
78
- // --- error paths ---
79
-
80
- test("missing file header errors", () => {
81
- const r = parsePatch("replace 1#A:\n+x\n");
82
- assert.equal(r.ok, false);
83
- });
84
-
85
- test("empty input errors", () => {
86
- assert.equal(parsePatch("").ok, false);
87
- assert.equal(parsePatch("\n\n").ok, false);
88
- });
89
-
90
- test("empty body errors", () => {
91
- const r = parsePatch("file: f.ts\nreplace 1#A:\n");
92
- assert.equal(r.ok, false);
93
- if (!r.ok) assert.equal(r.error.kind, "parse");
94
- });
95
-
96
- test("unknown verb errors (rejects SWAP/DEL)", () => {
97
- const r = parsePatch("file: f.ts\nSWAP 1#A:\n+x\n");
98
- assert.equal(r.ok, false);
99
- if (!r.ok) assert.equal(r.error.kind, "parse");
100
- });
101
-
102
- test("stray body errors", () => {
103
- const r = parsePatch("file: f.ts\n+x\n");
104
- assert.equal(r.ok, false);
105
- });
106
-
107
- test("delete with body errors", () => {
108
- const r = parsePatch("file: f.ts\ndelete 2#X\n+leak\n");
109
- // a + line right after delete → the next round treats it as a stray body
110
- assert.equal(r.ok, false);
111
- });
112
-
113
- test("error includes the input line number", () => {
114
- const r = parsePatch("file: f.ts\n\nreplace 1#A:\n+x\n\nSWAP 2#B:\n+y\n");
115
- assert.equal(r.ok, false);
116
- if (!r.ok) assert.ok(r.error.line && r.error.line >= 5);
117
- });
package/src/core/parse.ts DELETED
@@ -1,166 +0,0 @@
1
- /**
2
- * Strict parser: patch string → {@link ParsedPatch}.
3
- *
4
- * Core principle — zero guessing: only the canonical format is accepted (correct
5
- * verb + correct anchor + `+` body rows). Any variant (bare lines, legacy
6
- * `oldText`/`newText`, `SWAP`/`DEL`, etc.) is rejected and left to the
7
- * `transforms/normalize-legacy` middleware to normalize before parsing.
8
- *
9
- * Format:
10
- *
11
- * ```
12
- * file: <path>
13
- *
14
- * replace <line>#<hash>[..<line>#<hash>]:
15
- * +<text>
16
- *
17
- * delete <line>#<hash>[..<line>#<hash>]
18
- *
19
- * insert_after <line>#<hash>:
20
- * +<text>
21
- *
22
- * append:
23
- * +<text>
24
- * ```
25
- *
26
- * @module pi-hashline-edit/core
27
- */
28
-
29
- import type { Anchor, Edit, ParsedPatch, PatchError } from "./types.ts";
30
-
31
- export type ParseResult =
32
- | { readonly ok: true; readonly patch: ParsedPatch }
33
- | { readonly ok: false; readonly error: PatchError };
34
-
35
- function parseError(message: string, line?: number): PatchError {
36
- return { kind: "parse", message, line };
37
- }
38
-
39
- const ANCHOR_RE = /^(\d+)#([0-9A-Za-z]+)$/;
40
-
41
- function parseAnchorStr(s: string): Anchor | null {
42
- const m = ANCHOR_RE.exec(s);
43
- return m ? { line: Number(m[1]), hash: m[2] } : null;
44
- }
45
-
46
- function parseRangeStr(s: string): { start: Anchor; end?: Anchor } | null {
47
- const idx = s.indexOf("..");
48
- if (idx === -1) {
49
- const a = parseAnchorStr(s);
50
- return a ? { start: a } : null;
51
- }
52
- const a = parseAnchorStr(s.slice(0, idx));
53
- const b = parseAnchorStr(s.slice(idx + 2));
54
- if (!a || !b) return null;
55
- return { start: a, end: b };
56
- }
57
-
58
- type ParsedHeader =
59
- | { readonly error: string }
60
- | {
61
- readonly verb: string;
62
- readonly hasBody: boolean;
63
- readonly build: (body: string[]) => Edit;
64
- };
65
-
66
- /** Parse a single operation header (already trimmed). Trailing colon is optional (for body-bearing verbs). */
67
- function parseOpHeader(s: string): ParsedHeader {
68
- let core = s;
69
- if (core.endsWith(":")) core = core.slice(0, -1).trimEnd();
70
-
71
- const sp = core.indexOf(" ");
72
- const verb = sp === -1 ? core : core.slice(0, sp);
73
- const rest = sp === -1 ? "" : core.slice(sp + 1).trim();
74
-
75
- switch (verb) {
76
- case "replace": {
77
- const r = parseRangeStr(rest);
78
- if (!r) return { error: `replace needs "<line>#<hash>[..<line>#<hash>]", got: "${s}"` };
79
- return { verb, hasBody: true, build: (body) => ({ op: "replace", start: r.start, end: r.end, body }) };
80
- }
81
- case "delete": {
82
- const r = parseRangeStr(rest);
83
- if (!r) return { error: `delete needs "<line>#<hash>[..<line>#<hash>]", got: "${s}"` };
84
- return { verb, hasBody: false, build: () => ({ op: "delete", start: r.start, end: r.end }) };
85
- }
86
- case "insert_after":
87
- case "insert_before": {
88
- const a = parseAnchorStr(rest);
89
- if (!a) return { error: `${verb} needs "<line>#<hash>", got: "${s}"` };
90
- return { verb, hasBody: true, build: (body) => ({ op: verb, anchor: a, body }) };
91
- }
92
- case "append":
93
- case "prepend": {
94
- if (rest !== "") return { error: `${verb} takes no anchor, got: "${s}"` };
95
- return { verb, hasBody: true, build: (body) => ({ op: verb, body }) };
96
- }
97
- default:
98
- return {
99
- error: `unknown verb "${verb}". Use replace / delete / insert_after / insert_before / append / prepend`,
100
- };
101
- }
102
- }
103
-
104
- /**
105
- * Strictly parse a patch.
106
- *
107
- * @param input patch string (CRLF is normalized to LF automatically)
108
- * @returns parse result; malformed input returns `ok: false` + PatchError (with the input line number)
109
- */
110
- export function parsePatch(input: string): ParseResult {
111
- const normalized = input.replace(/\r\n/g, "\n");
112
- const lines = normalized.split("\n");
113
- const n = lines.length;
114
- let i = 0;
115
-
116
- while (i < n && lines[i].trim() === "") i++;
117
- if (i >= n) return { ok: false, error: parseError("empty input", 1) };
118
-
119
- const fileMatch = /^file:\s*(.+?)\s*$/i.exec(lines[i]);
120
- if (!fileMatch) {
121
- return {
122
- ok: false,
123
- error: parseError(`expected "file: <path>" on first non-blank line, got: "${lines[i]}"`, i + 1),
124
- };
125
- }
126
- const path = fileMatch[1];
127
- i++;
128
-
129
- const edits: Edit[] = [];
130
- while (i < n) {
131
- while (i < n && lines[i].trim() === "") i++;
132
- if (i >= n) break;
133
-
134
- const headerLineNo = i + 1;
135
- const raw = lines[i];
136
- const trimmed = raw.trim();
137
-
138
- if (trimmed.startsWith("+")) {
139
- return { ok: false, error: parseError(`stray body row has no preceding header: "${raw}"`, headerLineNo) };
140
- }
141
-
142
- const parsed = parseOpHeader(trimmed);
143
- if ("error" in parsed) {
144
- return { ok: false, error: parseError(parsed.error, headerLineNo) };
145
- }
146
- i++;
147
-
148
- let body: string[] = [];
149
- if (parsed.hasBody) {
150
- while (i < n && lines[i].startsWith("+")) {
151
- body.push(lines[i].slice(1));
152
- i++;
153
- }
154
- if (body.length === 0) {
155
- return {
156
- ok: false,
157
- error: parseError(`"${parsed.verb}" needs at least one "+TEXT" body row`, headerLineNo),
158
- };
159
- }
160
- }
161
-
162
- edits.push(parsed.build(body));
163
- }
164
-
165
- return { ok: true, patch: { path, edits } };
166
- }
@@ -1,60 +0,0 @@
1
- import { test } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { splitLines, joinLines, createSnapshot, verifyAnchor } from "./snapshot.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("createSnapshot records path/text/hashLen", () => {
27
- const s = createSnapshot("f.ts", "a\nb\nc\n");
28
- assert.equal(s.path, "f.ts");
29
- assert.equal(s.text, "a\nb\nc\n");
30
- assert.equal(s.lineHashes.length, 3);
31
- assert.equal(s.hashLen, 4);
32
- });
33
-
34
- test("createSnapshot custom hashLen", () => {
35
- assert.equal(createSnapshot("f", "a\n", 6).hashLen, 6);
36
- });
37
-
38
- test("createSnapshot records lineEnding", () => {
39
- assert.equal(createSnapshot("f", "a\nb\n").lineEnding, "lf");
40
- assert.equal(createSnapshot("f", "a\r\nb\r\n").lineEnding, "crlf");
41
- });
42
-
43
- test("verifyAnchor match", () => {
44
- const s = createSnapshot("f", "a\nb\n");
45
- assert.deepEqual(verifyAnchor(s, { line: 2, hash: s.lineHashes[1] }), { ok: true, line: 2 });
46
- });
47
-
48
- test("verifyAnchor hash_not_found", () => {
49
- const s = createSnapshot("f", "a\nb\n");
50
- const r = verifyAnchor(s, { line: 1, hash: "ZZZZ" });
51
- assert.equal(r.ok, false);
52
- if (!r.ok) assert.equal(r.error, "hash_not_found");
53
- });
54
-
55
- test("verifyAnchor line_mismatch (drift)", () => {
56
- const s = createSnapshot("f", "a\nb\n");
57
- const r = verifyAnchor(s, { line: 1, hash: s.lineHashes[1] });
58
- assert.equal(r.ok, false);
59
- if (!r.ok) assert.equal(r.error, "line_mismatch");
60
- });