Skip to content

Commit da82765

Browse files
wwwillchenclaude
andcommitted
Address PR review comments for line number handling
- Normalize CRLF to LF in addLineNumberPrefixes and read_file line-range branch to avoid embedded \r characters in output - Require at least 2 sequential line numbers for stripping (prevents false positives on single-line content) - Add fallback to original content when line number stripping causes match failure (handles false positive edge cases) - Only call unescapeMarkers for diff-format blocks, not 3-arg direct invocation path - Clamp displayed line range in error messages to actual file length - Update test expectations for new single-line behavior Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent f4c8ee0 commit da82765

4 files changed

Lines changed: 75 additions & 17 deletions

File tree

src/pro/main/ipc/handlers/local_agent/tools/read_file.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,12 @@ export const readFileTool: ToolDefinition<z.infer<typeof readFileSchema>> = {
9696
return addLineNumberPrefixes(content);
9797
}
9898

99-
const hasTrailingNewline = content.endsWith("\n");
100-
const lines = (hasTrailingNewline ? content.slice(0, -1) : content).split(
101-
"\n",
102-
);
99+
// Normalize CRLF to LF before line operations to avoid embedded \r characters
100+
const normalized = content.replace(/\r\n/g, "\n");
101+
const hasTrailingNewline = normalized.endsWith("\n");
102+
const lines = (
103+
hasTrailingNewline ? normalized.slice(0, -1) : normalized
104+
).split("\n");
103105
const startIdx = Math.max(0, (start ?? 1) - 1);
104106
const endIdx = Math.min(lines.length, end ?? lines.length);
105107
const result = lines.slice(startIdx, endIdx).join("\n");

src/pro/main/ipc/processors/line_number_utils.spec.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,10 +146,12 @@ describe("line_number_utils", () => {
146146
expect(result.hasLineNumbers).toBe(false);
147147
});
148148

