pi-hashline-edit-pro 4.4.3 → 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 +312 -63
- package/index.ts +22 -6
- 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/read-guidelines.md +2 -1
- 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/anchor-registry.ts +128 -39
- package/src/auto-read-all.ts +3 -1
- package/src/batch.ts +35 -7
- package/src/commit.ts +22 -4
- package/src/config-ui.ts +2 -1
- package/src/config.ts +6 -1
- package/src/constants.ts +4 -1
- package/src/copy-move.ts +793 -0
- package/src/edit-common.ts +21 -1
- package/src/file-reader.ts +4 -0
- package/src/grep.ts +3 -1
- package/src/hash-store/validation.ts +22 -0
- package/src/hash-store.ts +33 -5
- 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 +23 -3
- package/src/payload-contract.ts +29 -1
- package/src/read.ts +7 -5
- package/src/replace-render.ts +4 -4
- package/src/replace-undo.ts +13 -4
- package/src/replace.ts +17 -5
- package/src/utils.ts +40 -5
|
@@ -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
|
+
}
|
package/src/paths.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
|
-
import { isAbsolute, relative, resolve as resolvePath, join, dirname } from "node:path";
|
|
2
|
+
import { isAbsolute, relative, resolve as resolvePath, join, dirname, parse } from "node:path";
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
function homeBase(): string {
|
|
@@ -16,7 +16,11 @@ function configBase(): string {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export function configDir(): string {
|
|
19
|
-
|
|
19
|
+
const override = process.env.PI_HASHLINE_DIR;
|
|
20
|
+
if (override && !isAbsolute(override)) {
|
|
21
|
+
throw new Error("[E_CONFIG] PI_HASHLINE_DIR must be an absolute path");
|
|
22
|
+
}
|
|
23
|
+
return override || join(configBase(), "pi-hashline-edit-pro");
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
export function configPath(): string {
|
|
@@ -51,6 +55,22 @@ export function toCwd(filePath: string, cwd: string): string {
|
|
|
51
55
|
return isAbsolute(expanded) ? expanded : resolvePath(cwd, expanded);
|
|
52
56
|
}
|
|
53
57
|
|
|
58
|
+
function pathDepth(path: string): number {
|
|
59
|
+
const root = parse(path).root;
|
|
60
|
+
return path.slice(root.length).split(/[\\/]+/).filter((part) => part.length > 0).length;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function climbsToRoot(cwd: string, relativePath: string): boolean {
|
|
64
|
+
let climbs = 0;
|
|
65
|
+
for (const part of relativePath.split("/")) {
|
|
66
|
+
if (part !== "..") break;
|
|
67
|
+
climbs += 1;
|
|
68
|
+
}
|
|
69
|
+
return climbs > 0 && climbs >= pathDepth(resolvePath(cwd));
|
|
70
|
+
}
|
|
71
|
+
|
|
54
72
|
export function toDisplayPath(cwd: string, absolutePath: string, fallback?: string): string {
|
|
55
|
-
|
|
73
|
+
const rel = relative(cwd, absolutePath).replace(/\\/g, "/");
|
|
74
|
+
if (rel.length === 0 || climbsToRoot(cwd, rel)) return fallback ?? absolutePath;
|
|
75
|
+
return rel;
|
|
56
76
|
}
|
package/src/payload-contract.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { isRec, normalizeRequest, rejectUnknownFields, assertNoNul } from "./uti
|
|
|
4
4
|
const replacementLinesSchema = Type.Array(
|
|
5
5
|
Type.String({
|
|
6
6
|
description:
|
|
7
|
-
"One replacement line
|
|
7
|
+
"One replacement line. A real line break (\\n, \\r\\n, or \\r) splits it into lines and sets their endings; escape sequences are not decoded.",
|
|
8
8
|
}),
|
|
9
9
|
{
|
|
10
10
|
description:
|
|
@@ -131,3 +131,31 @@ export function assertInsertReq(request: unknown): asserts request is InsertReq
|
|
|
131
131
|
}
|
|
132
132
|
assertNoNul(request.lines);
|
|
133
133
|
}
|
|
134
|
+
|
|
135
|
+
const TRANSFER_KEYS = new Set(["path", "source_from", "source_to", "insert_after"]);
|
|
136
|
+
|
|
137
|
+
export interface TransferReq {
|
|
138
|
+
path?: string;
|
|
139
|
+
source_from: string;
|
|
140
|
+
source_to: string;
|
|
141
|
+
insert_after: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function assertTransferReq(request: unknown): asserts request is TransferReq {
|
|
145
|
+
if (!isRec(request)) {
|
|
146
|
+
throw new Error("[E_BAD_SHAPE] Copy/move request must be an object.");
|
|
147
|
+
}
|
|
148
|
+
rejectUnknownFields(request, TRANSFER_KEYS, "Copy/move request");
|
|
149
|
+
if (request.path !== undefined && typeof request.path !== "string") {
|
|
150
|
+
throw new Error('[E_BAD_SHAPE] Copy/move request field "path" must be a string when provided.');
|
|
151
|
+
}
|
|
152
|
+
if (typeof request.source_from !== "string" || request.source_from.length === 0) {
|
|
153
|
+
throw new Error('[E_BAD_SHAPE] Copy/move request requires a "source_from" string (4-char anchor from read output).');
|
|
154
|
+
}
|
|
155
|
+
if (typeof request.source_to !== "string" || request.source_to.length === 0) {
|
|
156
|
+
throw new Error('[E_BAD_SHAPE] Copy/move request requires a "source_to" string (4-char anchor from read output).');
|
|
157
|
+
}
|
|
158
|
+
if (typeof request.insert_after !== "string" || request.insert_after.length === 0) {
|
|
159
|
+
throw new Error('[E_BAD_SHAPE] Copy/move request requires an "insert_after" string (4-char anchor from read output).');
|
|
160
|
+
}
|
|
161
|
+
}
|
package/src/read.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { withReadPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-
|
|
|
19
19
|
import { valAccess } from "./validation";
|
|
20
20
|
import { readConfig } from "./config";
|
|
21
21
|
import { resolveTarget } from "./fs-write";
|
|
22
|
-
import { withAnchorSession, servedForPath, sessionKeyFor } from "./anchor-registry";
|
|
22
|
+
import { withAnchorSession, servedForPath, sessionKeyFor, formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
|
|
23
23
|
import { serveRows } from "./served";
|
|
24
24
|
import { getAutoReadAllSnapshot } from "./auto-read-all-state";
|
|
25
25
|
import { Text } from "@earendil-works/pi-tui";
|
|
@@ -258,10 +258,12 @@ export function regRead(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
|
|
|
258
258
|
);
|
|
259
259
|
serveRows(resolvedPath, fileHashes, fileLines, preview.servedHashes);
|
|
260
260
|
const snapshotId = await safeSnapId(absolutePath, "read");
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
261
|
+
const reclaimNotice = formatAnchorReclaimNotice(takeReclaimedPaths());
|
|
262
|
+
const previewText = [
|
|
263
|
+
preview.text,
|
|
264
|
+
hadUtf8DecodeErrors ? "[Non-UTF-8 bytes shown as U+FFFD; editing rewrites the file as UTF-8.]" : undefined,
|
|
265
|
+
reclaimNotice,
|
|
266
|
+
].filter((part): part is string => part !== undefined).join("\n\n");
|
|
265
267
|
|
|
266
268
|
return {
|
|
267
269
|
content: [{ type: "text", text: previewText }],
|
package/src/replace-render.ts
CHANGED
|
@@ -81,7 +81,7 @@ export function highlightBatchRefs(text: string, theme: FgT): string {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
export function fmtCall(
|
|
84
|
-
args: { path?: string; remove_from?: string; remove_to?: string; anchor?: string } | undefined,
|
|
84
|
+
args: { path?: string; remove_from?: string; remove_to?: string; anchor?: string; source_from?: string; source_to?: string; insert_after?: string } | undefined,
|
|
85
85
|
state: RRState,
|
|
86
86
|
expanded: boolean,
|
|
87
87
|
theme: CallT,
|
|
@@ -89,7 +89,7 @@ export function fmtCall(
|
|
|
89
89
|
): string {
|
|
90
90
|
const previewPath = state.preview && "path" in state.preview ? state.preview.path : undefined;
|
|
91
91
|
const path = args?.path ?? state.resolvedPath ?? previewPath;
|
|
92
|
-
const anchorFallback = typeof args?.remove_from === "string" && typeof args?.remove_to === "string" ? `${args.remove_from}→${args.remove_to}` : typeof args?.anchor === "string" ? args.anchor : undefined;
|
|
92
|
+
const anchorFallback = typeof args?.remove_from === "string" && typeof args?.remove_to === "string" ? `${args.remove_from}→${args.remove_to}` : typeof args?.anchor === "string" ? args.anchor : typeof args?.source_from === "string" && typeof args?.insert_after === "string" ? `${args.source_from}→${args.insert_after}` : undefined;
|
|
93
93
|
const pathDisplay =
|
|
94
94
|
typeof path === "string" && path.length > 0
|
|
95
95
|
? theme.fg("accent", path)
|
|
@@ -264,9 +264,9 @@ export function reuseMarkdown(context: any, content: string, theme: any): Markdo
|
|
|
264
264
|
export function makeRenderCall(
|
|
265
265
|
preview: (args: unknown, cwd: string, signal?: AbortSignal) => Promise<RPreview>,
|
|
266
266
|
options: {
|
|
267
|
-
getInput?: (args: unknown) => { path?: string; remove_from?: string; remove_to?: string; anchor?: string } | null;
|
|
267
|
+
getInput?: (args: unknown) => { path?: string; remove_from?: string; remove_to?: string; anchor?: string; source_from?: string; source_to?: string; insert_after?: string } | null;
|
|
268
268
|
toolName?: string;
|
|
269
|
-
resolveTarget?: (input: { remove_from?: string; remove_to?: string; anchor?: string }) => string | undefined;
|
|
269
|
+
resolveTarget?: (input: { remove_from?: string; remove_to?: string; anchor?: string; source_from?: string; source_to?: string; insert_after?: string }) => string | undefined;
|
|
270
270
|
} = {},
|
|
271
271
|
) {
|
|
272
272
|
const getInput = options.getInput ?? getPreviewInput;
|
package/src/replace-undo.ts
CHANGED
|
@@ -6,9 +6,10 @@ import { Type } from "typebox";
|
|
|
6
6
|
import { loadHashStore, persistSnapshot, upsertUndo, getUndoEntry, deleteUndo, type UndoRecord } from "./hash-store";
|
|
7
7
|
import { servedHashesFromDiff, serveRows } from "./served";
|
|
8
8
|
import { lineChecksum } from "./hashline";
|
|
9
|
-
import { freeAnchors, adoptAnchors, withAnchorSession } from "./anchor-registry";
|
|
9
|
+
import { freeAnchors, adoptAnchors, withAnchorSession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
|
|
10
10
|
import { resolveInCwd, writeAtomic, type FileIdentity } from "./fs-write";
|
|
11
11
|
import { toLF, stripBOM, restoreEndings, type LineEnding } from "./normalize";
|
|
12
|
+
import { joinSeparators } from "./line-endings";
|
|
12
13
|
import { genDiff, genPatch, spansFromHashes } from "./replace-diff";
|
|
13
14
|
import { getDiffContextLines } from "./config";
|
|
14
15
|
import { cntDiff, errCode, makePrepareArguments, splitLines } from "./utils";
|
|
@@ -22,8 +23,10 @@ export interface UndoEntry {
|
|
|
22
23
|
content: string;
|
|
23
24
|
bom: string;
|
|
24
25
|
originalEnding: LineEnding;
|
|
26
|
+
separators?: LineEnding[];
|
|
25
27
|
hashes: string[];
|
|
26
28
|
resultContent: string;
|
|
29
|
+
resultSeparators?: LineEnding[];
|
|
27
30
|
mode?: number;
|
|
28
31
|
}
|
|
29
32
|
|
|
@@ -44,8 +47,10 @@ export async function saveUndo(
|
|
|
44
47
|
content: entry.content,
|
|
45
48
|
bom: entry.bom,
|
|
46
49
|
ending: entry.originalEnding,
|
|
50
|
+
...(entry.separators !== undefined ? { separators: entry.separators } : {}),
|
|
47
51
|
hashes: entry.hashes,
|
|
48
52
|
resultContent: entry.resultContent,
|
|
53
|
+
...(entry.resultSeparators !== undefined ? { resultSeparators: entry.resultSeparators } : {}),
|
|
49
54
|
...(mode !== undefined ? { mode } : {}),
|
|
50
55
|
});
|
|
51
56
|
} catch (error) {
|
|
@@ -80,8 +85,10 @@ export async function getUndo(path: string): Promise<UndoEntry | undefined> {
|
|
|
80
85
|
content: record.content,
|
|
81
86
|
bom: record.bom,
|
|
82
87
|
originalEnding,
|
|
88
|
+
...(record.separators !== undefined ? { separators: record.separators as LineEnding[] } : {}),
|
|
83
89
|
hashes: record.hashes,
|
|
84
90
|
resultContent: record.resultContent,
|
|
91
|
+
...(record.resultSeparators !== undefined ? { resultSeparators: record.resultSeparators as LineEnding[] } : {}),
|
|
85
92
|
...(record.mode !== undefined ? { mode: record.mode } : {}),
|
|
86
93
|
};
|
|
87
94
|
} catch (error) {
|
|
@@ -171,7 +178,7 @@ export function regUndo(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
|
|
|
171
178
|
|
|
172
179
|
if (
|
|
173
180
|
currentRaw !== undefined &&
|
|
174
|
-
currentRaw !== undo.bom + restoreEndings(undo.resultContent, undo.originalEnding)
|
|
181
|
+
currentRaw !== undo.bom + (undo.resultSeparators !== undefined ? joinSeparators(undo.resultContent, undo.resultSeparators) : restoreEndings(undo.resultContent, undo.originalEnding))
|
|
175
182
|
) {
|
|
176
183
|
return {
|
|
177
184
|
content: [
|
|
@@ -187,7 +194,7 @@ export function regUndo(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
|
|
|
187
194
|
|
|
188
195
|
await writeAtomic(
|
|
189
196
|
mutationTargetPath,
|
|
190
|
-
undo.bom + restoreEndings(undo.content, undo.originalEnding),
|
|
197
|
+
undo.bom + (undo.separators !== undefined ? joinSeparators(undo.content, undo.separators) : restoreEndings(undo.content, undo.originalEnding)),
|
|
191
198
|
currentIdentity,
|
|
192
199
|
undo.mode ?? fallbackFileMode(),
|
|
193
200
|
);
|
|
@@ -237,6 +244,8 @@ export function regUndo(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
|
|
|
237
244
|
parts.push(
|
|
238
245
|
"Call read for fresh anchors.",
|
|
239
246
|
);
|
|
247
|
+
const reclaimNotice = formatAnchorReclaimNotice(takeReclaimedPaths());
|
|
248
|
+
if (reclaimNotice !== undefined) parts.push(reclaimNotice);
|
|
240
249
|
|
|
241
250
|
const patchResult = genPatch(path, currentNormalized, undo.content);
|
|
242
251
|
return {
|
|
@@ -255,7 +264,7 @@ export function regUndo(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
|
|
|
255
264
|
classification: "applied",
|
|
256
265
|
editsAttempted: 1,
|
|
257
266
|
noopEditsCount: 0,
|
|
258
|
-
warningsCount: 0,
|
|
267
|
+
warningsCount: reclaimNotice !== undefined ? 1 : 0,
|
|
259
268
|
firstChangedLine: restoredRange?.firstChangedLine,
|
|
260
269
|
lastChangedLine: restoredRange?.lastChangedLine,
|
|
261
270
|
addedLines: linesRemovedByReplace,
|
package/src/replace.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from "./replace-diff";
|
|
11
11
|
import { readNormFile, type NormFile } from "./file-reader";
|
|
12
12
|
import { editToolSchema, buildEditToolSchema, type ReqParams, assertReq, normReq } from "./payload-contract";
|
|
13
|
-
import { splitLines } from "./utils";
|
|
13
|
+
import { literalEscapeWarning, splitLines } from "./utils";
|
|
14
14
|
import { loadP, loadGuide } from "./prompts";
|
|
15
15
|
import { type FileIdentity } from "./fs-write";
|
|
16
16
|
import { applyEdit,
|
|
@@ -21,6 +21,7 @@ import { applyEdit,
|
|
|
21
21
|
AnchorMismatchError,
|
|
22
22
|
type HEdit,
|
|
23
23
|
type NEdit,
|
|
24
|
+
type StripWarningLocation,
|
|
24
25
|
} from "./hashline";
|
|
25
26
|
import { type RMetrics } from "./replace-response";
|
|
26
27
|
import {
|
|
@@ -56,6 +57,7 @@ export interface PipelineResult {
|
|
|
56
57
|
result: string;
|
|
57
58
|
bom: string;
|
|
58
59
|
originalEnding: LineEnding;
|
|
60
|
+
originalSeparators: LineEnding[];
|
|
59
61
|
hadUtf8DecodeErrors: boolean;
|
|
60
62
|
warnings: string[];
|
|
61
63
|
noopEdit?: NEdit;
|
|
@@ -67,6 +69,7 @@ export interface PipelineResult {
|
|
|
67
69
|
totalRemovedLines: number;
|
|
68
70
|
identity: FileIdentity;
|
|
69
71
|
spans?: DiffSpan[];
|
|
72
|
+
contentSeparators?: (LineEnding | undefined)[];
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
|
|
@@ -76,6 +79,9 @@ export interface ExecPipelineOptions {
|
|
|
76
79
|
store?: HashStore;
|
|
77
80
|
noPersist?: boolean;
|
|
78
81
|
preloadedNorm?: NormFile;
|
|
82
|
+
served?: ReadonlyMap<string, string>;
|
|
83
|
+
allowEmpty?: boolean;
|
|
84
|
+
stripWarning?: StripWarningLocation;
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
export function hashSpan(hashes: string[], from: string, to: string): [number, number] | undefined {
|
|
@@ -91,7 +97,7 @@ export function spanForEdit(originalHashes: string[], from: string, to: string,
|
|
|
91
97
|
const replacementCount = splitLines(resultContent).length - (originalHashes.length - (span[1] - span[0] + 1));
|
|
92
98
|
return { start: span[0], end: span[1], replacementCount };
|
|
93
99
|
}
|
|
94
|
-
async function noteAnchorError(absolutePath: string, error: unknown, noPersist?: boolean): Promise<void> {
|
|
100
|
+
export async function noteAnchorError(absolutePath: string, error: unknown, noPersist?: boolean): Promise<void> {
|
|
95
101
|
if (noPersist === true) return;
|
|
96
102
|
if (error instanceof RangeStaleError) {
|
|
97
103
|
adoptAnchors(absolutePath, error.rangeServedMap);
|
|
@@ -137,8 +143,8 @@ export async function execPipeline(
|
|
|
137
143
|
const { edit, warnings: editWarnings } = buildReplaceHEdit(params);
|
|
138
144
|
const hashStore = options?.store ?? await loadHashStore();
|
|
139
145
|
const preResolvedPath = await resolveTarget(toCwd(targetPath, cwd));
|
|
140
|
-
const served = servedForPath(preResolvedPath);
|
|
141
|
-
const { normalized: originalNormalized, bom, originalEnding, fileHashes: originalHashes, hadUtf8DecodeErrors, absolutePath, identity } = await readNormFile(
|
|
146
|
+
const served = options?.served ?? servedForPath(preResolvedPath);
|
|
147
|
+
const { normalized: originalNormalized, bom, originalEnding, endingSeparators: originalSeparators, fileHashes: originalHashes, hadUtf8DecodeErrors, absolutePath, identity } = await readNormFile(
|
|
142
148
|
targetPath, cwd, { signal: options?.signal, accessMode: options?.accessMode, maxLines: MAX_HASH_LINES, store: hashStore, noPersist: options?.noPersist, allocation: options?.noPersist ? "shadow" : "real", preloadedNorm: options?.preloadedNorm },
|
|
143
149
|
);
|
|
144
150
|
const displayPath = toDisplayPath(cwd, absolutePath, targetPath);
|
|
@@ -152,6 +158,8 @@ export async function execPipeline(
|
|
|
152
158
|
originalHashes,
|
|
153
159
|
displayPath,
|
|
154
160
|
served,
|
|
161
|
+
options?.allowEmpty,
|
|
162
|
+
options?.stripWarning,
|
|
155
163
|
);
|
|
156
164
|
} catch (error) {
|
|
157
165
|
await noteAnchorError(absolutePath, error, options?.noPersist);
|
|
@@ -181,6 +189,7 @@ export async function execPipeline(
|
|
|
181
189
|
result,
|
|
182
190
|
bom,
|
|
183
191
|
originalEnding,
|
|
192
|
+
originalSeparators,
|
|
184
193
|
hadUtf8DecodeErrors,
|
|
185
194
|
warnings,
|
|
186
195
|
noopEdit: anchorResult.noopEdit,
|
|
@@ -192,6 +201,7 @@ export async function execPipeline(
|
|
|
192
201
|
totalRemovedLines,
|
|
193
202
|
identity,
|
|
194
203
|
...(pipeSpans ? { spans: pipeSpans } : {}),
|
|
204
|
+
...(edit.content_separators !== undefined ? { contentSeparators: edit.content_separators } : {}),
|
|
195
205
|
};
|
|
196
206
|
}
|
|
197
207
|
|
|
@@ -263,6 +273,7 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
|
|
|
263
273
|
const canonical = normReq(params);
|
|
264
274
|
assertReq(canonical);
|
|
265
275
|
const normalizedParams = canonical;
|
|
276
|
+
const literalEscape = literalEscapeWarning(normalizedParams.replacement_lines, "replacement_lines");
|
|
266
277
|
const targetPath = await resolveEditTargetWithRequirement({
|
|
267
278
|
removeFrom: normalizedParams.remove_from,
|
|
268
279
|
removeTo: normalizedParams.remove_to,
|
|
@@ -287,6 +298,7 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
|
|
|
287
298
|
absolutePath,
|
|
288
299
|
mutationTargetPath,
|
|
289
300
|
editAnchors: [normalizedParams.remove_from, normalizedParams.remove_to],
|
|
301
|
+
prefixWarnings: literalEscape !== undefined ? [literalEscape] : [],
|
|
290
302
|
signal,
|
|
291
303
|
});
|
|
292
304
|
}
|
|
@@ -305,7 +317,7 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
|
|
|
305
317
|
cwd: ctx.cwd,
|
|
306
318
|
signal,
|
|
307
319
|
hedit: built.edit,
|
|
308
|
-
extraWarnings: built.warnings,
|
|
320
|
+
extraWarnings: [...(literalEscape !== undefined ? [literalEscape] : []), ...built.warnings],
|
|
309
321
|
});
|
|
310
322
|
});
|
|
311
323
|
});
|
package/src/utils.ts
CHANGED
|
@@ -342,10 +342,6 @@ export function decodeStringArray(value: unknown, warnings?: string[], label = "
|
|
|
342
342
|
return undefined;
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
-
function splitEditLines(text: string): string[] {
|
|
346
|
-
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
|
347
|
-
}
|
|
348
|
-
|
|
349
345
|
function normalizeLineFieldValue(value: unknown): string[] | undefined {
|
|
350
346
|
const candidate = typeof value === "string"
|
|
351
347
|
? value
|
|
@@ -356,7 +352,7 @@ function normalizeLineFieldValue(value: unknown): string[] | undefined {
|
|
|
356
352
|
const decoded = decodeStringArray(candidate);
|
|
357
353
|
if (decoded !== undefined) return decoded;
|
|
358
354
|
if (/^\[\s*\]$/.test(stripTrailingMemberCall(stripCodeFence(candidate)))) return [];
|
|
359
|
-
return
|
|
355
|
+
return [candidate];
|
|
360
356
|
}
|
|
361
357
|
|
|
362
358
|
function normalizeEditLines(record: Record<string, unknown>): void {
|
|
@@ -366,3 +362,42 @@ function normalizeEditLines(record: Record<string, unknown>): void {
|
|
|
366
362
|
if (lines !== undefined) record[key] = lines;
|
|
367
363
|
}
|
|
368
364
|
}
|
|
365
|
+
|
|
366
|
+
const LITERAL_ESCAPE_RE = /\\(?:u([0-9a-fA-F]{4})|([ntr"]))/g;
|
|
367
|
+
|
|
368
|
+
const REAL_LINE_BREAK_RE = /[\n\r]/;
|
|
369
|
+
|
|
370
|
+
function isSurrogateEscapePair(line: string, index: number, hex: string): boolean {
|
|
371
|
+
const code = Number.parseInt(hex, 16);
|
|
372
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
373
|
+
const next = /^\\u([0-9a-fA-F]{4})/.exec(line.slice(index + 6))?.[1];
|
|
374
|
+
if (next === undefined) return false;
|
|
375
|
+
const nextCode = Number.parseInt(next, 16);
|
|
376
|
+
return nextCode >= 0xdc00 && nextCode <= 0xdfff;
|
|
377
|
+
}
|
|
378
|
+
if (code >= 0xdc00 && code <= 0xdfff) {
|
|
379
|
+
const previous = /\\u([0-9a-fA-F]{4})$/.exec(line.slice(0, index))?.[1];
|
|
380
|
+
if (previous === undefined) return false;
|
|
381
|
+
const previousCode = Number.parseInt(previous, 16);
|
|
382
|
+
return previousCode >= 0xd800 && previousCode <= 0xdbff;
|
|
383
|
+
}
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function literalEscapeWarning(lines: string[], label: string): string | undefined {
|
|
388
|
+
for (const line of lines) {
|
|
389
|
+
if (!line.includes("\\")) continue;
|
|
390
|
+
const hasRealBreak = REAL_LINE_BREAK_RE.test(line);
|
|
391
|
+
for (const match of line.matchAll(LITERAL_ESCAPE_RE)) {
|
|
392
|
+
const hex = match[1];
|
|
393
|
+
const simple = match[2];
|
|
394
|
+
if (simple !== undefined && hasRealBreak) continue;
|
|
395
|
+
if (hex !== undefined) {
|
|
396
|
+
if (hex.toLowerCase() === "dddd") continue;
|
|
397
|
+
if (isSurrogateEscapePair(line, match.index, hex)) continue;
|
|
398
|
+
}
|
|
399
|
+
return `[W_LITERAL_ESCAPE] "${label}" contains the literal escape text "${match[0]}"`;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|