@oh-my-pi/pi-coding-agent 15.5.3 → 15.5.4
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 +29 -0
- package/dist/types/config/settings-schema.d.ts +27 -0
- package/dist/types/config.d.ts +31 -5
- package/dist/types/edit/file-snapshot-store.d.ts +18 -0
- package/dist/types/edit/hashline/diff.d.ts +30 -0
- package/dist/types/edit/hashline/execute.d.ts +29 -0
- package/dist/types/edit/hashline/filesystem.d.ts +57 -0
- package/dist/types/edit/hashline/index.d.ts +4 -0
- package/dist/types/edit/hashline/params.d.ts +12 -0
- package/dist/types/edit/index.d.ts +4 -3
- package/dist/types/edit/normalize.d.ts +4 -16
- package/dist/types/index.d.ts +0 -1
- package/dist/types/tools/index.d.ts +6 -5
- package/dist/types/tools/path-utils.d.ts +18 -0
- package/dist/types/utils/changelog.d.ts +8 -3
- package/package.json +8 -15
- package/src/config/settings-schema.ts +32 -0
- package/src/config.ts +42 -15
- package/src/edit/file-snapshot-store.ts +22 -0
- package/src/edit/hashline/diff.ts +88 -0
- package/src/edit/hashline/execute.ts +188 -0
- package/src/edit/hashline/filesystem.ts +129 -0
- package/src/edit/hashline/index.ts +4 -0
- package/src/edit/hashline/params.ts +11 -0
- package/src/edit/index.ts +7 -15
- package/src/edit/normalize.ts +11 -41
- package/src/edit/renderer.ts +1 -1
- package/src/edit/streaming.ts +8 -9
- package/src/index.ts +0 -1
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/sdk.ts +8 -1
- package/src/tools/ast-edit.ts +1 -1
- package/src/tools/ast-grep.ts +3 -3
- package/src/tools/index.ts +6 -5
- package/src/tools/path-utils.ts +81 -0
- package/src/tools/read.ts +14 -72
- package/src/tools/search.ts +136 -17
- package/src/tools/write.ts +3 -3
- package/src/utils/changelog.ts +11 -3
- package/src/utils/file-mentions.ts +1 -1
- package/dist/types/edit/file-read-cache.d.ts +0 -36
- package/dist/types/hashline/anchors.d.ts +0 -26
- package/dist/types/hashline/apply.d.ts +0 -14
- package/dist/types/hashline/constants.d.ts +0 -48
- package/dist/types/hashline/diff-preview.d.ts +0 -2
- package/dist/types/hashline/diff.d.ts +0 -16
- package/dist/types/hashline/execute.d.ts +0 -4
- package/dist/types/hashline/executor.d.ts +0 -56
- package/dist/types/hashline/hash.d.ts +0 -76
- package/dist/types/hashline/index.d.ts +0 -14
- package/dist/types/hashline/input.d.ts +0 -4
- package/dist/types/hashline/prefixes.d.ts +0 -7
- package/dist/types/hashline/recovery.d.ts +0 -21
- package/dist/types/hashline/stream.d.ts +0 -2
- package/dist/types/hashline/tokenizer.d.ts +0 -94
- package/dist/types/hashline/types.d.ts +0 -75
- package/src/edit/file-read-cache.ts +0 -138
- package/src/hashline/anchors.ts +0 -104
- package/src/hashline/apply.ts +0 -790
- package/src/hashline/bigrams.json +0 -649
- package/src/hashline/constants.ts +0 -60
- package/src/hashline/diff-preview.ts +0 -42
- package/src/hashline/diff.ts +0 -82
- package/src/hashline/execute.ts +0 -334
- package/src/hashline/executor.ts +0 -347
- package/src/hashline/grammar.lark +0 -22
- package/src/hashline/hash.ts +0 -131
- package/src/hashline/index.ts +0 -14
- package/src/hashline/input.ts +0 -137
- package/src/hashline/prefixes.ts +0 -111
- package/src/hashline/recovery.ts +0 -139
- package/src/hashline/stream.ts +0 -123
- package/src/hashline/tokenizer.ts +0 -473
- package/src/hashline/types.ts +0 -66
- package/src/prompts/tools/hashline.md +0 -83
package/src/hashline/executor.ts
DELETED
|
@@ -1,347 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
ABORT_WARNING,
|
|
3
|
-
IMPLICIT_CONTINUATION_WARNING,
|
|
4
|
-
INLINE_PAYLOAD_ACCEPTED_WARNING,
|
|
5
|
-
PAYLOAD_LINE_PREFIX_DEMOTED_WARNING,
|
|
6
|
-
REPLACE_PAIR_COALESCED_WARNING,
|
|
7
|
-
} from "./constants";
|
|
8
|
-
import {
|
|
9
|
-
HL_OP_CHARS,
|
|
10
|
-
HL_OP_DELETE,
|
|
11
|
-
HL_OP_INSERT_AFTER,
|
|
12
|
-
HL_OP_INSERT_BEFORE,
|
|
13
|
-
HL_OP_REPLACE,
|
|
14
|
-
HL_PAYLOAD_PREFIX,
|
|
15
|
-
} from "./hash";
|
|
16
|
-
import {
|
|
17
|
-
cloneCursor,
|
|
18
|
-
type HashlineToken,
|
|
19
|
-
HashlineTokenizer,
|
|
20
|
-
isDeleteOpWithPayload,
|
|
21
|
-
type ParsedRange,
|
|
22
|
-
} from "./tokenizer";
|
|
23
|
-
import type { Anchor, HashlineCursor, HashlineEdit } from "./types";
|
|
24
|
-
|
|
25
|
-
function validateRangeOrder(range: ParsedRange, lineNum: number): void {
|
|
26
|
-
if (range.end.line < range.start.line) {
|
|
27
|
-
throw new Error(`line ${lineNum}: range ${range.start.line}-${range.end.line} ends before it starts.`);
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function rangesEqual(a: ParsedRange, b: ParsedRange): boolean {
|
|
32
|
-
return a.start.line === b.start.line && a.end.line === b.end.line;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function rangeContains(outer: ParsedRange, inner: ParsedRange): boolean {
|
|
36
|
-
return outer.start.line <= inner.start.line && inner.end.line <= outer.end.line;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function expandRange(range: ParsedRange): Anchor[] {
|
|
40
|
-
const anchors: Anchor[] = [];
|
|
41
|
-
for (let line = range.start.line; line <= range.end.line; line++) {
|
|
42
|
-
anchors.push({ line });
|
|
43
|
-
}
|
|
44
|
-
return anchors;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
type PendingOp =
|
|
48
|
-
| { kind: "insert"; cursor: HashlineCursor; lineNum: number }
|
|
49
|
-
| { kind: "replace"; range: ParsedRange; lineNum: number };
|
|
50
|
-
|
|
51
|
-
interface Pending {
|
|
52
|
-
op: PendingOp;
|
|
53
|
-
payload: string[];
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Token-driven state machine that turns a stream of {@link HashlineToken}s
|
|
58
|
-
* into the flat list of {@link HashlineEdit}s applied downstream by the
|
|
59
|
-
* apply/diff layers.
|
|
60
|
-
*
|
|
61
|
-
* The executor owns:
|
|
62
|
-
* - the running edit index (kept monotonic across pending flushes),
|
|
63
|
-
* - the pending-payload buffer (lines accumulated for the most recently
|
|
64
|
-
* opened insert/replace op),
|
|
65
|
-
* - all parse-time diagnostics (range order, "delete with payload",
|
|
66
|
-
* orphan payload, unrecognized op),
|
|
67
|
-
* - the {@link terminated} flag set by `envelope-end`/`abort`.
|
|
68
|
-
*
|
|
69
|
-
* Tokens are dispatched in the order they arrive; the matching tokenizer
|
|
70
|
-
* supplies the line numbers carried inside each token so diagnostics line
|
|
71
|
-
* up with the source.
|
|
72
|
-
*/
|
|
73
|
-
export class HashlineExecutor {
|
|
74
|
-
#edits: HashlineEdit[] = [];
|
|
75
|
-
#warnings: string[] = [];
|
|
76
|
-
#editIndex = 0;
|
|
77
|
-
#pending: Pending | undefined;
|
|
78
|
-
#terminated = false;
|
|
79
|
-
|
|
80
|
-
/** True once an `envelope-end` or `abort` token has been observed. */
|
|
81
|
-
get terminated(): boolean {
|
|
82
|
-
return this.#terminated;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Consume one token. After `terminated` flips true subsequent feeds
|
|
87
|
-
* are silently ignored so callers can keep draining their tokenizer
|
|
88
|
-
* without explicit early-exit guards.
|
|
89
|
-
*/
|
|
90
|
-
feed(token: HashlineToken): void {
|
|
91
|
-
if (this.#terminated) return;
|
|
92
|
-
|
|
93
|
-
switch (token.kind) {
|
|
94
|
-
case "envelope-begin":
|
|
95
|
-
return;
|
|
96
|
-
case "envelope-end":
|
|
97
|
-
this.#terminated = true;
|
|
98
|
-
return;
|
|
99
|
-
case "abort":
|
|
100
|
-
this.#warnings.push(ABORT_WARNING);
|
|
101
|
-
this.#terminated = true;
|
|
102
|
-
return;
|
|
103
|
-
case "header":
|
|
104
|
-
this.#flushPending();
|
|
105
|
-
return;
|
|
106
|
-
case "blank":
|
|
107
|
-
return;
|
|
108
|
-
case "payload":
|
|
109
|
-
this.#handlePayload(token.text, token.lineNum);
|
|
110
|
-
return;
|
|
111
|
-
case "raw":
|
|
112
|
-
this.#handleRaw(token.text, token.lineNum);
|
|
113
|
-
return;
|
|
114
|
-
case "op-delete":
|
|
115
|
-
this.#flushPending();
|
|
116
|
-
if (token.trailingPayload) {
|
|
117
|
-
throw new Error(
|
|
118
|
-
`line ${token.lineNum}: ${HL_OP_DELETE} deletes only. Payload is forbidden after ${HL_OP_DELETE}; use ${HL_OP_REPLACE} to replace.`,
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
validateRangeOrder(token.range, token.lineNum);
|
|
122
|
-
for (const anchor of expandRange(token.range)) {
|
|
123
|
-
this.#edits.push({ kind: "delete", anchor, lineNum: token.lineNum, index: this.#editIndex++ });
|
|
124
|
-
}
|
|
125
|
-
return;
|
|
126
|
-
case "op-insert":
|
|
127
|
-
this.#flushPending();
|
|
128
|
-
this.#pending = {
|
|
129
|
-
op: { kind: "insert", cursor: token.cursor, lineNum: token.lineNum },
|
|
130
|
-
payload: [],
|
|
131
|
-
};
|
|
132
|
-
if (token.inlineBody !== undefined) {
|
|
133
|
-
this.#pending.payload.push(token.inlineBody);
|
|
134
|
-
if (!this.#warnings.includes(INLINE_PAYLOAD_ACCEPTED_WARNING)) {
|
|
135
|
-
this.#warnings.push(INLINE_PAYLOAD_ACCEPTED_WARNING);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
return;
|
|
139
|
-
case "op-replace":
|
|
140
|
-
validateRangeOrder(token.range, token.lineNum);
|
|
141
|
-
if (this.#pending !== undefined && this.#pending.op.kind === "replace") {
|
|
142
|
-
const outer = this.#pending.op.range;
|
|
143
|
-
const inner = token.range;
|
|
144
|
-
if (rangesEqual(outer, inner)) {
|
|
145
|
-
// Identical-range before/after pair. Drop the "before" payload
|
|
146
|
-
// silently; the second op proceeds as the lone winner. Other
|
|
147
|
-
// overlap shapes (different ranges, replace+delete, delete+delete)
|
|
148
|
-
// still hit the post-hoc validator.
|
|
149
|
-
this.#pending = undefined;
|
|
150
|
-
if (!this.#warnings.includes(REPLACE_PAIR_COALESCED_WARNING)) {
|
|
151
|
-
this.#warnings.push(REPLACE_PAIR_COALESCED_WARNING);
|
|
152
|
-
}
|
|
153
|
-
} else if (rangeContains(outer, inner)) {
|
|
154
|
-
// Model wrote a payload line in read-output `LINE:TEXT` format
|
|
155
|
-
// (or `A-B:TEXT` for a sub-range) inside an outer `A-B:` block.
|
|
156
|
-
// The tokenizer can't tell payload from op when the anchor and
|
|
157
|
-
// sigil shape are identical, so demote: append the op's inline
|
|
158
|
-
// body to the pending payload, strip the `LINE:` prefix, and
|
|
159
|
-
// keep accumulating. Without this the inner anchors would each
|
|
160
|
-
// register as their own delete and clash with the outer range.
|
|
161
|
-
this.#pending.payload.push(token.inlineBody ?? "");
|
|
162
|
-
if (!this.#warnings.includes(PAYLOAD_LINE_PREFIX_DEMOTED_WARNING)) {
|
|
163
|
-
this.#warnings.push(PAYLOAD_LINE_PREFIX_DEMOTED_WARNING);
|
|
164
|
-
}
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
this.#flushPending();
|
|
169
|
-
this.#pending = {
|
|
170
|
-
op: { kind: "replace", range: token.range, lineNum: token.lineNum },
|
|
171
|
-
payload: [],
|
|
172
|
-
};
|
|
173
|
-
if (token.inlineBody !== undefined) {
|
|
174
|
-
this.#pending.payload.push(token.inlineBody);
|
|
175
|
-
if (!this.#warnings.includes(INLINE_PAYLOAD_ACCEPTED_WARNING)) {
|
|
176
|
-
this.#warnings.push(INLINE_PAYLOAD_ACCEPTED_WARNING);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Flush any open pending op (with its full accumulated payload, including
|
|
185
|
-
* explicit `+` blank lines) and return the accumulated edits and warnings.
|
|
186
|
-
* The executor is single-use; reset() is required for reuse.
|
|
187
|
-
* Throws if two replace/delete ops target the same line with non-identical
|
|
188
|
-
* shapes (different ranges, replace+delete, delete+delete). Identical-range
|
|
189
|
-
* `A-B:` pairs in the same hunk are coalesced last-wins by `feed()` with a
|
|
190
|
-
* warning, so they never reach the validator.
|
|
191
|
-
*/
|
|
192
|
-
end(): { edits: HashlineEdit[]; warnings: string[] } {
|
|
193
|
-
this.#flushPending();
|
|
194
|
-
this.#validateNoOverlappingDeletes();
|
|
195
|
-
return { edits: this.#edits, warnings: this.#warnings };
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** Reset to a fresh state so the same instance can drive another parse. */
|
|
199
|
-
reset(): void {
|
|
200
|
-
this.#edits = [];
|
|
201
|
-
this.#warnings = [];
|
|
202
|
-
this.#editIndex = 0;
|
|
203
|
-
this.#pending = undefined;
|
|
204
|
-
this.#terminated = false;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Each `:` / `!` op contributes a delete edit per line in its range; if
|
|
209
|
-
* any line ends up targeted by deletes originating from two different
|
|
210
|
-
* source ops (distinguished by their `lineNum`), the patch is internally
|
|
211
|
-
* inconsistent. Identical-range `A-B:` pairs are already collapsed by
|
|
212
|
-
* `feed()`; remaining shapes here are an `A-B:` that overlaps a later
|
|
213
|
-
* `N!`/`N:` with a different range, or two `!` deletes on the same line.
|
|
214
|
-
* The applier would run both literally and the file would end up with two
|
|
215
|
-
* copies of the line, not a chosen winner.
|
|
216
|
-
*/
|
|
217
|
-
#validateNoOverlappingDeletes(): void {
|
|
218
|
-
const sourceLinesByAnchor = new Map<number, number[]>();
|
|
219
|
-
for (const edit of this.#edits) {
|
|
220
|
-
if (edit.kind !== "delete") continue;
|
|
221
|
-
let sourceLines = sourceLinesByAnchor.get(edit.anchor.line);
|
|
222
|
-
if (sourceLines === undefined) {
|
|
223
|
-
sourceLines = [];
|
|
224
|
-
sourceLinesByAnchor.set(edit.anchor.line, sourceLines);
|
|
225
|
-
}
|
|
226
|
-
if (!sourceLines.includes(edit.lineNum)) sourceLines.push(edit.lineNum);
|
|
227
|
-
}
|
|
228
|
-
for (const [anchorLine, sourceLines] of sourceLinesByAnchor) {
|
|
229
|
-
if (sourceLines.length < 2) continue;
|
|
230
|
-
const [firstOp, secondOp] = [...sourceLines].sort((a, b) => a - b);
|
|
231
|
-
throw new Error(
|
|
232
|
-
`line ${secondOp}: anchor line ${anchorLine} is already targeted by the ${HL_OP_REPLACE}/${HL_OP_DELETE} op on line ${firstOp}. ` +
|
|
233
|
-
`Issue ONE op per range; payload is only the final desired content, never a before/after pair.`,
|
|
234
|
-
);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
#handlePayload(text: string, lineNum: number): void {
|
|
239
|
-
if (this.#pending) {
|
|
240
|
-
this.#pending.payload.push(text);
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
throw new Error(
|
|
245
|
-
`line ${lineNum}: payload line has no preceding ${HL_OP_INSERT_BEFORE}, ${HL_OP_INSERT_AFTER}, ${HL_OP_REPLACE}, or ${HL_OP_DELETE} operation. ` +
|
|
246
|
-
`Got ${JSON.stringify(`${HL_PAYLOAD_PREFIX}${text}`)}.`,
|
|
247
|
-
);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
#handleRaw(text: string, lineNum: number): void {
|
|
251
|
-
if (this.#pending) {
|
|
252
|
-
if (text.trim().length === 0) return;
|
|
253
|
-
// Lenient legacy fallback: the tokenizer routes a line to `raw` only
|
|
254
|
-
// when it does not parse as an op, header, payload, or envelope
|
|
255
|
-
// marker. A `raw` token while a pending op exists is therefore an
|
|
256
|
-
// unambiguous continuation row that the model authored without the
|
|
257
|
-
// `+` prefix. Accept it as payload and warn so the canonical
|
|
258
|
-
// `+`-prefixed form remains preferred.
|
|
259
|
-
this.#pending.payload.push(text);
|
|
260
|
-
if (!this.#warnings.includes(IMPLICIT_CONTINUATION_WARNING)) {
|
|
261
|
-
this.#warnings.push(IMPLICIT_CONTINUATION_WARNING);
|
|
262
|
-
}
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// Whitespace-only raw lines outside any pending op are silently dropped;
|
|
267
|
-
// fully empty lines arrive as `blank` tokens.
|
|
268
|
-
if (text.trim().length === 0) return;
|
|
269
|
-
// Orphan raw text outside any pending op: pick the most specific
|
|
270
|
-
// diagnostic so the model sees the actionable hint.
|
|
271
|
-
if (isDeleteOpWithPayload(text)) {
|
|
272
|
-
throw new Error(
|
|
273
|
-
`line ${lineNum}: ${HL_OP_DELETE} deletes only. Payload is forbidden after ${HL_OP_DELETE}; use ${HL_OP_REPLACE} to replace.`,
|
|
274
|
-
);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
const firstChar = text[0];
|
|
278
|
-
const startsWithOp = firstChar !== undefined && HL_OP_CHARS.includes(firstChar);
|
|
279
|
-
if (startsWithOp || firstChar === "-" || firstChar === "@" || firstChar === "«" || firstChar === "»") {
|
|
280
|
-
throw new Error(
|
|
281
|
-
`line ${lineNum}: unrecognized op. Use LINE${HL_OP_INSERT_BEFORE} (insert before), LINE${HL_OP_INSERT_AFTER} (insert after), LINE${HL_OP_REPLACE} / A-B${HL_OP_REPLACE} (replace), or LINE${HL_OP_DELETE} / A-B${HL_OP_DELETE} (delete). ` +
|
|
282
|
-
`Got ${JSON.stringify(text)}.`,
|
|
283
|
-
);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
throw new Error(
|
|
287
|
-
`line ${lineNum}: payload line has no preceding ${HL_OP_INSERT_BEFORE}, ${HL_OP_INSERT_AFTER}, ${HL_OP_REPLACE}, or ${HL_OP_DELETE} operation. ` +
|
|
288
|
-
`Got ${JSON.stringify(text)}.`,
|
|
289
|
-
);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
#flushPending(): void {
|
|
293
|
-
const pending = this.#pending;
|
|
294
|
-
if (!pending) return;
|
|
295
|
-
|
|
296
|
-
const { op, payload } = pending;
|
|
297
|
-
const linesToInsert = payload.length === 0 ? [""] : payload;
|
|
298
|
-
|
|
299
|
-
if (op.kind === "insert") {
|
|
300
|
-
for (const text of linesToInsert) {
|
|
301
|
-
this.#edits.push({
|
|
302
|
-
kind: "insert",
|
|
303
|
-
cursor: cloneCursor(op.cursor),
|
|
304
|
-
text,
|
|
305
|
-
lineNum: op.lineNum,
|
|
306
|
-
index: this.#editIndex++,
|
|
307
|
-
});
|
|
308
|
-
}
|
|
309
|
-
} else {
|
|
310
|
-
for (const text of linesToInsert) {
|
|
311
|
-
this.#edits.push({
|
|
312
|
-
kind: "insert",
|
|
313
|
-
cursor: { kind: "before_anchor", anchor: { ...op.range.start } },
|
|
314
|
-
text,
|
|
315
|
-
lineNum: op.lineNum,
|
|
316
|
-
index: this.#editIndex++,
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
for (const anchor of expandRange(op.range)) {
|
|
320
|
-
this.#edits.push({ kind: "delete", anchor, lineNum: op.lineNum, index: this.#editIndex++ });
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
this.#pending = undefined;
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* Drive a full hashline diff through the tokenizer + executor pipeline and
|
|
330
|
-
* return the resulting edits plus any parse-time warnings. This is the
|
|
331
|
-
* convenience entry point most callers want; reach for {@link
|
|
332
|
-
* HashlineTokenizer}/{@link HashlineExecutor} directly only when you need
|
|
333
|
-
* streaming feeds, cross-section state, or custom token handling.
|
|
334
|
-
*/
|
|
335
|
-
export function parseHashline(diff: string): { edits: HashlineEdit[]; warnings: string[] } {
|
|
336
|
-
const tokenizer = new HashlineTokenizer();
|
|
337
|
-
const executor = new HashlineExecutor();
|
|
338
|
-
const drain = (tokens: HashlineToken[]): void => {
|
|
339
|
-
for (const token of tokens) {
|
|
340
|
-
if (executor.terminated) return;
|
|
341
|
-
executor.feed(token);
|
|
342
|
-
}
|
|
343
|
-
};
|
|
344
|
-
drain(tokenizer.feed(diff));
|
|
345
|
-
drain(tokenizer.end());
|
|
346
|
-
return executor.end();
|
|
347
|
-
}
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
start: begin_patch hunk+ end_patch
|
|
2
|
-
begin_patch: "*** Begin Patch" LF
|
|
3
|
-
end_patch: "*** End Patch" LF?
|
|
4
|
-
|
|
5
|
-
hunk: update_hunk
|
|
6
|
-
update_hunk: "$HFILE$" filename ("#" file_hash)? LF line_op*
|
|
7
|
-
|
|
8
|
-
filename: /([^\s#]+)/
|
|
9
|
-
file_hash: /[0-9a-f]{4}/
|
|
10
|
-
|
|
11
|
-
line_op: insert_before | insert_after | replace | delete
|
|
12
|
-
insert_before: anchor "$HOP_INSERT_BEFORE$" LF payload*
|
|
13
|
-
insert_after: anchor "$HOP_INSERT_AFTER$" LF payload*
|
|
14
|
-
replace: range "$HOP_REPLACE$" LF payload*
|
|
15
|
-
delete: range "$HOP_DELETE$" LF
|
|
16
|
-
payload: "+" /[^\n]*/ LF
|
|
17
|
-
|
|
18
|
-
anchor: LID | "EOF" | "BOF"
|
|
19
|
-
range: LID ("-" LID)?
|
|
20
|
-
LID: /[1-9]\d*/
|
|
21
|
-
|
|
22
|
-
%import common.LF
|
package/src/hashline/hash.ts
DELETED
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Core hash utilities shared by hashline edit mode, read/search output,
|
|
3
|
-
* and prompt helpers.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
const regexEscape = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Decoration prefix that may precede a line number in tool output:
|
|
10
|
-
* `>` (context line in grep), `-` (removed line), `*` (match line).
|
|
11
|
-
* Any combination, in any order, surrounded by optional
|
|
12
|
-
* whitespace. Output formatters emit at most one decoration per line; the
|
|
13
|
-
* parser stays liberal because it accepts whatever the model echoes back.
|
|
14
|
-
*/
|
|
15
|
-
export const HL_ANCHOR_DECORATION_RE_RAW = `\\s*[>\\-*]*\\s*`;
|
|
16
|
-
|
|
17
|
-
/** Capture-group regex source for a decorated bare line-number anchor. */
|
|
18
|
-
export const HL_ANCHOR_RE_RAW = `${HL_ANCHOR_DECORATION_RE_RAW}(\\d+)`;
|
|
19
|
-
|
|
20
|
-
/** Bare positive line-number Lid (no decorations, no captures, no anchors). */
|
|
21
|
-
export const HL_LINE_RE_RAW = `[1-9]\\d*`;
|
|
22
|
-
|
|
23
|
-
/** Capture-group form of {@link HL_LINE_RE_RAW}. */
|
|
24
|
-
export const HL_LINE_CAPTURE_RE_RAW = `([1-9]\\d*)`;
|
|
25
|
-
|
|
26
|
-
/** Four-hex-character file hash carried by a hashline section header. */
|
|
27
|
-
export const HL_FILE_HASH_RE_RAW = `[0-9a-f]{4}`;
|
|
28
|
-
|
|
29
|
-
/** Capture-group form of {@link HL_FILE_HASH_RE_RAW}. */
|
|
30
|
-
export const HL_FILE_HASH_CAPTURE_RE_RAW = `(${HL_FILE_HASH_RE_RAW})`;
|
|
31
|
-
|
|
32
|
-
/** Separator between a hashline file path and its file hash. */
|
|
33
|
-
export const HL_FILE_HASH_SEP = "#";
|
|
34
|
-
|
|
35
|
-
/** Separator between a line number and displayed line content in hashline mode. */
|
|
36
|
-
export const HL_LINE_BODY_SEP = ":";
|
|
37
|
-
|
|
38
|
-
/** Regex-escaped form of {@link HL_LINE_BODY_SEP}, safe for embedding inside a regex. */
|
|
39
|
-
export const HL_LINE_BODY_SEP_RE_RAW = regexEscape(HL_LINE_BODY_SEP);
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Representative file hashes for use in user-facing error messages and prompt
|
|
43
|
-
* examples.
|
|
44
|
-
*/
|
|
45
|
-
export const HL_FILE_HASH_EXAMPLES = ["1a2b", "3c4d", "9f3e"] as const;
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Format a comma-separated list of example anchors with an optional line-number
|
|
49
|
-
* prefix, quoted for inclusion in error messages: `"160", "42", "7"`.
|
|
50
|
-
*/
|
|
51
|
-
export function describeAnchorExamples(linePrefix = ""): string {
|
|
52
|
-
const examples = linePrefix ? [linePrefix, `${linePrefix.slice(0, -1) || "4"}2`, "7"] : ["160", "42", "7"];
|
|
53
|
-
return examples.map(e => `"${e}"`).join(", ");
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Substitute every grammar placeholder with the value derived from its
|
|
58
|
-
* TypeScript counterpart. Grammars that don't reference these placeholders
|
|
59
|
-
* pass through unchanged.
|
|
60
|
-
*/
|
|
61
|
-
export function resolveHashlineGrammarPlaceholders(grammar: string): string {
|
|
62
|
-
return grammar
|
|
63
|
-
.replaceAll("$HFMT$", "")
|
|
64
|
-
.replaceAll("$HFILE_HASH$", HL_FILE_HASH_RE_RAW)
|
|
65
|
-
.replaceAll("$HFILE_HASH_SEP$", HL_FILE_HASH_SEP)
|
|
66
|
-
.replaceAll("$HOP_INSERT_BEFORE$", HL_OP_INSERT_BEFORE)
|
|
67
|
-
.replaceAll("$HOP_INSERT_AFTER$", HL_OP_INSERT_AFTER)
|
|
68
|
-
.replaceAll("$HOP_REPLACE$", HL_OP_REPLACE)
|
|
69
|
-
.replaceAll("$HOP_DELETE$", HL_OP_DELETE)
|
|
70
|
-
.replaceAll("$HOP_CHARS$", HL_OP_CHARS)
|
|
71
|
-
.replaceAll("$HFILE$", HL_FILE_PREFIX);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* op lines have an `ANCHOR<SIGIL>[INLINE_PAYLOAD]` shape, where SIGIL is one of
|
|
76
|
-
* {@link HL_OP_INSERT_BEFORE}, {@link HL_OP_INSERT_AFTER}, {@link HL_OP_REPLACE},
|
|
77
|
-
* or {@link HL_OP_DELETE}. Multi-line payloads follow on subsequent lines
|
|
78
|
-
* prefixed with {@link HL_PAYLOAD_PREFIX}; that prefix is stripped before the
|
|
79
|
-
* payload is written.
|
|
80
|
-
*
|
|
81
|
-
* These constants are the single source of truth for the edit parser, grammar,
|
|
82
|
-
* renderer, and prompt.
|
|
83
|
-
*/
|
|
84
|
-
export const HL_OP_INSERT_BEFORE = "↑";
|
|
85
|
-
export const HL_OP_INSERT_AFTER = "↓";
|
|
86
|
-
export const HL_OP_REPLACE = ":";
|
|
87
|
-
export const HL_OP_DELETE = "!";
|
|
88
|
-
|
|
89
|
-
/** Prefix for payload continuation lines. The prefix itself is not written. */
|
|
90
|
-
export const HL_PAYLOAD_PREFIX = "+";
|
|
91
|
-
|
|
92
|
-
/** All hashline edit op sigils, concatenated for fast membership tests. */
|
|
93
|
-
export const HL_OP_CHARS = `${HL_OP_INSERT_BEFORE}${HL_OP_INSERT_AFTER}${HL_OP_REPLACE}${HL_OP_DELETE}`;
|
|
94
|
-
|
|
95
|
-
/** Hashline edit file section header marker. */
|
|
96
|
-
export const HL_FILE_PREFIX = "¶";
|
|
97
|
-
|
|
98
|
-
function normalizeFileHashText(text: string): string {
|
|
99
|
-
return text
|
|
100
|
-
.replace(/\r/g, "")
|
|
101
|
-
.split("\n")
|
|
102
|
-
.map(line => line.trimEnd())
|
|
103
|
-
.join("\n");
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Compute the 4-hex-character hash carried by a hashline section header.
|
|
108
|
-
* The hash normalizes CR characters and trailing whitespace before hashing so
|
|
109
|
-
* platform line endings and display-trimmed lines do not invalidate anchors.
|
|
110
|
-
*/
|
|
111
|
-
export function computeFileHash(text: string): string {
|
|
112
|
-
const normalized = normalizeFileHashText(text);
|
|
113
|
-
const low16 = Bun.hash.xxHash32(normalized, 0) & 0xffff;
|
|
114
|
-
return low16.toString(16).padStart(4, "0");
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** Format a hashline section header for a file path and file hash. */
|
|
118
|
-
export function formatHashlineHeader(filePath: string, fileHash: string): string {
|
|
119
|
-
return `${HL_FILE_PREFIX}${filePath}${HL_FILE_HASH_SEP}${fileHash}`;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** Formats a single numbered line as `LINE:TEXT`. */
|
|
123
|
-
export function formatNumberedLine(lineNumber: number, line: string): string {
|
|
124
|
-
return `${lineNumber}${HL_LINE_BODY_SEP}${line}`;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** Format file text with hashline-mode line-number prefixes for display. */
|
|
128
|
-
export function formatNumberedLines(text: string, startLine = 1): string {
|
|
129
|
-
const lines = text.split("\n");
|
|
130
|
-
return lines.map((line, i) => formatNumberedLine(startLine + i, line)).join("\n");
|
|
131
|
-
}
|
package/src/hashline/index.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
export * from "./anchors";
|
|
2
|
-
export * from "./apply";
|
|
3
|
-
export * from "./constants";
|
|
4
|
-
export * from "./diff";
|
|
5
|
-
export * from "./diff-preview";
|
|
6
|
-
export * from "./execute";
|
|
7
|
-
export * from "./executor";
|
|
8
|
-
export * from "./hash";
|
|
9
|
-
export * from "./input";
|
|
10
|
-
export * from "./prefixes";
|
|
11
|
-
export * from "./recovery";
|
|
12
|
-
export * from "./stream";
|
|
13
|
-
export * from "./tokenizer";
|
|
14
|
-
export * from "./types";
|
package/src/hashline/input.ts
DELETED
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
import * as path from "node:path";
|
|
2
|
-
import { HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./hash";
|
|
3
|
-
import { HashlineTokenizer } from "./tokenizer";
|
|
4
|
-
import type { HashlineInputSection, SplitHashlineOptions } from "./types";
|
|
5
|
-
|
|
6
|
-
// Pure classification — single shared tokenizer is safe.
|
|
7
|
-
const TOKENIZER = new HashlineTokenizer();
|
|
8
|
-
|
|
9
|
-
function unquoteHashlinePath(pathText: string): string {
|
|
10
|
-
if (pathText.length < 2) return pathText;
|
|
11
|
-
const first = pathText[0];
|
|
12
|
-
const last = pathText[pathText.length - 1];
|
|
13
|
-
if ((first === '"' || first === "'") && first === last) return pathText.slice(1, -1);
|
|
14
|
-
return pathText;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function normalizeHashlinePath(rawPath: string, cwd?: string): string {
|
|
18
|
-
const unquoted = unquoteHashlinePath(rawPath.trim());
|
|
19
|
-
if (!cwd || !path.isAbsolute(unquoted)) return unquoted;
|
|
20
|
-
const relative = path.relative(path.resolve(cwd), path.resolve(unquoted));
|
|
21
|
-
const isWithinCwd = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
22
|
-
return isWithinCwd ? relative || "." : unquoted;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Parse a `¶PATH[#hash]` header line. Returns `null` for lines that do not
|
|
27
|
-
* begin with the `¶` prefix; throws the existing "Input header must be …"
|
|
28
|
-
* error when a `¶`-prefixed line fails the strict shape (so malformed paths
|
|
29
|
-
* surface immediately instead of being silently re-classified as payload).
|
|
30
|
-
*/
|
|
31
|
-
function parseHashlineHeaderLine(line: string, cwd?: string): HashlineInputSection | null {
|
|
32
|
-
const trimmed = line.trimEnd();
|
|
33
|
-
if (!trimmed.startsWith(HL_FILE_PREFIX)) return null;
|
|
34
|
-
|
|
35
|
-
const token = TOKENIZER.tokenize(trimmed);
|
|
36
|
-
if (token.kind !== "header") {
|
|
37
|
-
throw new Error(
|
|
38
|
-
`Input header must be ${HL_FILE_PREFIX}PATH or ${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}HASH with a 4-hex file hash; got ${JSON.stringify(trimmed)}.`,
|
|
39
|
-
);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const parsedPath = normalizeHashlinePath(token.path, cwd);
|
|
43
|
-
if (parsedPath.length === 0) {
|
|
44
|
-
throw new Error(`Input header "${HL_FILE_PREFIX}" is empty; provide a file path.`);
|
|
45
|
-
}
|
|
46
|
-
return token.fileHash !== undefined
|
|
47
|
-
? { path: parsedPath, fileHash: token.fileHash, diff: "" }
|
|
48
|
-
: { path: parsedPath, diff: "" };
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function stripLeadingBlankLines(input: string): string {
|
|
52
|
-
const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
|
|
53
|
-
const lines = stripped.split("\n");
|
|
54
|
-
while (lines.length > 0) {
|
|
55
|
-
const head = lines[0].replace(/\r$/, "");
|
|
56
|
-
if (head.trim().length === 0 || TOKENIZER.tokenize(head).kind === "envelope-begin") {
|
|
57
|
-
lines.shift();
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
60
|
-
break;
|
|
61
|
-
}
|
|
62
|
-
return lines.join("\n");
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function containsRecognizableHashlineOperations(input: string): boolean {
|
|
66
|
-
for (const line of input.split(/\r?\n/)) {
|
|
67
|
-
if (TOKENIZER.isOp(line)) return true;
|
|
68
|
-
}
|
|
69
|
-
return false;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function normalizeFallbackInput(input: string, options: SplitHashlineOptions): string {
|
|
73
|
-
const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
|
|
74
|
-
const hasExplicitHeader = stripped
|
|
75
|
-
.split(/\r?\n/)
|
|
76
|
-
.some(rawLine => parseHashlineHeaderLine(rawLine, options.cwd) !== null);
|
|
77
|
-
if (hasExplicitHeader) return input;
|
|
78
|
-
|
|
79
|
-
if (!options.path || !containsRecognizableHashlineOperations(input)) return input;
|
|
80
|
-
const fallbackPath = normalizeHashlinePath(options.path, options.cwd);
|
|
81
|
-
if (fallbackPath.length === 0) return input;
|
|
82
|
-
return `${HL_FILE_PREFIX}${fallbackPath}\n${input}`;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export function splitHashlineInput(input: string, options: SplitHashlineOptions = {}): HashlineInputSection {
|
|
86
|
-
const [section] = splitHashlineInputs(input, options);
|
|
87
|
-
return section;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export function splitHashlineInputs(input: string, options: SplitHashlineOptions = {}): HashlineInputSection[] {
|
|
91
|
-
const stripped = stripLeadingBlankLines(normalizeFallbackInput(input, options));
|
|
92
|
-
const lines = stripped.split(/\r?\n/);
|
|
93
|
-
const firstLine = lines[0] ?? "";
|
|
94
|
-
|
|
95
|
-
if (parseHashlineHeaderLine(firstLine, options.cwd) === null) {
|
|
96
|
-
const preview = JSON.stringify(firstLine.slice(0, 120));
|
|
97
|
-
throw new Error(
|
|
98
|
-
`input must begin with "${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}HASH" on the first non-blank line for anchored edits; got: ${preview}. ` +
|
|
99
|
-
`Example: "${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}1a2b" then edit ops.`,
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const sections: HashlineInputSection[] = [];
|
|
104
|
-
let current: HashlineInputSection | undefined;
|
|
105
|
-
let currentLines: string[] = [];
|
|
106
|
-
|
|
107
|
-
const flush = () => {
|
|
108
|
-
if (!current) return;
|
|
109
|
-
const hasOps = currentLines.some(line => line.trim().length > 0);
|
|
110
|
-
if (hasOps) sections.push({ ...current, diff: currentLines.join("\n") });
|
|
111
|
-
currentLines = [];
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
for (const line of lines) {
|
|
115
|
-
const trimmed = line.trimEnd();
|
|
116
|
-
const token = TOKENIZER.tokenize(line);
|
|
117
|
-
if (token.kind === "envelope-end" || token.kind === "abort") break;
|
|
118
|
-
if (token.kind === "envelope-begin") continue;
|
|
119
|
-
|
|
120
|
-
// Route every `¶`-prefixed line through parseHashlineHeaderLine so
|
|
121
|
-
// malformed headers still raise the strict "Input header must be …"
|
|
122
|
-
// diagnostic (the tokenizer alone would silently classify them as
|
|
123
|
-
// payload).
|
|
124
|
-
if (trimmed.startsWith(HL_FILE_PREFIX)) {
|
|
125
|
-
const header = parseHashlineHeaderLine(line, options.cwd);
|
|
126
|
-
if (header !== null) {
|
|
127
|
-
flush();
|
|
128
|
-
current = header;
|
|
129
|
-
currentLines = [];
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
currentLines.push(line);
|
|
134
|
-
}
|
|
135
|
-
flush();
|
|
136
|
-
return sections;
|
|
137
|
-
}
|