Skip to content

Commit abc0784

Browse files
committed
Fix cargo watch code action filtering
There are two issues with the implementation of `provideCodeActions` introduced in #1439: 1. We're returning the code action based on the file its diagnostic is in; not the file the suggested fix is in. I'm not sure how often fixes are suggested cross-file but it's something we should handle. 2. We're not filtering code actions based on the passed range. The means if there is any suggestion in a file we'll show an action for every line of the file. I naively thought that VS Code would filter for us but that was wrong. Unfortunately the VS Code `CodeAction` object is very complex - it can handle edits across multiple files, run commands, etc. This makes it complex to check them for equality or see if any of their edits intersects with a specified range. To make it easier to work with suggestions this introduces a `SuggestedFix` model object and a `SuggestFixCollection` code action provider. This is a layer between the raw Rust JSON and VS Code's `CodeAction`s. I was reluctant to introduce another layer of abstraction here but my attempt to work directly with VS Code's model objects was worse.
1 parent 0e1912d commit abc0784

File tree

10 files changed

+495
-254
lines changed

10 files changed

+495
-254
lines changed

editors/code/src/commands/cargo_watch.ts

Lines changed: 21 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,15 @@ import * as vscode from 'vscode';
55

66
import { Server } from '../server';
77
import { terminate } from '../utils/processes';
8+
import { LineBuffer } from './line_buffer';
9+
import { StatusDisplay } from './watch_status';
10+
811
import {
912
mapRustDiagnosticToVsCode,
1013
RustDiagnostic
11-
} from '../utils/rust_diagnostics';
12-
import {
13-
areCodeActionsEqual,
14-
areDiagnosticsEqual
15-
} from '../utils/vscode_diagnostics';
16-
import { LineBuffer } from './line_buffer';
17-
import { StatusDisplay } from './watch_status';
14+
} from '../utils/diagnostics/rust';
15+
import SuggestedFixCollection from '../utils/diagnostics/SuggestedFixCollection';
16+
import { areDiagnosticsEqual } from '../utils/diagnostics/vscode';
1817

1918
export function registerCargoWatchProvider(
2019
subscriptions: vscode.Disposable[]
@@ -42,16 +41,13 @@ export function registerCargoWatchProvider(
4241
return provider;
4342
}
4443

45-
export class CargoWatchProvider
46-
implements vscode.Disposable, vscode.CodeActionProvider {
44+
export class CargoWatchProvider implements vscode.Disposable {
4745
private readonly diagnosticCollection: vscode.DiagnosticCollection;
4846
private readonly statusDisplay: StatusDisplay;
4947
private readonly outputChannel: vscode.OutputChannel;
5048

51-
private codeActions: {
52-
[fileUri: string]: vscode.CodeAction[];
53-
};
54-
private readonly codeActionDispose: vscode.Disposable;
49+
private suggestedFixCollection: SuggestedFixCollection;
50+
private codeActionDispose: vscode.Disposable;
5551

5652
private cargoProcess?: child_process.ChildProcess;
5753

@@ -66,13 +62,14 @@ export class CargoWatchProvider
6662
'Cargo Watch Trace'
6763
);
6864

69-
// Register code actions for rustc's suggested fixes
70-
this.codeActions = {};
65+
// Track `rustc`'s suggested fixes so we can convert them to code actions
66+
this.suggestedFixCollection = new SuggestedFixCollection();
7167
this.codeActionDispose = vscode.languages.registerCodeActionsProvider(
7268
[{ scheme: 'file', language: 'rust' }],
73-
this,
69+
this.suggestedFixCollection,
7470
{
75-
providedCodeActionKinds: [vscode.CodeActionKind.QuickFix]
71+
providedCodeActionKinds:
72+
SuggestedFixCollection.PROVIDED_CODE_ACTION_KINDS
7673
}
7774
);
7875
}
@@ -156,13 +153,6 @@ export class CargoWatchProvider
156153
this.codeActionDispose.dispose();
157154
}
158155

