@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/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
+ }
@@ -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
+ }