Skip to content

Commit 0121b7c

Browse files
authored
Dead code analysis editor mode via reanalyze (#334)
1 parent dbd21c7 commit 0121b7c

File tree

5 files changed

+394
-3
lines changed

5 files changed

+394
-3
lines changed

client/src/commands.ts

+17-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import * as fs from "fs";
2-
import { window } from "vscode";
2+
import { window, DiagnosticCollection } from "vscode";
33
import { LanguageClient, RequestType } from "vscode-languageclient/node";
4+
import {
5+
DiagnosticsResultCodeActionsMap,
6+
runDeadCodeAnalysisWithReanalyze,
7+
} from "./commands/dead_code_analysis";
48

59
interface CreateInterfaceRequestParams {
610
uri: string;
@@ -31,3 +35,15 @@ export const createInterface = (client: LanguageClient) => {
3135
uri: editor.document.uri.toString(),
3236
});
3337
};
38+
39+
export const deadCodeAnalysisWithReanalyze = (
40+
targetDir: string | null,
41+
diagnosticsCollection: DiagnosticCollection,
42+
diagnosticsResultCodeActions: DiagnosticsResultCodeActionsMap
43+
) => {
44+
runDeadCodeAnalysisWithReanalyze(
45+
targetDir,
46+
diagnosticsCollection,
47+
diagnosticsResultCodeActions
48+
);
49+
};
+259
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import * as cp from "child_process";
2+
import * as path from "path";
3+
import {
4+
window,
5+
DiagnosticCollection,
6+
Diagnostic,
7+
Range,
8+
Position,
9+
DiagnosticSeverity,
10+
Uri,
11+
CodeAction,
12+
CodeActionKind,
13+
WorkspaceEdit,
14+
DiagnosticTag,
15+
} from "vscode";
16+
17+
export type DiagnosticsResultCodeActionsMap = Map<
18+
string,
19+
{ range: Range; codeAction: CodeAction }[]
20+
>;
21+
22+
let fileInfoRegex = /File "(.+)", line (\d+), characters ([\d-]+)/g;
23+
24+
let extractFileInfo = (
25+
fileInfo: string
26+
): {
27+
filePath: string;
28+
line: string;
29+
characters: string;
30+
} | null => {
31+
let m;
32+
33+
let filePath: string | null = null;
34+
let line: string | null = null;
35+
let characters: string | null = null;
36+
37+
while ((m = fileInfoRegex.exec(fileInfo)) !== null) {
38+
if (m.index === fileInfoRegex.lastIndex) {
39+
fileInfoRegex.lastIndex++;
40+
}
41+
42+
m.forEach((match: string, groupIndex: number) => {
43+
switch (groupIndex) {
44+
case 1: {
45+
filePath = match;
46+
break;
47+
}
48+
case 2: {
49+
line = match;
50+
break;
51+
}
52+
case 3: {
53+
characters = match;
54+
break;
55+
}
56+
}
57+
});
58+
}
59+
60+
if (filePath != null && line != null && characters != null) {
61+
return {
62+
filePath,
63+
line,
64+
characters,
65+
};
66+
}
67+
68+
return null;
69+
};
70+
71+
let dceTextToDiagnostics = (
72+
dceText: string,
73+
diagnosticsResultCodeActions: DiagnosticsResultCodeActionsMap
74+
): {
75+
diagnosticsMap: Map<string, Diagnostic[]>;
76+
} => {
77+
let diagnosticsMap: Map<string, Diagnostic[]> = new Map();
78+
79+
// Each section with a single issue found is seprated by two line breaks in
80+
// the reanalyze output. The section contains information about the issue
81+
// itself, what line/char and in what file it was found, as well as a
82+
// suggestion for what you can replace the line containing the issue with to
83+
// suppress the issue reported.
84+
//
85+
// Here's an example of how a section typically looks:
86+
//
87+
// Warning Dead Value
88+
// File "/Users/zth/git/rescript-intro/src/Machine.res", line 2, characters 0-205
89+
// +use is never used
90+
// <-- line 2
91+
// @dead("+use") let use = (initialState: 'a, handleEvent: ('a, 'b) => 'a) => {
92+
dceText.split("\n\n").forEach((chunk) => {
93+
let [
94+
_title,
95+
fileInfo,
96+
text,
97+
98+
// These, if they exist, will power code actions for inserting the "fixed"
99+
// line that reanalyze might suggest.
100+
lineNumToReplace,
101+
lineContentToReplace,
102+
] = chunk.split("\n");
103+
104+
let processedFileInfo = extractFileInfo(fileInfo);
105+
106+
if (processedFileInfo != null) {
107+
let [startCharacter, endCharacter] =
108+
processedFileInfo.characters.split("-");
109+
110+
let parsedLine = parseInt(processedFileInfo.line, 10);
111+
112+
let startPos = new Position(
113+
// reanalyze reports lines as index 1 based, while VSCode wants them
114+
// index 0 based. reanalyze reports diagnostics for an entire file on
115+
// line 0 (and chars 0-0). So, we need to ensure that we don't give
116+
// VSCode a negative line index, or it'll be sad.
117+
Math.max(0, parsedLine - 1),
118+
Math.max(0, parseInt(startCharacter, 10))
119+
);
120+
121+
let endPos = new Position(
122+
Math.max(0, parsedLine - 1),
123+
Math.max(0, parseInt(endCharacter, 10))
124+
);
125+
126+
// Detect if this diagnostic is for the entire file. If so, reanalyze will
127+
// say that the issue is on line 0 and chars 0-0. This code below ensures
128+
// that the full file is highlighted, if that's the case.
129+
if (parsedLine === 0 && processedFileInfo.characters === "0-0") {
130+
startPos = new Position(0, 0);
131+
endPos = new Position(99999, 0);
132+
}
133+
134+
let issueLocationRange = new Range(startPos, endPos);
135+
136+
let diagnostic = new Diagnostic(
137+
issueLocationRange,
138+
text.trim(),
139+
DiagnosticSeverity.Warning
140+
);
141+
142+
// This will render the part of the code as unused
143+
diagnostic.tags = [DiagnosticTag.Unnecessary];
144+
145+
if (diagnosticsMap.has(processedFileInfo.filePath)) {
146+
diagnosticsMap.get(processedFileInfo.filePath).push(diagnostic);
147+
} else {
148+
diagnosticsMap.set(processedFileInfo.filePath, [diagnostic]);
149+
}
150+
151+
// If reanalyze suggests a fix, we'll set that up as a refactor code
152+
// action in VSCode. This way, it'll be easy to suppress the issue
153+
// reported if wanted. We also save the range of the issue, so we can
154+
// leverage that to make looking up the code actions for each cursor
155+
// position very cheap.
156+
if (lineNumToReplace != null && lineContentToReplace != null) {
157+
let actualLineToReplaceStr = lineNumToReplace.split("<-- line ").pop();
158+
159+
if (actualLineToReplaceStr != null) {
160+
let codeAction = new CodeAction(`Suppress dead code warning`);
161+
codeAction.kind = CodeActionKind.RefactorRewrite;
162+
163+
let codeActionEdit = new WorkspaceEdit();
164+
165+
// In the future, it would be cool to have an additional code action
166+
// here for automatically removing whatever the thing that's dead is.
167+
codeActionEdit.replace(
168+
Uri.parse(processedFileInfo.filePath),
169+
// Make sure the full line is replaced
170+
new Range(
171+
new Position(issueLocationRange.start.line, 0),
172+
new Position(issueLocationRange.start.line, 999999)
173+
),
174+
// reanalyze seems to add two extra spaces at the start of the line
175+
// content to replace.
176+
lineContentToReplace.slice(2)
177+
);
178+
179+
codeAction.edit = codeActionEdit;
180+
181+
if (diagnosticsResultCodeActions.has(processedFileInfo.filePath)) {
182+
diagnosticsResultCodeActions
183+
.get(processedFileInfo.filePath)
184+
.push({ range: issueLocationRange, codeAction });
185+
} else {
186+
diagnosticsResultCodeActions.set(processedFileInfo.filePath, [
187+
{ range: issueLocationRange, codeAction },
188+
]);
189+
}
190+
}
191+
}
192+
}
193+
});
194+
195+
return {
196+
diagnosticsMap,
197+
};
198+
};
199+
200+
export const runDeadCodeAnalysisWithReanalyze = (
201+
targetDir: string | null,
202+
diagnosticsCollection: DiagnosticCollection,
203+
diagnosticsResultCodeActions: DiagnosticsResultCodeActionsMap
204+
) => {
205+
let currentDocument = window.activeTextEditor.document;
206+
let cwd = targetDir ?? path.dirname(currentDocument.uri.fsPath);
207+
208+
let p = cp.spawn("npx", ["reanalyze", "-dce"], {
209+
cwd,
210+
});
211+
212+
if (p.stdout == null) {
213+
window.showErrorMessage("Something went wrong.");
214+
return;
215+
}
216+
217+
let data = "";
218+
219+
p.stdout.on("data", (d) => {
220+
data += d;
221+
});
222+
223+
p.stderr?.on("data", (e) => {
224+
// Sometimes the compiler artifacts has been corrupted in some way, and
225+
// reanalyze will spit out a "End_of_file" exception. The solution is to
226+
// clean and rebuild the ReScript project, which we can tell the user about
227+
// here.
228+
if (e.includes("End_of_file")) {
229+
window.showErrorMessage(
230+
`Something went wrong trying to run reanalyze. Please try cleaning and rebuilding your ReScript project.`
231+
);
232+
} else {
233+
window.showErrorMessage(
234+
`Something went wrong trying to run reanalyze: '${e}'`
235+
);
236+
}
237+
});
238+
239+
p.on("close", () => {
240+
diagnosticsResultCodeActions.clear();
241+
let { diagnosticsMap } = dceTextToDiagnostics(
242+
data,
243+
diagnosticsResultCodeActions
244+
);
245+
246+
// This smoothens the experience of the diagnostics updating a bit by
247+
// clearing only the visible diagnostics that has been fixed after the
248+
// updated diagnostics has been applied.
249+
diagnosticsCollection.forEach((uri, _) => {
250+
if (!diagnosticsMap.has(uri.fsPath)) {
251+
diagnosticsCollection.delete(uri);
252+
}
253+
});
254+
255+
diagnosticsMap.forEach((diagnostics, filePath) => {
256+
diagnosticsCollection.set(Uri.parse(filePath), diagnostics);
257+
});
258+
});
259+
};

0 commit comments

Comments
 (0)