159-
public provideCodeActions(
160-
document: vscode.TextDocument
161-
): vscode.ProviderResult<Array<vscode.Command | vscode.CodeAction>> {
162-
const documentActions = this.codeActions[document.uri.toString()];
163-
return documentActions || [];
164-
}
165-
166156
private logInfo(line: string) {
167157
if (Server.config.cargoWatchOptions.trace === 'verbose') {
168158
this.outputChannel.append(line);
@@ -181,7 +171,7 @@ export class CargoWatchProvider
181171
private parseLine(line: string) {
182172
if (line.startsWith('[Running')) {
183173
this.diagnosticCollection.clear();
184-
this.codeActions = {};
174+
this.suggestedFixCollection.clear();
185175
this.statusDisplay.show();
186176
}
187177

@@ -225,7 +215,7 @@ export class CargoWatchProvider
225215
return;
226216
}
227217

228-
const { location, diagnostic, codeActions } = mapResult;
218+
const { location, diagnostic, suggestedFixes } = mapResult;
229219
const fileUri = location.uri;
230220

231221
const diagnostics: vscode.Diagnostic[] = [
@@ -236,37 +226,22 @@ export class CargoWatchProvider
236226
const isDuplicate = diagnostics.some(d =>
237227
areDiagnosticsEqual(d, diagnostic)
238228
);
239-
240229
if (isDuplicate) {
241230
return;
242231
}
243232

244233
diagnostics.push(diagnostic);
245234
this.diagnosticCollection!.set(fileUri, diagnostics);
246235

247-
if (codeActions.length) {
248-
const fileUriString = fileUri.toString();
249-
const existingActions = this.codeActions[fileUriString] || [];
250-
251-
for (const newAction of codeActions) {
252-
const existingAction = existingActions.find(existing =>
253-
areCodeActionsEqual(existing, newAction)
236+
if (suggestedFixes.length) {
237+
for (const suggestedFix of suggestedFixes) {
238+
this.suggestedFixCollection.addSuggestedFixForDiagnostic(
239+
suggestedFix,
240+
diagnostic
254241
);
255-
256-
if (existingAction) {
257-
if (!existingAction.diagnostics) {
258-
existingAction.diagnostics = [];
259-
}
260-
// This action also applies to this diagnostic
261-
existingAction.diagnostics.push(diagnostic);
262-
} else {
263-
newAction.diagnostics = [diagnostic];
264-
existingActions.push(newAction);
265-
}
266242
}
267243

268244
// Have VsCode query us for the code actions
269-
this.codeActions[fileUriString] = existingActions;
270245
vscode.commands.executeCommand(
271246
'vscode.executeCodeActionProvider',
272247
fileUri,
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import * as assert from 'assert';
2+
import * as vscode from 'vscode';
3+
4+
import { SuggestionApplicability } from '../../../utils/diagnostics/rust';
5+
import SuggestedFix from '../../../utils/diagnostics/SuggestedFix';
6+
7+
const location1 = new vscode.Location(
8+
vscode.Uri.file('/file/1'),
9+
new vscode.Range(new vscode.Position(1, 2), new vscode.Position(3, 4))
10+
);
11+
12+
const location2 = new vscode.Location(
13+
vscode.Uri.file('/file/2'),
14+
new vscode.Range(new vscode.Position(5, 6), new vscode.Position(7, 8))
15+
);
16+
17+
describe('SuggestedFix', () => {
18+
describe('isEqual', () => {
19+
it('should treat identical instances as equal', () => {
20+
const suggestion1 = new SuggestedFix(
21+
'Replace me!',
22+
location1,
23+
'With this!'
24+
);
25+
26+
const suggestion2 = new SuggestedFix(
27+
'Replace me!',
28+
location1,
29+
'With this!'
30+
);
31+
32+
assert(suggestion1.isEqual(suggestion2));
33+
});
34+
35+
it('should treat instances with different titles as inequal', () => {
36+
const suggestion1 = new SuggestedFix(
37+
'Replace me!',
38+
location1,
39+
'With this!'
40+
);
41+
42+
const suggestion2 = new SuggestedFix(
43+
'Not the same title!',
44+
location1,
45+
'With this!'
46+
);
47+
48+
assert(!suggestion1.isEqual(suggestion2));
49+
});
50+
51+
it('should treat instances with different replacements as inequal', () => {
52+
const suggestion1 = new SuggestedFix(
53+
'Replace me!',
54+
location1,
55+
'With this!'
56+
);
57+
58+
const suggestion2 = new SuggestedFix(
59+
'Replace me!',
60+
location1,
61+
'With something else!'
62+
);
63+
64+
assert(!suggestion1.isEqual(suggestion2));
65+
});
66+
67+
it('should treat instances with different locations as inequal', () => {
68+
const suggestion1 = new SuggestedFix(
69+
'Replace me!',
70+
location1,
71+
'With this!'
72+
);
73+
74+
const suggestion2 = new SuggestedFix(
75+
'Replace me!',
76+
location2,
77+
'With this!'
78+
);
79+
80+
assert(!suggestion1.isEqual(suggestion2));
81+
});
82+
83+
it('should treat instances with different applicability as inequal', () => {
84+
const suggestion1 = new SuggestedFix(
85+
'Replace me!',
86+
location1,
87+
'With this!',
88+
SuggestionApplicability.MachineApplicable
89+
);
90+
91+
const suggestion2 = new SuggestedFix(
92+
'Replace me!',
93+
location2,
94+
'With this!',
95+
SuggestionApplicability.HasPlaceholders
96+
);
97+
98+
assert(!suggestion1.isEqual(suggestion2));
99+
});
100+
});
101+
102+
describe('toCodeAction', () => {
103+
it('should map a simple suggestion', () => {
104+
const suggestion = new SuggestedFix(
105+
'Replace me!',
106+
location1,
107+
'With this!'
108+
);
109+
110+
const codeAction = suggestion.toCodeAction();
111+
assert.strictEqual(codeAction.kind, vscode.CodeActionKind.QuickFix);
112+
assert.strictEqual(codeAction.title, 'Replace me!');
113+
assert.strictEqual(codeAction.isPreferred, false);
114+
115+
const edit = codeAction.edit;
116+
if (!edit) {
117+
return assert.fail('Code Action edit unexpectedly missing');
118+
}
119+
120+
const editEntries = edit.entries();
121+
assert.strictEqual(editEntries.length, 1);
122+
123+
const [[editUri, textEdits]] = editEntries;
124+
assert.strictEqual(editUri.toString(), location1.uri.toString());
125+
126+
assert.strictEqual(textEdits.length, 1);
127+
const [textEdit] = textEdits;
128+
129+
assert(textEdit.range.isEqual(location1.range));
130+
assert.strictEqual(textEdit.newText, 'With this!');
131+
});
132+
});
133+
});
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import * as assert from 'assert';
2+
import * as vscode from 'vscode';
3+
4+
import SuggestedFix from '../../../utils/diagnostics/SuggestedFix';
5+
import SuggestedFixCollection from '../../../utils/diagnostics/SuggestedFixCollection';
6+
7+
const uri1 = vscode.Uri.file('/file/1');
8+
const uri2 = vscode.Uri.file('/file/2');
9+
10+
const mockDocument1 = ({
11+
uri: uri1
12+
} as unknown) as vscode.TextDocument;
13+
14+
const mockDocument2 = ({
15+
uri: uri2
16+
} as unknown) as vscode.TextDocument;
17+
18+
const range1 = new vscode.Range(
19+
new vscode.Position(1, 2),
20+
new vscode.Position(3, 4)
21+
);
22+
const range2 = new vscode.Range(
23+
new vscode.Position(5, 6),
24+
new vscode.Position(7, 8)
25+
);
26+
27+
const diagnostic1 = new vscode.Diagnostic(range1, 'First diagnostic');
28+
const diagnostic2 = new vscode.Diagnostic(range2, 'Second diagnostic');
29+
30+
// This is a mutable object so return a fresh instance every time
31+
function suggestion1(): SuggestedFix {
32+
return new SuggestedFix(
33+
'Replace me!',
34+
new vscode.Location(uri1, range1),
35+
'With this!'
36+
);
37+
}
38+
39+
describe('SuggestedFixCollection', () => {
40+
it('should add a suggestion then return it as a code action', () => {
41+
const suggestedFixes = new SuggestedFixCollection();
42+
suggestedFixes.addSuggestedFixForDiagnostic(suggestion1(), diagnostic1);
43+
44+
// Specify the document and range that exactly matches
45+
const codeActions = suggestedFixes.provideCodeActions(
46+
mockDocument1,
47+
range1
48+
);
49+
50+
assert.strictEqual(codeActions.length, 1);
51+
const [codeAction] = codeActions;
52+
assert.strictEqual(codeAction.title, suggestion1().title);
53+
54+
const { diagnostics } = codeAction;
55+
if (!diagnostics) {
56+
return assert.fail('Diagnostics unexpectedly missing');
57+
}
58+
59+
assert.strictEqual(diagnostics.length, 1);
60+
assert.strictEqual(diagnostics[0], diagnostic1);
61+
});
62+
63+
it('should not return code actions for different ranges', () => {
64+
const suggestedFixes = new SuggestedFixCollection();
65+
suggestedFixes.addSuggestedFixForDiagnostic(suggestion1(), diagnostic1);
66+
67+
const codeActions = suggestedFixes.provideCodeActions(
68+
mockDocument1,
69+
range2
70+
);
71+
72+
assert(!codeActions || codeActions.length === 0);
73+
});
74+
75+
it('should not return code actions for different documents', () => {
76+
const suggestedFixes = new SuggestedFixCollection();
77+
suggestedFixes.addSuggestedFixForDiagnostic(suggestion1(), diagnostic1);
78+
79+
const codeActions = suggestedFixes.provideCodeActions(
80+
mockDocument2,
81+
range1
82+
);
83+
84+
assert(!codeActions || codeActions.length === 0);
85+
});
86+
87+
it('should not return code actions that have been cleared', () => {
88+
const suggestedFixes = new SuggestedFixCollection();
89+
suggestedFixes.addSuggestedFixForDiagnostic(suggestion1(), diagnostic1);
90+
suggestedFixes.clear();
91+
92+
const codeActions = suggestedFixes.provideCodeActions(
93+
mockDocument1,
94+
range1
95+
);
96+
97+
assert(!codeActions || codeActions.length === 0);
98+
});
99+
100+
it('should merge identical suggestions together', () => {
101+
const suggestedFixes = new SuggestedFixCollection();
102+
103+
// Add the same suggestion for two diagnostics
104+
suggestedFixes.addSuggestedFixForDiagnostic(suggestion1(), diagnostic1);
105+
suggestedFixes.addSuggestedFixForDiagnostic(suggestion1(), diagnostic2);
106+
107+
const codeActions = suggestedFixes.provideCodeActions(
108+
mockDocument1,
109+
range1
110+
);
111+
112+
assert.strictEqual(codeActions.length, 1);
113+
const [codeAction] = codeActions;
114+
const { diagnostics } = codeAction;
115+
116+
if (!diagnostics) {
117+
return assert.fail('Diagnostics unexpectedly missing');
118+
}
119+
120+
// We should be associated with both diagnostics
121+
assert.strictEqual(diagnostics.length, 2);
122+
assert.strictEqual(diagnostics[0], diagnostic1);
123+
assert.strictEqual(diagnostics[1], diagnostic2);
124+
});
125+
});

0 commit comments

Comments
 (0)