Skip to content

Commit 427936a

Browse files
committed
fix(language_server): workspace edits as one batch when source.fixAll.oxc is the context
1 parent 4817c7e commit 427936a

File tree

6 files changed

+238
-29
lines changed

6 files changed

+238
-29
lines changed

crates/oxc_language_server/src/main.rs

Lines changed: 51 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,8 @@ impl LanguageServer for Backend {
270270
r.diagnostic.range == params.range
271271
|| range_overlaps(params.range, r.diagnostic.range)
272272
});
273+
let mut quick_fixes: Vec<TextEdit> = vec![];
274+
273275
for report in reports {
274276
// TODO: Would be better if we had exact rule name from the diagnostic instead of having to parse it.
275277
let mut rule_name: Option<String> = None;
@@ -296,30 +298,38 @@ impl LanguageServer for Backend {
296298
}
297299
}
298300
};
299-
code_actions_vec.push(CodeActionOrCommand::CodeAction(CodeAction {
300-
title,
301-
kind: Some(if is_source_fix_all_oxc {
302-
CODE_ACTION_KIND_SOURCE_FIX_ALL_OXC
303-
} else {
304-
CodeActionKind::QUICKFIX
305-
}),
306-
is_preferred: Some(true),
307-
edit: Some(WorkspaceEdit {
308-
#[expect(clippy::disallowed_types)]
309-
changes: Some(std::collections::HashMap::from([(
310-
uri.clone(),
311-
vec![TextEdit {
312-
range: fixed_content.range,
313-
new_text: fixed_content.code.clone(),
314-
}],
315-
)])),
316-
..WorkspaceEdit::default()
317-
}),
318-
disabled: None,
319-
data: None,
320-
diagnostics: None,
321-
command: None,
322-
}));
301+
302+
// when source.fixAll.oxc we collect all changes at ones
303+
// and return them as one workspace edit.
304+
// it is possible that one fix will change the range for the next fix
305+
// see oxc-project/oxc#10422
306+
if is_source_fix_all_oxc {
307+
quick_fixes.push(TextEdit {
308+
range: fixed_content.range,
309+
new_text: fixed_content.code.clone(),
310+
});
311+
} else {
312+
code_actions_vec.push(CodeActionOrCommand::CodeAction(CodeAction {
313+
title,
314+
kind: Some(CodeActionKind::QUICKFIX),
315+
is_preferred: Some(true),
316+
edit: Some(WorkspaceEdit {
317+
#[expect(clippy::disallowed_types)]
318+
changes: Some(std::collections::HashMap::from([(
319+
uri.clone(),
320+
vec![TextEdit {
321+
range: fixed_content.range,
322+
new_text: fixed_content.code.clone(),
323+
}],
324+
)])),
325+
..WorkspaceEdit::default()
326+
}),
327+
disabled: None,
328+
data: None,
329+
diagnostics: None,
330+
command: None,
331+
}));
332+
}
323333
}
324334

325335
code_actions_vec.push(
@@ -395,6 +405,23 @@ impl LanguageServer for Backend {
395405
command: None,
396406
}));
397407
}
408+
409+
if is_source_fix_all_oxc && !quick_fixes.is_empty() {
410+
code_actions_vec.push(CodeActionOrCommand::CodeAction(CodeAction {
411+
title: "quick fix".to_string(),
412+
kind: Some(CODE_ACTION_KIND_SOURCE_FIX_ALL_OXC),
413+
is_preferred: Some(true),
414+
edit: Some(WorkspaceEdit {
415+
#[expect(clippy::disallowed_types)]
416+
changes: Some(std::collections::HashMap::from([(uri, quick_fixes)])),
417+
..WorkspaceEdit::default()
418+
}),
419+
disabled: None,
420+
data: None,
421+
diagnostics: None,
422+
command: None,
423+
}));
424+
}
398425
}
399426

