jeopi-hashline 16.2.13
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 +343 -0
- package/README.md +82 -0
- package/dist/types/apply.d.ts +10 -0
- package/dist/types/block.d.ts +39 -0
- package/dist/types/diff-preview.d.ts +14 -0
- package/dist/types/format.d.ts +83 -0
- package/dist/types/fs.d.ts +109 -0
- package/dist/types/index.d.ts +17 -0
- package/dist/types/input.d.ts +110 -0
- package/dist/types/messages.d.ts +127 -0
- package/dist/types/mismatch.d.ts +44 -0
- package/dist/types/normalize.d.ts +20 -0
- package/dist/types/parser.d.ts +27 -0
- package/dist/types/patcher.d.ts +118 -0
- package/dist/types/prefixes.d.ts +42 -0
- package/dist/types/recovery.d.ts +44 -0
- package/dist/types/snapshots.d.ts +127 -0
- package/dist/types/stream.d.ts +2 -0
- package/dist/types/tokenizer.d.ts +73 -0
- package/dist/types/types.d.ts +172 -0
- package/package.json +64 -0
- package/src/apply.ts +1281 -0
- package/src/block.ts +168 -0
- package/src/diff-preview.ts +124 -0
- package/src/format.ts +141 -0
- package/src/fs.ts +246 -0
- package/src/grammar.lark +29 -0
- package/src/index.ts +17 -0
- package/src/input.ts +462 -0
- package/src/messages.ts +267 -0
- package/src/mismatch.ts +118 -0
- package/src/normalize.ts +38 -0
- package/src/parser.ts +456 -0
- package/src/patcher.ts +608 -0
- package/src/prefixes.ts +142 -0
- package/src/prompt.md +172 -0
- package/src/recovery.ts +418 -0
- package/src/snapshots.ts +282 -0
- package/src/stream.ts +132 -0
- package/src/tokenizer.ts +557 -0
- package/src/types.ts +172 -0
package/src/block.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expand deferred block edits (`replace_block N:` / `delete_block N` /
|
|
3
|
+
* `insert_after_block N:`) into concrete inserts + deletes.
|
|
4
|
+
*
|
|
5
|
+
* The hashline parser cannot expand a block edit on its own — the line span is
|
|
6
|
+
* unknown until file text + path (→ language) are available. This transform
|
|
7
|
+
* runs at every apply/preview boundary that has text: it calls the injected
|
|
8
|
+
* {@link BlockResolver} to resolve each block's `[start, end]` span, then emits
|
|
9
|
+
* the exact same edits the concrete form produces in the parser: `replace
|
|
10
|
+
* start.=end:` inserts + deletes for a replace, a pure range delete for a
|
|
11
|
+
* delete, and plain `after_anchor` inserts at `end` for an insert-after. After
|
|
12
|
+
* it runs, no `block` edits remain, so {@link applyEdits} (and recovery) only
|
|
13
|
+
* ever see resolved edits.
|
|
14
|
+
*/
|
|
15
|
+
import { STRUCTURAL_CLOSER_RE } from "./apply";
|
|
16
|
+
import {
|
|
17
|
+
BLOCK_RESOLVER_UNAVAILABLE,
|
|
18
|
+
blockSingleLineMessage,
|
|
19
|
+
blockUnresolvedMessage,
|
|
20
|
+
insertAfterBlockCloserLoweredWarning,
|
|
21
|
+
insertAfterBlockUnresolvedLoweredWarning,
|
|
22
|
+
} from "./messages";
|
|
23
|
+
import type { BlockResolution, BlockResolver, Cursor, Edit } from "./types";
|
|
24
|
+
|
|
25
|
+
export interface ResolveBlockEditsOptions {
|
|
26
|
+
/**
|
|
27
|
+
* How to handle a replace/delete block edit that cannot be resolved
|
|
28
|
+
* (missing resolver or a `null` span). `"throw"` (default) raises a
|
|
29
|
+
* `blockUnresolvedMessage` error — used by the authoritative apply + final
|
|
30
|
+
* preview paths. `"drop"` silently skips the edit — used by the streaming
|
|
31
|
+
* preview, where a half-written file or transient parse error must not
|
|
32
|
+
* throw. Unresolvable `insert_after_block N:` edits never reach this: they
|
|
33
|
+
* are lowered to plain `insert after N:` with a warning.
|
|
34
|
+
*/
|
|
35
|
+
onUnresolved?: "throw" | "drop";
|
|
36
|
+
/**
|
|
37
|
+
* Invoked once per successfully resolved block edit, in patch order, with
|
|
38
|
+
* the anchor line and the concrete span it resolved to. Lets the host echo
|
|
39
|
+
* the resolution back to the caller. Never fired for dropped/unresolvable
|
|
40
|
+
* edits.
|
|
41
|
+
*/
|
|
42
|
+
onResolved?: (resolution: BlockResolution) => void;
|
|
43
|
+
/**
|
|
44
|
+
* Invoked once per diagnostic produced while resolving — currently the
|
|
45
|
+
* `insert_after_block N:` lowerings (closer anchor or unresolvable block).
|
|
46
|
+
* Hosts should surface these on the apply result's `warnings`.
|
|
47
|
+
*/
|
|
48
|
+
onWarning?: (message: string) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** True when at least one edit is an unresolved deferred block edit. */
|
|
52
|
+
export function hasBlockEdit(edits: readonly Edit[]): boolean {
|
|
53
|
+
return edits.some(edit => edit.kind === "block");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve every deferred block edit in `edits` against `text` (parsed as the
|
|
58
|
+
* language inferred from `path`). Non-block edits pass through untouched.
|
|
59
|
+
* Returns a fresh edit list with no `block` variants. The fast path returns the
|
|
60
|
+
* input unchanged when there is nothing to resolve.
|
|
61
|
+
*
|
|
62
|
+
* Synthesized inserts/deletes carry sequential `index` values for readability
|
|
63
|
+
* only — {@link applyEdits} re-derives every edit's index from array order, so
|
|
64
|
+
* the passthrough edits keeping their original indices is harmless.
|
|
65
|
+
*/
|
|
66
|
+
export function resolveBlockEdits(
|
|
67
|
+
edits: readonly Edit[],
|
|
68
|
+
text: string,
|
|
69
|
+
path: string,
|
|
70
|
+
resolver: BlockResolver | undefined,
|
|
71
|
+
options: ResolveBlockEditsOptions = {},
|
|
72
|
+
): readonly Edit[] {
|
|
73
|
+
if (!hasBlockEdit(edits)) return edits;
|
|
74
|
+
const onUnresolved = options.onUnresolved ?? "throw";
|
|
75
|
+
const resolved: Edit[] = [];
|
|
76
|
+
let synthIndex = 0;
|
|
77
|
+
for (const edit of edits) {
|
|
78
|
+
if (edit.kind !== "block") {
|
|
79
|
+
resolved.push(edit);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const op = edit.mode === "insert_after" ? "insert_after" : edit.payloads.length === 0 ? "delete" : "replace";
|
|
83
|
+
const span = resolver ? resolver({ path, text, line: edit.anchor.line }) : null;
|
|
84
|
+
if (span === null) {
|
|
85
|
+
// `insert_after_block N:` never fails the patch — lower it to plain
|
|
86
|
+
// `insert after N:` with a warning instead. Two flavors:
|
|
87
|
+
// - anchored on a pure closing-delimiter line: no block begins
|
|
88
|
+
// there, but line N IS the end of one, and "after the end of the
|
|
89
|
+
// block" is exactly the plain form — warn with the opener rule.
|
|
90
|
+
// - otherwise (unsupported language, blank line, unparsable block,
|
|
91
|
+
// or no resolver wired): "after the block at N" degrades to
|
|
92
|
+
// "after line N" — warn to verify the landing line.
|
|
93
|
+
if (op === "insert_after") {
|
|
94
|
+
const anchorText = text.split("\n")[edit.anchor.line - 1];
|
|
95
|
+
const isCloser = anchorText !== undefined && STRUCTURAL_CLOSER_RE.test(anchorText);
|
|
96
|
+
options.onWarning?.(
|
|
97
|
+
isCloser
|
|
98
|
+
? insertAfterBlockCloserLoweredWarning(edit.anchor.line)
|
|
99
|
+
: insertAfterBlockUnresolvedLoweredWarning(edit.anchor.line),
|
|
100
|
+
);
|
|
101
|
+
for (const payload of edit.payloads) {
|
|
102
|
+
const cursor: Cursor = { kind: "after_anchor", anchor: { line: edit.anchor.line } };
|
|
103
|
+
resolved.push({ kind: "insert", cursor, text: payload, lineNum: edit.lineNum, index: synthIndex++ });
|
|
104
|
+
}
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (onUnresolved === "drop") continue;
|
|
108
|
+
throw new Error(
|
|
109
|
+
`line ${edit.lineNum}: ${
|
|
110
|
+
resolver ? blockUnresolvedMessage(edit.anchor.line, op, text.split("\n")) : BLOCK_RESOLVER_UNAVAILABLE
|
|
111
|
+
}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (span.start === span.end) {
|
|
115
|
+
// A single-line block resolution means line N is a bare statement, not
|
|
116
|
+
// the opening line of a multi-line construct — the common mis-anchor
|
|
117
|
+
// that lands a body in the wrong scope (e.g. between a `case` body line
|
|
118
|
+
// and its `break;`). The plain op is exact for one line, so reject and
|
|
119
|
+
// point at it; drop instead on the lenient preview path.
|
|
120
|
+
if (onUnresolved === "drop") continue;
|
|
121
|
+
throw new Error(`line ${edit.lineNum}: ${blockSingleLineMessage(edit.anchor.line, op)}`);
|
|
122
|
+
}
|
|
123
|
+
options.onResolved?.({
|
|
124
|
+
anchorLine: edit.anchor.line,
|
|
125
|
+
start: span.start,
|
|
126
|
+
end: span.end,
|
|
127
|
+
op,
|
|
128
|
+
});
|
|
129
|
+
if (op === "insert_after") {
|
|
130
|
+
// Mirror the parser's `insert after N:` lowering: one `after_anchor`
|
|
131
|
+
// insert per payload row, anchored on the block's last line. The
|
|
132
|
+
// `blockStart` tag lets the applier's landing correction slide a
|
|
133
|
+
// body that claims a depth inside the block back across the block's
|
|
134
|
+
// trailing closer lines.
|
|
135
|
+
for (const payload of edit.payloads) {
|
|
136
|
+
const cursor: Cursor = { kind: "after_anchor", anchor: { line: span.end } };
|
|
137
|
+
resolved.push({
|
|
138
|
+
kind: "insert",
|
|
139
|
+
cursor,
|
|
140
|
+
text: payload,
|
|
141
|
+
lineNum: edit.lineNum,
|
|
142
|
+
index: synthIndex++,
|
|
143
|
+
blockStart: span.start,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
// Mirror the parser's `replace start.=end:` expansion exactly: one
|
|
149
|
+
// `before_anchor` replacement insert per payload row at `span.start`,
|
|
150
|
+
// then one delete per line across `[span.start, span.end]`. An empty
|
|
151
|
+
// `payloads` (from `delete_block N`) emits no inserts — a pure deletion.
|
|
152
|
+
for (const payload of edit.payloads) {
|
|
153
|
+
const cursor: Cursor = { kind: "before_anchor", anchor: { line: span.start } };
|
|
154
|
+
resolved.push({
|
|
155
|
+
kind: "insert",
|
|
156
|
+
cursor,
|
|
157
|
+
text: payload,
|
|
158
|
+
lineNum: edit.lineNum,
|
|
159
|
+
index: synthIndex++,
|
|
160
|
+
mode: "replacement",
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
for (let line = span.start; line <= span.end; line++) {
|
|
164
|
+
resolved.push({ kind: "delete", anchor: { line }, lineNum: edit.lineNum, index: synthIndex++ });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return resolved;
|
|
168
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-number a unified diff that uses the `+<lineNum>|content` /
|
|
3
|
+
* `-<lineNum>|content` / ` <lineNum>|content` line format into a compact
|
|
4
|
+
* current-file preview. Removed lines are counted for stats and post-edit
|
|
5
|
+
* offset tracking, but omitted from the preview. Added and context lines are
|
|
6
|
+
* anchored to their post-edit positions so a follow-up edit can reuse visible
|
|
7
|
+
* concrete lines directly. Long contiguous added runs are summarized with a
|
|
8
|
+
* `…` marker instead of echoing every inserted line.
|
|
9
|
+
*
|
|
10
|
+
* This is intentionally decoupled from the diff producer: anything that
|
|
11
|
+
* emits the `<sign><lineNum>|<content>` shape works.
|
|
12
|
+
*/
|
|
13
|
+
import type { CompactDiffOptions, CompactDiffPreview } from "./types";
|
|
14
|
+
|
|
15
|
+
const DEFAULT_ADDED_RUN_CONTEXT_LINES = 2;
|
|
16
|
+
|
|
17
|
+
const PREVIEW_ELISION_MARKER = "…";
|
|
18
|
+
/** Blank row separating non-contiguous regions of a numbered diff. */
|
|
19
|
+
const PREVIEW_GAP_ROW = "";
|
|
20
|
+
const RAW_ELISION_MARKERS = new Set(["...", PREVIEW_ELISION_MARKER, `+${PREVIEW_ELISION_MARKER}`]);
|
|
21
|
+
|
|
22
|
+
function isPreviewSeparator(line: string | undefined): boolean {
|
|
23
|
+
return line === PREVIEW_ELISION_MARKER || line === PREVIEW_GAP_ROW;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function appendPreviewLine(output: string[], line: string): void {
|
|
27
|
+
const normalized = RAW_ELISION_MARKERS.has(line) ? PREVIEW_ELISION_MARKER : line;
|
|
28
|
+
// Separators (elision markers, blank gap rows) never stack: omitted
|
|
29
|
+
// removed lines between two separators would otherwise leave them
|
|
30
|
+
// adjacent. A leading separator is dropped outright.
|
|
31
|
+
if (isPreviewSeparator(normalized) && (output.length === 0 || isPreviewSeparator(output[output.length - 1]))) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
output.push(normalized);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ParsedDiffLine {
|
|
38
|
+
kind: "+" | "-" | " ";
|
|
39
|
+
lineNumber: number;
|
|
40
|
+
content: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeAddedRunContext(value: number | undefined): number {
|
|
44
|
+
if (value === undefined || !Number.isFinite(value)) return DEFAULT_ADDED_RUN_CONTEXT_LINES;
|
|
45
|
+
return Math.max(1, Math.trunc(value));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseNumberedDiffLine(line: string): ParsedDiffLine | undefined {
|
|
49
|
+
const kind = line[0];
|
|
50
|
+
if (kind !== "+" && kind !== "-" && kind !== " ") return undefined;
|
|
51
|
+
|
|
52
|
+
const body = line.slice(1);
|
|
53
|
+
const sep = body.indexOf("|");
|
|
54
|
+
if (sep === -1) return undefined;
|
|
55
|
+
|
|
56
|
+
const lineNumber = Number.parseInt(body.slice(0, sep), 10);
|
|
57
|
+
if (!Number.isFinite(lineNumber)) return undefined;
|
|
58
|
+
|
|
59
|
+
return { kind, lineNumber, content: body.slice(sep + 1) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function appendAddedRun(output: string[], run: string[], edgeLines: number): void {
|
|
63
|
+
if (run.length === 0) return;
|
|
64
|
+
|
|
65
|
+
const collapseThreshold = edgeLines * 2 + 1;
|
|
66
|
+
if (run.length <= collapseThreshold) {
|
|
67
|
+
for (const text of run) appendPreviewLine(output, text);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (let i = 0; i < edgeLines; i++) appendPreviewLine(output, run[i]);
|
|
72
|
+
appendPreviewLine(output, PREVIEW_ELISION_MARKER);
|
|
73
|
+
for (let i = run.length - edgeLines; i < run.length; i++) appendPreviewLine(output, run[i]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function buildCompactDiffPreview(diff: string, options: CompactDiffOptions = {}): CompactDiffPreview {
|
|
77
|
+
const lines = diff.length === 0 ? [] : diff.split("\n");
|
|
78
|
+
const addedRunContext = normalizeAddedRunContext(options.maxAddedRunContext ?? options.maxUnchangedRun);
|
|
79
|
+
let addedLines = 0;
|
|
80
|
+
let removedLines = 0;
|
|
81
|
+
const formatted: string[] = [];
|
|
82
|
+
const addedRun: string[] = [];
|
|
83
|
+
|
|
84
|
+
const flushAddedRun = (): void => {
|
|
85
|
+
appendAddedRun(formatted, addedRun, addedRunContext);
|
|
86
|
+
addedRun.length = 0;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// External diff producers number `+` lines with the post-edit line number,
|
|
90
|
+
// `-` lines with the pre-edit line number, and context lines with the
|
|
91
|
+
// pre-edit line number. To emit fresh line numbers usable for follow-up
|
|
92
|
+
// edits, convert context-line numbers to post-edit positions by tracking
|
|
93
|
+
// the running offset (added so far - removed so far) as we walk the diff.
|
|
94
|
+
for (const line of lines) {
|
|
95
|
+
const parsed = parseNumberedDiffLine(line);
|
|
96
|
+
if (!parsed) {
|
|
97
|
+
flushAddedRun();
|
|
98
|
+
appendPreviewLine(formatted, line);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
switch (parsed.kind) {
|
|
103
|
+
case "+": {
|
|
104
|
+
addedLines++;
|
|
105
|
+
addedRun.push(`${parsed.lineNumber}:${parsed.content}`);
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
case "-":
|
|
109
|
+
flushAddedRun();
|
|
110
|
+
removedLines++;
|
|
111
|
+
break;
|
|
112
|
+
default: {
|
|
113
|
+
flushAddedRun();
|
|
114
|
+
const newLineNumber = parsed.lineNumber + addedLines - removedLines;
|
|
115
|
+
appendPreviewLine(formatted, `${newLineNumber}:${parsed.content}`);
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
flushAddedRun();
|
|
121
|
+
while (formatted.length > 0 && isPreviewSeparator(formatted[formatted.length - 1])) formatted.pop();
|
|
122
|
+
|
|
123
|
+
return { preview: formatted.join("\n"), addedLines, removedLines };
|
|
124
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hashline format primitives: sigils, separators, regex fragments, and
|
|
3
|
+
* display helpers. These are the single source of truth for the parser, the
|
|
4
|
+
* tokenizer, the prompt, and the formal grammar.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Cursor } from "./types";
|
|
8
|
+
|
|
9
|
+
/** File-section header delimiters: `[path#hash]`. */
|
|
10
|
+
export const HL_FILE_PREFIX = "[";
|
|
11
|
+
export const HL_FILE_SUFFIX = "]";
|
|
12
|
+
|
|
13
|
+
/** Payload sigil for literal body rows. */
|
|
14
|
+
export const HL_PAYLOAD_REPLACE = "+";
|
|
15
|
+
|
|
16
|
+
/** Hunk-header keyword for concrete line replacement. */
|
|
17
|
+
export const HL_REPLACE_KEYWORD = "SWAP";
|
|
18
|
+
/** Hunk-header keyword for concrete line deletion. */
|
|
19
|
+
export const HL_DELETE_KEYWORD = "DEL";
|
|
20
|
+
/** Hunk-header keyword for insertion operations. */
|
|
21
|
+
export const HL_INSERT_KEYWORD = "INS";
|
|
22
|
+
/** Insert position keyword for inserting before a concrete line. */
|
|
23
|
+
export const HL_INSERT_BEFORE = "PRE";
|
|
24
|
+
/** Insert position keyword for inserting after a concrete line. */
|
|
25
|
+
export const HL_INSERT_AFTER = "POST";
|
|
26
|
+
/** Insert position keyword for inserting at the start of the file. */
|
|
27
|
+
export const HL_INSERT_HEAD = "HEAD";
|
|
28
|
+
/** Insert position keyword for inserting at the end of the file. */
|
|
29
|
+
export const HL_INSERT_TAIL = "TAIL";
|
|
30
|
+
/** Hunk-header keyword: `SWAP.BLK N:` resolves N to a tree-sitter block range and replaces its span. */
|
|
31
|
+
export const HL_REPLACE_BLOCK_KEYWORD = "SWAP.BLK";
|
|
32
|
+
/** Hunk-header keyword: `DEL.BLK N` resolves N to a tree-sitter block range and deletes its span. */
|
|
33
|
+
export const HL_DELETE_BLOCK_KEYWORD = "DEL.BLK";
|
|
34
|
+
/** Hunk-header keyword: `INS.BLK.POST N:` inserts after the last line of the tree-sitter block at N. */
|
|
35
|
+
export const HL_INSERT_AFTER_BLOCK_KEYWORD = "INS.BLK.POST";
|
|
36
|
+
/** File-level keyword: `REM` deletes the whole file named by the section header. */
|
|
37
|
+
export const HL_REM_KEYWORD = "REM";
|
|
38
|
+
/** File-level keyword: `MV DEST` renames/moves the section file to `DEST`. */
|
|
39
|
+
export const HL_MOVE_KEYWORD = "MV";
|
|
40
|
+
export const HL_HEADER_COLON = ":";
|
|
41
|
+
|
|
42
|
+
/** Separator between a hashline file path and its opaque snapshot tag. */
|
|
43
|
+
export const HL_FILE_HASH_SEP = "#";
|
|
44
|
+
|
|
45
|
+
/** Separator between two line numbers in a range, e.g. `5.=10`. */
|
|
46
|
+
export const HL_RANGE_SEP = ".=";
|
|
47
|
+
|
|
48
|
+
/** Separator between a line number and displayed line content in hashline mode. */
|
|
49
|
+
export const HL_LINE_BODY_SEP = ":";
|
|
50
|
+
|
|
51
|
+
function regexEscape(str: string): string {
|
|
52
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Bare positive line-number Lid (no decorations, no captures, no anchors). */
|
|
56
|
+
export const HL_LINE_RE_RAW = `[1-9]\\d*`;
|
|
57
|
+
|
|
58
|
+
/** Capture-group form of {@link HL_LINE_RE_RAW}. */
|
|
59
|
+
export const HL_LINE_CAPTURE_RE_RAW = `(${HL_LINE_RE_RAW})`;
|
|
60
|
+
|
|
61
|
+
/** Format a concrete replacement hunk header. */
|
|
62
|
+
export function formatReplaceHeader(start: number, end: number): string {
|
|
63
|
+
return `${HL_REPLACE_KEYWORD} ${start}${HL_RANGE_SEP}${end}${HL_HEADER_COLON}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Format a concrete deletion hunk header. */
|
|
67
|
+
export function formatDeleteHeader(start: number, end = start): string {
|
|
68
|
+
return start === end ? `${HL_DELETE_KEYWORD} ${start}` : `${HL_DELETE_KEYWORD} ${start}${HL_RANGE_SEP}${end}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Format an insertion hunk header for a cursor position. */
|
|
72
|
+
export function formatInsertHeader(cursor: Cursor): string {
|
|
73
|
+
switch (cursor.kind) {
|
|
74
|
+
case "before_anchor":
|
|
75
|
+
return `${HL_INSERT_KEYWORD}.${HL_INSERT_BEFORE} ${cursor.anchor.line}${HL_HEADER_COLON}`;
|
|
76
|
+
case "after_anchor":
|
|
77
|
+
return `${HL_INSERT_KEYWORD}.${HL_INSERT_AFTER} ${cursor.anchor.line}${HL_HEADER_COLON}`;
|
|
78
|
+
case "bof":
|
|
79
|
+
return `${HL_INSERT_KEYWORD}.${HL_INSERT_HEAD}${HL_HEADER_COLON}`;
|
|
80
|
+
case "eof":
|
|
81
|
+
return `${HL_INSERT_KEYWORD}.${HL_INSERT_TAIL}${HL_HEADER_COLON}`;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Number of hex characters in a content-derived file-hash tag. */
|
|
86
|
+
export const HL_FILE_HASH_LENGTH = 4;
|
|
87
|
+
/** Canonical uppercase hexadecimal content-hash tag carried by a hashline section header. */
|
|
88
|
+
export const HL_FILE_HASH_RE_RAW = `[0-9A-F]{${HL_FILE_HASH_LENGTH}}`;
|
|
89
|
+
/** Capture-group form of {@link HL_FILE_HASH_RE_RAW}. */
|
|
90
|
+
export const HL_FILE_HASH_CAPTURE_RE_RAW = `(${HL_FILE_HASH_RE_RAW})`;
|
|
91
|
+
/** Regex-escaped form of {@link HL_LINE_BODY_SEP}, safe for embedding inside a regex. */
|
|
92
|
+
export const HL_LINE_BODY_SEP_RE_RAW = regexEscape(HL_LINE_BODY_SEP);
|
|
93
|
+
/**
|
|
94
|
+
* Representative file-hash tags for use in user-facing error messages and
|
|
95
|
+
* prompt examples.
|
|
96
|
+
*/
|
|
97
|
+
export const HL_FILE_HASH_EXAMPLES = ["1A2B", "3C4D", "9F3E"] as const;
|
|
98
|
+
/**
|
|
99
|
+
* Normalize text before hashing: trim trailing `[ \t\r]` from every line (and
|
|
100
|
+
* the final line) in a single pass so CRLF endings and display-trimmed lines
|
|
101
|
+
* do not invalidate a tag.
|
|
102
|
+
*/
|
|
103
|
+
function normalizeFileHashText(text: string): string {
|
|
104
|
+
return text.replace(/[ \t\r]+(?=\n|$)/g, "");
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Compute the content-derived hash tag carried by a hashline section header.
|
|
108
|
+
* The tag is a 4-hex fingerprint of the whole file's normalized text: any read
|
|
109
|
+
* of byte-identical content mints the same tag, and a follow-up edit anchored
|
|
110
|
+
* at any line validates whenever the live file still hashes to it.
|
|
111
|
+
*/
|
|
112
|
+
export function computeFileHash(text: string): string {
|
|
113
|
+
const normalized = normalizeFileHashText(text);
|
|
114
|
+
const low16 = Bun.hash.xxHash32(normalized, 0) & 0xffff;
|
|
115
|
+
return low16.toString(16).padStart(HL_FILE_HASH_LENGTH, "0").toUpperCase();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Format a comma-separated list of example anchors with an optional line-number
|
|
120
|
+
* prefix, quoted for inclusion in error messages: `"160", "42", "7"`.
|
|
121
|
+
*/
|
|
122
|
+
export function describeAnchorExamples(linePrefix = ""): string {
|
|
123
|
+
const examples = linePrefix ? [linePrefix, `${linePrefix.slice(0, -1) || "4"}2`, "7"] : ["160", "42", "7"];
|
|
124
|
+
return examples.map(e => `"${e}"`).join(", ");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Format a hashline section header for a file path and snapshot tag. */
|
|
128
|
+
export function formatHashlineHeader(filePath: string, fileHash: string): string {
|
|
129
|
+
return `${HL_FILE_PREFIX}${filePath}${HL_FILE_HASH_SEP}${fileHash}${HL_FILE_SUFFIX}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Formats a single numbered line as `LINE:TEXT`. */
|
|
133
|
+
export function formatNumberedLine(lineNumber: number, line: string): string {
|
|
134
|
+
return `${lineNumber}${HL_LINE_BODY_SEP}${line}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Format file text with hashline-mode line-number prefixes for display. */
|
|
138
|
+
export function formatNumberedLines(text: string, startLine = 1): string {
|
|
139
|
+
const lines = text.split("\n");
|
|
140
|
+
return lines.map((line, i) => formatNumberedLine(startLine + i, line)).join("\n");
|
|
141
|
+
}
|
package/src/fs.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage seam for the hashline patcher. {@link Filesystem} is intentionally
|
|
3
|
+
* minimal — `readText`, `writeText`, `exists` — so any backing store can be
|
|
4
|
+
* adapted: disk, memory, S3, an LSP text-document protocol, a Git tree, a
|
|
5
|
+
* VFS, etc.
|
|
6
|
+
*
|
|
7
|
+
* The patcher does its own BOM stripping and LF normalization between
|
|
8
|
+
* {@link Filesystem.readText} and {@link Filesystem.writeText}; the FS deals
|
|
9
|
+
* only in raw text strings.
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from "node:fs/promises";
|
|
12
|
+
import * as pathModule from "node:path";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Result returned by {@link Filesystem.writeText}. The patcher echoes back
|
|
16
|
+
* `text` so adapters that transform on serialization (e.g. notebooks) can
|
|
17
|
+
* report what actually landed on disk.
|
|
18
|
+
*/
|
|
19
|
+
export interface WriteResult {
|
|
20
|
+
/** Final text that was persisted. May differ from the input if the FS transformed it. */
|
|
21
|
+
text: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
import type { FileOp } from "./types";
|
|
25
|
+
|
|
26
|
+
/** Optional hints for {@link Filesystem.preflightWrite}. */
|
|
27
|
+
export interface PreflightWriteOptions {
|
|
28
|
+
fileOp?: FileOp;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* ENOENT-like error thrown by {@link Filesystem.readText} when a path is
|
|
33
|
+
* missing. Carrying a `code` property keeps the contract compatible with
|
|
34
|
+
* `node:fs` callers that already check `err.code === "ENOENT"`.
|
|
35
|
+
*/
|
|
36
|
+
export class NotFoundError extends Error {
|
|
37
|
+
readonly code = "ENOENT";
|
|
38
|
+
|
|
39
|
+
constructor(path: string, cause?: unknown) {
|
|
40
|
+
super(`File not found: ${path}`);
|
|
41
|
+
this.name = "NotFoundError";
|
|
42
|
+
if (cause !== undefined) (this as Error & { cause?: unknown }).cause = cause;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Type guard for {@link NotFoundError} and structurally-compatible errors. */
|
|
47
|
+
export function isNotFound(error: unknown): boolean {
|
|
48
|
+
if (error instanceof NotFoundError) return true;
|
|
49
|
+
if (error instanceof Error && (error as Error & { code?: string }).code === "ENOENT") return true;
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Abstract storage backend the {@link Patcher} reads from and writes to.
|
|
55
|
+
* Subclass for new backends; the package ships {@link InMemoryFilesystem} and
|
|
56
|
+
* {@link NodeFilesystem} for the most common cases.
|
|
57
|
+
*
|
|
58
|
+
* Implementations work with raw text — the patcher handles BOM stripping and
|
|
59
|
+
* line-ending normalization itself. `readText` MUST throw {@link
|
|
60
|
+
* NotFoundError} (or any error for which {@link isNotFound} returns true)
|
|
61
|
+
* when the path doesn't exist; that's how the patcher detects a create-vs-
|
|
62
|
+
* update.
|
|
63
|
+
*/
|
|
64
|
+
export abstract class Filesystem {
|
|
65
|
+
/** Read the file's full text content. Throw on missing file. */
|
|
66
|
+
abstract readText(path: string): Promise<string>;
|
|
67
|
+
|
|
68
|
+
/** Read raw bytes for backends whose text is a direct decode of persisted bytes. */
|
|
69
|
+
readBinary?(path: string): Promise<Uint8Array | undefined>;
|
|
70
|
+
|
|
71
|
+
/** Validate that `path` is writable before a prepared batch starts committing. */
|
|
72
|
+
async preflightWrite(_path: string, _options?: PreflightWriteOptions): Promise<void> {}
|
|
73
|
+
|
|
74
|
+
/** Persist `content` at `path`. Returns the actual final text that was written. */
|
|
75
|
+
abstract writeText(path: string, content: string): Promise<WriteResult>;
|
|
76
|
+
|
|
77
|
+
/** Delete the file at `path`. Default: not supported. */
|
|
78
|
+
async delete(path: string): Promise<void> {
|
|
79
|
+
throw new Error(`Filesystem does not support delete: ${path}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Move/rename `from` to `to`. When `content` is provided the destination
|
|
84
|
+
* receives that text; otherwise implementations may preserve the source bytes.
|
|
85
|
+
*/
|
|
86
|
+
async move(from: string, to: string, content?: string): Promise<void> {
|
|
87
|
+
void content;
|
|
88
|
+
throw new Error(`Filesystem does not support move: ${from} -> ${to}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Return true when the path exists and can be read. Default: probe via {@link readText}. */
|
|
92
|
+
async exists(path: string): Promise<boolean> {
|
|
93
|
+
try {
|
|
94
|
+
await this.readText(path);
|
|
95
|
+
return true;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (isNotFound(error)) return false;
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Canonical path used as a key by external caches (e.g. snapshot
|
|
104
|
+
* stores). The default is identity; override to return an absolute or
|
|
105
|
+
* otherwise canonicalised path so producers and consumers of cached
|
|
106
|
+
* snapshots agree on the key without each having to redo the resolution.
|
|
107
|
+
*/
|
|
108
|
+
canonicalPath(path: string): string {
|
|
109
|
+
return path;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Whether a section whose authored path is missing may be redirected to
|
|
114
|
+
* the file its snapshot tag names (tag-based path recovery in
|
|
115
|
+
* {@link Patcher.prepare}). `resolvedPath` is the canonical path the
|
|
116
|
+
* redirect would read and write. Default: allow.
|
|
117
|
+
*
|
|
118
|
+
* Hosts that grant write privileges by path shape override this to refuse
|
|
119
|
+
* redirects that could escalate beyond what the caller approved — e.g. an
|
|
120
|
+
* internal-URL authored target (approved read-only), or a `resolvedPath`
|
|
121
|
+
* outside the working tree (a sandbox/vault/out-of-tree write).
|
|
122
|
+
*/
|
|
123
|
+
allowTagPathRecovery(_authoredPath: string, _resolvedPath: string): boolean {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* In-memory {@link Filesystem}. Useful for tests, sandboxes, dry-runs, and as
|
|
130
|
+
* a building block for stacked adapters (e.g. an LRU layer on top).
|
|
131
|
+
*/
|
|
132
|
+
export class InMemoryFilesystem extends Filesystem {
|
|
133
|
+
#files = new Map<string, string>();
|
|
134
|
+
|
|
135
|
+
constructor(initial?: Iterable<readonly [string, string]>) {
|
|
136
|
+
super();
|
|
137
|
+
if (initial) {
|
|
138
|
+
for (const [path, content] of initial) this.#files.set(path, content);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async readText(path: string): Promise<string> {
|
|
143
|
+
const text = this.#files.get(path);
|
|
144
|
+
if (text === undefined) throw new NotFoundError(path);
|
|
145
|
+
return text;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async writeText(path: string, content: string): Promise<WriteResult> {
|
|
149
|
+
this.#files.set(path, content);
|
|
150
|
+
return { text: content };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async delete(path: string): Promise<void> {
|
|
154
|
+
if (!this.#files.delete(path)) throw new NotFoundError(path);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async move(from: string, to: string, content?: string): Promise<void> {
|
|
158
|
+
const existing = this.#files.get(from);
|
|
159
|
+
if (existing === undefined) throw new NotFoundError(from);
|
|
160
|
+
const finalContent = content ?? existing;
|
|
161
|
+
this.#files.set(to, finalContent);
|
|
162
|
+
this.#files.delete(from);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async exists(path: string): Promise<boolean> {
|
|
166
|
+
return this.#files.has(path);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Synchronous helper for setting up fixtures without awaiting. */
|
|
170
|
+
set(path: string, content: string): void {
|
|
171
|
+
this.#files.set(path, content);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Synchronous helper for inspecting state without awaiting. */
|
|
175
|
+
get(path: string): string | undefined {
|
|
176
|
+
return this.#files.get(path);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Wipe all entries. */
|
|
180
|
+
clear(): void {
|
|
181
|
+
this.#files.clear();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Iterate `[path, content]` pairs. */
|
|
185
|
+
entries(): IterableIterator<[string, string]> {
|
|
186
|
+
return this.#files.entries();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Disk-backed {@link Filesystem} using Bun's file APIs. The default for CLI
|
|
192
|
+
* use. Paths are accepted as-is; callers responsible for any cwd or
|
|
193
|
+
* jail/sandbox resolution should wrap this with their own subclass.
|
|
194
|
+
*/
|
|
195
|
+
export class NodeFilesystem extends Filesystem {
|
|
196
|
+
async readText(path: string): Promise<string> {
|
|
197
|
+
const file = Bun.file(path);
|
|
198
|
+
if (!(await file.exists())) throw new NotFoundError(path);
|
|
199
|
+
return file.text();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async readBinary(path: string): Promise<Uint8Array> {
|
|
203
|
+
try {
|
|
204
|
+
return await fs.readFile(path);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (isNotFound(error)) throw new NotFoundError(path, error);
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async writeText(path: string, content: string): Promise<WriteResult> {
|
|
212
|
+
await Bun.write(path, content);
|
|
213
|
+
return { text: content };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async delete(path: string): Promise<void> {
|
|
217
|
+
try {
|
|
218
|
+
await fs.rm(path);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (isNotFound(error)) throw new NotFoundError(path, error);
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async move(from: string, to: string, content?: string): Promise<void> {
|
|
226
|
+
if (content !== undefined) {
|
|
227
|
+
await Bun.write(to, content);
|
|
228
|
+
await this.delete(from);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
await fs.rename(from, to);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
if (isNotFound(error)) throw new NotFoundError(from, error);
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
canonicalPath(path: string): string {
|
|
240
|
+
return pathModule.resolve(path);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async exists(path: string): Promise<boolean> {
|
|
244
|
+
return Bun.file(path).exists();
|
|
245
|
+
}
|
|
246
|
+
}
|