@ladbabynpm/picc-edit 0.1.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/LICENSE +21 -0
- package/README.md +59 -0
- package/index.ts +342 -0
- package/package.json +60 -0
- package/src/constants.ts +15 -0
- package/src/diff.ts +209 -0
- package/src/edit.ts +283 -0
- package/src/editUtils.ts +203 -0
- package/src/file.ts +162 -0
- package/src/path.ts +62 -0
- package/src/prompt.ts +39 -0
- package/src/readState.ts +52 -0
- package/src/renderDiff.ts +185 -0
- package/src/windowsPaths.ts +31 -0
package/src/diff.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// picc-write — src/diff.ts
|
|
3
|
+
//
|
|
4
|
+
//
|
|
5
|
+
// Adaptors vs upstream:
|
|
6
|
+
// - `countLinesChanged` is made pure (returns `{ added, removed }`) — no
|
|
7
|
+
// analytics / LOC counters / logging.
|
|
8
|
+
// - A local structural `Hunk` type stands in for `diff`'s
|
|
9
|
+
// `StructuredPatchHunk` (avoid importing from `diff`'s internal types).
|
|
10
|
+
// =============================================================================
|
|
11
|
+
|
|
12
|
+
import { diffLines, structuredPatch } from "diff";
|
|
13
|
+
|
|
14
|
+
export const CONTEXT_LINES = 3;
|
|
15
|
+
|
|
16
|
+
/** Structural type matching `diff`'s `StructuredPatchHunk`. */
|
|
17
|
+
export interface Hunk {
|
|
18
|
+
oldStart: number;
|
|
19
|
+
oldLines: number;
|
|
20
|
+
newStart: number;
|
|
21
|
+
newLines: number;
|
|
22
|
+
lines: string[];
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// For some reason, & confuses the diff library, so we replace it with a token,
|
|
27
|
+
// then substitute it back in after the diff is computed.
|
|
28
|
+
const AMPERSAND_TOKEN = "<<:AMPERSAND_TOKEN:>>";
|
|
29
|
+
const DOLLAR_TOKEN = "<<:DOLLAR_TOKEN:>>";
|
|
30
|
+
|
|
31
|
+
function escapeForDiff(s: string): string {
|
|
32
|
+
return s.replaceAll("&", AMPERSAND_TOKEN).replaceAll("$", DOLLAR_TOKEN);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function unescapeFromDiff(s: string): string {
|
|
36
|
+
return s.replaceAll(AMPERSAND_TOKEN, "&").replaceAll(DOLLAR_TOKEN, "$");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Count lines added and removed in a patch. For new files, pass the content
|
|
41
|
+
* string as the second parameter (all lines count as additions).
|
|
42
|
+
*/
|
|
43
|
+
export function countLinesChanged(
|
|
44
|
+
patch: Hunk[],
|
|
45
|
+
newFileContent?: string,
|
|
46
|
+
): { added: number; removed: number } {
|
|
47
|
+
let numAdditions = 0;
|
|
48
|
+
let numRemovals = 0;
|
|
49
|
+
|
|
50
|
+
if (patch.length === 0 && newFileContent) {
|
|
51
|
+
numAdditions = newFileContent.split(/\r?\n/).length;
|
|
52
|
+
} else {
|
|
53
|
+
for (const hunk of patch) {
|
|
54
|
+
for (const line of hunk.lines) {
|
|
55
|
+
if (line.startsWith("+")) numAdditions++;
|
|
56
|
+
else if (line.startsWith("-")) numRemovals++;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { added: numAdditions, removed: numRemovals };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Generate a display-oriented diff string using a **unified** line-number
|
|
66
|
+
* counter (the new file's line number for all lines).
|
|
67
|
+
*
|
|
68
|
+
* Numbering rules:
|
|
69
|
+
* - Context: ` NNN content`, counter advances
|
|
70
|
+
* - Added: `+NNN content`, counter advances
|
|
71
|
+
* - Removed: `-NNN content`, counter does NOT advance
|
|
72
|
+
*
|
|
73
|
+
* Context collapsing mirrors pi's `generateDiffString`: only up to
|
|
74
|
+
* `contextLines` lines are shown before/after a change; larger gaps are
|
|
75
|
+
* replaced with a ` ...` marker.
|
|
76
|
+
*/
|
|
77
|
+
export function generateDisplayDiff(
|
|
78
|
+
oldContent: string,
|
|
79
|
+
newContent: string,
|
|
80
|
+
contextLines: number = CONTEXT_LINES,
|
|
81
|
+
): string {
|
|
82
|
+
const parts = diffLines(oldContent, newContent);
|
|
83
|
+
const output: string[] = [];
|
|
84
|
+
|
|
85
|
+
const oldLines = oldContent.split("\n");
|
|
86
|
+
const newLines = newContent.split("\n");
|
|
87
|
+
const maxLineNum = Math.max(oldLines.length, newLines.length);
|
|
88
|
+
const lineNumWidth = String(maxLineNum).length;
|
|
89
|
+
|
|
90
|
+
let newLineNum = 1;
|
|
91
|
+
let lastWasChange = false;
|
|
92
|
+
|
|
93
|
+
for (let i = 0; i < parts.length; i++) {
|
|
94
|
+
const part = parts[i];
|
|
95
|
+
const raw = part.value.split("\n");
|
|
96
|
+
if (raw[raw.length - 1] === "") {
|
|
97
|
+
raw.pop();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (part.added || part.removed) {
|
|
101
|
+
for (const line of raw) {
|
|
102
|
+
const lineNum = String(newLineNum).padStart(lineNumWidth, " ");
|
|
103
|
+
if (part.added) {
|
|
104
|
+
output.push(`+${lineNum} ${line}`);
|
|
105
|
+
newLineNum++;
|
|
106
|
+
} else {
|
|
107
|
+
output.push(`-${lineNum} ${line}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
lastWasChange = true;
|
|
111
|
+
} else {
|
|
112
|
+
// Context lines — collapse large gaps
|
|
113
|
+
const nextPartIsChange =
|
|
114
|
+
i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
|
|
115
|
+
const hasLeadingChange = lastWasChange;
|
|
116
|
+
const hasTrailingChange = nextPartIsChange;
|
|
117
|
+
|
|
118
|
+
if (hasLeadingChange && hasTrailingChange) {
|
|
119
|
+
if (raw.length <= contextLines * 2) {
|
|
120
|
+
for (const line of raw) {
|
|
121
|
+
const lineNum = String(newLineNum).padStart(lineNumWidth, " ");
|
|
122
|
+
output.push(` ${lineNum} ${line}`);
|
|
123
|
+
newLineNum++;
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
const leading = raw.slice(0, contextLines);
|
|
127
|
+
const trailing = raw.slice(raw.length - contextLines);
|
|
128
|
+
const skipped = raw.length - leading.length - trailing.length;
|
|
129
|
+
for (const line of leading) {
|
|
130
|
+
const lineNum = String(newLineNum).padStart(lineNumWidth, " ");
|
|
131
|
+
output.push(` ${lineNum} ${line}`);
|
|
132
|
+
newLineNum++;
|
|
133
|
+
}
|
|
134
|
+
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
|
|
135
|
+
newLineNum += skipped;
|
|
136
|
+
for (const line of trailing) {
|
|
137
|
+
const lineNum = String(newLineNum).padStart(lineNumWidth, " ");
|
|
138
|
+
output.push(` ${lineNum} ${line}`);
|
|
139
|
+
newLineNum++;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} else if (hasLeadingChange) {
|
|
143
|
+
const shown = raw.slice(0, contextLines);
|
|
144
|
+
const skipped = raw.length - shown.length;
|
|
145
|
+
for (const line of shown) {
|
|
146
|
+
const lineNum = String(newLineNum).padStart(lineNumWidth, " ");
|
|
147
|
+
output.push(` ${lineNum} ${line}`);
|
|
148
|
+
newLineNum++;
|
|
149
|
+
}
|
|
150
|
+
if (skipped > 0) {
|
|
151
|
+
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
|
|
152
|
+
newLineNum += skipped;
|
|
153
|
+
}
|
|
154
|
+
} else if (hasTrailingChange) {
|
|
155
|
+
const skipped = Math.max(0, raw.length - contextLines);
|
|
156
|
+
if (skipped > 0) {
|
|
157
|
+
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
|
|
158
|
+
newLineNum += skipped;
|
|
159
|
+
}
|
|
160
|
+
for (const line of raw.slice(skipped)) {
|
|
161
|
+
const lineNum = String(newLineNum).padStart(lineNumWidth, " ");
|
|
162
|
+
output.push(` ${lineNum} ${line}`);
|
|
163
|
+
newLineNum++;
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
newLineNum += raw.length;
|
|
167
|
+
}
|
|
168
|
+
lastWasChange = false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return output.join("\n");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Compute a structured patch between two contents, with `&`/`$` escaped
|
|
177
|
+
* through the diff algorithm and unescaped on the returned lines.
|
|
178
|
+
*/
|
|
179
|
+
export function getPatchFromContents({
|
|
180
|
+
filePath,
|
|
181
|
+
oldContent,
|
|
182
|
+
newContent,
|
|
183
|
+
ignoreWhitespace = false,
|
|
184
|
+
}: {
|
|
185
|
+
filePath: string;
|
|
186
|
+
oldContent: string;
|
|
187
|
+
newContent: string;
|
|
188
|
+
ignoreWhitespace?: boolean;
|
|
189
|
+
}): Hunk[] {
|
|
190
|
+
const result = structuredPatch(
|
|
191
|
+
filePath,
|
|
192
|
+
filePath,
|
|
193
|
+
escapeForDiff(oldContent),
|
|
194
|
+
escapeForDiff(newContent),
|
|
195
|
+
undefined,
|
|
196
|
+
undefined,
|
|
197
|
+
{
|
|
198
|
+
ignoreWhitespace,
|
|
199
|
+
context: CONTEXT_LINES,
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
if (!result) {
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
return result.hunks.map((h) => ({
|
|
206
|
+
...h,
|
|
207
|
+
lines: h.lines.map(unescapeFromDiff),
|
|
208
|
+
}));
|
|
209
|
+
}
|
package/src/edit.ts
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// picc-edit — src/edit.ts
|
|
3
|
+
//
|
|
4
|
+
// Behavior:
|
|
5
|
+
// - Existing file: must have been read this session (readState) and not
|
|
6
|
+
// modified since that read. `old_string` is matched (with quote
|
|
7
|
+
// normalization), must be unique unless `replace_all`.
|
|
8
|
+
// - `old_string === ''` on a missing file = create; on a non-empty existing
|
|
9
|
+
// file = error.
|
|
10
|
+
// - Writes with explicit LF handling (the model's sent line endings are
|
|
11
|
+
// respected as-is — no repo resampling).
|
|
12
|
+
// - Returns an `EditOutcome` with a structured patch (leading tabs → 2
|
|
13
|
+
// spaces, for display only) + `originalFile`.
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
16
|
+
import { mkdir } from "node:fs/promises";
|
|
17
|
+
import { dirname } from "node:path";
|
|
18
|
+
import {
|
|
19
|
+
FILE_MODIFIED_SINCE_READ_ERROR,
|
|
20
|
+
FILE_NOT_READ_ERROR,
|
|
21
|
+
} from "./constants.js";
|
|
22
|
+
import { getPatchFromContents, type Hunk } from "./diff.js";
|
|
23
|
+
import {
|
|
24
|
+
applyEditToFile,
|
|
25
|
+
findActualString,
|
|
26
|
+
preserveQuoteStyle,
|
|
27
|
+
} from "./editUtils.js";
|
|
28
|
+
import {
|
|
29
|
+
convertLeadingTabsToSpaces,
|
|
30
|
+
findSimilarFile,
|
|
31
|
+
getFileModificationTime,
|
|
32
|
+
type LineEndingType,
|
|
33
|
+
readFileSyncWithMetadata,
|
|
34
|
+
writeTextContent,
|
|
35
|
+
} from "./file.js";
|
|
36
|
+
import { expandPath } from "./path.js";
|
|
37
|
+
import { readStateGet, readStateSet } from "./readState.js";
|
|
38
|
+
|
|
39
|
+
export type EditInput = {
|
|
40
|
+
file_path: string;
|
|
41
|
+
old_string: string;
|
|
42
|
+
new_string: string;
|
|
43
|
+
replace_all?: boolean;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type EditOutcome = {
|
|
47
|
+
filePath: string;
|
|
48
|
+
oldString: string;
|
|
49
|
+
newString: string;
|
|
50
|
+
originalFile: string;
|
|
51
|
+
/** Post-edit file content, with leading tabs converted to 2 spaces (display
|
|
52
|
+
* space, same content as was written). Used by the TUI diff viewer. */
|
|
53
|
+
newFile: string;
|
|
54
|
+
structuredPatch: Hunk[];
|
|
55
|
+
replaceAll: boolean;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Format the "Added N lines, removed M lines" summary line shown above the
|
|
60
|
+
* diff in the TUI, matching Claude Code's FileEditToolUpdatedMessage. Numbers
|
|
61
|
+
* are pluralized and the removal verb is lowercase when additions are present.
|
|
62
|
+
*/
|
|
63
|
+
export function editSummaryText(
|
|
64
|
+
additions: number,
|
|
65
|
+
removals: number,
|
|
66
|
+
): string {
|
|
67
|
+
const parts: string[] = [];
|
|
68
|
+
if (additions > 0) {
|
|
69
|
+
parts.push(`Added ${additions} line${additions === 1 ? "" : "s"}`);
|
|
70
|
+
}
|
|
71
|
+
if (removals > 0) {
|
|
72
|
+
parts.push(
|
|
73
|
+
`${additions > 0 ? "removed" : "Removed"} ${removals} line${removals === 1 ? "" : "s"}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return parts.length > 0 ? parts.join(", ") : "Applied";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Thrown for read-guard / edit-validation failures with a model-facing msg. */
|
|
80
|
+
export class EditGuardError extends Error {
|
|
81
|
+
// biome-ignore lint/complexity/noUselessConstructor: kept for `instanceof` semantics.
|
|
82
|
+
constructor(message: string) {
|
|
83
|
+
super(message);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isEnoent(e: unknown): boolean {
|
|
88
|
+
return (
|
|
89
|
+
typeof e === "object" &&
|
|
90
|
+
e !== null &&
|
|
91
|
+
(e as NodeJS.ErrnoException).code === "ENOENT"
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Read the current file state for editing. Returns `fileExists: false` with
|
|
97
|
+
* empty content for a missing file; rethrows non-ENOENT errors.
|
|
98
|
+
*/
|
|
99
|
+
function readFileForEdit(
|
|
100
|
+
absoluteFilePath: string,
|
|
101
|
+
): {
|
|
102
|
+
content: string;
|
|
103
|
+
fileExists: boolean;
|
|
104
|
+
encoding: BufferEncoding;
|
|
105
|
+
lineEndings: LineEndingType;
|
|
106
|
+
} {
|
|
107
|
+
try {
|
|
108
|
+
const meta = readFileSyncWithMetadata(absoluteFilePath);
|
|
109
|
+
return {
|
|
110
|
+
content: meta.content,
|
|
111
|
+
fileExists: true,
|
|
112
|
+
encoding: meta.encoding,
|
|
113
|
+
lineEndings: meta.lineEndings,
|
|
114
|
+
};
|
|
115
|
+
} catch (e) {
|
|
116
|
+
if (isEnoent(e)) {
|
|
117
|
+
return {
|
|
118
|
+
content: "",
|
|
119
|
+
fileExists: false,
|
|
120
|
+
encoding: "utf8",
|
|
121
|
+
lineEndings: "LF",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
throw e;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Execute an edit, enforcing the read-first / modified-since-read guards and
|
|
130
|
+
* the string-match rules. Returns an `EditOutcome` (the write already
|
|
131
|
+
* performed), or throws `EditGuardError` (guards / match errors) / a plain
|
|
132
|
+
* `Error` (filesystem / OS errors).
|
|
133
|
+
*/
|
|
134
|
+
export async function editOutcome(
|
|
135
|
+
input: EditInput,
|
|
136
|
+
cwd: string,
|
|
137
|
+
): Promise<EditOutcome> {
|
|
138
|
+
const { old_string, new_string, replace_all = false } = input;
|
|
139
|
+
|
|
140
|
+
const fullFilePath = expandPath(input.file_path, cwd);
|
|
141
|
+
const dir = dirname(fullFilePath);
|
|
142
|
+
|
|
143
|
+
// Ensure parent directory exists (outside the critical section).
|
|
144
|
+
await mkdir(dir, { recursive: true });
|
|
145
|
+
|
|
146
|
+
// SECURITY: skip filesystem ops for UNC paths to prevent NTLM credential
|
|
147
|
+
// leaks (permission layer would otherwise handle them).
|
|
148
|
+
if (fullFilePath.startsWith("\\\\") || fullFilePath.startsWith("//")) {
|
|
149
|
+
throw new EditGuardError(
|
|
150
|
+
`Cannot edit network path: ${fullFilePath}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (old_string === new_string) {
|
|
155
|
+
throw new EditGuardError(
|
|
156
|
+
"No changes to make: old_string and new_string are exactly the same.",
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const { content: originalContent, fileExists, encoding } =
|
|
161
|
+
readFileForEdit(fullFilePath);
|
|
162
|
+
|
|
163
|
+
// File doesn't exist:
|
|
164
|
+
// - empty old_string → new file creation (valid)
|
|
165
|
+
// - else → error, with a similar-file suggestion
|
|
166
|
+
if (!fileExists) {
|
|
167
|
+
if (old_string === "") {
|
|
168
|
+
const updatedFile = applyEditToFile(
|
|
169
|
+
originalContent,
|
|
170
|
+
old_string,
|
|
171
|
+
new_string,
|
|
172
|
+
replace_all,
|
|
173
|
+
);
|
|
174
|
+
writeTextContent(fullFilePath, updatedFile, encoding, "LF");
|
|
175
|
+
readStateSet(fullFilePath, {
|
|
176
|
+
content: updatedFile,
|
|
177
|
+
timestamp: getFileModificationTime(fullFilePath),
|
|
178
|
+
});
|
|
179
|
+
const patch = getPatchFromContents({
|
|
180
|
+
filePath: fullFilePath,
|
|
181
|
+
oldContent: convertLeadingTabsToSpaces(originalContent),
|
|
182
|
+
newContent: convertLeadingTabsToSpaces(updatedFile),
|
|
183
|
+
});
|
|
184
|
+
return {
|
|
185
|
+
filePath: input.file_path,
|
|
186
|
+
oldString: old_string,
|
|
187
|
+
newString: new_string,
|
|
188
|
+
originalFile: originalContent,
|
|
189
|
+
newFile: convertLeadingTabsToSpaces(updatedFile),
|
|
190
|
+
structuredPatch: patch,
|
|
191
|
+
replaceAll: replace_all,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
const similar = findSimilarFile(fullFilePath);
|
|
195
|
+
let message = `File does not exist. Note: your current working directory is ${cwd}.`;
|
|
196
|
+
if (similar) {
|
|
197
|
+
message += ` Did you mean ${similar}?`;
|
|
198
|
+
}
|
|
199
|
+
throw new EditGuardError(message);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// File exists with empty old_string — only valid if the file is empty.
|
|
203
|
+
if (old_string === "") {
|
|
204
|
+
if (originalContent.trim() !== "") {
|
|
205
|
+
throw new EditGuardError(
|
|
206
|
+
"Cannot create new file - file already exists.",
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Read guard (existing file).
|
|
212
|
+
const lastRead = readStateGet(fullFilePath);
|
|
213
|
+
if (!lastRead || lastRead.offset !== undefined || lastRead.limit !== undefined) {
|
|
214
|
+
throw new EditGuardError(FILE_NOT_READ_ERROR);
|
|
215
|
+
}
|
|
216
|
+
const lastWriteTime = getFileModificationTime(fullFilePath);
|
|
217
|
+
if (
|
|
218
|
+
lastWriteTime > lastRead.timestamp &&
|
|
219
|
+
originalContent !== lastRead.content
|
|
220
|
+
) {
|
|
221
|
+
throw new EditGuardError(FILE_MODIFIED_SINCE_READ_ERROR);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Match the string (with quote normalization).
|
|
225
|
+
const actualOldString = findActualString(originalContent, old_string);
|
|
226
|
+
if (!actualOldString) {
|
|
227
|
+
throw new EditGuardError(
|
|
228
|
+
`String to replace not found in file.\nString: ${old_string}`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const matches = originalContent.split(actualOldString).length - 1;
|
|
233
|
+
if (matches > 1 && !replace_all) {
|
|
234
|
+
throw new EditGuardError(
|
|
235
|
+
`Found ${matches} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: ${old_string}`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Apply the edit (preserving the file's curly-quote style).
|
|
240
|
+
const actualNewString = preserveQuoteStyle(
|
|
241
|
+
old_string,
|
|
242
|
+
actualOldString,
|
|
243
|
+
new_string,
|
|
244
|
+
);
|
|
245
|
+
const updatedFile = applyEditToFile(
|
|
246
|
+
originalContent,
|
|
247
|
+
actualOldString,
|
|
248
|
+
actualNewString,
|
|
249
|
+
replace_all,
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
if (updatedFile === originalContent) {
|
|
253
|
+
throw new EditGuardError(
|
|
254
|
+
"Original and edited file match exactly. Failed to apply edit.",
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Write to disk with LF handling.
|
|
259
|
+
writeTextContent(fullFilePath, updatedFile, encoding, "LF");
|
|
260
|
+
|
|
261
|
+
// Record this edit as a fresh full read (invalidates stale re-edits).
|
|
262
|
+
readStateSet(fullFilePath, {
|
|
263
|
+
content: updatedFile,
|
|
264
|
+
timestamp: getFileModificationTime(fullFilePath),
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const newFile = convertLeadingTabsToSpaces(updatedFile);
|
|
268
|
+
const structuredPatch = getPatchFromContents({
|
|
269
|
+
filePath: fullFilePath,
|
|
270
|
+
oldContent: convertLeadingTabsToSpaces(originalContent),
|
|
271
|
+
newContent: newFile,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
filePath: input.file_path,
|
|
276
|
+
oldString: actualOldString,
|
|
277
|
+
newString: actualNewString,
|
|
278
|
+
originalFile: originalContent,
|
|
279
|
+
newFile,
|
|
280
|
+
structuredPatch,
|
|
281
|
+
replaceAll: replace_all,
|
|
282
|
+
};
|
|
283
|
+
}
|
package/src/editUtils.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Claude can't output curly quotes, so we define them as constants here for
|
|
2
|
+
// Claude to use in the code. We normalize curly quotes to straight quotes
|
|
3
|
+
// when applying edits.
|
|
4
|
+
export const LEFT_SINGLE_CURLY_QUOTE = "\u2018";
|
|
5
|
+
export const RIGHT_SINGLE_CURLY_QUOTE = "\u2019";
|
|
6
|
+
export const LEFT_DOUBLE_CURLY_QUOTE = "\u201c";
|
|
7
|
+
export const RIGHT_DOUBLE_CURLY_QUOTE = "\u201d";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Normalizes quotes in a string by converting curly quotes to straight quotes.
|
|
11
|
+
*/
|
|
12
|
+
export function normalizeQuotes(str: string): string {
|
|
13
|
+
return str
|
|
14
|
+
.replaceAll(LEFT_SINGLE_CURLY_QUOTE, "'")
|
|
15
|
+
.replaceAll(RIGHT_SINGLE_CURLY_QUOTE, "'")
|
|
16
|
+
.replaceAll(LEFT_DOUBLE_CURLY_QUOTE, '"')
|
|
17
|
+
.replaceAll(RIGHT_DOUBLE_CURLY_QUOTE, '"');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Strips trailing whitespace from each line in a string while preserving line
|
|
22
|
+
* endings.
|
|
23
|
+
*/
|
|
24
|
+
export function stripTrailingWhitespace(str: string): string {
|
|
25
|
+
const lines = str.split(/(\r\n|\n|\r)/);
|
|
26
|
+
|
|
27
|
+
let result = "";
|
|
28
|
+
for (let i = 0; i < lines.length; i++) {
|
|
29
|
+
const part = lines[i];
|
|
30
|
+
if (part !== undefined) {
|
|
31
|
+
if (i % 2 === 0) {
|
|
32
|
+
// Even indices are line content
|
|
33
|
+
result += part.replace(/\s+$/, "");
|
|
34
|
+
} else {
|
|
35
|
+
// Odd indices are line endings
|
|
36
|
+
result += part;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Finds the actual string in the file content that matches the search string,
|
|
46
|
+
* accounting for quote normalization.
|
|
47
|
+
*/
|
|
48
|
+
export function findActualString(
|
|
49
|
+
fileContent: string,
|
|
50
|
+
searchString: string,
|
|
51
|
+
): string | null {
|
|
52
|
+
// First try exact match
|
|
53
|
+
if (fileContent.includes(searchString)) {
|
|
54
|
+
return searchString;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Try with normalized quotes
|
|
58
|
+
const normalizedSearch = normalizeQuotes(searchString);
|
|
59
|
+
const normalizedFile = normalizeQuotes(fileContent);
|
|
60
|
+
|
|
61
|
+
const searchIndex = normalizedFile.indexOf(normalizedSearch);
|
|
62
|
+
if (searchIndex !== -1) {
|
|
63
|
+
// Find the actual string in the file that matches
|
|
64
|
+
return fileContent.substring(searchIndex, searchIndex + searchString.length);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* When old_string matched via quote normalization (curly quotes in file,
|
|
72
|
+
* straight quotes from model), apply the same curly quote style to new_string
|
|
73
|
+
* so the edit preserves the file's typography.
|
|
74
|
+
*/
|
|
75
|
+
export function preserveQuoteStyle(
|
|
76
|
+
oldString: string,
|
|
77
|
+
actualOldString: string,
|
|
78
|
+
newString: string,
|
|
79
|
+
): string {
|
|
80
|
+
// If they're the same, no normalization happened
|
|
81
|
+
if (oldString === actualOldString) {
|
|
82
|
+
return newString;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Detect which curly quote types were in the file
|
|
86
|
+
const hasDoubleQuotes =
|
|
87
|
+
actualOldString.includes(LEFT_DOUBLE_CURLY_QUOTE) ||
|
|
88
|
+
actualOldString.includes(RIGHT_DOUBLE_CURLY_QUOTE);
|
|
89
|
+
const hasSingleQuotes =
|
|
90
|
+
actualOldString.includes(LEFT_SINGLE_CURLY_QUOTE) ||
|
|
91
|
+
actualOldString.includes(RIGHT_SINGLE_CURLY_QUOTE);
|
|
92
|
+
|
|
93
|
+
if (!hasDoubleQuotes && !hasSingleQuotes) {
|
|
94
|
+
return newString;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let result = newString;
|
|
98
|
+
|
|
99
|
+
if (hasDoubleQuotes) {
|
|
100
|
+
result = applyCurlyDoubleQuotes(result);
|
|
101
|
+
}
|
|
102
|
+
if (hasSingleQuotes) {
|
|
103
|
+
result = applyCurlySingleQuotes(result);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isOpeningContext(chars: string[], index: number): boolean {
|
|
110
|
+
if (index === 0) {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
const prev = chars[index - 1];
|
|
114
|
+
return (
|
|
115
|
+
prev === " " ||
|
|
116
|
+
prev === "\t" ||
|
|
117
|
+
prev === "\n" ||
|
|
118
|
+
prev === "\r" ||
|
|
119
|
+
prev === "(" ||
|
|
120
|
+
prev === "[" ||
|
|
121
|
+
prev === "{" ||
|
|
122
|
+
prev === "\u2014" || // em dash
|
|
123
|
+
prev === "\u2013" // en dash
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function applyCurlyDoubleQuotes(str: string): string {
|
|
128
|
+
const chars = [...str];
|
|
129
|
+
const result: string[] = [];
|
|
130
|
+
for (let i = 0; i < chars.length; i++) {
|
|
131
|
+
if (chars[i] === '"') {
|
|
132
|
+
result.push(
|
|
133
|
+
isOpeningContext(chars, i)
|
|
134
|
+
? LEFT_DOUBLE_CURLY_QUOTE
|
|
135
|
+
: RIGHT_DOUBLE_CURLY_QUOTE,
|
|
136
|
+
);
|
|
137
|
+
} else {
|
|
138
|
+
result.push(chars[i]!);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return result.join("");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function applyCurlySingleQuotes(str: string): string {
|
|
145
|
+
const chars = [...str];
|
|
146
|
+
const result: string[] = [];
|
|
147
|
+
for (let i = 0; i < chars.length; i++) {
|
|
148
|
+
if (chars[i] === "'") {
|
|
149
|
+
// Don't convert apostrophes in contractions (e.g., "don't", "it's")
|
|
150
|
+
const prev = i > 0 ? chars[i - 1] : undefined;
|
|
151
|
+
const next = i < chars.length - 1 ? chars[i + 1] : undefined;
|
|
152
|
+
const prevIsLetter = prev !== undefined && /\p{L}/u.test(prev);
|
|
153
|
+
const nextIsLetter = next !== undefined && /\p{L}/u.test(next);
|
|
154
|
+
if (prevIsLetter && nextIsLetter) {
|
|
155
|
+
// Apostrophe in a contraction — use right single curly quote
|
|
156
|
+
result.push(RIGHT_SINGLE_CURLY_QUOTE);
|
|
157
|
+
} else {
|
|
158
|
+
result.push(
|
|
159
|
+
isOpeningContext(chars, i)
|
|
160
|
+
? LEFT_SINGLE_CURLY_QUOTE
|
|
161
|
+
: RIGHT_SINGLE_CURLY_QUOTE,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
result.push(chars[i]!);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return result.join("");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Applies a single edit to `originalContent`, returning the updated content.
|
|
173
|
+
*
|
|
174
|
+
* The replace callback uses the function form (`() => replace`) so that
|
|
175
|
+
* special `$` sequences in `newString` (`$&`, `$1`, ...) are treated
|
|
176
|
+
* literally rather than as replacement patterns.
|
|
177
|
+
*
|
|
178
|
+
* When deleting a line (`newString === ''`), a trailing newline is stripped
|
|
179
|
+
* from the removed region so we don't leave a blank line behind.
|
|
180
|
+
*/
|
|
181
|
+
export function applyEditToFile(
|
|
182
|
+
originalContent: string,
|
|
183
|
+
oldString: string,
|
|
184
|
+
newString: string,
|
|
185
|
+
replaceAll: boolean = false,
|
|
186
|
+
): string {
|
|
187
|
+
const f = replaceAll
|
|
188
|
+
? (content: string, search: string, replace: string) =>
|
|
189
|
+
content.replaceAll(search, () => replace)
|
|
190
|
+
: (content: string, search: string, replace: string) =>
|
|
191
|
+
content.replace(search, () => replace);
|
|
192
|
+
|
|
193
|
+
if (newString !== "") {
|
|
194
|
+
return f(originalContent, oldString, newString);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const stripTrailingNewline =
|
|
198
|
+
!oldString.endsWith("\n") && originalContent.includes(oldString + "\n");
|
|
199
|
+
|
|
200
|
+
return stripTrailingNewline
|
|
201
|
+
? f(originalContent, oldString + "\n", newString)
|
|
202
|
+
: f(originalContent, oldString, newString);
|
|
203
|
+
}
|