1 module served.linters.dscanner;
2 
3 import std.algorithm;
4 import std.conv;
5 import std.file;
6 import std.json;
7 import std.path;
8 import std.string;
9 
10 import served.extension;
11 import served.linters.diagnosticmanager;
12 import served.types;
13 
14 import workspaced.api;
15 import workspaced.coms;
16 
17 import workspaced.com.dscanner;
18 
19 import dscanner.analysis.config : StaticAnalysisConfig, Check;
20 
21 import dscanner.analysis.local_imports : LocalImportCheck;
22 
23 static immutable string DScannerDiagnosticSource = "DScanner";
24 static immutable string SyntaxHintDiagnosticSource = "serve-d";
25 
26 //dfmt off
27 static immutable StaticAnalysisConfig servedDefaultDscannerConfig = {
28 	could_be_immutable_check: Check.disabled,
29 	undocumented_declaration_check: Check.disabled
30 };
31 //dfmt on
32 
33 enum DiagnosticSlot = 0;
34 
35 void lint(Document document)
36 {
37 	if (document.getLanguageId != "d")
38 		return;
39 
40 	auto instance = activeInstance = backend.getBestInstance!DscannerComponent(
41 			document.uri.uriToFile);
42 	if (!instance)
43 		return;
44 
45 	auto fileConfig = config(document.uri);
46 	if (!fileConfig.d.enableLinting || !fileConfig.d.enableStaticLinting)
47 		return;
48 
49 	auto ignoredKeys = fileConfig.dscanner.ignoredKeys;
50 
51 	auto ini = getDscannerIniForDocument(document.uri, instance);
52 	auto issues = instance.get!DscannerComponent.lint(document.uri.uriToFile,
53 			ini, document.rawText, false, servedDefaultDscannerConfig, true).getYield;
54 	Diagnostic[] result;
55 
56 	foreach (issue; issues)
57 	{
58 		if (ignoredKeys.canFind(issue.key))
59 			continue;
60 		Diagnostic d;
61 		scope text = document.lineAtScope(cast(uint) issue.line - 1).stripRight;
62 		string keyNormalized = issue.key.startsWith("dscanner.")
63 			? issue.key["dscanner.".length .. $] : issue.key;
64 		if (text.canFind("@suppress(all)", "@suppress:all",
65 				"@suppress(" ~ issue.key ~ ")", "@suppress:" ~ issue.key,
66 				"@suppress(" ~ keyNormalized ~ ")", "@suppress:" ~ keyNormalized) || text
67 				.endsWith("stfu"))
68 			continue;
69 
70 		if (!d.adjustRangeForType(document, issue))
71 			continue;
72 		d.adjustSeverityForType(document, issue);
73 
74 		if (!d.source.isNull && d.source.get.length)
75 		{
76 			// handled by previous functions
77 			result ~= d;
78 			continue;
79 		}
80 
81 		if (issue.key.startsWith("workspaced"))
82 			d.source = SyntaxHintDiagnosticSource;
83 		else
84 			d.source = DScannerDiagnosticSource;
85 
86 		d.message = issue.description;
87 		d.code = JSONValue(issue.key);
88 		result ~= d;
89 	}
90 
91 	createDiagnosticsFor!DiagnosticSlot(document.uri) = result;
92 	updateDiagnostics(document.uri);
93 }
94 
95 void clear()
96 {
97 	diagnostics[DiagnosticSlot] = null;
98 	updateDiagnostics();
99 }
100 
101 string getDscannerIniForDocument(DocumentUri document, WorkspaceD.Instance instance = null)
102 {
103 	if (!instance)
104 		instance = backend.getBestInstance!DscannerComponent(document.uriToFile);
105 
106 	if (!instance)
107 		return "dscanner.ini";
108 
109 	auto ini = buildPath(instance.cwd, "dscanner.ini");
110 	if (!exists(ini))
111 		ini = "dscanner.ini";
112 	return ini;
113 }
114 
115 /// Sets the range for the diagnostic from the issue
116 /// Returns: `false` if this issue should be discarded (handled by other issues)
117 bool adjustRangeForType(ref Diagnostic d, Document document, DScannerIssue issue)
118 {
119 	d.range = TextRange(
120 		document.lineColumnBytesToPosition(issue.range[0].line - 1, issue.range[0].column - 1),
121 		document.lineColumnBytesToPosition(issue.range[1].line - 1, issue.range[1].column - 1)
122 	);
123 
124 	auto s = issue.description;
125 	if (s.startsWith("Line is longer than ") && s.endsWith(" characters"))
126 	{
127 		d.range.start.character = s["Line is longer than ".length .. $ - " characters".length].to!uint;
128 		d.range.end.character = 1000;
129 	}
130 
131 	return true;
132 }
133 
134 void adjustSeverityForType(ref Diagnostic d, Document, DScannerIssue issue)
135 {
136 	if (issue.key == "dscanner.suspicious.unused_parameter"
137 			|| issue.key == "dscanner.suspicious.unused_variable")
138 	{
139 		d.severity = DiagnosticSeverity.hint;
140 		d.tags = opt([DiagnosticTag.unnecessary]);
141 	}
142 	else
143 	{
144 		d.severity = issue.type == "error" ? DiagnosticSeverity.error : DiagnosticSeverity
145 			.warning;
146 	}
147 }
148 
149 version (unittest)
150 {
151 	import dscanner.analysis.config : defaultStaticAnalysisConfig;
152 	import inifiled : writeINIFile;
153 	import std.array : array;
154 	import std.file : tempDir, write;
155 	import std.path : buildPath;
156 	import std.range : enumerate;
157 
158 	private class DiagnosticTester
159 	{
160 		WorkspaceD backend;
161 		DscannerComponent dscanner;
162 		string dscannerIni;
163 
164 		DScannerIssue[] issues;
165 		Diagnostic[] diagnostics;
166 
167 		this(string id)
168 		{
169 			backend = new WorkspaceD();
170 			// use instance-less
171 			dscanner = new DscannerComponent();
172 			dscanner.workspaced = backend;
173 
174 			auto config = defaultStaticAnalysisConfig;
175 			foreach (ref value; config.tupleof)
176 				static if (is(typeof(value) == string))
177 					value = "enabled";
178 
179 			dscannerIni = buildPath(tempDir(), id ~ "-dscanner.ini");
180 			writeINIFile(config, dscannerIni);
181 		}
182 
183 		~this()
184 		{
185 			shutdown(true);
186 		}
187 
188 		void shutdown(bool dtor)
189 		{
190 			if (dscanner)
191 				dscanner.shutdown(dtor);
192 			dscanner = null;
193 			if (backend)
194 				backend.shutdown(dtor);
195 			backend = null;
196 		}
197 
198 		DScannerIssue[] lint(scope const(char)[] code)
199 		{
200 			return dscanner.lint("", dscannerIni, code, false,
201 				servedDefaultDscannerConfig, true).getBlocking();
202 		}
203 
204 		auto diagnosticsAt(Position location)
205 		{
206 			return diagnostics.enumerate.filter!(a
207 				=> a.value.range.contains(location));
208 		}
209 
210 		Diagnostic[] diagnosticsAt(Position location, string key)
211 		{
212 			return diagnostics
213 				.filter!(a
214 					=> a.range.contains(location)
215 					&& a.code.get.type == JSONType..string
216 					&& a.code.get.str == key)
217 				.array;
218 		}
219 
220 		Diagnostic[] syntaxErrorsAt(Position location)
221 		{
222 			return diagnosticsAt(location).filter!(a => !issues[a.index].key.length)
223 				.map!"a.value"
224 				.array;
225 		}
226 
227 		void build(Document document)
228 		{
229 			issues = lint(document.rawText);
230 
231 			diagnostics = null;
232 			foreach (issue; issues)
233 			{
234 				Diagnostic d;
235 				if (!d.adjustRangeForType(document, issue))
236 					continue;
237 				d.adjustSeverityForType(document, issue);
238 
239 				if (!d.source.isNull && d.source.get.length)
240 				{
241 					// handled by previous functions
242 					diagnostics ~= d;
243 					continue;
244 				}
245 
246 				d.code = JSONValue(issue.key).opt;
247 				d.message = issue.description;
248 				diagnostics ~= d;
249 			}
250 		}
251 	}
252 }
253 
254 unittest
255 {
256 	DiagnosticTester test = new DiagnosticTester("test-syntax-errors");
257 	scope (exit) test.shutdown(false);
258 
259 	Document document = Document.nullDocument(q{
260 void main()
261 {
262 	if x == 4 {
263 	}
264 }
265 });
266 
267 	test.build(document);
268 	assert(test.syntaxErrorsAt(Position(0, 0)).length == 0);
269 	assert(test.syntaxErrorsAt(Position(3, 4)).length == 1);
270 	assert(test.syntaxErrorsAt(Position(3, 4))[0].message == "Expected `(` instead of `x`");
271 	assert(test.syntaxErrorsAt(Position(3, 4))[0].range == TextRange(3, 1, 3, 5));
272 
273 	document = Document.nullDocument(q{
274 void main()
275 {
276 	foo()
277 }
278 });
279 
280 	test.build(document);
281 	assert(test.syntaxErrorsAt(Position(0, 0)).length == 0);
282 	assert(test.syntaxErrorsAt(Position(3, 3)).length == 0);
283 	assert(test.syntaxErrorsAt(Position(3, 4)).length == 1);
284 	assert(test.syntaxErrorsAt(Position(3, 4))[0].message == "Expected `;` instead of `}`");
285 	assert(test.syntaxErrorsAt(Position(3, 4))[0].range == TextRange(3, 4, 3, 6));
286 
287 	document = Document.nullDocument(q{
288 void main()
289 {
290 	foo(hello)  {}
291 }
292 });
293 
294 	test.build(document);
295 	assert(test.syntaxErrorsAt(Position(3, 3)).length == 0);
296 	assert(test.syntaxErrorsAt(Position(3, 3)).length == 0);
297 	assert(test.syntaxErrorsAt(Position(3, 4)).length == 0);
298 	assert(test.syntaxErrorsAt(Position(3, 9)).length == 0);
299 	assert(test.syntaxErrorsAt(Position(3, 10)).length == 1);
300 	assert(test.syntaxErrorsAt(Position(3, 10))[0].message == "Expected `;` instead of `{`");
301 	assert(test.syntaxErrorsAt(Position(3, 10))[0].range == TextRange(3, 10, 3, 15));
302 
303 	document = Document.nullDocument(q{
304 void main()
305 {
306 	foo..foreach(a; b);
307 }
308 });
309 
310 	test.build(document);
311 	assert(test.syntaxErrorsAt(Position(0, 0)).length == 0);
312 	assert(test.syntaxErrorsAt(Position(3, 4)).length == 0);
313 	assert(test.syntaxErrorsAt(Position(3, 5)).length == 1);
314 	assert(test.syntaxErrorsAt(Position(3, 5))[0].message == "Expected identifier instead of reserved keyword `foreach`");
315 	assert(test.syntaxErrorsAt(Position(3, 5))[0].range == TextRange(3, 5, 3, 12));
316 
317 	document = Document.nullDocument(q{
318 void main()
319 {
320 	foo.
321 	foreach(a; b);
322 }
323 });
324 
325 	test.build(document);
326 	// import std.stdio; stderr.writeln("diagnostics:\n", test.diagnostics);
327 	assert(test.syntaxErrorsAt(Position(0, 0)).length == 0);
328 	assert(test.syntaxErrorsAt(Position(3, 4)).length == 0);
329 	assert(test.syntaxErrorsAt(Position(3, 5)).length == 1);
330 	assert(test.syntaxErrorsAt(Position(3, 5))[0].message == "Expected identifier");
331 	assert(test.syntaxErrorsAt(Position(3, 5))[0].range == TextRange(3, 5, 4, 1));
332 }
333 
334 unittest
335 {
336 	DiagnosticTester test = new DiagnosticTester("test-syntax-issues");
337 	scope (exit) test.shutdown(false);
338 
339 	Document document = Document.nullDocument(q{
340 void main()
341 {
342 	foreach (auto key; value)
343 	{
344 	}
345 }
346 });
347 
348 	test.build(document);
349 	assert(test.diagnosticsAt(Position(0, 0), "workspaced.foreach-auto").length == 0);
350 	auto diag = test.diagnosticsAt(Position(3, 11), "workspaced.foreach-auto");
351 	assert(diag.length == 1);
352 	assert(diag[0].range == TextRange(3, 10, 3, 14));
353 }
354 
355 unittest
356 {
357 	DiagnosticTester test = new DiagnosticTester("test-syntax-issues");
358 	scope (exit) test.shutdown(false);
359 
360 	Document document = Document.nullDocument(q{
361 void main()
362 {
363 	foreach (/* cool */ auto key; value)
364 	{
365 	}
366 }
367 });
368 
369 	test.build(document);
370 	assert(test.diagnosticsAt(Position(0, 0), "workspaced.foreach-auto").length == 0);
371 	auto diag = test.diagnosticsAt(Position(3, 22), "workspaced.foreach-auto");
372 	assert(diag.length == 1);
373 	assert(diag[0].range == TextRange(3, 21, 3, 25));
374 }
375 
376 unittest
377 {
378 	DiagnosticTester test = new DiagnosticTester("test-suspicious-local-imports");
379 	scope (exit) test.shutdown(false);
380 
381 	Document document = Document.nullDocument(q{
382 void main()
383 {
384 	import   imports.stdio;
385 
386 	writeln("hello");
387 }
388 });
389 
390 	test.build(document);
391 	assert(test.diagnosticsAt(Position(0, 0), LocalImportCheckKEY).length == 0);
392 	auto diag = test.diagnosticsAt(Position(3, 11), LocalImportCheckKEY);
393 	assert(diag.length == 1);
394 	assert(diag[0].range == TextRange(3, 1, 3, 24));
395 }