pi-hashline-edit-pro 4.4.4 → 4.5.0
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/README.md +44 -11
- package/index.ts +19 -4
- package/package.json +4 -3
- package/prompts/copy-guidelines.md +1 -0
- package/prompts/copy-snippet.md +1 -0
- package/prompts/copy.md +3 -0
- package/prompts/grep.md +1 -1
- package/prompts/insert.md +1 -1
- package/prompts/move-guidelines.md +1 -0
- package/prompts/move-snippet.md +1 -0
- package/prompts/move.md +3 -0
- package/prompts/replace-guidelines.md +0 -2
- package/prompts/replace.md +1 -1
- package/prompts/undo-last-change-guidelines.md +2 -1
- package/prompts/undo-last-change-snippet.md +1 -1
- package/prompts/undo-last-change.md +1 -1
- package/src/batch.ts +28 -6
- package/src/commit.ts +17 -4
- package/src/config-ui.ts +2 -1
- package/src/config.ts +6 -1
- package/src/constants.ts +1 -1
- package/src/copy-move.ts +793 -0
- package/src/edit-common.ts +19 -0
- package/src/file-reader.ts +4 -0
- package/src/hash-store/validation.ts +22 -0
- package/src/hash-store.ts +32 -4
- package/src/hashline/apply.ts +8 -3
- package/src/hashline/index.ts +3 -0
- package/src/hashline/parse.ts +22 -10
- package/src/hashline/resolve.ts +23 -8
- package/src/insert.ts +28 -10
- package/src/line-endings.ts +151 -0
- package/src/paths.ts +18 -2
- package/src/payload-contract.ts +29 -1
- package/src/replace-render.ts +4 -4
- package/src/replace-undo.ts +9 -2
- package/src/replace.ts +17 -5
- package/src/utils.ts +40 -5
package/src/edit-common.ts
CHANGED
|
@@ -96,6 +96,25 @@ export function withUndoPrompts(base: { description: string; snippet: string; gu
|
|
|
96
96
|
return { description: base.description, snippet: base.snippet, guidelines };
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
export function withTransferPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags, toolName: "copy" | "move"): { description: string; snippet: string; guidelines: string[] } {
|
|
100
|
+
const descriptionParts = [base.description];
|
|
101
|
+
const snippetParts = [base.snippet];
|
|
102
|
+
const guidelines = [...base.guidelines];
|
|
103
|
+
if (flags.requirePath) {
|
|
104
|
+
descriptionParts.push("Also give `path` matching the source or destination file the anchors were served for; it is required and must match anchor ownership.");
|
|
105
|
+
snippetParts.push("; include `path` (required)");
|
|
106
|
+
guidelines.push(`\`${toolName}\`: include \`path\` matching the source or destination file the anchors were served for; it is required.`);
|
|
107
|
+
} else {
|
|
108
|
+
descriptionParts.push("Path resolution is anchor-only; do not pass `path`.");
|
|
109
|
+
guidelines.push(`\`${toolName}\`: path resolution is anchor-only; don't pass \`path\`.`);
|
|
110
|
+
}
|
|
111
|
+
if (flags.strictInput) {
|
|
112
|
+
descriptionParts.push("Strict-input mode is on: auto-fixable slips are rejected instead of fixed with warnings.");
|
|
113
|
+
guidelines.push(`\`${toolName}\`: strict-input is on: auto-fixable slips are rejected instead of fixed.`);
|
|
114
|
+
}
|
|
115
|
+
return { description: descriptionParts.join(" "), snippet: snippetParts.join(""), guidelines };
|
|
116
|
+
}
|
|
117
|
+
|
|
99
118
|
function staleAnchorMessage(ref: string, hash: string, owners: Array<OwnedAnchor | undefined>): string {
|
|
100
119
|
const knownPaths = new Set<string>();
|
|
101
120
|
for (const owner of owners) {
|
package/src/file-reader.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { loadFileKindAndText, type LFile } from "./file-kind";
|
|
|
5
5
|
import { resolveTarget, type FileIdentity } from "./fs-write";
|
|
6
6
|
import { toCwd, toDisplayPath } from "./paths";
|
|
7
7
|
import { detectEnding, toLF, stripBOM, type LineEnding } from "./normalize";
|
|
8
|
+
import { splitSeparators } from "./line-endings";
|
|
8
9
|
import { abortIf, errCode, assertLineLimit } from "./utils";
|
|
9
10
|
import { ANCHOR_POOL_EXHAUSTED_PREFIX } from "./constants";
|
|
10
11
|
import { valKind, valAccess } from "./validation";
|
|
@@ -14,6 +15,7 @@ export interface NormFile {
|
|
|
14
15
|
normalized: string;
|
|
15
16
|
bom: string;
|
|
16
17
|
originalEnding: LineEnding;
|
|
18
|
+
endingSeparators: LineEnding[];
|
|
17
19
|
fileHashes: string[];
|
|
18
20
|
hadUtf8DecodeErrors: boolean;
|
|
19
21
|
identity: FileIdentity;
|
|
@@ -91,6 +93,7 @@ export async function readNormFile(
|
|
|
91
93
|
abortIf(signal);
|
|
92
94
|
const { bom, text: rawContent } = stripBOM(file.text);
|
|
93
95
|
const originalEnding = detectEnding(rawContent);
|
|
96
|
+
const endingSeparators = splitSeparators(rawContent);
|
|
94
97
|
const normalized = toLF(rawContent);
|
|
95
98
|
|
|
96
99
|
if (options?.maxLines !== undefined) assertLineLimit(normalized, path, options.maxLines);
|
|
@@ -106,6 +109,7 @@ export async function readNormFile(
|
|
|
106
109
|
normalized,
|
|
107
110
|
bom,
|
|
108
111
|
originalEnding,
|
|
112
|
+
endingSeparators,
|
|
109
113
|
fileHashes,
|
|
110
114
|
identity,
|
|
111
115
|
hadUtf8DecodeErrors: file.hadUtf8DecodeErrors === true,
|
|
@@ -60,3 +60,25 @@ export function isBusyError(error: unknown): boolean {
|
|
|
60
60
|
}
|
|
61
61
|
return error instanceof Error && /busy|locked/i.test(error.message);
|
|
62
62
|
}
|
|
63
|
+
|
|
64
|
+
export interface SeparatorParse {
|
|
65
|
+
ok: boolean;
|
|
66
|
+
value?: string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseSeparators(raw: unknown): SeparatorParse {
|
|
70
|
+
if (raw === null || raw === undefined || raw === "") return { ok: true };
|
|
71
|
+
if (typeof raw !== "string") return { ok: false };
|
|
72
|
+
let parsed: unknown;
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(raw);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error("[parseSeparators] failed to parse stored separators JSON:", error);
|
|
77
|
+
return { ok: false };
|
|
78
|
+
}
|
|
79
|
+
if (!Array.isArray(parsed) || !parsed.every((entry) => entry === "\n" || entry === "\r" || entry === "\r\n")) {
|
|
80
|
+
console.error("[parseSeparators] stored separators did not pass validation:", JSON.stringify(parsed).slice(0, 200));
|
|
81
|
+
return { ok: false };
|
|
82
|
+
}
|
|
83
|
+
return { ok: true, value: parsed as string[] };
|
|
84
|
+
}
|
package/src/hash-store.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
isValidSnapshot,
|
|
12
12
|
isCorruptionError,
|
|
13
13
|
parseHashList,
|
|
14
|
+
parseSeparators,
|
|
14
15
|
} from "./hash-store/validation";
|
|
15
16
|
import {
|
|
16
17
|
withBusyRetry,
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
withBusyRetryAsync,
|
|
19
20
|
} from "./hash-store/retry";
|
|
20
21
|
import { snapshotCache, cacheSnapshot, SNAPSHOT_CACHE_LIMIT } from "./hash-store/cache";
|
|
22
|
+
import { countNewlines } from "./line-endings";
|
|
21
23
|
|
|
22
24
|
export { isValidHashList, parseHashList, parseStoredHashes, isCorruptionError };
|
|
23
25
|
export { SNAPSHOT_CACHE_LIMIT };
|
|
@@ -135,8 +137,10 @@ export interface UndoRecord {
|
|
|
135
137
|
content: string;
|
|
136
138
|
bom: string;
|
|
137
139
|
ending: string;
|
|
140
|
+
separators?: string[];
|
|
138
141
|
hashes: string[];
|
|
139
142
|
resultContent: string;
|
|
143
|
+
resultSeparators?: string[];
|
|
140
144
|
mode?: number;
|
|
141
145
|
}
|
|
142
146
|
|
|
@@ -181,8 +185,10 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
|
|
|
181
185
|
"content TEXT NOT NULL, " +
|
|
182
186
|
"bom TEXT NOT NULL, " +
|
|
183
187
|
"ending TEXT NOT NULL, " +
|
|
188
|
+
"separators TEXT, " +
|
|
184
189
|
"hashes TEXT NOT NULL, " +
|
|
185
190
|
"result_content TEXT NOT NULL, " +
|
|
191
|
+
"result_separators TEXT, " +
|
|
186
192
|
"updated_at INTEGER NOT NULL" +
|
|
187
193
|
")"
|
|
188
194
|
);
|
|
@@ -192,6 +198,12 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
|
|
|
192
198
|
try {
|
|
193
199
|
db.exec("ALTER TABLE undo ADD COLUMN mode INTEGER");
|
|
194
200
|
} catch {}
|
|
201
|
+
try {
|
|
202
|
+
db.exec("ALTER TABLE undo ADD COLUMN separators TEXT");
|
|
203
|
+
} catch {}
|
|
204
|
+
try {
|
|
205
|
+
db.exec("ALTER TABLE undo ADD COLUMN result_separators TEXT");
|
|
206
|
+
} catch {}
|
|
195
207
|
const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'version'").get() as { value?: string } | undefined;
|
|
196
208
|
if (versionRow && versionRow.value !== String(HASH_STORE_VERSION)) {
|
|
197
209
|
db.exec("DELETE FROM snapshots");
|
|
@@ -210,11 +222,11 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
|
|
|
210
222
|
"ON CONFLICT(path) DO UPDATE SET checksum = excluded.checksum, line_count = excluded.line_count, hashes = excluded.hashes, line_checksums = excluded.line_checksums, updated_at = excluded.updated_at"
|
|
211
223
|
);
|
|
212
224
|
const undoUpsertStmt = db.prepare(
|
|
213
|
-
"INSERT INTO undo (path, content, bom, ending, hashes, result_content, mode, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) " +
|
|
214
|
-
"ON CONFLICT(path) DO UPDATE SET content = excluded.content, bom = excluded.bom, ending = excluded.ending, hashes = excluded.hashes, result_content = excluded.result_content, mode = excluded.mode, updated_at = excluded.updated_at"
|
|
225
|
+
"INSERT INTO undo (path, content, bom, ending, separators, hashes, result_content, result_separators, mode, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
|
|
226
|
+
"ON CONFLICT(path) DO UPDATE SET content = excluded.content, bom = excluded.bom, ending = excluded.ending, separators = excluded.separators, hashes = excluded.hashes, result_content = excluded.result_content, result_separators = excluded.result_separators, mode = excluded.mode, updated_at = excluded.updated_at"
|
|
215
227
|
);
|
|
216
228
|
const undoGetStmt = db.prepare(
|
|
217
|
-
"SELECT content, bom, ending, hashes, result_content, mode FROM undo WHERE path = ?"
|
|
229
|
+
"SELECT content, bom, ending, separators, hashes, result_content, result_separators, mode FROM undo WHERE path = ?"
|
|
218
230
|
);
|
|
219
231
|
const undoDelStmt = db.prepare("DELETE FROM undo WHERE path = ?");
|
|
220
232
|
const snapshotTimeStmt = db.prepare("SELECT updated_at FROM snapshots WHERE path = ?");
|
|
@@ -543,8 +555,10 @@ export function upsertUndo(store: HashStore, path: string, entry: UndoRecord): v
|
|
|
543
555
|
entry.content,
|
|
544
556
|
entry.bom,
|
|
545
557
|
entry.ending,
|
|
558
|
+
entry.separators ? JSON.stringify(entry.separators) : null,
|
|
546
559
|
JSON.stringify(entry.hashes),
|
|
547
560
|
entry.resultContent,
|
|
561
|
+
entry.resultSeparators ? JSON.stringify(entry.resultSeparators) : null,
|
|
548
562
|
typeof entry.mode === "number" ? entry.mode : null,
|
|
549
563
|
Date.now(),
|
|
550
564
|
);
|
|
@@ -560,12 +574,26 @@ export function getUndoEntry(store: HashStore, path: string): UndoRecord | undef
|
|
|
560
574
|
store.stmts.undoDelete(path);
|
|
561
575
|
return undefined;
|
|
562
576
|
}
|
|
577
|
+
const separators = parseSeparators(row.separators);
|
|
578
|
+
const resultSeparators = parseSeparators(row.result_separators);
|
|
579
|
+
const resultContent = row.result_content as string;
|
|
580
|
+
if (
|
|
581
|
+
!separators.ok ||
|
|
582
|
+
!resultSeparators.ok ||
|
|
583
|
+
(separators.value !== undefined && separators.value.length !== countNewlines(content)) ||
|
|
584
|
+
(resultSeparators.value !== undefined && resultSeparators.value.length !== countNewlines(resultContent))
|
|
585
|
+
) {
|
|
586
|
+
store.stmts.undoDelete(path);
|
|
587
|
+
return undefined;
|
|
588
|
+
}
|
|
563
589
|
return {
|
|
564
590
|
content,
|
|
565
591
|
bom: row.bom as string,
|
|
566
592
|
ending: row.ending as string,
|
|
593
|
+
...(separators.value !== undefined ? { separators: separators.value } : {}),
|
|
567
594
|
hashes: parsed,
|
|
568
|
-
resultContent
|
|
595
|
+
resultContent,
|
|
596
|
+
...(resultSeparators.value !== undefined ? { resultSeparators: resultSeparators.value } : {}),
|
|
569
597
|
...(typeof row.mode === "number" ? { mode: row.mode } : {}),
|
|
570
598
|
};
|
|
571
599
|
}
|
package/src/hashline/apply.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type RHEdit,
|
|
12
12
|
type NEdit,
|
|
13
13
|
type HEdit,
|
|
14
|
+
type StripWarningLocation,
|
|
14
15
|
} from "./resolve";
|
|
15
16
|
|
|
16
17
|
type LIdx = {
|
|
@@ -160,6 +161,7 @@ export function planEdit(
|
|
|
160
161
|
servedHashes?: ReadonlyMap<string, string>;
|
|
161
162
|
signal?: AbortSignal;
|
|
162
163
|
baseFileLines?: string[];
|
|
164
|
+
stripWarning?: StripWarningLocation;
|
|
163
165
|
},
|
|
164
166
|
): PlannedEdit {
|
|
165
167
|
const signal = options?.signal;
|
|
@@ -171,8 +173,9 @@ export function planEdit(
|
|
|
171
173
|
|
|
172
174
|
const rangeFixed = swapReversedRanges(edit, fileHashes);
|
|
173
175
|
const prefixFixed = stripDiffPrefixes(
|
|
174
|
-
stripBarePrefixes(rangeFixed, warnings),
|
|
176
|
+
stripBarePrefixes(rangeFixed, warnings, options?.stripWarning),
|
|
175
177
|
warnings,
|
|
178
|
+
options?.stripWarning,
|
|
176
179
|
);
|
|
177
180
|
|
|
178
181
|
const { resolved: initialResolved, mismatches } = valEdit(
|
|
@@ -213,6 +216,8 @@ export function applyEdit(
|
|
|
213
216
|
precomputedHashes?: string[],
|
|
214
217
|
filePath?: string,
|
|
215
218
|
servedHashes?: ReadonlyMap<string, string>,
|
|
219
|
+
allowEmpty = false,
|
|
220
|
+
stripWarning?: StripWarningLocation,
|
|
216
221
|
): {
|
|
217
222
|
content: string;
|
|
218
223
|
firstChangedLine: number | undefined;
|
|
@@ -224,7 +229,7 @@ export function applyEdit(
|
|
|
224
229
|
if (precomputedHashes === undefined) {
|
|
225
230
|
throw new Error("[E_BAD_SHAPE] applyEdit requires the file's allocated anchors; derive them via lineHashes(content, path) first.");
|
|
226
231
|
}
|
|
227
|
-
const planned = planEdit(content, edit, precomputedHashes, { filePath, servedHashes, signal });
|
|
232
|
+
const planned = planEdit(content, edit, precomputedHashes, { filePath, servedHashes, signal, stripWarning });
|
|
228
233
|
const lineIndex = buildIdx(content);
|
|
229
234
|
const warnings = planned.warnings;
|
|
230
235
|
const resolved = planned.resolved;
|
|
@@ -240,7 +245,7 @@ export function applyEdit(
|
|
|
240
245
|
}
|
|
241
246
|
|
|
242
247
|
const result = assemble(content, spanResult, signal);
|
|
243
|
-
assertNotEmpty(content, result);
|
|
248
|
+
if (!allowEmpty) assertNotEmpty(content, result);
|
|
244
249
|
const range = changedRange(content, result);
|
|
245
250
|
|
|
246
251
|
return {
|
package/src/hashline/index.ts
CHANGED
|
@@ -18,7 +18,9 @@ export {
|
|
|
18
18
|
export {
|
|
19
19
|
parseHashRef,
|
|
20
20
|
parseText,
|
|
21
|
+
parseTextWithSeparators,
|
|
21
22
|
type Anchor,
|
|
23
|
+
type ParsedText,
|
|
22
24
|
} from "./parse";
|
|
23
25
|
|
|
24
26
|
export {
|
|
@@ -32,6 +34,7 @@ export {
|
|
|
32
34
|
valEdit,
|
|
33
35
|
stripBarePrefixes,
|
|
34
36
|
stripDiffPrefixes,
|
|
37
|
+
type StripWarningLocation,
|
|
35
38
|
swapReversedRanges,
|
|
36
39
|
assertRangeServed,
|
|
37
40
|
RangeStaleError,
|
package/src/hashline/parse.ts
CHANGED
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
} from "./hash";
|
|
5
5
|
import { ALPH_RE } from "./alphabet";
|
|
6
6
|
import { NEW_CONTENT_NOT_ARRAY_MSG } from "../constants";
|
|
7
|
+
import { splitWithEndings } from "../line-endings";
|
|
8
|
+
import type { LineEnding } from "../normalize";
|
|
7
9
|
|
|
8
10
|
const HASH_EXTRACT_RE = new RegExp(HASH_CLASS);
|
|
9
11
|
|
|
@@ -68,15 +70,25 @@ function unwrapJsonEnvelope(line: string): string {
|
|
|
68
70
|
}
|
|
69
71
|
}
|
|
70
72
|
|
|
73
|
+
export interface ParsedText {
|
|
74
|
+
lines: string[];
|
|
75
|
+
separators: (LineEnding | undefined)[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function parseTextWithSeparators(edit: string[]): ParsedText {
|
|
79
|
+
if (!Array.isArray(edit) || edit.some((line) => typeof line !== "string")) {
|
|
80
|
+
throw new Error(NEW_CONTENT_NOT_ARRAY_MSG);
|
|
81
|
+
}
|
|
82
|
+
const lines: string[] = [];
|
|
83
|
+
const separators: (LineEnding | undefined)[] = [];
|
|
84
|
+
for (const line of edit) {
|
|
85
|
+
const parsed = splitWithEndings(unwrapJsonEnvelope(line));
|
|
86
|
+
lines.push(...parsed.lines);
|
|
87
|
+
separators.push(...parsed.endings);
|
|
88
|
+
}
|
|
89
|
+
return { lines, separators };
|
|
90
|
+
}
|
|
91
|
+
|
|
71
92
|
export function parseText(edit: string[]): string[] {
|
|
72
|
-
|
|
73
|
-
throw new Error(NEW_CONTENT_NOT_ARRAY_MSG);
|
|
74
|
-
}
|
|
75
|
-
const out: string[] = [];
|
|
76
|
-
for (const line of edit) {
|
|
77
|
-
const unwrapped = unwrapJsonEnvelope(line);
|
|
78
|
-
const normalized = unwrapped.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
79
|
-
out.push(...normalized.split("\n"));
|
|
80
|
-
}
|
|
81
|
-
return out;
|
|
93
|
+
return parseTextWithSeparators(edit).lines;
|
|
82
94
|
}
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { abortIf, rejectUnknownFields, clipLine, decodeStringArray, assertNoNul } from "../utils";
|
|
2
|
-
import { parseHashRef,
|
|
2
|
+
import { parseHashRef, parseTextWithSeparators, type Anchor } from "./parse";
|
|
3
3
|
import { HASH_SEP, stripRowPrefix, lineChecksum, type RowPrefixKind } from "./hash";
|
|
4
4
|
import { HASH_RUN } from "./alphabet";
|
|
5
5
|
import { NEW_CONTENT_NOT_ARRAY_MSG, MAX_RANGE_STALE_LINES } from "../constants";
|
|
6
|
+
import type { LineEnding } from "../normalize";
|
|
6
7
|
|
|
7
8
|
export type RAnchor = {
|
|
8
9
|
line: number;
|
|
9
10
|
hash: string;
|
|
10
11
|
};
|
|
11
12
|
|
|
12
|
-
export type HEdit = {
|
|
13
|
+
export type HEdit = {
|
|
14
|
+
content_lines: string[];
|
|
15
|
+
hash_bounds: [Anchor, Anchor];
|
|
16
|
+
content_separators?: (LineEnding | undefined)[];
|
|
17
|
+
};
|
|
13
18
|
export type RHEdit = {
|
|
14
19
|
content_lines: string[];
|
|
15
20
|
hash_bounds: [RAnchor, RAnchor];
|
|
@@ -158,7 +163,8 @@ export function stripAnchorRow(
|
|
|
158
163
|
export function resEdit(edit: HTEdit, warnings?: string[]): HEdit {
|
|
159
164
|
assertItem(edit as Record<string, unknown>);
|
|
160
165
|
|
|
161
|
-
const
|
|
166
|
+
const parsed = parseTextWithSeparators(decodeStringArray(edit.replacement_lines, warnings) ?? edit.replacement_lines);
|
|
167
|
+
const replaceLines = parsed.lines;
|
|
162
168
|
assertNoNul(replaceLines);
|
|
163
169
|
const bounds = [edit.remove_from, edit.remove_to].map((ref) => {
|
|
164
170
|
return stripAnchorRow(ref.trim(), "remove_from/remove_to entry", warnings);
|
|
@@ -166,6 +172,7 @@ export function resEdit(edit: HTEdit, warnings?: string[]): HEdit {
|
|
|
166
172
|
return {
|
|
167
173
|
content_lines: replaceLines,
|
|
168
174
|
hash_bounds: [parseHashRef(bounds[0]), parseHashRef(bounds[1])],
|
|
175
|
+
...(parsed.separators.some((separator) => separator !== undefined) ? { content_separators: parsed.separators } : {}),
|
|
169
176
|
};
|
|
170
177
|
}
|
|
171
178
|
|
|
@@ -180,12 +187,18 @@ function warnUnicodeEsc(
|
|
|
180
187
|
}
|
|
181
188
|
}
|
|
182
189
|
|
|
190
|
+
export interface StripWarningLocation {
|
|
191
|
+
label: string;
|
|
192
|
+
indexOffset: number;
|
|
193
|
+
}
|
|
194
|
+
|
|
183
195
|
function stripRowPrefixes(
|
|
184
196
|
edit: HEdit,
|
|
185
197
|
warnings: string[],
|
|
186
198
|
kinds: RowPrefixKind[],
|
|
187
199
|
code: string,
|
|
188
200
|
marker: string,
|
|
201
|
+
location: StripWarningLocation,
|
|
189
202
|
): HEdit {
|
|
190
203
|
const stripped: number[] = [];
|
|
191
204
|
const contentLines = edit.content_lines.map((line, lineIndex) => {
|
|
@@ -195,17 +208,19 @@ function stripRowPrefixes(
|
|
|
195
208
|
return result.text;
|
|
196
209
|
});
|
|
197
210
|
if (stripped.length === 0) return edit;
|
|
198
|
-
const locations = stripped.map((i) =>
|
|
211
|
+
const locations = stripped.map((i) => `${location.label} line ${i + 1 + location.indexOffset}`).join(", ");
|
|
199
212
|
warnings.push(`${code} Stripped ${marker} from ${locations}.`);
|
|
200
213
|
return { ...edit, content_lines: contentLines };
|
|
201
214
|
}
|
|
202
215
|
|
|
203
|
-
|
|
204
|
-
|
|
216
|
+
const DEFAULT_STRIP_WARNING_LOCATION: StripWarningLocation = { label: "replacement_lines", indexOffset: 0 };
|
|
217
|
+
|
|
218
|
+
export function stripBarePrefixes(edit: HEdit, warnings: string[], location: StripWarningLocation = DEFAULT_STRIP_WARNING_LOCATION): HEdit {
|
|
219
|
+
return stripRowPrefixes(edit, warnings, ["bare"], "[W_BARE_HASH_PREFIX]", '"anchor│" prefix', location);
|
|
205
220
|
}
|
|
206
221
|
|
|
207
|
-
export function stripDiffPrefixes(edit: HEdit, warnings: string[]): HEdit {
|
|
208
|
-
return stripRowPrefixes(edit, warnings, ["plus", "minus"], "[W_INVALID_PATCH]", "diff-preview marker");
|
|
222
|
+
export function stripDiffPrefixes(edit: HEdit, warnings: string[], location: StripWarningLocation = DEFAULT_STRIP_WARNING_LOCATION): HEdit {
|
|
223
|
+
return stripRowPrefixes(edit, warnings, ["plus", "minus"], "[W_INVALID_PATCH]", "diff-preview marker", location);
|
|
209
224
|
}
|
|
210
225
|
|
|
211
226
|
export function swapReversedRanges(
|
package/src/insert.ts
CHANGED
|
@@ -5,12 +5,13 @@ import { execPipeline, type ReqParams, type ReplaceDetails, previewFromPipe, pre
|
|
|
5
5
|
import { commitEdit } from "./commit";
|
|
6
6
|
import { batchMemberFor, ensureBatchBase, executeBatchMember, noteBatchFailure } from "./batch";
|
|
7
7
|
import { readNormFile, type NormFile } from "./file-reader";
|
|
8
|
-
import { MAX_HASH_LINES, parseHashRef, resEdit, resolveAnchorLine, type Anchor, type HEdit } from "./hashline";
|
|
8
|
+
import { MAX_HASH_LINES, parseHashRef, parseTextWithSeparators, resEdit, resolveAnchorLine, type Anchor, type HEdit, type StripWarningLocation } from "./hashline";
|
|
9
|
+
import type { LineEnding } from "./normalize";
|
|
9
10
|
import { stripAnchorRow } from "./hashline/resolve";
|
|
10
11
|
import { withAnchorSession } from "./anchor-registry";
|
|
11
12
|
import { loadP, loadGuide } from "./prompts";
|
|
12
13
|
import { assertInsertReq, normReq, type InsertReq } from "./payload-contract";
|
|
13
|
-
import { decodeStringArray, isRec, splitLines } from "./utils";
|
|
14
|
+
import { decodeStringArray, isRec, literalEscapeWarning, splitLines } from "./utils";
|
|
14
15
|
import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper, resolveEditTargetWithRequirement, throwIfStrictInput, withInsertPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
|
|
15
16
|
import type { RPreview, RRState } from "./replace-render";
|
|
16
17
|
export { assertInsertReq, type InsertReq };
|
|
@@ -27,7 +28,7 @@ const insertDirectionSchema = Type.Union(
|
|
|
27
28
|
|
|
28
29
|
const insertLinesSchema = Type.Array(
|
|
29
30
|
Type.String({
|
|
30
|
-
description: "One line to insert
|
|
31
|
+
description: "One line to insert. A real line break (\\n, \\r\\n, or \\r) splits it into lines and sets their endings; escape sequences are not decoded.",
|
|
31
32
|
}),
|
|
32
33
|
{
|
|
33
34
|
description: 'One string per line; [""] is a blank line; never include the anchor line.',
|
|
@@ -73,7 +74,8 @@ export function buildInsertEdit(
|
|
|
73
74
|
preload: NormFile,
|
|
74
75
|
ref: Anchor,
|
|
75
76
|
path: string,
|
|
76
|
-
): { editParams: ReqParams; anchorLine: string | undefined } {
|
|
77
|
+
): { editParams: ReqParams; anchorLine: string | undefined; contentSeparators: (LineEnding | undefined)[] } {
|
|
78
|
+
const parsed = parseTextWithSeparators(req.lines);
|
|
77
79
|
const fileLines = splitLines(preload.normalized);
|
|
78
80
|
const line = resolveAnchorLine(ref, fileLines, preload.fileHashes, path);
|
|
79
81
|
const anchorLine = preload.normalized.length === 0 ? undefined : fileLines[line - 1];
|
|
@@ -82,12 +84,21 @@ export function buildInsertEdit(
|
|
|
82
84
|
remove_to: ref.hash,
|
|
83
85
|
replacement_lines:
|
|
84
86
|
anchorLine === undefined
|
|
85
|
-
? [...
|
|
87
|
+
? [...parsed.lines]
|
|
86
88
|
: req.direction === "after"
|
|
87
|
-
? [anchorLine, ...
|
|
88
|
-
: [...
|
|
89
|
+
? [anchorLine, ...parsed.lines]
|
|
90
|
+
: [...parsed.lines, anchorLine],
|
|
89
91
|
};
|
|
90
|
-
|
|
92
|
+
const contentSeparators = anchorLine === undefined
|
|
93
|
+
? parsed.separators
|
|
94
|
+
: req.direction === "after"
|
|
95
|
+
? [undefined, ...parsed.separators]
|
|
96
|
+
: [...parsed.separators, undefined];
|
|
97
|
+
return { editParams, anchorLine, contentSeparators };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function insertStripWarning(anchorLine: string | undefined, direction: "before" | "after"): StripWarningLocation {
|
|
101
|
+
return { label: "lines", indexOffset: anchorLine !== undefined && direction === "after" ? -1 : 0 };
|
|
91
102
|
}
|
|
92
103
|
|
|
93
104
|
export async function insertPreview(request: unknown, cwd: string, signal?: AbortSignal): Promise<RPreview> {
|
|
@@ -114,12 +125,13 @@ export async function insertPreview(request: unknown, cwd: string, signal?: Abor
|
|
|
114
125
|
allocation: "shadow",
|
|
115
126
|
signal,
|
|
116
127
|
});
|
|
117
|
-
const { editParams } = buildInsertEdit(normalized, preload, ref, targetPath);
|
|
128
|
+
const { editParams, anchorLine } = buildInsertEdit(normalized, preload, ref, targetPath);
|
|
118
129
|
const pipe = await execPipeline(targetPath, editParams, cwd, {
|
|
119
130
|
accessMode: constants.R_OK,
|
|
120
131
|
noPersist: true,
|
|
121
132
|
preloadedNorm: preload,
|
|
122
133
|
signal,
|
|
134
|
+
stripWarning: insertStripWarning(anchorLine, normalized.direction),
|
|
123
135
|
});
|
|
124
136
|
return previewFromPipe(pipe);
|
|
125
137
|
} catch (error: unknown) {
|
|
@@ -180,6 +192,8 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
|
|
|
180
192
|
}
|
|
181
193
|
assertInsertReq(canonical);
|
|
182
194
|
const req = canonical;
|
|
195
|
+
const literalEscape = literalEscapeWarning(req.lines, "lines");
|
|
196
|
+
if (literalEscape !== undefined) insertWarnings.push(literalEscape);
|
|
183
197
|
const targetPath = await resolveEditTargetWithRequirement({
|
|
184
198
|
anchor: req.anchor,
|
|
185
199
|
providedPath: req.path,
|
|
@@ -224,6 +238,8 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
|
|
|
224
238
|
hedit,
|
|
225
239
|
extraWarnings: [...anchorWarnings, ...insertWarnings, ...resWarnings],
|
|
226
240
|
foldedLines: built.anchorLine === undefined ? 0 : 1,
|
|
241
|
+
stripWarning: insertStripWarning(built.anchorLine, req.direction),
|
|
242
|
+
contentSeparators: built.contentSeparators,
|
|
227
243
|
});
|
|
228
244
|
}
|
|
229
245
|
const preload = await readNormFile(targetPath, ctx.cwd, {
|
|
@@ -231,11 +247,12 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
|
|
|
231
247
|
accessMode: constants.R_OK | constants.W_OK,
|
|
232
248
|
maxLines: MAX_HASH_LINES,
|
|
233
249
|
});
|
|
234
|
-
const { editParams, anchorLine } = buildInsertEdit(req, preload, ref, targetPath);
|
|
250
|
+
const { editParams, anchorLine, contentSeparators } = buildInsertEdit(req, preload, ref, targetPath);
|
|
235
251
|
const pipe = await execPipeline(targetPath, editParams, ctx.cwd, {
|
|
236
252
|
accessMode: constants.R_OK | constants.W_OK,
|
|
237
253
|
signal,
|
|
238
254
|
preloadedNorm: preload,
|
|
255
|
+
stripWarning: insertStripWarning(anchorLine, req.direction),
|
|
239
256
|
});
|
|
240
257
|
return commitEdit(pipe, {
|
|
241
258
|
path: pipe.path,
|
|
@@ -250,6 +267,7 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
|
|
|
250
267
|
noopNoun: "Insertion",
|
|
251
268
|
foldedAnchorLines: anchorLine === undefined ? 0 : 1,
|
|
252
269
|
prefixWarnings: [...anchorWarnings, ...insertWarnings],
|
|
270
|
+
endingOverrides: contentSeparators,
|
|
253
271
|
});
|
|
254
272
|
});
|
|
255
273
|
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { LineEnding } from "./normalize";
|
|
2
|
+
|
|
3
|
+
export interface EndingSpan {
|
|
4
|
+
start: number;
|
|
5
|
+
end: number;
|
|
6
|
+
replacementCount: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function countNewlines(content: string): number {
|
|
10
|
+
let count = 0;
|
|
11
|
+
for (let index = 0; index < content.length; index++) {
|
|
12
|
+
if (content.charCodeAt(index) === 10) count += 1;
|
|
13
|
+
}
|
|
14
|
+
return count;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function splitSeparators(raw: string): LineEnding[] {
|
|
18
|
+
const separators: LineEnding[] = [];
|
|
19
|
+
for (let index = 0; index < raw.length; index++) {
|
|
20
|
+
const char = raw[index]!;
|
|
21
|
+
if (char === "\r") {
|
|
22
|
+
if (raw[index + 1] === "\n") {
|
|
23
|
+
separators.push("\r\n");
|
|
24
|
+
index += 1;
|
|
25
|
+
} else {
|
|
26
|
+
separators.push("\r");
|
|
27
|
+
}
|
|
28
|
+
} else if (char === "\n") {
|
|
29
|
+
separators.push("\n");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return separators;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function joinSeparators(content: string, separators: readonly LineEnding[]): string {
|
|
36
|
+
let out = "";
|
|
37
|
+
let separatorIndex = 0;
|
|
38
|
+
for (const char of content) {
|
|
39
|
+
if (char === "\n") {
|
|
40
|
+
out += separators[separatorIndex] ?? "\n";
|
|
41
|
+
separatorIndex += 1;
|
|
42
|
+
} else {
|
|
43
|
+
out += char;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sourceLineMap(baseLineCount: number, spans: readonly EndingSpan[]): number[] {
|
|
50
|
+
const sorted = [...spans].sort((left, right) => left.start - right.start);
|
|
51
|
+
const map: number[] = [];
|
|
52
|
+
let baseLine = 0;
|
|
53
|
+
for (const span of sorted) {
|
|
54
|
+
while (baseLine < span.start && baseLine < baseLineCount) {
|
|
55
|
+
map.push(baseLine);
|
|
56
|
+
baseLine += 1;
|
|
57
|
+
}
|
|
58
|
+
const spanLength = span.end >= span.start ? span.end - span.start + 1 : 0;
|
|
59
|
+
for (let offset = 0; offset < span.replacementCount; offset++) {
|
|
60
|
+
if (offset < spanLength) map.push(span.start + offset);
|
|
61
|
+
else map.push(spanLength > 0 ? span.end : span.start - 1);
|
|
62
|
+
}
|
|
63
|
+
baseLine = span.end >= span.start ? span.end + 1 : span.start;
|
|
64
|
+
}
|
|
65
|
+
while (baseLine < baseLineCount) {
|
|
66
|
+
map.push(baseLine);
|
|
67
|
+
baseLine += 1;
|
|
68
|
+
}
|
|
69
|
+
return map;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function separatorsForSpans(
|
|
73
|
+
baseSeparators: readonly LineEnding[],
|
|
74
|
+
baseLineCount: number,
|
|
75
|
+
spans: readonly EndingSpan[],
|
|
76
|
+
resultContent: string,
|
|
77
|
+
fallback: LineEnding,
|
|
78
|
+
): LineEnding[] {
|
|
79
|
+
const mapped = sourceLineMap(baseLineCount, spans).map((source) =>
|
|
80
|
+
source >= 0 && source < baseSeparators.length ? baseSeparators[source]! : "",
|
|
81
|
+
);
|
|
82
|
+
const needed = countNewlines(resultContent);
|
|
83
|
+
const result: LineEnding[] = [];
|
|
84
|
+
for (let index = 0; index < needed; index++) {
|
|
85
|
+
const candidate = mapped[index];
|
|
86
|
+
if (candidate !== undefined && candidate !== "") result.push(candidate);
|
|
87
|
+
else result.push(result[index - 1] ?? fallback);
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function endingsForRange(separators: readonly LineEnding[], startLine: number, endLine: number): (LineEnding | undefined)[] {
|
|
93
|
+
const endings: (LineEnding | undefined)[] = [];
|
|
94
|
+
for (let line = startLine; line <= endLine; line += 1) {
|
|
95
|
+
endings.push(separators[line - 1]);
|
|
96
|
+
}
|
|
97
|
+
return endings;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function splitWithEndings(text: string): { lines: string[]; endings: (LineEnding | undefined)[] } {
|
|
101
|
+
const lines: string[] = [];
|
|
102
|
+
const endings: (LineEnding | undefined)[] = [];
|
|
103
|
+
let current = "";
|
|
104
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
105
|
+
const char = text[index]!;
|
|
106
|
+
if (char === "\r") {
|
|
107
|
+
const crlf = text[index + 1] === "\n";
|
|
108
|
+
endings.push(crlf ? "\r\n" : "\r");
|
|
109
|
+
lines.push(current);
|
|
110
|
+
current = "";
|
|
111
|
+
if (crlf) index += 1;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (char === "\n") {
|
|
115
|
+
endings.push("\n");
|
|
116
|
+
lines.push(current);
|
|
117
|
+
current = "";
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
current += char;
|
|
121
|
+
}
|
|
122
|
+
lines.push(current);
|
|
123
|
+
endings.push(undefined);
|
|
124
|
+
return { lines, endings };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function applyEndingOverrides(separators: LineEnding[], spanStart: number, endings: readonly (LineEnding | undefined)[] | undefined): void {
|
|
128
|
+
if (endings === undefined) return;
|
|
129
|
+
for (let index = 0; index < endings.length; index += 1) {
|
|
130
|
+
const ending = endings[index];
|
|
131
|
+
if (ending === undefined) continue;
|
|
132
|
+
const target = spanStart + index;
|
|
133
|
+
if (target >= 0 && target < separators.length) separators[target] = ending;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function applySpanEndings(separators: LineEnding[], spans: readonly { start: number; end: number; replacementCount: number; endings?: (LineEnding | undefined)[] }[]): void {
|
|
138
|
+
const sorted = [...spans].sort((left, right) => left.start - right.start);
|
|
139
|
+
let offset = 0;
|
|
140
|
+
for (const span of sorted) {
|
|
141
|
+
if (span.endings !== undefined) {
|
|
142
|
+
for (let index = 0; index < span.endings.length; index += 1) {
|
|
143
|
+
const ending = span.endings[index];
|
|
144
|
+
if (ending === undefined) continue;
|
|
145
|
+
const target = span.start + offset + index;
|
|
146
|
+
if (target >= 0 && target < separators.length) separators[target] = ending;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
offset += span.replacementCount - (span.end - span.start + 1);
|
|
150
|
+
}
|
|
151
|
+
}
|