Skip to content

Commit 3968c96

Browse files
committed
extract potential code actions from diagnostics where appropriate, and add a first batch of code actions
1 parent a17409b commit 3968c96

File tree

3 files changed

+421
-5
lines changed

3 files changed

+421
-5
lines changed

server/src/codeActions.ts

Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
// This file holds code actions derived from diagnostics. There are more code
2+
// actions available in the extension, but they are derived via the analysis
3+
// OCaml binary.
4+
import * as p from "vscode-languageserver-protocol";
5+
6+
export type filesCodeActions = {
7+
[key: string]: { range: p.Range; codeAction: p.CodeAction }[];
8+
};
9+
10+
interface findCodeActionsConfig {
11+
diagnostic: p.Diagnostic;
12+
diagnosticMessage: string[];
13+
file: string;
14+
range: p.Range;
15+
addFoundActionsHere: filesCodeActions;
16+
}
17+
18+
export let findCodeActionsInDiagnosticsMessage = ({
19+
diagnostic,
20+
diagnosticMessage,
21+
file,
22+
range,
23+
addFoundActionsHere: codeActions,
24+
}: findCodeActionsConfig) => {
25+
diagnosticMessage.forEach((line, index, array) => {
26+
// Because of how actions work, there can only be one per diagnostic. So,
27+
// halt whenever a code action has been found.
28+
let actions = [
29+
didYouMeanAction,
30+
addUndefinedRecordFields,
31+
simpleConversion,
32+
topLevelUnitType,
33+
];
34+
35+
for (let action of actions) {
36+
if (
37+
action({
38+
array,
39+
codeActions,
40+
diagnostic,
41+
file,
42+
index,
43+
line,
44+
range,
45+
})
46+
) {
47+
break;
48+
}
49+
}
50+
});
51+
};
52+
53+
interface codeActionExtractorConfig {
54+
line: string;
55+
index: number;
56+
array: string[];
57+
file: string;
58+
range: p.Range;
59+
diagnostic: p.Diagnostic;
60+
codeActions: filesCodeActions;
61+
}
62+
63+
type codeActionExtractor = (config: codeActionExtractorConfig) => boolean;
64+
65+
let didYouMeanAction: codeActionExtractor = ({
66+
codeActions,
67+
diagnostic,
68+
file,
69+
line,
70+
range,
71+
}) => {
72+
if (line.startsWith("Hint: Did you mean")) {
73+
let regex = /Did you mean ([A-Za-z0-9_]*)?/;
74+
let match = line.match(regex);
75+
76+
if (match === null) {
77+
return false;
78+
}
79+
80+
let [_, suggestion] = match;
81+
82+
if (suggestion != null) {
83+
codeActions[file] = codeActions[file] || [];
84+
let codeAction: p.CodeAction = {
85+
title: `Replace with '${suggestion}'`,
86+
edit: {
87+
changes: {
88+
[file]: [{ range, newText: suggestion }],
89+
},
90+
},
91+
diagnostics: [diagnostic],
92+
};
93+
94+
codeActions[file].push({
95+
range,
96+
codeAction,
97+
});
98+
99+
return true;
100+
}
101+
}
102+
103+
return false;
104+
};
105+
106+
let addUndefinedRecordFields: codeActionExtractor = ({
107+
array,
108+
codeActions,
109+
diagnostic,
110+
file,
111+
index,
112+
line,
113+
range,
114+
}) => {
115+
if (line.startsWith("Some record fields are undefined:")) {
116+
let recordFieldNames = line
117+
.trim()
118+
.split("Some record fields are undefined: ")[1]
119+
?.split(" ");
120+
121+
// This collects the rest of the fields if fields are printed on
122+
// multiple lines.
123+
array.slice(index + 1).forEach((line) => {
124+
recordFieldNames.push(...line.trim().split(" "));
125+
});
126+
127+
if (recordFieldNames != null) {
128+
codeActions[file] = codeActions[file] || [];
129+
130+
// The formatter outputs trailing commas automatically if the record
131+
// definition is on multiple lines, and no trailing comma if it's on a
132+
// single line. We need to adapt to this so we don't accidentally
133+
// insert an invalid comma.
134+
let multilineRecordDefinitionBody = range.start.line !== range.end.line;
135+
136+
// Let's build up the text we're going to insert.
137+
let newText = "";
138+
139+
if (multilineRecordDefinitionBody) {
140+
// If it's a multiline body, we know it looks like this:
141+
// ```
142+
// let someRecord = {
143+
// atLeastOneExistingField: string,
144+
// }
145+
// ```
146+
// We can figure out the formatting from the range the code action
147+
// gives us. We'll insert to the direct left of the ending brace.
148+
149+
// The end char is the closing brace, and it's always going to be 2
150+
// characters back from the record fields.
151+
let paddingCharacters = multilineRecordDefinitionBody
152+
? range.end.character + 2
153+
: 0;
154+
let paddingContentRecordField = Array.from({
155+
length: paddingCharacters,
156+
}).join(" ");
157+
let paddingContentEndBrace = Array.from({
158+
length: range.end.character,
159+
}).join(" ");
160+
161+
recordFieldNames.forEach((fieldName, index) => {
162+
if (index === 0) {
163+
// This adds spacing from the ending brace up to the equivalent
164+
// of the last record field name, needed for the first inserted
165+
// record field name.
166+
newText += " ";
167+
} else {
168+
// The rest of the new record field names will start from a new
169+
// line, so they need left padding all the way to the same level
170+
// as the rest of the record fields.
171+
newText += paddingContentRecordField;
172+
}
173+
174+
newText += `${fieldName}: assert false,\n`;
175+
});
176+
177+
// Let's put the end brace back where it was (we still have it to the direct right of us).
178+
newText += `${paddingContentEndBrace}`;
179+
} else {
180+
// A single line record definition body is a bit easier - we'll just add the new fields on the same line.
181+
newText += ", ";
182+
newText += recordFieldNames
183+
.map((fieldName) => `${fieldName}: assert false`)
184+
.join(", ");
185+
}
186+
187+
let codeAction: p.CodeAction = {
188+
title: `Add missing record fields`,
189+
edit: {
190+
changes: {
191+
[file]: [
192+
{
193+
range: {
194+
start: {
195+
line: range.end.line,
196+
character: range.end.character - 1,
197+
},
198+
end: {
199+
line: range.end.line,
200+
character: range.end.character - 1,
201+
},
202+
},
203+
newText,
204+
},
205+
],
206+
},
207+
},
208+
diagnostics: [diagnostic],
209+
};
210+
211+
codeActions[file].push({
212+
range,
213+
codeAction,
214+
});
215+
216+
return true;
217+
}
218+
}
219+
220+
return false;
221+
};
222+
223+
let simpleConversion: codeActionExtractor = ({
224+
line,
225+
codeActions,
226+
file,
227+
range,
228+
diagnostic,
229+
}) => {
230+
if (line.startsWith("You can convert ")) {
231+
let regex = /You can convert (\w*) to (\w*) with ([\w.]*).$/;
232+
let match = line.match(regex);
233+
234+
if (match === null) {
235+
return false;
236+
}
237+
238+
let [_, from, to, fn] = match;
239+
240+
if (from != null && to != null && fn != null) {
241+
codeActions[file] = codeActions[file] || [];
242+
let codeAction: p.CodeAction = {
243+
title: `Convert ${from} to ${to} with ${fn}`,
244+
edit: {
245+
changes: {
246+
[file]: [
247+
{
248+
range: {
249+
start: {
250+
line: range.start.line,
251+
character: range.start.character,
252+
},
253+
end: {
254+
line: range.start.line,
255+
character: range.start.character,
256+
},
257+
},
258+
newText: `${fn}(`,
259+
},
260+
{
261+
range: {
262+
start: {
263+
line: range.end.line,
264+
character: range.end.character,
265+
},
266+
end: {
267+
line: range.end.line,
268+
character: range.end.character,
269+
},
270+
},
271+
newText: `)`,
272+
},
273+
],
274+
},
275+
},
276+
diagnostics: [diagnostic],
277+
};
278+
279+
codeActions[file].push({
280+
range,
281+
codeAction,
282+
});
283+
284+
return true;
285+
}
286+
}
287+
288+
return false;
289+
};
290+
291+
let topLevelUnitType: codeActionExtractor = ({
292+
line,
293+
codeActions,
294+
file,
295+
range,
296+
diagnostic,
297+
}) => {
298+
if (line.startsWith("Toplevel expression is expected to have unit type.")) {
299+
codeActions[file] = codeActions[file] || [];
300+
let codeAction: p.CodeAction = {
301+
title: `Wrap expression in ignore`,
302+
edit: {
303+
changes: {
304+
[file]: [
305+
{
306+
range: {
307+
start: {
308+
line: range.start.line,
309+
character: range.start.character,
310+
},
311+
end: {
312+
line: range.start.line,
313+
character: range.start.character,
314+
},
315+
},
316+
newText: `ignore(`,
317+
},
318+
{
319+
range: {
320+
start: {
321+
line: range.end.line,
322+
character: range.end.character,
323+
},
324+
end: {
325+
line: range.end.line,
326+
character: range.end.character,
327+
},
328+
},
329+
newText: `)`,
330+
},
331+
],
332+
},
333+
},
334+
diagnostics: [diagnostic],
335+
};
336+
337+
codeActions[file].push({
338+
range,
339+
codeAction,
340+
});
341+
342+
return true;
343+
}
344+
345+
return false;
346+
};

0 commit comments

Comments
 (0)