@d3ara1n/pi-hashline-edit 0.1.1 → 0.1.2

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/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
- });
@@ -1,84 +0,0 @@
1
- /**
2
- * File snapshot and anchor verification.
3
- *
4
- * A snapshot records the original text + per-line hash at read time; at apply
5
- * time it verifies "current file == snapshot" (stale check) and that each
6
- * anchor's hash matches its line number (guards against the model
7
- * misremembering).
8
- *
9
- * CRLF: splitLines normalizes by stripping the trailing `\r` from each line
10
- * (hashes are based on clean lines, matching the `\r`-free content the model
11
- * copies from the display); createSnapshot records the original line ending,
12
- * and joinLines restores it per the recorded ending — guaranteeing a CRLF file
13
- * keeps its line endings after edit.
14
- *
15
- * @module pi-hashline-edit/core
16
- */
17
-
18
- import { hashFileLines } from "./hash.ts";
19
- import type { Anchor, FileSnapshot, LineEnding } from "./types.ts";
20
-
21
- /**
22
- * Split text into lines, stripping the trailing `\r` of each line (CRLF
23
- * normalization, so hashes are based on clean lines).
24
- *
25
- * Convention: a trailing newline is treated as the terminator of the last line,
26
- * not as producing an extra empty trailing line.
27
- * - `"a\nb\n"` → `["a", "b"]`
28
- * - `"a\r\nb\r\n"` → `["a", "b"]` (`\r` stripped)
29
- * - `"a\n\n"` → `["a", ""]`
30
- * - `""` → `[]`
31
- */
32
- export function splitLines(text: string): string[] {
33
- if (text === "") return [];
34
- const normalized = text.endsWith("\n") ? text.slice(0, -1) : text;
35
- return normalized.split("\n").map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
36
- }
37
-
38
- /** Detect the dominant line ending of the text (any `\r\n` counts as CRLF). */
39
- export function detectLineEnding(text: string): LineEnding {
40
- return text.includes("\r\n") ? "crlf" : "lf";
41
- }
42
-
43
- /** Join a line array back into text, restoring the given line ending (default LF). Non-empty files end with a newline. */
44
- export function joinLines(lines: readonly string[], ending: LineEnding = "lf"): string {
45
- if (lines.length === 0) return "";
46
- const sep = ending === "crlf" ? "\r\n" : "\n";
47
- return lines.join(sep) + sep;
48
- }
49
-
50
- /** Create a snapshot for a file: record original text + line ending + per-line context-aware hash. */
51
- export function createSnapshot(path: string, text: string, len = 4): FileSnapshot {
52
- const lines = splitLines(text);
53
- const lineHashes = hashFileLines(lines, len);
54
- return { path, lineHashes, text, hashLen: len, lineEnding: detectLineEnding(text) };
55
- }
56
-
57
- /** Anchor verification result. */
58
- export type AnchorVerifyResult =
59
- | { readonly ok: true; readonly line: number }
60
- | {
61
- readonly ok: false;
62
- readonly error: "hash_not_found" | "line_mismatch" | "collision";
63
- /** Line number(s) where the hash actually appears (1-based). */
64
- readonly found?: readonly number[];
65
- };
66
-
67
- /**
68
- * Verify an anchor against the snapshot.
69
- *
70
- * - hash is unique and the line number matches → `ok`
71
- * - hash is unique but the line number differs → `line_mismatch` (`found` gives the real line number; drift, handled by the relocate middleware)
72
- * - hash appears at multiple lines → `collision` (`found` gives all positions)
73
- * - hash does not exist → `hash_not_found` (file changed, needs re-read)
74
- */
75
- export function verifyAnchor(snapshot: FileSnapshot, anchor: Anchor): AnchorVerifyResult {
76
- const found: number[] = [];
77
- for (let i = 0; i < snapshot.lineHashes.length; i++) {
78
- if (snapshot.lineHashes[i] === anchor.hash) found.push(i + 1);
79
- }
80
- if (found.length === 0) return { ok: false, error: "hash_not_found" };
81
- if (found.length > 1) return { ok: false, error: "collision", found };
82
- if (found[0] !== anchor.line) return { ok: false, error: "line_mismatch", found };
83
- return { ok: true, line: anchor.line };
84
- }