400427
if code_actions_vec.is_empty() {

editors/vscode/client/extension.spec.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import {
44
commands,
55
DiagnosticSeverity,
66
languages,
7+
Position,
78
ProviderResult,
9+
Range,
810
Uri,
911
window,
1012
workspace,
@@ -128,6 +130,38 @@ suite('code actions', () => {
128130
],
129131
);
130132
});
133+
134+
// https://github.com/oxc-project/oxc/issues/10422
135+
test('code action `source.fixAll.oxc` on editor.codeActionsOnSave', async () => {
136+
let file = Uri.joinPath(WORKSPACE_DIR, 'fixtures', 'file.js');
137+
let expectedFile = Uri.joinPath(WORKSPACE_DIR, 'fixtures', 'expected.txt');
138+
139+
await workspace.getConfiguration('editor').update('codeActionsOnSave', {
140+
'source.fixAll.oxc': 'always',
141+
});
142+
await workspace.saveAll();
143+
144+
const range = new Range(new Position(0, 0), new Position(0, 0));
145+
const edit = new WorkspaceEdit();
146+
edit.replace(file, range, ' ');
147+
148+
await sleep(1000);
149+
150+
await loadFixture('fixall_with_code_actions_on_save');
151+
await workspace.openTextDocument(file);
152+
await workspace.applyEdit(edit);
153+
await sleep(1000);
154+
await workspace.saveAll();
155+
await sleep(500);
156+
157+
const content = await workspace.fs.readFile(file);
158+
const expected = await workspace.fs.readFile(expectedFile);
159+
160+
strictEqual(content.toString(), expected.toString());
161+
162+
await workspace.getConfiguration('editor').update('codeActionsOnSave', undefined);
163+
await workspace.saveAll();
164+
});
131165
});
132166

