Skip to content

Commit 411ff5e

Browse files
authored
Detect line ending style and normalize line endings in edit string to… (#103)
* Detect line ending style and normalize line endings in edit string to file style * added test and optimisation * add stats * typo
1 parent ffb8d1b commit 411ff5e

6 files changed

Lines changed: 684 additions & 31 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ This project extends the MCP Filesystem Server to enable:
297297
Created as part of exploring Claude MCPs: https://youtube.com/live/TlbjFDbl5Us
298298

299299
## DONE
300-
- **29-04-2025 Telemetry Opt Out trought configuration** - There is now setting to disable telemetry in config, ask in chat
300+
- **29-04-2025 Telemetry Opt Out through configuration** - There is now setting to disable telemetry in config, ask in chat
301301
- **23-04-2025 Enhanced edit functionality** - Improved format, added fuzzy search and multi-occurrence replacements, should fail less and use edit block more often
302302
- **16-04-2025 Better configurations** - Improved settings for allowed paths, commands and shell environments
303303
- **14-04-2025 Windows environment fixes** - Resolved issues specific to Windows platforms

src/tools/edit.ts

Lines changed: 101 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { recursiveFuzzyIndexOf, getSimilarityRatio } from './fuzzySearch.js';
44
import { capture } from '../utils/capture.js';
55
import { EditBlockArgsSchema } from "./schemas.js";
66
import path from 'path';
7+
import { detectLineEnding, normalizeLineEndings } from '../utils/lineEndingHandler.js';
78

89
interface SearchReplace {
910
search: string;
@@ -24,6 +25,71 @@ interface FuzzyMatch {
2425
*/
2526
const FUZZY_THRESHOLD = 0.7;
2627

28+
/**
29+
* Extract character code data from diff
30+
* @param expected The string that was searched for
31+
* @param actual The string that was found
32+
* @returns Character code statistics
33+
*/
34+
function getCharacterCodeData(expected: string, actual: string): {
35+
report: string;
36+
uniqueCount: number;
37+
diffLength: number;
38+
} {
39+
// Find common prefix and suffix
40+
let prefixLength = 0;
41+
const minLength = Math.min(expected.length, actual.length);
42+
43+
// Determine common prefix length
44+
while (prefixLength < minLength &&
45+
expected[prefixLength] === actual[prefixLength]) {
46+
prefixLength++;
47+
}
48+
49+
// Determine common suffix length
50+
let suffixLength = 0;
51+
while (suffixLength < minLength - prefixLength &&
52+
expected[expected.length - 1 - suffixLength] === actual[actual.length - 1 - suffixLength]) {
53+
suffixLength++;
54+
}
55+
56+
// Extract the different parts
57+
const expectedDiff = expected.substring(prefixLength, expected.length - suffixLength);
58+
const actualDiff = actual.substring(prefixLength, actual.length - suffixLength);
59+
60+
// Count unique character codes in the diff
61+
const characterCodes = new Map<number, number>();
62+
const fullDiff = expectedDiff + actualDiff;
63+
64+
for (let i = 0; i < fullDiff.length; i++) {
65+
const charCode = fullDiff.charCodeAt(i);
66+
characterCodes.set(charCode, (characterCodes.get(charCode) || 0) + 1);
67+
}
68+
69+
// Create character codes string report
70+
const charCodeReport: string[] = [];
71+
characterCodes.forEach((count, code) => {
72+
// Include character representation for better readability
73+
const char = String.fromCharCode(code);
74+
// Make special characters more readable
75+
const charDisplay = code < 32 || code > 126 ? `\\x${code.toString(16).padStart(2, '0')}` : char;
76+
charCodeReport.push(`${code}:${count}[${charDisplay}]`);
77+
});
78+
79+
// Sort by character code for consistency
80+
charCodeReport.sort((a, b) => {
81+
const codeA = parseInt(a.split(':')[0]);
82+
const codeB = parseInt(b.split(':')[0]);
83+
return codeA - codeB;
84+
});
85+
86+
return {
87+
report: charCodeReport.join(','),
88+
uniqueCount: characterCodes.size,
89+
diffLength: fullDiff.length
90+
};
91+
}
92+
2793
export async function performSearchReplace(filePath: string, block: SearchReplace, expectedReplacements: number = 1): Promise<ServerResult> {
2894
// Check for empty search string to prevent infinite loops
2995
if (block.search === "") {
@@ -49,14 +115,20 @@ export async function performSearchReplace(filePath: string, block: SearchReplac
49115
throw new Error('Wrong content for file ' + filePath);
50116
}
51117

118+
// Detect file's line ending style
119+
const fileLineEnding = detectLineEnding(content);
120+
121+
// Normalize search string to match file's line endings
122+
const normalizedSearch = normalizeLineEndings(block.search, fileLineEnding);
123+
52124
// First try exact match
53125
let tempContent = content;
54126
let count = 0;
55-
let pos = tempContent.indexOf(block.search);
127+
let pos = tempContent.indexOf(normalizedSearch);
56128

57129
while (pos !== -1) {
58130
count++;
59-
pos = tempContent.indexOf(block.search, pos + 1);
131+
pos = tempContent.indexOf(normalizedSearch, pos + 1);
60132
}
61133

62134
// If exact match found and count matches expected replacements, proceed with exact replacement
@@ -66,14 +138,14 @@ export async function performSearchReplace(filePath: string, block: SearchReplac
66138

67139
// If we're only replacing one occurrence, replace it directly
68140
if (expectedReplacements === 1) {
69-
const searchIndex = newContent.indexOf(block.search);
141+
const searchIndex = newContent.indexOf(normalizedSearch);
70142
newContent =
71143
newContent.substring(0, searchIndex) +
72-
block.replace +
73-
newContent.substring(searchIndex + block.search.length);
144+
normalizeLineEndings(block.replace, fileLineEnding) +
145+
newContent.substring(searchIndex + normalizedSearch.length);
74146
} else {
75147
// Replace all occurrences using split and join for multiple replacements
76-
newContent = newContent.split(block.search).join(block.replace);
148+
newContent = newContent.split(normalizedSearch).join(normalizeLineEndings(block.replace, fileLineEnding));
77149
}
78150

79151
await writeFile(filePath, newContent);
@@ -111,20 +183,29 @@ export async function performSearchReplace(filePath: string, block: SearchReplac
111183
// Calculate execution time in milliseconds
112184
const executionTime = performance.now() - startTime;
113185

186+
// Generate diff and gather character code data
187+
const diff = highlightDifferences(block.search, fuzzyResult.value);
188+
189+
// Count character codes in diff
190+
const characterCodeData = getCharacterCodeData(block.search, fuzzyResult.value);
191+
192+
// Combine all fuzzy search data for single capture
193+
const fuzzySearchData = {
194+
similarity: similarity,
195+
execution_time_ms: executionTime,
196+
search_length: block.search.length,
197+
file_size: content.length,
198+
threshold: FUZZY_THRESHOLD,
199+
found_text_length: fuzzyResult.value.length,
200+
character_codes: characterCodeData.report,
201+
unique_character_count: characterCodeData.uniqueCount,
202+
total_diff_length: characterCodeData.diffLength
203+
};
204+
114205
// Check if the fuzzy match is "close enough"
115206
if (similarity >= FUZZY_THRESHOLD) {
116-
// Format differences for clearer output
117-
const diff = highlightDifferences(block.search, fuzzyResult.value);
118-
119-
// Capture the fuzzy search event
120-
capture('server_fuzzy_search_performed', {
121-
similarity: similarity,
122-
execution_time_ms: executionTime,
123-
search_length: block.search.length,
124-
file_size: content.length,
125-
threshold: FUZZY_THRESHOLD,
126-
found_text_length: fuzzyResult.value.length
127-
});
207+
// Capture the fuzzy search event with all data
208+
capture('server_fuzzy_search_performed', fuzzySearchData);
128209

129210
// If we allow fuzzy matches, we would make the replacement here
130211
// For now, we'll return a detailed message about the fuzzy match
@@ -138,14 +219,9 @@ export async function performSearchReplace(filePath: string, block: SearchReplac
138219
};
139220
} else {
140221
// If the fuzzy match isn't close enough
141-
// Still capture the fuzzy search event even for unsuccessful matches
222+
// Still capture the fuzzy search event with all data
142223
capture('server_fuzzy_search_performed', {
143-
similarity: similarity,
144-
execution_time_ms: executionTime,
145-
search_length: block.search.length,
146-
file_size: content.length,
147-
threshold: FUZZY_THRESHOLD,
148-
found_text_length: fuzzyResult.value.length,
224+
...fuzzySearchData,
149225
below_threshold: true
150226
});
151227

src/tools/filesystem.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,8 @@ export async function readFileFromDisk(filePath: string): Promise<FileResult> {
306306
} else {
307307
// For all other files, try to read as UTF-8 text
308308
try {
309-
const content = await fs.readFile(validPath, "utf-8");
309+
const buffer = await fs.readFile(validPath);
310+
const content = buffer.toString('utf-8');
310311

311312
return { content, mimeType, isImage };
312313
} catch (error) {
@@ -355,7 +356,7 @@ export async function writeFile(filePath: string, content: string): Promise<void
355356
// Capture file extension in telemetry without capturing the file path
356357
capture('server_write_file', {fileExtension: fileExtension});
357358

358-
await fs.writeFile(validPath, content, "utf-8");
359+
await fs.writeFile(validPath, content);
359360
}
360361

361362
export interface MultiFileResult {

src/utils/lineEndingHandler.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* Line ending types
3+
*/
4+
export type LineEndingStyle = '\r\n' | '\n' | '\r';
5+
6+
/**
7+
* Detect the line ending style used in a file - Optimized version
8+
* This algorithm uses early termination for maximum performance
9+
*/
10+
export function detectLineEnding(content: string): LineEndingStyle {
11+
for (let i = 0; i < content.length; i++) {
12+
if (content[i] === '\r') {
13+
if (i + 1 < content.length && content[i + 1] === '\n') {
14+
return '\r\n';
15+
}
16+
return '\r';
17+
}
18+
if (content[i] === '\n') {
19+
return '\n';
20+
}
21+
}
22+
23+
// Default to system line ending if no line endings found
24+
return process.platform === 'win32' ? '\r\n' : '\n';
25+
}
26+
27+
/**
28+
* Normalize line endings to match the target style
29+
*/
30+
export function normalizeLineEndings(text: string, targetLineEnding: LineEndingStyle): string {
31+
// First normalize to LF
32+
let normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
33+
34+
// Then convert to target
35+
if (targetLineEnding === '\r\n') {
36+
return normalized.replace(/\n/g, '\r\n');
37+
} else if (targetLineEnding === '\r') {
38+
return normalized.replace(/\n/g, '\r');
39+
}
40+
41+
return normalized;
42+
}
43+
44+
/**
45+
* Analyze line ending usage in content
46+
*/
47+
export function analyzeLineEndings(content: string): {
48+
style: LineEndingStyle;
49+
count: number;
50+
hasMixed: boolean;
51+
} {
52+
let crlfCount = 0;
53+
let lfCount = 0;
54+
let crCount = 0;
55+
56+
// Count line endings
57+
for (let i = 0; i < content.length; i++) {
58+
if (content[i] === '\r') {
59+
if (i + 1 < content.length && content[i + 1] === '\n') {
60+
crlfCount++;
61+
i++; // Skip the LF
62+
} else {
63+
crCount++;
64+
}
65+
} else if (content[i] === '\n') {
66+
lfCount++;
67+
}
68+
}
69+
70+
// Determine predominant style
71+
const total = crlfCount + lfCount + crCount;
72+
let style: LineEndingStyle;
73+
74+
if (crlfCount > lfCount && crlfCount > crCount) {
75+
style = '\r\n';
76+
} else if (lfCount > crCount) {
77+
style = '\n';
78+
} else {
79+
style = '\r';
80+
}
81+
82+
// Check for mixed line endings
83+
const usedStyles = [crlfCount > 0, lfCount > 0, crCount > 0].filter(Boolean).length;
84+
const hasMixed = usedStyles > 1;
85+
86+
return {
87+
style,
88+
count: total,
89+
hasMixed
90+
};
91+
}

test/test-directory-creation.js

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
// Import the filesystem module and assert for testing
1111
import { createDirectory } from '../dist/tools/filesystem.js';
12+
import { configManager } from '../dist/config-manager.js';
1213
import fs from 'fs/promises';
1314
import path from 'path';
1415
import { fileURLToPath } from 'url';
@@ -50,12 +51,26 @@ async function setup() {
5051
// Create base test directory
5152
await fs.mkdir(BASE_TEST_DIR, { recursive: true });
5253
console.log(`✓ Setup: created base test directory: ${BASE_TEST_DIR}`);
54+
55+
// Save original config to restore later
56+
const originalConfig = await configManager.getConfig();
57+
58+
// Set allowed directories to include our test directory
59+
await configManager.setValue('allowedDirectories', [BASE_TEST_DIR]);
60+
console.log(`✓ Setup: set allowed directories to include: ${BASE_TEST_DIR}`);
61+
62+
return originalConfig;
5363
}
5464

5565
/**
5666
* Teardown function to clean up after tests
5767
*/
58-
async function teardown() {
68+
async function teardown(originalConfig) {
69+
if (originalConfig) {
70+
// Restore original config
71+
await configManager.updateConfig(originalConfig);
72+
}
73+
5974
await cleanupTestDirectories();
6075
console.log('✓ Teardown: test directories cleaned up');
6176
}
@@ -92,14 +107,17 @@ async function testDirectoryCreation() {
92107

93108
// Export the main test function
94109
export default async function runTests() {
110+
let originalConfig;
95111
try {
96-
await setup();
112+
originalConfig = await setup();
97113
await testDirectoryCreation();
98114
} catch (error) {
99115
console.error('❌ Test failed:', error.message);
100116
return false;
101117
} finally {
102-
await teardown();
118+
if (originalConfig) {
119+
await teardown(originalConfig);
120+
}
103121
}
104122
return true;
105123
}

0 commit comments

Comments
 (0)