149-
it("strips line numbers from single line", () => {
149+
it("does not strip line numbers from single line (too ambiguous)", () => {
150+
// Single lines matching the pattern are too ambiguous to confidently strip
151+
// (e.g., "42| some data" could be actual file content, not a line number prefix)
150152
const result = stripLineNumberPrefixes("1| hello");
151-
expect(result.content).toBe("hello");
152-
expect(result.hasLineNumbers).toBe(true);
153+
expect(result.content).toBe("1| hello");
154+
expect(result.hasLineNumbers).toBe(false);
153155
});
154156

155157
it("strips line numbers from multiple lines", () => {

src/pro/main/ipc/processors/line_number_utils.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ export function addLineNumberPrefixes(
4040
return "";
4141
}
4242

43-
const lines = content.split("\n");
43+
// Normalize CRLF to LF for consistent output (line numbers are for display to LLM, not for writing back)
44+
const lines = content.split(/\r?\n/);
4445
const totalLines = lines.length;
4546
// Calculate width based on the largest line number
4647
const maxLineNumber = startLineNumber + totalLines - 1;
@@ -71,7 +72,8 @@ export function stripLineNumberPrefixes(content: string): {
7172
return { content: "", hasLineNumbers: false };
7273
}
7374

74-
const lines = content.split("\n");
75+
// Normalize CRLF to LF for consistent processing
76+
const lines = content.split(/\r?\n/);
7577
const extractedNumbers: number[] = [];
7678

7779
// Check if all non-empty lines have line number prefixes
@@ -90,14 +92,20 @@ export function stripLineNumberPrefixes(content: string): {
9092
return { content, hasLineNumbers: false };
9193
}
9294

95+
// Require at least 2 line numbers to validate sequentiality.
96+
// Single lines matching the pattern are too ambiguous to confidently strip
97+
// (e.g., "42| some data" could be actual file content, not a line number prefix).
98+
// Also handles the all-empty-lines edge case where extractedNumbers would be empty.
99+
if (extractedNumbers.length < 2) {
100+
return { content, hasLineNumbers: false };
101+
}
102+
93103
// Verify that extracted line numbers are sequential (monotonically increasing by 1)
94104
// This dramatically reduces false positives on content that coincidentally matches
95105
// the line number pattern (e.g., "1| Alice", "2| Bob" as data content)
96-
if (extractedNumbers.length > 1) {
97-
for (let i = 1; i < extractedNumbers.length; i++) {
98-
if (extractedNumbers[i] !== extractedNumbers[i - 1] + 1) {
99-
return { content, hasLineNumbers: false };
100-
}
106+
for (let i = 1; i < extractedNumbers.length; i++) {
107+
if (extractedNumbers[i] !== extractedNumbers[i - 1] + 1) {
108+
return { content, hasLineNumbers: false };
101109
}
102110
}
103111

src/pro/main/ipc/processors/search_replace_line_numbers_processor.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,13 @@ function generateNoMatchError(
184184
});
185185

186186
lines.push("");
187+
// Clamp the displayed end line to actual file length
188+
const displayEnd = Math.min(
189+
bestMatch.startIndex + searchLines.length,
190+
resultLines.length,
191+
);
187192
lines.push(
188-
`BEST PARTIAL MATCH (${bestMatch.matchingLines}/${searchLines.length} lines matched at file lines ${bestMatch.startIndex + 1}-${bestMatch.startIndex + searchLines.length}):`,
193+
`BEST PARTIAL MATCH (${bestMatch.matchingLines}/${searchLines.length} lines matched at file lines ${bestMatch.startIndex + 1}-${displayEnd}):`,
189194
);
190195

191196
for (let j = 0; j < searchLines.length; j++) {
@@ -393,16 +398,30 @@ export function applySearchReplaceWithLineNumbers(
393398
let resultLines = originalContent.split(/\r?\n/);
394399
let appliedCount = 0;
395400

401+
// Track whether we're processing diff-format blocks (2-arg path) vs direct invocation (3-arg path)
402+
const isDiffFormat = newContent === undefined;
403+
396404
for (const block of blocks) {
397405
let { searchContent, replaceContent } = block;
398406

399-
searchContent = unescapeMarkers(searchContent);
400-
replaceContent = unescapeMarkers(replaceContent);
407+
// Only unescape markers for diff-format blocks (they were escaped by escapeSearchReplaceMarkers).
408+
// In the 3-arg direct invocation path, content was never escaped, so skip unescaping
409+
// to avoid corrupting user content that happens to contain backslash-prefixed markers.
410+
if (isDiffFormat) {
411+
searchContent = unescapeMarkers(searchContent);
412+
replaceContent = unescapeMarkers(replaceContent);
413+
}
414+
415+
// Save original content before stripping line numbers for fallback
416+
const originalSearchContent = searchContent;
417+
const originalReplaceContent = replaceContent;
418+
let usedLineNumberStripping = false;
401419

402420
// Strip line numbers from search content if present
403421
const strippedSearch = stripLineNumberPrefixes(searchContent);
404422
if (strippedSearch.hasLineNumbers) {
405423
searchContent = strippedSearch.content;
424+
usedLineNumberStripping = true;
406425
logger.debug("Stripped line number prefixes from search content");
407426
}
408427

@@ -447,6 +466,33 @@ export function applySearchReplaceWithLineNumbers(
447466
}
448467
}
449468

469+
// If still no match and we stripped line numbers, try again with original (unstripped) content.
470+
// This handles false positives where the file actually contains the N| pattern as real content.
471+
if (
472+
matchResult.error &&
473+
!matchResult.ambiguousPositions &&
474+
usedLineNumberStripping
475+
) {
476+
const originalSearchLines =
477+
originalSearchContent === ""
478+
? []
479+
: originalSearchContent.split(/\r?\n/);
480+
const originalMatchResult = cascadingMatch(
481+
resultLines,
482+
originalSearchLines,
483+
);
484+
if (!originalMatchResult.error) {
485+
matchResult = originalMatchResult;
486+
searchLines = originalSearchLines;
487+
replaceContent = originalReplaceContent;
488+
replaceLines =
489+
replaceContent === "" ? [] : replaceContent.split(/\r?\n/);
490+
logger.debug(
491+
"Matched after falling back to original (un-stripped) search content",
492+
);
493+
}
494+
}
495+
450496
if (matchResult.error) {
451497
// Log detailed diagnostic information for debugging
452498
logMatchFailure(resultLines, searchLines, appliedCount);

0 commit comments

Comments
 (0)