@oh-my-pi/pi-coding-agent 14.5.7 → 14.5.9
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 +43 -0
- package/package.json +7 -7
- package/src/config/model-registry.ts +23 -1
- package/src/config/settings-schema.ts +23 -0
- package/src/edit/modes/atom.lark +7 -5
- package/src/edit/modes/atom.ts +462 -56
- package/src/edit/modes/hashline.ts +21 -1
- package/src/lsp/index.ts +2 -4
- package/src/lsp/render.ts +0 -3
- package/src/lsp/types.ts +1 -4
- package/src/lsp/utils.ts +18 -14
- package/src/modes/components/settings-defs.ts +10 -0
- package/src/modes/controllers/command-controller.ts +17 -0
- package/src/modes/controllers/event-controller.ts +14 -9
- package/src/modes/controllers/input-controller.ts +13 -1
- package/src/modes/interactive-mode.ts +44 -23
- package/src/modes/types.ts +5 -2
- package/src/modes/utils/context-usage.ts +294 -0
- package/src/prompts/tools/atom.md +99 -44
- package/src/prompts/tools/exit-plan-mode.md +5 -39
- package/src/prompts/tools/lsp.md +2 -3
- package/src/prompts/tools/recipe.md +16 -0
- package/src/prompts/tools/task.md +34 -147
- package/src/prompts/tools/todo-write.md +22 -64
- package/src/session/compaction/compaction.ts +35 -22
- package/src/session/session-dump-format.ts +1 -0
- package/src/slash-commands/builtin-registry.ts +12 -5
- package/src/tools/bash.ts +149 -115
- package/src/tools/debug.ts +57 -70
- package/src/tools/index.ts +11 -0
- package/src/tools/recipe/index.ts +80 -0
- package/src/tools/recipe/render.ts +19 -0
- package/src/tools/recipe/runner.ts +219 -0
- package/src/tools/recipe/runners/cargo.ts +131 -0
- package/src/tools/recipe/runners/index.ts +8 -0
- package/src/tools/recipe/runners/just.ts +73 -0
- package/src/tools/recipe/runners/make.ts +101 -0
- package/src/tools/recipe/runners/pkg.ts +165 -0
- package/src/tools/recipe/runners/task.ts +72 -0
- package/src/tools/renderers.ts +2 -0
package/src/edit/modes/atom.ts
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
* @Lid move cursor to just after the anchored line
|
|
8
8
|
* Lid=TEXT set the anchored line to TEXT and move cursor after it
|
|
9
9
|
* -Lid delete the anchored line and move cursor to its slot
|
|
10
|
+
* LidA..LidB=TEXT replace a range; following \TEXT lines continue it
|
|
11
|
+
* \TEXT append TEXT to the active replacement (set or range)
|
|
10
12
|
* +TEXT insert TEXT at the cursor
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
+
* ^ move cursor to beginning of file
|
|
14
|
+
* $ move cursor to end of file
|
|
13
15
|
*/
|
|
14
16
|
|
|
15
17
|
import * as fs from "node:fs/promises";
|
|
@@ -57,6 +59,11 @@ export type AtomParams = Static<typeof atomEditParamsSchema>;
|
|
|
57
59
|
const LID_RE = /^([1-9]\d*)([a-z]{2})/;
|
|
58
60
|
const LID_EXACT_RE = /^([1-9]\d*)([a-z]{2})$/;
|
|
59
61
|
|
|
62
|
+
// Sentinel hash used for interior line anchors synthesized from `-LidA..LidB`
|
|
63
|
+
// range deletes. validateAtomAnchors recognizes this and skips hash checking
|
|
64
|
+
// (only the start and end Lids' hashes are validated by the user).
|
|
65
|
+
const RANGE_INTERIOR_HASH = "**";
|
|
66
|
+
|
|
60
67
|
interface ParsedAnchor {
|
|
61
68
|
line: number;
|
|
62
69
|
hash: string;
|
|
@@ -67,6 +74,7 @@ type ParsedOp = { op: "set"; text: string; allowOldNewRepair: boolean } | { op:
|
|
|
67
74
|
type AnchorStmt =
|
|
68
75
|
| { kind: "bare_anchor"; anchor: ParsedAnchor; lineNum: number }
|
|
69
76
|
| { kind: "anchor_op"; anchor: ParsedAnchor; op: ParsedOp; lineNum: number }
|
|
77
|
+
| { kind: "before_anchor"; anchor: ParsedAnchor; lineNum: number }
|
|
70
78
|
| { kind: "bof"; lineNum: number }
|
|
71
79
|
| { kind: "eof"; lineNum: number };
|
|
72
80
|
|
|
@@ -79,6 +87,7 @@ type InsertStmt = {
|
|
|
79
87
|
type DiffishAddStmt = {
|
|
80
88
|
kind: "diffish_add";
|
|
81
89
|
anchor: ParsedAnchor;
|
|
90
|
+
separator: "=" | "|";
|
|
82
91
|
text: string;
|
|
83
92
|
lineNum: number;
|
|
84
93
|
};
|
|
@@ -92,7 +101,11 @@ type DeleteWithOldStmt = {
|
|
|
92
101
|
|
|
93
102
|
type ParsedStmt = AnchorStmt | InsertStmt | DiffishAddStmt | DeleteWithOldStmt;
|
|
94
103
|
|
|
95
|
-
type AtomCursor =
|
|
104
|
+
type AtomCursor =
|
|
105
|
+
| { kind: "bof" }
|
|
106
|
+
| { kind: "eof" }
|
|
107
|
+
| { kind: "anchor"; anchor: Anchor }
|
|
108
|
+
| { kind: "before_anchor"; anchor: Anchor };
|
|
96
109
|
|
|
97
110
|
export type AtomEdit =
|
|
98
111
|
| { kind: "insert"; cursor: AtomCursor; text: string; lineNum: number; index: number }
|
|
@@ -119,11 +132,12 @@ interface IndexedAnchorEdit {
|
|
|
119
132
|
}
|
|
120
133
|
|
|
121
134
|
function cloneCursor(cursor: AtomCursor): AtomCursor {
|
|
122
|
-
if (cursor.kind
|
|
123
|
-
return { kind: "
|
|
135
|
+
if (cursor.kind === "anchor") return { kind: "anchor", anchor: { ...cursor.anchor } };
|
|
136
|
+
if (cursor.kind === "before_anchor") return { kind: "before_anchor", anchor: { ...cursor.anchor } };
|
|
137
|
+
return cursor;
|
|
124
138
|
}
|
|
125
139
|
|
|
126
|
-
function parseLidStmt(body: string, lineNum: number):
|
|
140
|
+
function parseLidStmt(body: string, lineNum: number): ParsedStmt[] | null {
|
|
127
141
|
const m = LID_RE.exec(body);
|
|
128
142
|
if (!m) return null;
|
|
129
143
|
|
|
@@ -131,22 +145,133 @@ function parseLidStmt(body: string, lineNum: number): AnchorStmt | null {
|
|
|
131
145
|
const hash = m[2];
|
|
132
146
|
const rest = body.slice(m[0].length);
|
|
133
147
|
const anchor = { line: ln, hash };
|
|
148
|
+
|
|
149
|
+
// Range replace: `LidA..LidB=TEXT` deletes the inclusive range LidA..LidB
|
|
150
|
+
// and inserts TEXT in its place. Following insert statements append more
|
|
151
|
+
// replacement lines through the normal hunk reorder path. Legacy `|` is
|
|
152
|
+
// accepted as a set separator for parity with single-line `Lid|TEXT`.
|
|
153
|
+
// Bare `LidA..LidB` recovers the common missing-`-` typo for range delete.
|
|
154
|
+
if (rest.startsWith("..")) {
|
|
155
|
+
const m2 = LID_RE.exec(rest.slice(2));
|
|
156
|
+
if (m2) {
|
|
157
|
+
const endLn = Number.parseInt(m2[1], 10);
|
|
158
|
+
const endHash = m2[2];
|
|
159
|
+
const after = rest.slice(2 + m2[0].length);
|
|
160
|
+
const range = `${ln}${hash}..${endLn}${endHash}`;
|
|
161
|
+
if (endLn < ln) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`Diff line ${lineNum}: range \`${range}\` ends before it starts. Use \`LidA..LidB=TEXT\` with LidA's line number ≤ LidB's.`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
if (endLn === ln && endHash !== hash) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`Diff line ${lineNum}: range \`${range}\` uses two different hashes for the same line. Copy the same Lid at both endpoints or use \`${ln}${hash}=TEXT\` for a single-line replacement.`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const stmts: ParsedStmt[] = [];
|
|
173
|
+
for (let l = ln; l <= endLn; l++) {
|
|
174
|
+
const h = l === ln ? hash : l === endLn ? endHash : RANGE_INTERIOR_HASH;
|
|
175
|
+
stmts.push({
|
|
176
|
+
kind: "anchor_op",
|
|
177
|
+
anchor: { line: l, hash: h },
|
|
178
|
+
op: { op: "delete" },
|
|
179
|
+
lineNum,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (after.trim().length === 0) return stmts;
|
|
184
|
+
|
|
185
|
+
const replacement = /^[ \t]*([=|])(.*)$/.exec(after);
|
|
186
|
+
if (replacement) {
|
|
187
|
+
if (replacement[2].includes("\r")) {
|
|
188
|
+
throw new Error(`Diff line ${lineNum}: set value contains a carriage return; use a single-line value.`);
|
|
189
|
+
}
|
|
190
|
+
stmts.push({ kind: "insert", text: replacement[2], lineNum });
|
|
191
|
+
return stmts;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
134
196
|
if (rest.length === 0) {
|
|
135
|
-
return { kind: "bare_anchor", anchor, lineNum };
|
|
197
|
+
return [{ kind: "bare_anchor", anchor, lineNum }];
|
|
136
198
|
}
|
|
137
199
|
|
|
138
200
|
const replacement = /^[ \t]*([=|])(.*)$/.exec(rest);
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
201
|
+
if (replacement) {
|
|
202
|
+
return [
|
|
203
|
+
{
|
|
204
|
+
kind: "anchor_op",
|
|
205
|
+
anchor,
|
|
206
|
+
op: { op: "set", text: replacement[2], allowOldNewRepair: replacement[1] === "|" },
|
|
207
|
+
lineNum,
|
|
208
|
+
},
|
|
209
|
+
];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Compound shorthand: `Lid+TEXT` collapses cursor-after-Lid + insert TEXT.
|
|
213
|
+
// Models sometimes write a run like `103rd=A` / `103rd+B` / `103rd+C` to
|
|
214
|
+
// mean "set 103 to A, then insert B and C below it". Treat each `Lid+...`
|
|
215
|
+
// as an independent cursor-move + insert; this matches semantics of the
|
|
216
|
+
// canonical `@Lid` + `+TEXT` two-line form.
|
|
217
|
+
if (rest[0] === "+") {
|
|
218
|
+
return [
|
|
219
|
+
{ kind: "bare_anchor", anchor, lineNum },
|
|
220
|
+
{ kind: "insert", text: rest.slice(1), lineNum },
|
|
221
|
+
];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return null;
|
|
146
225
|
}
|
|
147
226
|
|
|
148
227
|
function parseDeleteStmt(body: string, lineNum: number): ParsedStmt[] | null {
|
|
149
228
|
const trimmedBody = body.trimStart();
|
|
229
|
+
|
|
230
|
+
// Range delete: `-LidA..LidB` deletes the contiguous range LidA..LidB inclusive.
|
|
231
|
+
const rangeRe = /^([1-9]\d*)([a-z]{2})\.\.([1-9]\d*)([a-z]{2})$/;
|
|
232
|
+
const rangeMatch = rangeRe.exec(trimmedBody);
|
|
233
|
+
if (rangeMatch) {
|
|
234
|
+
const startLine = Number.parseInt(rangeMatch[1], 10);
|
|
235
|
+
const startHash = rangeMatch[2];
|
|
236
|
+
const endLine = Number.parseInt(rangeMatch[3], 10);
|
|
237
|
+
const endHash = rangeMatch[4];
|
|
238
|
+
if (endLine < startLine) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`Diff line ${lineNum}: range \`-${startLine}${startHash}..${endLine}${endHash}\` ends before it starts. Use \`-LidA..LidB\` with LidA's line number ≤ LidB's.`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
if (endLine === startLine && endHash !== startHash) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`Diff line ${lineNum}: range \`-${startLine}${startHash}..${endLine}${endHash}\` uses two different hashes for the same line. Copy the same Lid at both endpoints or use \`-${startLine}${startHash}\` for a single-line delete.`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
const stmts: ParsedStmt[] = [];
|
|
249
|
+
for (let ln = startLine; ln <= endLine; ln++) {
|
|
250
|
+
const hash = ln === startLine ? startHash : ln === endLine ? endHash : RANGE_INTERIOR_HASH;
|
|
251
|
+
stmts.push({
|
|
252
|
+
kind: "anchor_op",
|
|
253
|
+
anchor: { line: ln, hash },
|
|
254
|
+
op: { op: "delete" },
|
|
255
|
+
lineNum,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return stmts;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// `-LidA..LidB|TEXT` and `-LidA..LidB=TEXT` are not valid: ranges have no
|
|
262
|
+
// `|` (delete-with-old) form. Models reach for these when trying to
|
|
263
|
+
// "delete the range and replace with TEXT" — point at the `LidA..LidB=TEXT`
|
|
264
|
+
// shorthand instead.
|
|
265
|
+
const rangeWithSuffix = /^([1-9]\d*)([a-z]{2})\.\.([1-9]\d*)([a-z]{2})[ \t]*[=|](.*)$/.exec(trimmedBody);
|
|
266
|
+
if (rangeWithSuffix) {
|
|
267
|
+
const lidA = `${rangeWithSuffix[1]}${rangeWithSuffix[2]}`;
|
|
268
|
+
const lidB = `${rangeWithSuffix[3]}${rangeWithSuffix[4]}`;
|
|
269
|
+
const text = rangeWithSuffix[5];
|
|
270
|
+
throw new Error(
|
|
271
|
+
`Diff line ${lineNum}: \`-${lidA}..${lidB}\` cannot have a \`|\`/\`=\` suffix. To delete the range, use \`-${lidA}..${lidB}\` alone. To replace the range with one new line, drop the leading \`-\` and use \`${lidA}..${lidB}=${text}\`.`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
150
275
|
const exact = LID_EXACT_RE.exec(trimmedBody);
|
|
151
276
|
if (exact) {
|
|
152
277
|
const ln = Number.parseInt(exact[1], 10);
|
|
@@ -171,6 +296,17 @@ function parseDeleteStmt(body: string, lineNum: number): ParsedStmt[] | null {
|
|
|
171
296
|
return null;
|
|
172
297
|
}
|
|
173
298
|
|
|
299
|
+
function parseIndentedHashlineStmt(line: string, lineNum: number): ParsedStmt[] | null {
|
|
300
|
+
const trimmed = line.trimStart();
|
|
301
|
+
if (trimmed === line) return null;
|
|
302
|
+
const stmts = parseLidStmt(trimmed, lineNum);
|
|
303
|
+
if (!stmts) return null;
|
|
304
|
+
const safeHashlineEcho = stmts.every(
|
|
305
|
+
stmt => stmt.kind === "bare_anchor" || (stmt.kind === "anchor_op" && stmt.op.op === "set"),
|
|
306
|
+
);
|
|
307
|
+
return safeHashlineEcho ? stmts : null;
|
|
308
|
+
}
|
|
309
|
+
|
|
174
310
|
function throwMalformedLidDiagnostic(line: string, lineNum: number, raw: string): never {
|
|
175
311
|
const text = line.trimStart();
|
|
176
312
|
const withoutLegacyMove = text.startsWith("@@ ") ? text.slice(3).trimStart() : text;
|
|
@@ -200,18 +336,29 @@ function parseDiffLine(raw: string, lineNum: number): ParsedStmt[] {
|
|
|
200
336
|
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
|
201
337
|
if (line.length === 0) return [];
|
|
202
338
|
|
|
339
|
+
// `# ...` comments are silently ignored. Models often add section headers
|
|
340
|
+
// or annotations like `# Test 1: replace enum`; treating these as literal
|
|
341
|
+
// inserts corrupts files, and the canonical syntax has no comment op.
|
|
342
|
+
if (line[0] === "#") return [];
|
|
343
|
+
|
|
344
|
+
const indentedHashline = parseIndentedHashlineStmt(line, lineNum);
|
|
345
|
+
if (indentedHashline) return indentedHashline;
|
|
346
|
+
|
|
203
347
|
// `+TEXT` inserts at the cursor. Everything after `+` is content. A
|
|
204
348
|
// `+Lid|TEXT` or `+Lid=TEXT` line is a diff-ish add (unified-diff trap):
|
|
205
349
|
// emit a tagged stmt so the normalizer can fuse it with a preceding `-Lid`.
|
|
206
350
|
if (line[0] === "+") {
|
|
207
351
|
const body = line.slice(1);
|
|
352
|
+
if (body.startsWith(RANGE_CONTINUATION_SENTINEL)) {
|
|
353
|
+
return [{ kind: "insert", text: body.slice(RANGE_CONTINUATION_SENTINEL.length), lineNum }];
|
|
354
|
+
}
|
|
208
355
|
const m = LID_RE.exec(body);
|
|
209
356
|
if (m) {
|
|
210
357
|
const sep = body[m[0].length];
|
|
211
358
|
if (sep === "=" || sep === "|") {
|
|
212
359
|
const ln = Number.parseInt(m[1], 10);
|
|
213
360
|
const text = body.slice(m[0].length + 1);
|
|
214
|
-
return [{ kind: "diffish_add", anchor: { line: ln, hash: m[2] }, text, lineNum }];
|
|
361
|
+
return [{ kind: "diffish_add", anchor: { line: ln, hash: m[2] }, separator: sep, text, lineNum }];
|
|
215
362
|
}
|
|
216
363
|
}
|
|
217
364
|
|
|
@@ -237,8 +384,58 @@ function parseDiffLine(raw: string, lineNum: number): ParsedStmt[] {
|
|
|
237
384
|
}
|
|
238
385
|
|
|
239
386
|
// Canonical file-scope locators.
|
|
240
|
-
if (line === "
|
|
241
|
-
if (line === "
|
|
387
|
+
if (line === "^") return [{ kind: "bof", lineNum }];
|
|
388
|
+
if (line === "$") return [{ kind: "eof", lineNum }];
|
|
389
|
+
|
|
390
|
+
// Compound shorthand: `^+TEXT` and `$+TEXT` collapse a file-scope cursor
|
|
391
|
+
// move and an insert onto one line. Models occasionally do this when
|
|
392
|
+
// creating files from scratch (`^+content`) instead of the canonical
|
|
393
|
+
// `^\n+content`. No legitimate op line starts with `^+` or `$+`, so the
|
|
394
|
+
// expansion is unambiguous.
|
|
395
|
+
if (line.length >= 2 && (line[0] === "^" || line[0] === "$") && line[1] === "+") {
|
|
396
|
+
const cursor: ParsedStmt = line[0] === "^" ? { kind: "bof", lineNum } : { kind: "eof", lineNum };
|
|
397
|
+
return [cursor, { kind: "insert", text: line.slice(2), lineNum }];
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// `^=TEXT` and `$=TEXT` are not valid: `^` and `$` are cursor moves only.
|
|
401
|
+
// Models reach for these when trying to "replace the last line" or
|
|
402
|
+
// "replace the first line"; emit a clear diagnostic instead of falling
|
|
403
|
+
// through to "unrecognized op".
|
|
404
|
+
if (line.length >= 2 && (line[0] === "^" || line[0] === "$") && line[1] === "=") {
|
|
405
|
+
const where = line[0] === "^" ? "first" : "last";
|
|
406
|
+
const sym = line[0];
|
|
407
|
+
throw new Error(
|
|
408
|
+
`Diff line ${lineNum}: \`${sym}=TEXT\` is not a valid op. \`${sym}\` only moves the cursor (${sym === "^" ? "BOF" : "EOF"}); it cannot replace a line. To replace the ${where} line, use its Lid (e.g. \`5xx=TEXT\`). To insert at ${sym === "^" ? "BOF" : "EOF"}, use \`${sym}\` followed by \`+TEXT\` on the next line.`,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// `^Lid` cursor moves BEFORE the anchored line (insert above). Compound
|
|
413
|
+
// shorthand `^Lid+TEXT` collapses the cursor move and an insert into one
|
|
414
|
+
// line. `^Lid=TEXT` and `^Lid|TEXT` are flagged as ambiguous: pick either
|
|
415
|
+
// `^Lid` (cursor before) + `+TEXT`, or `Lid=TEXT` (replace in place).
|
|
416
|
+
if (line[0] === "^" && line.length > 1) {
|
|
417
|
+
const m = LID_RE.exec(line.slice(1));
|
|
418
|
+
if (m) {
|
|
419
|
+
const ln = Number.parseInt(m[1], 10);
|
|
420
|
+
const hash = m[2];
|
|
421
|
+
const sep = line[1 + m[0].length];
|
|
422
|
+
if (sep === undefined) {
|
|
423
|
+
return [{ kind: "before_anchor", anchor: { line: ln, hash }, lineNum }];
|
|
424
|
+
}
|
|
425
|
+
if (sep === "+") {
|
|
426
|
+
const text = line.slice(1 + m[0].length + 1);
|
|
427
|
+
return [
|
|
428
|
+
{ kind: "before_anchor", anchor: { line: ln, hash }, lineNum },
|
|
429
|
+
{ kind: "insert", text, lineNum },
|
|
430
|
+
];
|
|
431
|
+
}
|
|
432
|
+
if (sep === "=" || sep === "|") {
|
|
433
|
+
throw new Error(
|
|
434
|
+
`Diff line ${lineNum}: \`^${ln}${hash}${sep}...\` mixes \`^Lid\` (cursor before line) with \`Lid=TEXT\` (replace line). Pick one: \`^${ln}${hash}\` then \`+TEXT\` on the next line to insert above; or \`${ln}${hash}=TEXT\` to replace the line in place.`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
242
439
|
|
|
243
440
|
// `-Lid` deletes the anchored line. Leniently accept `- Lid` and the
|
|
244
441
|
// historical `-Lid TEXT` delete-then-insert recovery.
|
|
@@ -259,7 +456,7 @@ function parseDiffLine(raw: string, lineNum: number): ParsedStmt[] {
|
|
|
259
456
|
if (deleteStmt) return deleteStmt;
|
|
260
457
|
|
|
261
458
|
const lidStmt = parseLidStmt(body, lineNum);
|
|
262
|
-
if (lidStmt) return
|
|
459
|
+
if (lidStmt) return lidStmt;
|
|
263
460
|
|
|
264
461
|
throwMalformedLidDiagnostic(line, lineNum, raw);
|
|
265
462
|
}
|
|
@@ -268,17 +465,17 @@ function parseDiffLine(raw: string, lineNum: number): ParsedStmt[] {
|
|
|
268
465
|
// `@Lid|TEXT`, `@$`, and `@^`.
|
|
269
466
|
if (line[0] === "@") {
|
|
270
467
|
const body = line.slice(1);
|
|
271
|
-
if (body === "
|
|
272
|
-
if (body === "
|
|
468
|
+
if (body === "^") return [{ kind: "bof", lineNum }];
|
|
469
|
+
if (body === "$") return [{ kind: "eof", lineNum }];
|
|
273
470
|
const lidStmt = parseLidStmt(body, lineNum);
|
|
274
|
-
if (lidStmt) return
|
|
471
|
+
if (lidStmt) return lidStmt;
|
|
275
472
|
throwMalformedLidDiagnostic(line, lineNum, raw);
|
|
276
473
|
}
|
|
277
474
|
|
|
278
475
|
// `Lid=TEXT` sets the anchored line. Legacy `Lid|TEXT` remains accepted.
|
|
279
476
|
// A bare `Lid` is a cursor move.
|
|
280
477
|
const lidStmt = parseLidStmt(line, lineNum);
|
|
281
|
-
if (lidStmt) return
|
|
478
|
+
if (lidStmt) return lidStmt;
|
|
282
479
|
|
|
283
480
|
if (/^[a-z]{2}(?=[ \t]*[=|])/.test(line) || /^[1-9]\d*(?=[ \t]*[=|]|$)/.test(line)) {
|
|
284
481
|
throwMalformedLidDiagnostic(line, lineNum, raw);
|
|
@@ -288,14 +485,82 @@ function parseDiffLine(raw: string, lineNum: number): ParsedStmt[] {
|
|
|
288
485
|
// emitted multi-line content after a `Lid=` or similar without `+` prefixes,
|
|
289
486
|
// or pasted raw context. Silently treating these as inserts corrupts files.
|
|
290
487
|
const preview = line.length > 80 ? `${line.slice(0, 80)}…` : line;
|
|
488
|
+
const trailingDash = /^([1-9]\d*[a-z]{2})-\s*$/.exec(line);
|
|
489
|
+
if (trailingDash) {
|
|
490
|
+
throw new Error(
|
|
491
|
+
`Diff line ${lineNum}: \`${line}\` looks like a delete with the operator on the wrong side. Use \`-${trailingDash[1]}\` to delete that line.`,
|
|
492
|
+
);
|
|
493
|
+
}
|
|
291
494
|
throw new Error(
|
|
292
495
|
`Diff line ${lineNum}: unrecognized op. Lines must start with \`+\`, \`-\`, \`@\`, \`$\`, \`^\`, or a Lid (\`Lid=TEXT\`). To insert literal text use \`+TEXT\`. Got "${preview}".`,
|
|
293
496
|
);
|
|
294
497
|
}
|
|
295
498
|
|
|
499
|
+
// Lines that look like recognized atom ops. Used to delimit range-replace
|
|
500
|
+
// recovery continuation: after `LidA..LidB=TEXT` (or legacy `|` separator),
|
|
501
|
+
// is treated as literal replacement text for backward compatibility.
|
|
502
|
+
const OP_LINE_HEAD_RE = /^([+\-@$^!]|[1-9]\d*[a-z]{2}|[ \t]*$)/;
|
|
503
|
+
const RANGE_CONTINUATION_SENTINEL = "\u0000";
|
|
504
|
+
|
|
505
|
+
function isRangeReplaceStart(line: string): boolean {
|
|
506
|
+
return /^[1-9]\d*[a-z]{2}\.\.[1-9]\d*[a-z]{2}[ \t]*[=|]/.test(line);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// A single-line `Lid=TEXT` (or legacy `Lid|TEXT`, with optional leading `@`)
|
|
510
|
+
// also opens a replacement that `\TEXT` continuation lines may extend. The
|
|
511
|
+
// continuation lines become inserts at the cursor (which sits on the just-set
|
|
512
|
+
// line), turning `Lid=A` + `\B` + `\C` into "set the line to A, then insert B
|
|
513
|
+
// and C below it" — i.e. a multi-line rewrite of one anchor without forcing
|
|
514
|
+
// the user to switch to the `LidA..LidB=` range form.
|
|
515
|
+
function isReplaceStart(line: string): boolean {
|
|
516
|
+
if (isRangeReplaceStart(line)) return true;
|
|
517
|
+
const stripped = line.startsWith("@") ? line.slice(1) : line;
|
|
518
|
+
return /^[1-9]\d*[a-z]{2}[ \t]*[=|]/.test(stripped);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Explicit continuation uses `\TEXT` after a replacement op (`Lid=FIRST` or
|
|
522
|
+
// `LidA..LidB=FIRST`). The leading backslash is the continuation marker; the
|
|
523
|
+
// rest of the line is inserted literally, so `\\TEXT` inserts a line starting
|
|
524
|
+
// with `\TEXT`. Raw unprefixed continuation remains an undocumented
|
|
525
|
+
// best-effort recovery for range replacements only, kept for old transcripts.
|
|
526
|
+
function preprocessRangeReplaceContinuation(diff: string): string {
|
|
527
|
+
const lines = diff.split("\n");
|
|
528
|
+
let inRangeReplace = false;
|
|
529
|
+
let inReplace = false;
|
|
530
|
+
for (let i = 0; i < lines.length; i++) {
|
|
531
|
+
const rawLine = lines[i];
|
|
532
|
+
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
533
|
+
|
|
534
|
+
if (line.startsWith("\\")) {
|
|
535
|
+
if (!inReplace) {
|
|
536
|
+
throw new Error(
|
|
537
|
+
`Diff line ${i + 1}: \\TEXT continuation is only valid immediately after a Lid=TEXT or LidA..LidB=FIRST_LINE replacement.`,
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
lines[i] = `+${RANGE_CONTINUATION_SENTINEL}${rawLine.slice(1)}`;
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (inRangeReplace) {
|
|
545
|
+
if (line.length === 0 || OP_LINE_HEAD_RE.test(line)) {
|
|
546
|
+
inRangeReplace = isRangeReplaceStart(line);
|
|
547
|
+
inReplace = isReplaceStart(line);
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
lines[i] = `+${RANGE_CONTINUATION_SENTINEL}${rawLine}`;
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
inRangeReplace = isRangeReplaceStart(line);
|
|
556
|
+
inReplace = isReplaceStart(line);
|
|
557
|
+
}
|
|
558
|
+
return lines.join("\n");
|
|
559
|
+
}
|
|
560
|
+
|
|
296
561
|
function tokenizeDiff(diff: string): ParsedStmt[] {
|
|
297
562
|
const out: ParsedStmt[] = [];
|
|
298
|
-
const lines = diff.split("\n");
|
|
563
|
+
const lines = preprocessRangeReplaceContinuation(diff).split("\n");
|
|
299
564
|
for (let i = 0; i < lines.length; i++) {
|
|
300
565
|
const lineNum = i + 1;
|
|
301
566
|
const stmts = parseDiffLine(lines[i], lineNum);
|
|
@@ -327,11 +592,14 @@ function tokenizeDiff(diff: string): ParsedStmt[] {
|
|
|
327
592
|
// Detect contiguous `[delete | delete_with_old]+ [insert | diffish_add]+`
|
|
328
593
|
// hunks and reorder so adds land at the FIRST delete's slot (block
|
|
329
594
|
// replacement). Single-line `-Lid` + `+Lid|TEXT` (same Lid) fuses to a
|
|
330
|
-
// `set
|
|
595
|
+
// `set`; malformed standalone or mismatched `+Lid|TEXT`/`+Lid=TEXT` lines
|
|
596
|
+
// throw instead of silently dropping the Lid prefix.
|
|
331
597
|
function normalizeHunks(stmts: ParsedStmt[]): ParsedStmt[] {
|
|
332
598
|
const isDelete = (s: ParsedStmt): boolean =>
|
|
333
599
|
(s.kind === "anchor_op" && s.op.op === "delete") || s.kind === "delete_with_old";
|
|
334
600
|
const isAdd = (s: ParsedStmt): boolean => s.kind === "insert" || s.kind === "diffish_add";
|
|
601
|
+
const formatDiffishAdd = (stmt: DiffishAddStmt): string =>
|
|
602
|
+
`+${stmt.anchor.line}${stmt.anchor.hash}${stmt.separator}${stmt.text}`;
|
|
335
603
|
const out: ParsedStmt[] = [];
|
|
336
604
|
let i = 0;
|
|
337
605
|
while (i < stmts.length) {
|
|
@@ -340,7 +608,7 @@ function normalizeHunks(stmts: ParsedStmt[]): ParsedStmt[] {
|
|
|
340
608
|
if (stmt.kind === "diffish_add") {
|
|
341
609
|
const lid = `${stmt.anchor.line}${stmt.anchor.hash}`;
|
|
342
610
|
throw new Error(
|
|
343
|
-
`Diff line ${stmt.lineNum}:
|
|
611
|
+
`Diff line ${stmt.lineNum}: \`${formatDiffishAdd(stmt)}\` is unified-diff syntax, not edit syntax. To replace a line, use \`${lid}=TEXT\`; to insert literal text, use \`+TEXT\` without a Lid prefix.`,
|
|
344
612
|
);
|
|
345
613
|
}
|
|
346
614
|
out.push(stmt);
|
|
@@ -368,7 +636,7 @@ function normalizeHunks(stmts: ParsedStmt[]): ParsedStmt[] {
|
|
|
368
636
|
const lid = `${add.anchor.line}${add.anchor.hash}`;
|
|
369
637
|
if (!deletedLids.has(lid)) {
|
|
370
638
|
throw new Error(
|
|
371
|
-
`Diff line ${add.lineNum}:
|
|
639
|
+
`Diff line ${add.lineNum}: \`${formatDiffishAdd(add)}\` references a Lid not in the preceding delete run. Use plain \`+TEXT\` for replacement lines, or delete \`${lid}\` before using a unified-diff recovery line for that Lid.`,
|
|
372
640
|
);
|
|
373
641
|
}
|
|
374
642
|
}
|
|
@@ -444,7 +712,12 @@ function splitContiguousDeletes(deletes: ParsedStmt[]): ParsedStmt[][] {
|
|
|
444
712
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
445
713
|
|
|
446
714
|
export function parseAtom(diff: string): AtomEdit[] {
|
|
715
|
+
return parseAtomWithWarnings(diff).edits;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
export function parseAtomWithWarnings(diff: string): { edits: AtomEdit[]; warnings: string[] } {
|
|
447
719
|
const edits: AtomEdit[] = [];
|
|
720
|
+
const warnings: string[] = [];
|
|
448
721
|
let cursor: AtomCursor = { kind: "eof" };
|
|
449
722
|
let index = 0;
|
|
450
723
|
|
|
@@ -473,7 +746,12 @@ export function parseAtom(diff: string): AtomEdit[] {
|
|
|
473
746
|
}
|
|
474
747
|
|
|
475
748
|
if (stmt.kind === "diffish_add") {
|
|
476
|
-
throw new Error("Internal
|
|
749
|
+
throw new Error("Internal edit error: unresolved diff-ish add reached parser.");
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (stmt.kind === "before_anchor") {
|
|
753
|
+
cursor = { kind: "before_anchor", anchor: makeAnchor(stmt.anchor) };
|
|
754
|
+
continue;
|
|
477
755
|
}
|
|
478
756
|
|
|
479
757
|
const anchor = makeAnchor(stmt.anchor);
|
|
@@ -502,7 +780,7 @@ export function parseAtom(diff: string): AtomEdit[] {
|
|
|
502
780
|
index++;
|
|
503
781
|
}
|
|
504
782
|
|
|
505
|
-
return edits;
|
|
783
|
+
return { edits, warnings };
|
|
506
784
|
}
|
|
507
785
|
|
|
508
786
|
function formatNoAtomEditDiagnostic(_path: string, diff: string): string {
|
|
@@ -523,7 +801,7 @@ function formatNoAtomEditDiagnostic(_path: string, diff: string): string {
|
|
|
523
801
|
|
|
524
802
|
function getAtomEditAnchors(edit: AtomEdit): Anchor[] {
|
|
525
803
|
if (edit.kind === "set" || edit.kind === "delete") return [edit.anchor];
|
|
526
|
-
if (edit.cursor.kind === "anchor") return [edit.cursor.anchor];
|
|
804
|
+
if (edit.cursor.kind === "anchor" || edit.cursor.kind === "before_anchor") return [edit.cursor.anchor];
|
|
527
805
|
return [];
|
|
528
806
|
}
|
|
529
807
|
|
|
@@ -535,6 +813,7 @@ function validateAtomAnchors(edits: AtomEdit[], fileLines: string[], warnings: s
|
|
|
535
813
|
if (anchor.line < 1 || anchor.line > fileLines.length) {
|
|
536
814
|
throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
|
|
537
815
|
}
|
|
816
|
+
if (anchor.hash === RANGE_INTERIOR_HASH) continue;
|
|
538
817
|
const actualHash = computeLineHash(anchor.line, fileLines[anchor.line - 1]);
|
|
539
818
|
if (actualHash === anchor.hash) continue;
|
|
540
819
|
|
|
@@ -658,8 +937,8 @@ function applyFileCursorInserts(
|
|
|
658
937
|
|
|
659
938
|
function getAnchorForAnchorEdit(edit: IndexedAnchorEdit["edit"]): Anchor {
|
|
660
939
|
if (edit.kind !== "insert") return edit.anchor;
|
|
661
|
-
if (edit.cursor.kind !== "anchor") {
|
|
662
|
-
throw new Error("Internal
|
|
940
|
+
if (edit.cursor.kind !== "anchor" && edit.cursor.kind !== "before_anchor") {
|
|
941
|
+
throw new Error("Internal edit error: file-scoped insert reached anchor application.");
|
|
663
942
|
}
|
|
664
943
|
return edit.cursor.anchor;
|
|
665
944
|
}
|
|
@@ -779,7 +1058,7 @@ export function applyAtomEdits(text: string, edits: AtomEdit[]): AtomApplyResult
|
|
|
779
1058
|
const anchorEdits: IndexedAnchorEdit[] = [];
|
|
780
1059
|
const fileInserts: Extract<AtomEdit, { kind: "insert" }>[] = [];
|
|
781
1060
|
edits.forEach((edit, idx) => {
|
|
782
|
-
if (edit.kind === "insert" && edit.cursor.kind !== "anchor") {
|
|
1061
|
+
if (edit.kind === "insert" && edit.cursor.kind !== "anchor" && edit.cursor.kind !== "before_anchor") {
|
|
783
1062
|
fileInserts.push(edit);
|
|
784
1063
|
return;
|
|
785
1064
|
}
|
|
@@ -808,12 +1087,17 @@ export function applyAtomEdits(text: string, edits: AtomEdit[]): AtomApplyResult
|
|
|
808
1087
|
let replacement: string[] = [currentLine];
|
|
809
1088
|
let replacementSet = false;
|
|
810
1089
|
let anchorMutated = false;
|
|
1090
|
+
const beforeLines: string[] = [];
|
|
811
1091
|
const afterLines: string[] = [];
|
|
812
1092
|
|
|
813
1093
|
for (const { edit } of bucket) {
|
|
814
1094
|
switch (edit.kind) {
|
|
815
1095
|
case "insert":
|
|
816
|
-
|
|
1096
|
+
if (edit.cursor.kind === "before_anchor") {
|
|
1097
|
+
beforeLines.push(edit.text);
|
|
1098
|
+
} else {
|
|
1099
|
+
afterLines.push(edit.text);
|
|
1100
|
+
}
|
|
817
1101
|
break;
|
|
818
1102
|
case "set":
|
|
819
1103
|
replacement = [edit.allowOldNewRepair ? repairAtomOldNewSetLine(currentLine, edit.text) : edit.text];
|
|
@@ -832,7 +1116,10 @@ export function applyAtomEdits(text: string, edits: AtomEdit[]): AtomApplyResult
|
|
|
832
1116
|
}
|
|
833
1117
|
|
|
834
1118
|
const replacementProducesNoChange =
|
|
835
|
-
|
|
1119
|
+
beforeLines.length === 0 &&
|
|
1120
|
+
afterLines.length === 0 &&
|
|
1121
|
+
replacement.length === 1 &&
|
|
1122
|
+
replacement[0] === currentLine;
|
|
836
1123
|
if (replacementProducesNoChange) {
|
|
837
1124
|
const firstEdit = bucket[0]?.edit;
|
|
838
1125
|
const anchor = firstEdit ? getAnchorForAnchorEdit(firstEdit) : undefined;
|
|
@@ -848,14 +1135,14 @@ export function applyAtomEdits(text: string, edits: AtomEdit[]): AtomApplyResult
|
|
|
848
1135
|
continue;
|
|
849
1136
|
}
|
|
850
1137
|
|
|
851
|
-
const combined = [...replacement, ...afterLines];
|
|
1138
|
+
const combined = [...beforeLines, ...replacement, ...afterLines];
|
|
852
1139
|
fileLines.splice(idx, 1, ...combined);
|
|
853
|
-
if (anchorMutated) {
|
|
1140
|
+
if (anchorMutated || beforeLines.length > 0) {
|
|
854
1141
|
trackFirstChanged(line);
|
|
855
1142
|
} else if (afterLines.length > 0) {
|
|
856
1143
|
trackFirstChanged(line + 1);
|
|
857
1144
|
}
|
|
858
|
-
if (!replacementSet && afterLines.length === 0) continue;
|
|
1145
|
+
if (!replacementSet && beforeLines.length === 0 && afterLines.length === 0) continue;
|
|
859
1146
|
}
|
|
860
1147
|
|
|
861
1148
|
const fileFirstChangedLine = applyFileCursorInserts(fileLines, fileInserts);
|
|
@@ -928,7 +1215,7 @@ function parseAtomHeaderLine(line: string, cwd?: string): string | null {
|
|
|
928
1215
|
if (body.startsWith(" ")) body = body.slice(1);
|
|
929
1216
|
const parsedPath = normalizeAtomPath(body, cwd);
|
|
930
1217
|
if (parsedPath.length === 0) {
|
|
931
|
-
throw new Error(`
|
|
1218
|
+
throw new Error(`Input header "${FILE_HEADER_PREFIX}" is empty; provide a file path.`);
|
|
932
1219
|
}
|
|
933
1220
|
return parsedPath;
|
|
934
1221
|
}
|
|
@@ -936,21 +1223,21 @@ function parseAtomHeaderLine(line: string, cwd?: string): string | null {
|
|
|
936
1223
|
function parseSingleAtomPathArgument(rawPath: string, directive: string, lineNum: number, cwd?: string): string {
|
|
937
1224
|
const trimmed = rawPath.trim();
|
|
938
1225
|
if (trimmed.length === 0) {
|
|
939
|
-
throw new Error(`
|
|
1226
|
+
throw new Error(`Diff line ${lineNum}: ${directive} requires exactly one non-empty destination path.`);
|
|
940
1227
|
}
|
|
941
1228
|
|
|
942
1229
|
const quote = trimmed[0];
|
|
943
1230
|
if (quote === '"' || quote === "'") {
|
|
944
1231
|
if (trimmed.length < 2 || trimmed[trimmed.length - 1] !== quote) {
|
|
945
|
-
throw new Error(`
|
|
1232
|
+
throw new Error(`Diff line ${lineNum}: ${directive} requires exactly one destination path.`);
|
|
946
1233
|
}
|
|
947
1234
|
} else if (/\s/.test(trimmed)) {
|
|
948
|
-
throw new Error(`
|
|
1235
|
+
throw new Error(`Diff line ${lineNum}: ${directive} requires exactly one destination path.`);
|
|
949
1236
|
}
|
|
950
1237
|
|
|
951
1238
|
const destination = normalizeAtomPath(trimmed, cwd);
|
|
952
1239
|
if (destination.length === 0) {
|
|
953
|
-
throw new Error(`
|
|
1240
|
+
throw new Error(`Diff line ${lineNum}: ${directive} requires exactly one non-empty destination path.`);
|
|
954
1241
|
}
|
|
955
1242
|
return destination;
|
|
956
1243
|
}
|
|
@@ -965,11 +1252,11 @@ function parseAtomWholeFileOperationLine(
|
|
|
965
1252
|
return { kind: "delete", lineNum };
|
|
966
1253
|
}
|
|
967
1254
|
if (line.startsWith(`${REMOVE_FILE_OPERATION} `) || line.startsWith(`${REMOVE_FILE_OPERATION}\t`)) {
|
|
968
|
-
throw new Error(`
|
|
1255
|
+
throw new Error(`Diff line ${lineNum}: ${REMOVE_FILE_OPERATION} does not take a destination path.`);
|
|
969
1256
|
}
|
|
970
1257
|
|
|
971
1258
|
if (line === MOVE_FILE_OPERATION) {
|
|
972
|
-
throw new Error(`
|
|
1259
|
+
throw new Error(`Diff line ${lineNum}: ${MOVE_FILE_OPERATION} requires exactly one non-empty destination path.`);
|
|
973
1260
|
}
|
|
974
1261
|
if (line.startsWith(`${MOVE_FILE_OPERATION} `) || line.startsWith(`${MOVE_FILE_OPERATION}\t`)) {
|
|
975
1262
|
const rawDestination = line.slice(MOVE_FILE_OPERATION.length);
|
|
@@ -1001,7 +1288,7 @@ function getAtomWholeFileOperation(
|
|
|
1001
1288
|
if (parsed) {
|
|
1002
1289
|
if (operation) {
|
|
1003
1290
|
throw new Error(
|
|
1004
|
-
`
|
|
1291
|
+
`Edit section ${sectionPath}: use only one ${REMOVE_FILE_OPERATION} or ${MOVE_FILE_OPERATION} operation.`,
|
|
1005
1292
|
);
|
|
1006
1293
|
}
|
|
1007
1294
|
operation = parsed;
|
|
@@ -1014,7 +1301,7 @@ function getAtomWholeFileOperation(
|
|
|
1014
1301
|
|
|
1015
1302
|
if (operation && hasLineEdit) {
|
|
1016
1303
|
throw new Error(
|
|
1017
|
-
`
|
|
1304
|
+
`Edit section ${sectionPath} mixes ${operationToken} with line edits; ${REMOVE_FILE_OPERATION} and ${MOVE_FILE_OPERATION} must be the only operation in their section.`,
|
|
1018
1305
|
);
|
|
1019
1306
|
}
|
|
1020
1307
|
|
|
@@ -1032,8 +1319,10 @@ function containsRecognizableAtomOperations(input: string): boolean {
|
|
|
1032
1319
|
if (line.length === 0) continue;
|
|
1033
1320
|
if (line[0] === "+") return true;
|
|
1034
1321
|
if (line === "$" || line === "^") return true;
|
|
1035
|
-
if (
|
|
1036
|
-
if (
|
|
1322
|
+
if (/^\$\+.*$/.test(line)) return true;
|
|
1323
|
+
if (/^\^[1-9]\d*[a-z]{2}(?:\+.*)?$/.test(line)) return true;
|
|
1324
|
+
if (/^- ?[1-9]\d*[a-z]{2}(?:\.\.[1-9]\d*[a-z]{2})?(?:[ \t]*[=|].*| .*)?$/.test(line)) return true;
|
|
1325
|
+
if (/^@?[1-9]\d*[a-z]{2}(?:\+.*|[ \t]*[=|].*|\.\.[1-9]\d*[a-z]{2}[ \t]*=.*)?$/.test(line)) return true;
|
|
1037
1326
|
if (/^@@ (?:BOF|EOF|(?:- ?)?[1-9]\d*[a-z]{2}(?:[ \t]*[=|].*)?)$/.test(line)) return true;
|
|
1038
1327
|
}
|
|
1039
1328
|
return false;
|
|
@@ -1048,8 +1337,42 @@ function stripLeadingBlankLines(input: string): string {
|
|
|
1048
1337
|
return lines.join("\n");
|
|
1049
1338
|
}
|
|
1050
1339
|
|
|
1340
|
+
function normalizeStandaloneFileOpInput(input: string, cwd?: string): string | null {
|
|
1341
|
+
const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
|
|
1342
|
+
const lines = stripped.split("\n");
|
|
1343
|
+
let firstIdx = -1;
|
|
1344
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1345
|
+
if (lines[i].replace(/\r$/, "").trim().length > 0) {
|
|
1346
|
+
firstIdx = i;
|
|
1347
|
+
break;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
if (firstIdx === -1) return null;
|
|
1351
|
+
const firstLine = lines[firstIdx].replace(/\r$/, "");
|
|
1352
|
+
const remaining = lines.slice(firstIdx + 1).join("\n");
|
|
1353
|
+
if (remaining.trim().length > 0) return null;
|
|
1354
|
+
|
|
1355
|
+
const rmMatch = /^!rm\s+(\S.*)$/.exec(firstLine);
|
|
1356
|
+
if (rmMatch) {
|
|
1357
|
+
const sourcePath = parseSingleAtomPathArgument(rmMatch[1], REMOVE_FILE_OPERATION, firstIdx + 1, cwd);
|
|
1358
|
+
return `${FILE_HEADER_PREFIX}${sourcePath}\n${REMOVE_FILE_OPERATION}`;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
const mvMatch = /^!mv\s+(\S+)\s+(\S.*)$/.exec(firstLine);
|
|
1362
|
+
if (mvMatch) {
|
|
1363
|
+
const sourcePath = parseSingleAtomPathArgument(mvMatch[1], MOVE_FILE_OPERATION, firstIdx + 1, cwd);
|
|
1364
|
+
const destPath = parseSingleAtomPathArgument(mvMatch[2], MOVE_FILE_OPERATION, firstIdx + 1, cwd);
|
|
1365
|
+
return `${FILE_HEADER_PREFIX}${sourcePath}\n${MOVE_FILE_OPERATION} ${destPath}`;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
return null;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1051
1371
|
function normalizeFallbackInput(input: string, options: SplitAtomOptions): string {
|
|
1052
|
-
if (hasAtomHeaderLine(input)
|
|
1372
|
+
if (hasAtomHeaderLine(input)) return input;
|
|
1373
|
+
const standalone = normalizeStandaloneFileOpInput(input, options.cwd);
|
|
1374
|
+
if (standalone !== null) return standalone;
|
|
1375
|
+
if (!options.path || !containsRecognizableAtomOperations(input)) {
|
|
1053
1376
|
return input;
|
|
1054
1377
|
}
|
|
1055
1378
|
const fallbackPath = normalizeAtomPath(options.path, options.cwd);
|
|
@@ -1082,10 +1405,11 @@ export function splitAtomInputs(input: string, options: SplitAtomOptions = {}):
|
|
|
1082
1405
|
const lines = stripped.split("\n");
|
|
1083
1406
|
const firstLine = (lines[0] ?? "").replace(/\r$/, "");
|
|
1084
1407
|
if (!firstLine.startsWith(FILE_HEADER_PREFIX)) {
|
|
1408
|
+
const preview = JSON.stringify(firstLine.slice(0, 120));
|
|
1085
1409
|
throw new Error(
|
|
1086
|
-
`
|
|
1087
|
-
|
|
1088
|
-
|
|
1410
|
+
`input must begin with "${FILE_HEADER_PREFIX}<path>" on the first non-blank line; got: ${preview}.\n` +
|
|
1411
|
+
`Example: "${FILE_HEADER_PREFIX}src/foo.ts" then your edit ops on the following lines. ` +
|
|
1412
|
+
`To delete a file: "${FILE_HEADER_PREFIX}<path>\\n!rm". To rename: "${FILE_HEADER_PREFIX}<src>\\n!mv <dest>".`,
|
|
1089
1413
|
);
|
|
1090
1414
|
}
|
|
1091
1415
|
|
|
@@ -1146,7 +1470,13 @@ async function readAtomFile(absolutePath: string): Promise<ReadAtomFileResult> {
|
|
|
1146
1470
|
}
|
|
1147
1471
|
|
|
1148
1472
|
function hasAnchorScopedEdit(edits: AtomEdit[]): boolean {
|
|
1149
|
-
return edits.some(
|
|
1473
|
+
return edits.some(
|
|
1474
|
+
edit =>
|
|
1475
|
+
edit.kind === "set" ||
|
|
1476
|
+
edit.kind === "delete" ||
|
|
1477
|
+
edit.cursor.kind === "anchor" ||
|
|
1478
|
+
edit.cursor.kind === "before_anchor",
|
|
1479
|
+
);
|
|
1150
1480
|
}
|
|
1151
1481
|
|
|
1152
1482
|
function formatNoChangeDiagnostic(path: string, result: AtomApplyResult): string {
|
|
@@ -1162,6 +1492,12 @@ function formatNoChangeDiagnostic(path: string, result: AtomApplyResult): string
|
|
|
1162
1492
|
})
|
|
1163
1493
|
.join("\n");
|
|
1164
1494
|
diagnostic += `\n${details}`;
|
|
1495
|
+
const setNoops = result.noopEdits.filter(e => e.reason.startsWith("replacement is identical"));
|
|
1496
|
+
if (setNoops.length > 0) {
|
|
1497
|
+
diagnostic +=
|
|
1498
|
+
"\n\nHint: each `Lid=TEXT` you emit MUST contain text that differs from the line currently anchored by Lid. " +
|
|
1499
|
+
"Do not echo lines back from `read` output unchanged. If you intended to leave a line as-is, omit it from the patch.";
|
|
1500
|
+
}
|
|
1165
1501
|
}
|
|
1166
1502
|
return diagnostic;
|
|
1167
1503
|
}
|
|
@@ -1219,6 +1555,60 @@ async function executeAtomWholeFileOperation(
|
|
|
1219
1555
|
};
|
|
1220
1556
|
}
|
|
1221
1557
|
|
|
1558
|
+
async function preflightAtomSection(options: ExecuteAtomSingleOptions & AtomInputSection): Promise<void> {
|
|
1559
|
+
const { session, path: sectionPath, diff } = options;
|
|
1560
|
+
if (options.wholeFileOperation) {
|
|
1561
|
+
const { wholeFileOperation } = options;
|
|
1562
|
+
const absolutePath = resolvePlanPath(session, sectionPath);
|
|
1563
|
+
if (sectionPath.endsWith(".ipynb")) {
|
|
1564
|
+
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
|
|
1565
|
+
}
|
|
1566
|
+
if (wholeFileOperation.kind === "delete") {
|
|
1567
|
+
enforcePlanModeWrite(session, sectionPath, { op: "delete" });
|
|
1568
|
+
await assertEditableFile(absolutePath, sectionPath);
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
const destinationPath = wholeFileOperation.destination;
|
|
1573
|
+
if (destinationPath.endsWith(".ipynb")) {
|
|
1574
|
+
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
|
|
1575
|
+
}
|
|
1576
|
+
enforcePlanModeWrite(session, sectionPath, { op: "update", move: destinationPath });
|
|
1577
|
+
const absoluteDestinationPath = resolvePlanPath(session, destinationPath);
|
|
1578
|
+
if (absoluteDestinationPath === absolutePath) {
|
|
1579
|
+
throw new Error("rename path is the same as source path");
|
|
1580
|
+
}
|
|
1581
|
+
await assertEditableFile(absolutePath, sectionPath);
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
const { edits } = parseAtomWithWarnings(diff);
|
|
1586
|
+
if (edits.length === 0 && diff.trim().length > 0) {
|
|
1587
|
+
throw new Error(formatNoAtomEditDiagnostic(sectionPath, diff));
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
enforcePlanModeWrite(session, sectionPath, { op: "update" });
|
|
1591
|
+
if (sectionPath.endsWith(".ipynb") && edits.length > 0) {
|
|
1592
|
+
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
const absolutePath = resolvePlanPath(session, sectionPath);
|
|
1596
|
+
const source = await readAtomFile(absolutePath);
|
|
1597
|
+
if (!source.exists && hasAnchorScopedEdit(edits)) {
|
|
1598
|
+
throw new Error(`File not found: ${sectionPath}`);
|
|
1599
|
+
}
|
|
1600
|
+
if (source.exists) {
|
|
1601
|
+
assertEditableFileContent(source.rawContent, sectionPath);
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
const { text } = stripBom(source.rawContent);
|
|
1605
|
+
const originalNormalized = normalizeToLF(text);
|
|
1606
|
+
const result = applyAtomEdits(originalNormalized, edits);
|
|
1607
|
+
if (originalNormalized === result.lines && (result.noopEdits?.length ?? 0) === 0) {
|
|
1608
|
+
throw new Error(formatNoChangeDiagnostic(sectionPath, result));
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1222
1612
|
async function executeAtomSection(
|
|
1223
1613
|
options: ExecuteAtomSingleOptions & AtomInputSection,
|
|
1224
1614
|
): Promise<AgentToolResult<EditToolDetails, typeof atomEditParamsSchema>> {
|
|
@@ -1227,7 +1617,7 @@ async function executeAtomSection(
|
|
|
1227
1617
|
return executeAtomWholeFileOperation({ ...options, wholeFileOperation: options.wholeFileOperation });
|
|
1228
1618
|
}
|
|
1229
1619
|
|
|
1230
|
-
const edits =
|
|
1620
|
+
const { edits, warnings: parseWarnings } = parseAtomWithWarnings(diff);
|
|
1231
1621
|
if (edits.length === 0 && diff.trim().length > 0) {
|
|
1232
1622
|
throw new Error(formatNoAtomEditDiagnostic(path, diff));
|
|
1233
1623
|
}
|
|
@@ -1253,7 +1643,18 @@ async function executeAtomSection(
|
|
|
1253
1643
|
const originalNormalized = normalizeToLF(text);
|
|
1254
1644
|
const result = applyAtomEdits(originalNormalized, edits);
|
|
1255
1645
|
if (originalNormalized === result.lines) {
|
|
1256
|
-
|
|
1646
|
+
const allNoop = (result.noopEdits?.length ?? 0) > 0;
|
|
1647
|
+
if (!allNoop) {
|
|
1648
|
+
throw new Error(formatNoChangeDiagnostic(path, result));
|
|
1649
|
+
}
|
|
1650
|
+
// Every edit was a no-op (TEXT identical to the anchored line). Returning
|
|
1651
|
+
// success here breaks retry loops where models hammer the same `Lid=TEXT`
|
|
1652
|
+
// when TEXT happens to already match. The response makes the no-op
|
|
1653
|
+
// explicit so the model knows nothing changed and to move on.
|
|
1654
|
+
return {
|
|
1655
|
+
content: [{ type: "text", text: formatNoChangeDiagnostic(path, result) }],
|
|
1656
|
+
details: { diff: "", op: "update", meta: outputMeta().get() },
|
|
1657
|
+
};
|
|
1257
1658
|
}
|
|
1258
1659
|
|
|
1259
1660
|
const finalContent = bom + restoreLineEndings(result.lines, originalEnding);
|
|
@@ -1272,7 +1673,8 @@ async function executeAtomSection(
|
|
|
1272
1673
|
.diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
|
|
1273
1674
|
.get();
|
|
1274
1675
|
const preview = buildCompactHashlineDiffPreview(diffResult.diff);
|
|
1275
|
-
const
|
|
1676
|
+
const allWarnings = [...parseWarnings, ...(result.warnings ?? [])];
|
|
1677
|
+
const warningsBlock = allWarnings.length > 0 ? `\n\nWarnings:\n${allWarnings.join("\n")}` : "";
|
|
1276
1678
|
const previewBlock = preview.preview ? `\n${preview.preview}` : "";
|
|
1277
1679
|
const resultText = preview.preview ? `${path}:` : source.exists ? `Updated ${path}` : `Created ${path}`;
|
|
1278
1680
|
|
|
@@ -1302,6 +1704,10 @@ export async function executeAtomSingle(
|
|
|
1302
1704
|
return executeAtomSection({ ...options, ...section });
|
|
1303
1705
|
}
|
|
1304
1706
|
|
|
1707
|
+
for (const section of sections) {
|
|
1708
|
+
await preflightAtomSection({ ...options, ...section });
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1305
1711
|
const results = [];
|
|
1306
1712
|
for (const section of sections) {
|
|
1307
1713
|
results.push({
|