Skip to content

Commit 51c1b14

Browse files
committed
Implement strict whitespace handling for indentation-sensitive files and refactor the diff processor to improve patch application accuracy
1 parent 39211d6 commit 51c1b14

15 files changed

Lines changed: 233 additions & 49 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
<a href="https://marketplace.visualstudio.com/items?itemName=robertpiosik.gemini-coder"><img src="https://img.shields.io/badge/Install-VS_Code_Marketplace-blue" alt="Get from Visual Studio Code Marketplace" /></a> <a href="https://open-vsx.org/extension/robertpiosik/gemini-coder"><img src="https://img.shields.io/badge/Install-Open_VSX_Registry-a60ee5" alt="Get from Open VSX Registry" /></a> <a href="https://github.com/robertpiosik/CodeWebChat/blob/dev/LICENSE"><img src="https://img.shields.io/badge/License-GPL--3.0-green.svg" alt="GPL-3.0 license" /></a>
1212
</p>
1313

14-
Code Web Chat is a free and open-source (FOSS), independent AI coding toolkit for VS Code.
14+
Code Web Chat is a free and open-source, independent AI coding toolkit for VS Code.
1515

1616
✅ **Construct coding prompts for...**
1717

‎apps/browser/src/content-scripts/send-prompt-content-script/utils/apply-response-styles.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export const apply_chat_response_button_style = (button: HTMLButtonElement) => {
77
button.style.border = 'none'
88
button.style.display = 'flex'
99
button.style.alignItems = 'center'
10-
button.style.outlineOffset = '4px'
10+
button.style.outline = 'none'
1111

1212
const svg = button.querySelector('svg')
1313
if (svg) {

‎apps/editor/src/commands/apply-chat-response-command/handlers/diff-handler.ts‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ import { remove_directory_if_empty } from '../utils/file-operations'
1111

1212
const execAsync = promisify(exec)
1313

14+
const INDENTATION_BASED_EXTENSIONS = new Set(['.py', '.yaml', '.yml'])
15+
16+
const is_indentation_based_file = (file_path: string): boolean => {
17+
const ext = path.extname(file_path).toLowerCase()
18+
return INDENTATION_BASED_EXTENSIONS.has(ext)
19+
}
20+
1421
interface PatchFileInfo {
1522
from_path?: string
1623
to_path?: string
@@ -449,12 +456,12 @@ export const apply_git_patch = async (
449456
// Attempt 1: git apply with --recount
450457
if (!success) {
451458
try {
452-
const contains_python_file = file_paths.some((path) =>
453-
path.toLowerCase().endsWith('.py')
459+
const is_indentation_sensitive = file_paths.some((path) =>
460+
is_indentation_based_file(path)
454461
)
455462
const flags = [
456463
'--whitespace=fix',
457-
contains_python_file ? '' : '--ignore-whitespace',
464+
is_indentation_sensitive ? '' : '--ignore-whitespace',
458465
'--recount'
459466
]
460467
.filter(Boolean)
@@ -485,9 +492,13 @@ export const apply_git_patch = async (
485492
try {
486493
const file_path_safe = create_safe_path(workspace_path, file_paths[0])
487494
if (file_path_safe == null) throw new Error('File path is null')
495+
496+
const use_strict_whitespace = is_indentation_based_file(file_path_safe)
497+
488498
applied_content = await process_diff({
489499
file_path: file_path_safe,
490-
diff_path_patch: temp_file
500+
diff_patch_path: temp_file,
501+
use_strict_whitespace
491502
})
492503
success = true
493504
diff_application_method = 'search_and_replace'
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { apply_diff } from './diff-processor'
2+
import * as fs from 'fs'
3+
import * as path from 'path'
4+
5+
describe('diff-processor', () => {
6+
const load_test_case_file = (
7+
type: string,
8+
test_case: string,
9+
filename: string
10+
): string => {
11+
return fs.readFileSync(
12+
path.join(__dirname, 'test-cases', type, test_case, filename),
13+
'utf-8'
14+
)
15+
}
16+
17+
describe('apply_diff', () => {
18+
it('applies diff with multiple hunks correctly', async () => {
19+
const test_case = 'multiple-hunks'
20+
const original = load_test_case_file('', test_case, 'original.txt')
21+
const diff = load_test_case_file('', test_case, 'diff.txt')
22+
const expected = load_test_case_file('', test_case, 'expected.txt')
23+
24+
const result = apply_diff({
25+
original_code: original,
26+
diff_patch: diff
27+
})
28+
29+
expect(result).toBe(expected)
30+
})
31+
32+
it('applies diff with insertions at start and end of file correctly', async () => {
33+
const test_case = 'insert-start-end'
34+
const original = load_test_case_file('', test_case, 'original.txt')
35+
const diff = load_test_case_file('', test_case, 'diff.txt')
36+
const expected = load_test_case_file('', test_case, 'expected.txt')
37+
38+
const result = apply_diff({
39+
original_code: original,
40+
diff_patch: diff
41+
})
42+
43+
expect(result).toBe(expected)
44+
})
45+
46+
it('applies diff with deletions only correctly', async () => {
47+
const test_case = 'deletion-only'
48+
const original = load_test_case_file('', test_case, 'original.txt')
49+
const diff = load_test_case_file('', test_case, 'diff.txt')
50+
const expected = load_test_case_file('', test_case, 'expected.txt')
51+
52+
const result = apply_diff({
53+
original_code: original,
54+
diff_patch: diff
55+
})
56+
57+
expect(result).toBe(expected)
58+
})
59+
})
60+
})

‎apps/editor/src/commands/apply-chat-response-command/utils/diff-processor/diff-processor.ts‎

Lines changed: 93 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,19 @@ import * as vscode from 'vscode'
22
import * as fs from 'fs'
33
import { Logger } from '@shared/utils/logger'
44

5+
interface ReplaceLine {
6+
content: string
7+
search_index: number | null
8+
}
9+
510
class SearchBlock {
611
public search_lines: string[]
7-
public replace_lines: string[]
12+
public replace_lines: ReplaceLine[]
813
public search_block_start_index: number
914

1015
public constructor(
1116
search_lines: string[],
12-
replace_lines: string[],
17+
replace_lines: ReplaceLine[],
1318
search_block_start_index: number
1419
) {
1520
this.search_lines = search_lines
@@ -28,13 +33,15 @@ class SearchBlock {
2833
export const process_diff = async (params: {
2934
file_path: string
3035
diff_patch_path: string
36+
use_strict_whitespace?: boolean
3137
}): Promise<string> => {
3238
const file_content = fs.readFileSync(params.file_path, 'utf8')
3339
const diff_patch_content = fs.readFileSync(params.diff_patch_path, 'utf8')
3440

3541
const result = apply_diff({
3642
original_code: file_content,
37-
diff_patch: diff_patch_content
43+
diff_patch: diff_patch_content,
44+
use_strict_whitespace: params.use_strict_whitespace
3845
})
3946

4047
try {
@@ -55,6 +62,7 @@ export const process_diff = async (params: {
5562
export const apply_diff = (params: {
5663
original_code: string
5764
diff_patch: string
65+
use_strict_whitespace?: boolean
5866
}): string => {
5967
try {
6068
const original_code_normalized = params.original_code.replace(/\r\n/g, '\n')
@@ -79,12 +87,15 @@ export const apply_diff = (params: {
7987
.replace(/\r/g, '')
8088
.replace(/\n/g, '')
8189

82-
const line_normalized_2 = line_normalized.replace(/\s+/g, '')
83-
const line_normalized_3 = line_normalized_2.toLowerCase()
90+
let line_normalized_processed = line_normalized.replace(/\s+/g, '')
91+
92+
if (params.use_strict_whitespace) {
93+
line_normalized_processed = line_normalized
94+
}
8495

8596
original_code_lines_normalized.push({
8697
key: line_count,
87-
value: line_normalized_3
98+
value: line_normalized_processed
8899
})
89100

90101
line_count++
@@ -118,96 +129,122 @@ export const apply_diff = (params: {
118129
.replace(/\r\n/g, '')
119130
.replace(/\r/g, '')
120131
.replace(/\n/g, '')
121-
if (line_normalized.startsWith(' ')) {
122-
line_normalized = line_normalized.replace(/^\s+/, '~')
132+
133+
let line_normalized_processed = ''
134+
135+
if (params.use_strict_whitespace) {
136+
if (line_normalized.startsWith(' ')) {
137+
line_normalized_processed = '~' + line_normalized.substring(1)
138+
} else {
139+
line_normalized_processed = line_normalized
140+
}
141+
} else {
142+
if (line_normalized.startsWith(' ')) {
143+
line_normalized = line_normalized.replace(/^\s+/, '~')
144+
}
145+
line_normalized_processed = line_normalized.replace(/\s+/g, '')
123146
}
124147

125-
const line_normalized_2 = line_normalized.replace(/\s+/g, '')
126-
const line_normalized_3 = line_normalized_2.toLowerCase()
127-
patch_lines_normalized.push(line_normalized_3)
148+
patch_lines_normalized.push(line_normalized_processed)
128149
}
129150

130-
const search_replace_blocks = []
131-
let search_chunks = []
132-
let replace_chunks = []
151+
const search_replace_blocks: SearchBlock[] = []
152+
let search_chunks: string[] = []
153+
let replace_chunks: ReplaceLine[] = []
133154

134155
let inside_replace_block = false
156+
let current_block_has_changes = false
135157

136158
for (let i = 0; i < patch_lines_normalized.length; i++) {
137159
const line = patch_lines_normalized[i]
138160
const line_original = patch_lines_original[i]
139161

140162
if (line.startsWith('@@')) {
141163
if (search_chunks.length > 0 || replace_chunks.length > 0) {
142-
search_replace_blocks.push(
143-
new SearchBlock(search_chunks, replace_chunks, -1)
144-
)
164+
if (current_block_has_changes) {
165+
search_replace_blocks.push(
166+
new SearchBlock(search_chunks, replace_chunks, -1)
167+
)
168+
}
145169
}
146170
search_chunks = []
147171
replace_chunks = []
148172
inside_replace_block = false
173+
current_block_has_changes = false
149174
}
150175

151176
if (line.startsWith('-') || line.startsWith('~')) {
152177
if (inside_replace_block) {
153178
inside_replace_block = false
154179

155180
if (search_chunks.length > 0 || replace_chunks.length > 0) {
156-
search_replace_blocks.push(
157-
new SearchBlock(search_chunks, replace_chunks, -1)
158-
)
181+
if (current_block_has_changes) {
182+
search_replace_blocks.push(
183+
new SearchBlock(search_chunks, replace_chunks, -1)
184+
)
185+
}
159186
}
160187

161188
search_chunks = []
162189
replace_chunks = []
190+
current_block_has_changes = false
163191
}
164192

165-
if (line.startsWith('--')) {
166-
// Remove the leading '-' from the search line only if there are two -
167-
// Fix for files with lines that start with - eg. markdown
168-
search_chunks.push(line.substring(1, line.length))
169-
} else if (line.startsWith('~nnn') || line.startsWith('-~nnn')) {
193+
if (line.startsWith('-')) {
194+
current_block_has_changes = true
195+
}
196+
197+
if (line.startsWith('~nnn') || line.startsWith('-~nnn')) {
170198
search_chunks.push('~nnn')
171199
} else {
172200
search_chunks.push(line.replace(/^-/, '').replace(/^~/, ''))
173201
}
174202

175-
// Also replace unchanged lines
176203
if (line.startsWith('~nnn')) {
177-
replace_chunks.push(line_original.replace(/^~nnn/, '') + '\n')
204+
replace_chunks.push({
205+
content: line_original.replace(/^~nnn/, '') + '\n',
206+
search_index: search_chunks.length - 1
207+
})
178208
} else if (line.startsWith('~')) {
179-
replace_chunks.push(line_original.replace(/^~/, '') + '\n')
209+
replace_chunks.push({
210+
content: line_original + '\n',
211+
search_index: search_chunks.length - 1
212+
})
180213
}
181214

182215
continue
183216
}
184217

185218
if (line.startsWith('+')) {
186219
inside_replace_block = true
220+
current_block_has_changes = true
187221

188-
if (line.startsWith('++')) {
189-
// Remove the leading '+' from the search line only if there are two +
190-
replace_chunks.push(line_original.substring(1, line.length) + '\n')
191-
} else if (line.startsWith('+~nnn')) {
192-
replace_chunks.push(line_original.replace(/^\+~nnn/, '') + '\n')
222+
if (line.startsWith('+~nnn')) {
223+
replace_chunks.push({
224+
content: line_original.replace(/^\+~nnn/, '') + '\n',
225+
search_index: null
226+
})
193227
} else {
194-
replace_chunks.push(line_original.replace(/^\+/, '') + '\n')
228+
replace_chunks.push({
229+
content: line_original.replace(/^\+/, '') + '\n',
230+
search_index: null
231+
})
195232
}
196233
}
197234
}
198235

199236
if (search_chunks.length > 0) {
200-
// A extra trailing new line is sometimes added by the AI model
201237
if (search_chunks[search_chunks.length - 1] == '~nnn') {
202238
search_chunks.pop()
203239
replace_chunks.pop()
204240
}
205241

206242
if (search_chunks.length != 0 || replace_chunks.length != 0) {
207-
// This is crucial for diffs that only have deletions
208-
search_replace_blocks.push(
209-
new SearchBlock(search_chunks, replace_chunks, -1)
210-
)
243+
if (current_block_has_changes) {
244+
search_replace_blocks.push(
245+
new SearchBlock(search_chunks, replace_chunks, -1)
246+
)
247+
}
211248
}
212249
}
213250

@@ -216,20 +253,19 @@ export const apply_diff = (params: {
216253
const search_replace_block = search_replace_blocks[i]
217254
const search_string = search_replace_block.search_lines.join('')
218255

219-
// We start search from the previous found index to ensure duplicate code is not found at wrong index
220256
let found = false
221257
for (
222258
let j = previous_found_index;
223259
j < original_code_lines_normalized.length;
224260
j++
225261
) {
226-
// This is a special case when a patch hunk starts with one or more + lines and no context lines
227262
if (
228263
search_replace_block.search_lines.length == 0 &&
229264
previous_found_index == 0
230265
) {
231266
search_replace_block.search_block_start_index = -1
232267
found = true
268+
break
233269
} else {
234270
const chunk = original_code_lines_normalized.slice(
235271
j,
@@ -246,7 +282,8 @@ export const apply_diff = (params: {
246282
search_replace_block.search_block_start_index = chunk[0].key
247283
found = true
248284

249-
previous_found_index = chunk[0].key
285+
previous_found_index =
286+
chunk[0].key + search_replace_block.search_lines.length
250287
break
251288
}
252289
}
@@ -270,7 +307,20 @@ export const apply_diff = (params: {
270307
const start_index =
271308
block.get_start_index() == -1 ? 0 : block.get_start_index()
272309
const search_count = block.get_search_count()
273-
const replacement_content = block.replace_lines
310+
const replacement_content = block.replace_lines.map((line) => {
311+
if (
312+
line.search_index !== null &&
313+
start_index + line.search_index < original_code_lines.length
314+
) {
315+
let originalContent =
316+
original_code_lines[start_index + line.search_index]
317+
if (!originalContent.endsWith('\n')) {
318+
originalContent += '\n'
319+
}
320+
return originalContent
321+
}
322+
return line.content
323+
})
274324

275325
if (start_index < 0 || start_index > result_lines.length) {
276326
continue

0 commit comments

Comments
 (0)