133167
suite('E2E Diagnostics', () => {

editors/vscode/client/test-helpers.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,7 @@ export type OxlintConfig = {
3131

3232
export const WORKSPACE_DIR = workspace.workspaceFolders![0].uri;
3333

34-
const rootOxlintConfigPath = WORKSPACE_DIR + '/.oxlintrc.json';
35-
const rootOxlintConfigUri = Uri.parse(rootOxlintConfigPath);
34+
const rootOxlintConfigUri = Uri.joinPath(WORKSPACE_DIR, '.oxlintrc.json');
3635

3736
export async function sleep(ms: number): Promise<void> {
3837
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -53,15 +52,14 @@ export async function createOxlintConfiguration(configuration: OxlintConfig): Pr
5352
});
5453
await workspace.applyEdit(edit);
5554
}
56-
5755
export async function loadFixture(fixture: string): Promise<void> {
5856
const absolutePath = path.resolve(`${__dirname}/../fixtures/${fixture}`);
5957
// do not copy directly into the workspace folder. FileWatcher will detect them as a deletion and stop itself.
60-
await workspace.fs.copy(Uri.file(absolutePath), Uri.joinPath(WORKSPACE_DIR, 'diagnostic'), { overwrite: true });
58+
await workspace.fs.copy(Uri.file(absolutePath), Uri.joinPath(WORKSPACE_DIR, 'fixtures'), { overwrite: true });
6159
}
6260

6361
export async function getDiagnostics(file: string): Promise<Diagnostic[]> {
64-
const fileUri = Uri.joinPath(WORKSPACE_DIR, 'diagnostic', file);
62+
const fileUri = Uri.joinPath(WORKSPACE_DIR, 'fixtures', file);
6563
await window.showTextDocument(fileUri);
6664
await sleep(500);
6765
const diagnostics = languages.getDiagnostics(fileUri);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"rules": {
3+
"unicorn/switch-case-braces": "warn"
4+
}
5+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
export function processChanges(changeHistory, maxChangesToShow) {
2+
if (!changeHistory || !changeHistory.changes) {
3+
return [];
4+
}
5+
6+
const { patch = [] } = changeHistory.changes;
7+
const summaryItems = [];
8+
const processedPaths = new Set();
9+
let totalChanges = 0;
10+
11+
// Process patch operations
12+
patch.forEach(({ op, path, value }) => {
13+
totalChanges++;
14+
15+
// Skip if we've reached the display limit
16+
if (summaryItems.length >= maxChangesToShow) return;
17+
18+
// Format path for display
19+
const displayPath = formatPathForDisplay(path);
20+
21+
// Skip duplicates or paths we've already processed
22+
if (processedPaths.has(displayPath)) return;
23+
processedPaths.add(displayPath);
24+
25+
// Handle different operation types
26+
switch (op) {
27+
case 'add': {if (path.match(/^\/references\/\d+$/)) {
28+
if (value && typeof value === 'object' && 'key' in value) {
29+
summaryItems.push({
30+
label: 'References.' + value.key,
31+
displayValue: <span className='text-green-600 font-medium'>Added</span>
32+
});
33+
}
34+
} else {
35+
summaryItems.push({
36+
label: displayPath,
37+
displayValue: <span className='text-green-600 font-medium'>Added</span>
38+
});
39+
}
40+
break;
41+
}
42+
43+
case 'remove': {
44+
summaryItems.push({
45+
label: displayPath,
46+
displayValue: <span className='text-red-600 font-medium'>Removed</span>
47+
});
48+
break;
49+
}
50+
51+
case 'replace': {if (path === '/version') return;
52+
summaryItems.push({
53+
label: displayPath,
54+
displayValue: <span className='text-blue-600 font-medium'>Updated</span>
55+
});
56+
break;
57+
}
58+
59+
default: {
60+
summaryItems.push({
61+
label: displayPath,
62+
displayValue: <span className='text-gray-600'>{op}</span>
63+
});
64+
}
65+
}
66+
});
67+
68+
// Add metadata about more changes
69+
summaryItems.moreChangesCount = Math.max(0, totalChanges - maxChangesToShow);
70+
71+
return summaryItems;
72+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
export function processChanges(changeHistory, maxChangesToShow) {
2+
if (!changeHistory || !changeHistory.changes) {
3+
return [];
4+
}
5+
6+
const { patch = [] } = changeHistory.changes;
7+
const summaryItems = [];
8+
const processedPaths = new Set();
9+
let totalChanges = 0;
10+
11+
// Process patch operations
12+
patch.forEach(({ op, path, value }) => {
13+
totalChanges++;
14+
15+
// Skip if we've reached the display limit
16+
if (summaryItems.length >= maxChangesToShow) return;
17+
18+
// Format path for display
19+
const displayPath = formatPathForDisplay(path);
20+
21+
// Skip duplicates or paths we've already processed
22+
if (processedPaths.has(displayPath)) return;
23+
processedPaths.add(displayPath);
24+
25+
// Handle different operation types
26+
switch (op) {
27+
case 'add':
28+
// Special handling for references
29+
if (path.match(/^\/references\/\d+$/)) {
30+
if (value && typeof value === 'object' && 'key' in value) {
31+
summaryItems.push({
32+
label: 'References.' + value.key,
33+
displayValue: <span className='text-green-600 font-medium'>Added</span>
34+
});
35+
}
36+
} else {
37+
summaryItems.push({
38+
label: displayPath,
39+
displayValue: <span className='text-green-600 font-medium'>Added</span>
40+
});
41+
}
42+
break;
43+
44+
case 'remove':
45+
summaryItems.push({
46+
label: displayPath,
47+
displayValue: <span className='text-red-600 font-medium'>Removed</span>
48+
});
49+
break;
50+
51+
case 'replace':
52+
// Skip version changes as we display them separately
53+
if (path === '/version') return;
54+
55+
summaryItems.push({
56+
label: displayPath,
57+
displayValue: <span className='text-blue-600 font-medium'>Updated</span>
58+
});
59+
break;
60+
61+
default:
62+
summaryItems.push({
63+
label: displayPath,
64+
displayValue: <span className='text-gray-600'>{op}</span>
65+
});
66+
}
67+
});
68+
69+
// Add metadata about more changes
70+
summaryItems.moreChangesCount = Math.max(0, totalChanges - maxChangesToShow);
71+
72+
return summaryItems;
73+
}

0 commit comments

Comments
 (0)