@d3ara1n/pi-hashline-edit 0.1.0 → 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.
@@ -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("解析 replace 单行", () => {
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("解析 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("解析 delete(无 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("解析 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("解析 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("多操作混合", () => {
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 保留字面(含 + 前缀、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("冒号可选", () => {
65
- const r = parsePatch("file: f.ts\nreplace 1#A\n+x\n");
66
- assert.equal(r.ok, true);
67
- });
68
-
69
- test("CRLF 归一", () => {
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"]); // 无 \r
75
- }
76
- });
77
-
78
- // —— 错误路径 ——
79
-
80
- test("缺 file 头报错", () => {
81
- const r = parsePatch("replace 1#A:\n+x\n");
82
- assert.equal(r.ok, false);
83
- });
84
-
85
- test("空输入报错", () => {
86
- assert.equal(parsePatch("").ok, false);
87
- assert.equal(parsePatch("\n\n").ok, false);
88
- });
89
-
90
- test("空 body 报错", () => {
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("未知 verb 报错(拒绝 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 报错", () => {
103
- const r = parsePatch("file: f.ts\n+x\n");
104
- assert.equal(r.ok, false);
105
- });
106
-
107
- test("delete 带 body 报错", () => {
108
- const r = parsePatch("file: f.ts\ndelete 2#X\n+leak\n");
109
- // delete 后紧跟 + 行 → 下一轮判为 stray body
110
- assert.equal(r.ok, false);
111
- });
112
-
113
- test("错误含输入行号", () => {
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,165 +0,0 @@
1
- /**
2
- * 严格解析器:patch 字符串 → {@link ParsedPatch}。
3
- *
4
- * 核心零猜测:只接受规范格式(正确 verb + 正确锚 + `+` body 行),
5
- * 任何变体(裸行、旧 `oldText`/`newText`、`SWAP`/`DEL` 等)一律拒绝,
6
- * 交给 `transforms/normalize-legacy` 中间件归一化后再进入解析。
7
- *
8
- * 格式:
9
- *
10
- * ```
11
- * file: <path>
12
- *
13
- * replace <line>#<hash>[..<line>#<hash>]:
14
- * +<text>
15
- *
16
- * delete <line>#<hash>[..<line>#<hash>]
17
- *
18
- * insert_after <line>#<hash>:
19
- * +<text>
20
- *
21
- * append:
22
- * +<text>
23
- * ```
24
- *
25
- * @module pi-hashline-edit/core
26
- */
27
-
28
- import type { Anchor, Edit, ParsedPatch, PatchError } from "./types.ts";
29
-
30
- export type ParseResult =
31
- | { readonly ok: true; readonly patch: ParsedPatch }
32
- | { readonly ok: false; readonly error: PatchError };
33
-
34
- function parseError(message: string, line?: number): PatchError {
35
- return { kind: "parse", message, line };
36
- }
37
-
38
- const ANCHOR_RE = /^(\d+)#([0-9A-Za-z]+)$/;
39
-
40
- function parseAnchorStr(s: string): Anchor | null {
41
- const m = ANCHOR_RE.exec(s);
42
- return m ? { line: Number(m[1]), hash: m[2] } : null;
43
- }
44
-
45
- function parseRangeStr(s: string): { start: Anchor; end?: Anchor } | null {
46
- const idx = s.indexOf("..");
47
- if (idx === -1) {
48
- const a = parseAnchorStr(s);
49
- return a ? { start: a } : null;
50
- }
51
- const a = parseAnchorStr(s.slice(0, idx));
52
- const b = parseAnchorStr(s.slice(idx + 2));
53
- if (!a || !b) return null;
54
- return { start: a, end: b };
55
- }
56
-
57
- type ParsedHeader =
58
- | { readonly error: string }
59
- | {
60
- readonly verb: string;
61
- readonly hasBody: boolean;
62
- readonly build: (body: string[]) => Edit;
63
- };
64
-
65
- /** 解析单个操作头(已 trim)。末尾冒号可选(有 body 的 verb)。 */
66
- function parseOpHeader(s: string): ParsedHeader {
67
- let core = s;
68
- if (core.endsWith(":")) core = core.slice(0, -1).trimEnd();
69
-
70
- const sp = core.indexOf(" ");
71
- const verb = sp === -1 ? core : core.slice(0, sp);
72
- const rest = sp === -1 ? "" : core.slice(sp + 1).trim();
73
-
74
- switch (verb) {
75
- case "replace": {
76
- const r = parseRangeStr(rest);
77
- if (!r) return { error: `replace needs "<line>#<hash>[..<line>#<hash>]", got: "${s}"` };
78
- return { verb, hasBody: true, build: (body) => ({ op: "replace", start: r.start, end: r.end, body }) };
79
- }
80
- case "delete": {
81
- const r = parseRangeStr(rest);
82
- if (!r) return { error: `delete needs "<line>#<hash>[..<line>#<hash>]", got: "${s}"` };
83
- return { verb, hasBody: false, build: () => ({ op: "delete", start: r.start, end: r.end }) };
84
- }
85
- case "insert_after":
86
- case "insert_before": {
87
- const a = parseAnchorStr(rest);
88
- if (!a) return { error: `${verb} needs "<line>#<hash>", got: "${s}"` };
89
- return { verb, hasBody: true, build: (body) => ({ op: verb, anchor: a, body }) };
90
- }
91
- case "append":
92
- case "prepend": {
93
- if (rest !== "") return { error: `${verb} takes no anchor, got: "${s}"` };
94
- return { verb, hasBody: true, build: (body) => ({ op: verb, body }) };
95
- }
96
- default:
97
- return {
98
- error: `unknown verb "${verb}". Use replace / delete / insert_after / insert_before / append / prepend`,
99
- };
100
- }
101
- }
102
-
103
- /**
104
- * 严格解析 patch。
105
- *
106
- * @param input patch 字符串(CRLF 自动归一为 LF)
107
- * @returns 解析结果;非法格式返回 `ok: false` + PatchError(含输入行号)
108
- */
109
- export function parsePatch(input: string): ParseResult {
110
- const normalized = input.replace(/\r\n/g, "\n");
111
- const lines = normalized.split("\n");
112
- const n = lines.length;
113
- let i = 0;
114
-
115
- while (i < n && lines[i].trim() === "") i++;
116
- if (i >= n) return { ok: false, error: parseError("empty input", 1) };
117
-
118
- const fileMatch = /^file:\s*(.+?)\s*$/i.exec(lines[i]);
119
- if (!fileMatch) {
120
- return {
121
- ok: false,
122
- error: parseError(`expected "file: <path>" on first non-blank line, got: "${lines[i]}"`, i + 1),
123
- };
124
- }
125
- const path = fileMatch[1];
126
- i++;
127
-
128
- const edits: Edit[] = [];
129
- while (i < n) {
130
- while (i < n && lines[i].trim() === "") i++;
131
- if (i >= n) break;
132
-
133
- const headerLineNo = i + 1;
134
- const raw = lines[i];
135
- const trimmed = raw.trim();
136
-
137
- if (trimmed.startsWith("+")) {
138
- return { ok: false, error: parseError(`stray body row has no preceding header: "${raw}"`, headerLineNo) };
139
- }
140
-
141
- const parsed = parseOpHeader(trimmed);
142
- if ("error" in parsed) {
143
- return { ok: false, error: parseError(parsed.error, headerLineNo) };
144
- }
145
- i++;
146
-
147
- let body: string[] = [];
148
- if (parsed.hasBody) {
149
- while (i < n && lines[i].startsWith("+")) {
150
- body.push(lines[i].slice(1));
151
- i++;
152
- }
153
- if (body.length === 0) {
154
- return {
155
- ok: false,
156
- error: parseError(`"${parsed.verb}" needs at least one "+TEXT" body row`, headerLineNo),
157
- };
158
- }
159
- }
160
-
161
- edits.push(parsed.build(body));
162
- }
163
-
164
- return { ok: true, patch: { path, edits } };
165
- }
@@ -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 边界", () => {
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 去掉 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 行尾恢复", () => {
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 记录 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 自定义 hashLen", () => {
35
- assert.equal(createSnapshot("f", "a\n", 6).hashLen, 6);
36
- });
37
-
38
- test("createSnapshot 记录 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 匹配", () => {
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(漂移)", () => {
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,78 +0,0 @@
1
- /**
2
- * 文件快照与锚校验。
3
- *
4
- * 快照在 read 时记录原文 + 每行 hash;apply 时校验「当前文件 == 快照」
5
- * (stale 检查)以及每个锚的 hash 与行号对得上(防模型记错)。
6
- *
7
- * CRLF:splitLines 归一化去掉每行尾的 \r(hash 基于干净行,与模型从显示中
8
- * 复制的无 \r 内容一致),createSnapshot 记录原行尾,joinLines 按记录的
9
- * 行尾恢复——保证 CRLF 文件 edit 后行尾不变。
10
- *
11
- * @module pi-hashline-edit/core
12
- */
13
-
14
- import { hashFileLines } from "./hash.ts";
15
- import type { Anchor, FileSnapshot, LineEnding } from "./types.ts";
16
-
17
- /**
18
- * 按行分割文本,去掉每行尾的 \r(CRLF 归一化,hash 基于干净行)。
19
- *
20
- * 约定:末尾换行视为最后一行的终止符,不产生多余空尾行。
21
- * - `"a\nb\n"` → `["a", "b"]`
22
- * - `"a\r\nb\r\n"` → `["a", "b"]`(\r 去掉)
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
- /** 检测文本的主导行尾(含 \r\n 即视为 CRLF)。 */
33
- export function detectLineEnding(text: string): LineEnding {
34
- return text.includes("\r\n") ? "crlf" : "lf";
35
- }
36
-
37
- /** 把行数组 join 成文本,按指定行尾恢复(默认 LF)。非空文件末尾带换行。 */
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
- }
43
-
44
- /** 为文件创建快照:记录原文 + 行尾 + 每行 context-aware hash。 */
45
- export function createSnapshot(path: string, text: string, len = 4): FileSnapshot {
46
- const lines = splitLines(text);
47
- const lineHashes = hashFileLines(lines, len);
48
- return { path, lineHashes, text, hashLen: len, lineEnding: detectLineEnding(text) };
49
- }
50
-
51
- /** 锚校验结果。 */
52
- export type AnchorVerifyResult =
53
- | { readonly ok: true; readonly line: number }
54
- | {
55
- readonly ok: false;
56
- readonly error: "hash_not_found" | "line_mismatch" | "collision";
57
- /** hash 实际出现的行号(1-based)。 */
58
- readonly found?: readonly number[];
59
- };
60
-
61
- /**
62
- * 在快照中校验锚。
63
- *
64
- * - hash 唯一存在且行号匹配 → `ok`
65
- * - hash 唯一存在但行号不符 → `line_mismatch`(`found` 给真实行号;漂移,可由 relocate 中间件处理)
66
- * - hash 多处出现 → `collision`(`found` 给所有位置)
67
- * - hash 不存在 → `hash_not_found`(文件已变,需重读)
68
- */
69
- export function verifyAnchor(snapshot: FileSnapshot, anchor: Anchor): AnchorVerifyResult {
70
- const found: number[] = [];
71
- for (let i = 0; i < snapshot.lineHashes.length; i++) {
72
- if (snapshot.lineHashes[i] === anchor.hash) found.push(i + 1);
73
- }
74
- if (found.length === 0) return { ok: false, error: "hash_not_found" };
75
- if (found.length > 1) return { ok: false, error: "collision", found };
76
- if (found[0] !== anchor.line) return { ok: false, error: "line_mismatch", found };
77
- return { ok: true, line: anchor.line };
78
- }