1 module served.linters.dscanner;
2 
3 import std.algorithm;
4 import std.conv;
5 import std.file;
6 import std.path;
7 import std.string;
8 
9 import served.extension;
10 import served.linters.diagnosticmanager;
11 import served.types;
12 
13 import workspaced.api;
14 import workspaced.coms;
15 
16 import workspaced.com.dscanner;
17 
18 import dscanner.analysis.config : StaticAnalysisConfig, Check;
19 
20 import dscanner.analysis.local_imports : LocalImportCheck;
21 
22 static immutable string DScannerDiagnosticSource = "DScanner";
23 static immutable string SyntaxHintDiagnosticSource = "serve-d";
24 
25 //dfmt off
26 static immutable StaticAnalysisConfig servedDefaultDscannerConfig = {
27 	could_be_immutable_check: Check.disabled,
28 	undocumented_declaration_check: Check.disabled
29 };
30 //dfmt on
31 
32 enum DiagnosticSlot = 0;
33 
34 void lint(Document document)
35 {
36 	if (document.getLanguageId != "d")
37 		return;
38 
39 	auto instance = activeInstance = backend.getBestInstance!DscannerComponent(
40 			document.uri.uriToFile);
41 	if (!instance)
42 		return;
43 
44 	auto fileConfig = config(document.uri);
45 	if (!fileConfig.d.enableLinting || !fileConfig.d.enableStaticLinting)
46 		return;
47 
48 	auto ignoredKeys = fileConfig.dscanner.ignoredKeys;
49 
50 	auto ini = getDscannerIniForDocument(document.uri, instance);
51 	auto issues = instance.get!DscannerComponent.lint(document.uri.uriToFile,
52 			ini, document.rawText, false, servedDefaultDscannerConfig, true).getYield;
53 	Diagnostic[] result;
54 
55 	foreach (issue; issues)
56 	{
57 		if (ignoredKeys.canFind(issue.key))
58 			continue;
59 		Diagnostic d;
60 		scope text = document.lineAtScope(cast(uint) issue.line - 1).stripRight;
61 		string keyNormalized = issue.key.startsWith("dscanner.")
62 			? issue.key["dscanner.".length .. $] : issue.key;
63 		if (text.canFind("@suppress(all)", "@suppress:all",
64 				"@suppress(" ~ issue.key ~ ")", "@suppress:" ~ issue.key,
65 				"@suppress(" ~ keyNormalized ~ ")", "@suppress:" ~ keyNormalized) || text
66 				.endsWith("stfu"))
67 			continue;
68 
69 		if (!d.adjustRangeForType(document, issue))
70 			continue;
71 		d.adjustSeverityForType(document, issue);
72 
73 		if (d.source.orDefault.length)
74 		{
75 			// handled by previous functions
76 			result ~= d;
77 			continue;
78 		}
79 
80 		if (issue.key.startsWith("workspaced"))
81 			d.source = SyntaxHintDiagnosticSource;
82 		else
83 			d.source = DScannerDiagnosticSource;
84 
85 		d.message = issue.description;
86 		d.code = JsonValue(issue.key);
87 		result ~= d;
88 	}
89 
90 	createDiagnosticsFor!DiagnosticSlot(document.uri) = result;
91 	updateDiagnostics(document.uri);
92 }
93 
94 void clear()
95 {
96 	diagnostics[DiagnosticSlot] = null;
97 	updateDiagnostics();
98 }
99 
100 string getDscannerIniForDocument(DocumentUri document, WorkspaceD.Instance instance = null)
101 {
102 	if (!instance)
103 		instance = backend.getBestInstance!DscannerComponent(document.uriToFile);
104 
105 	if (!instance)
106 		return "dscanner.ini";
107 
108 	auto ini = buildPath(instance.cwd, "dscanner.ini");
109 	if (!exists(ini))
110 		ini = "dscanner.ini";
111 	return ini;
112 }
113 
114 /// Sets the range for the diagnostic from the issue
115 /// Returns: `false` if this issue should be discarded (handled by other issues)
116 bool adjustRangeForType(ref Diagnostic d, Document document, DScannerIssue issue)
117 {
118 	d.range = TextRange(
119 		document.lineColumnBytesToPosition(issue.range[0].line - 1, issue.range[0].column - 1),
120 		document.lineColumnBytesToPosition(issue.range[1].line - 1, issue.range[1].column - 1)
121 	);
122 
123 	auto s = issue.description;
124 	if (s.startsWith("Line is longer than ") && s.endsWith(" characters"))
125 	{
126 		d.range.start.character = s["Line is longer than ".length .. $ - " characters".length].to!uint;
127 		d.range.end.line = d.range.start.line + 1;
128 		d.range.end.character = 0;
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.deref.match!(
216 						(string s) => s == key,
217 						_ => false
218 					))
219 				.array;
220 		}
221 
222 		Diagnostic[] syntaxErrorsAt(Position location)
223 		{
224 			return diagnosticsAt(location).filter!(a => !issues[a.index].key.length)
225 				.map!"a.value"
226 				.array;
227 		}
228 
229 		void build(Document document)
230 		{
231 			issues = lint(document.rawText);
232 
233 			diagnostics = null;
234 			foreach (issue; issues)
235 			{
236 				Diagnostic d;
237 				if (!d.adjustRangeForType(document, issue))
238 					continue;
239 				d.adjustSeverityForType(document, issue);
240 
241 				if (d.source.orDefault.length)
242 				{
243 					// handled by previous functions
244 					diagnostics ~= d;
245 					continue;
246 				}
247 
248 				d.code = JsonValue(issue.key);
249 				d.message = issue.description;
250 				diagnostics ~= d;
251 			}
252 		}
253 	}
254 }
255 
256 unittest
257 {
258 	DiagnosticTester test = new DiagnosticTester("test-syntax-errors");
259 	scope (exit) test.shutdown(false);
260 
261 	Document document = Document.nullDocument(q{
262 void main()
263 {
264 	if x == 4 {
265 	}
266 }
267 });
268 
269 	test.build(document);
270 	assert(test.syntaxErrorsAt(Position(0, 0)).length == 0);
271 	assert(test.syntaxErrorsAt(Position(3, 4)).length == 1);
272 	assert(test.syntaxErrorsAt(Position(3, 4))[0].message == "Expected `(` instead of `x`");
273 	assert(test.syntaxErrorsAt(Position(3, 4))[0].range == TextRange(3, 1, 3, 5));
274 
275 	document = Document.nullDocument(q{
276 void main()
277 {
278 	foo()
279 }
280 });
281 
282 	test.build(document);
283 	assert(test.syntaxErrorsAt(Position(0, 0)).length == 0);
284 	assert(test.syntaxErrorsAt(Position(3, 3)).length == 0);
285 	assert(test.syntaxErrorsAt(Position(3, 4)).length == 1);
286 	assert(test.syntaxErrorsAt(Position(3, 4))[0].message == "Expected `;` instead of `}`");
287 	assert(test.syntaxErrorsAt(Position(3, 4))[0].range == TextRange(3, 4, 3, 6));
288 
289 	document = Document.nullDocument(q{
290 void main()
291 {
292 	foo(hello)  {}
293 }
294 });
295 
296 	test.build(document);
297 	assert(test.syntaxErrorsAt(Position(3, 3)).length == 0);
298 	assert(test.syntaxErrorsAt(Position(3, 3)).length == 0);
299 	assert(test.syntaxErrorsAt(Position(3, 4)).length == 0);
300 	assert(test.syntaxErrorsAt(Position(3, 9)).length == 0);
301 	assert(test.syntaxErrorsAt(Position(3, 10)).length == 1);
302 	assert(test.syntaxErrorsAt(Position(3, 10))[0].message == "Expected `;` instead of `{`");
303 	assert(test.syntaxErrorsAt(Position(3, 10))[0].range == TextRange(3, 10, 3, 15));
304 
305 	document = Document.nullDocument(q{
306 void main()
307 {
308 	foo..foreach(a; b);
309 }
310 });
311 
312 	test.build(document);
313 	assert(test.syntaxErrorsAt(Position(0, 0)).length == 0);
314 	assert(test.syntaxErrorsAt(Position(3, 4)).length == 0);
315 	assert(test.syntaxErrorsAt(Position(3, 5)).length == 1);
316 	assert(test.syntaxErrorsAt(Position(3, 5))[0].message == "Expected identifier instead of reserved keyword `foreach`");
317 	assert(test.syntaxErrorsAt(Position(3, 5))[0].range == TextRange(3, 5, 3, 12));
318 
319 	document = Document.nullDocument(q{
320 void main()
321 {
322 	foo.
323 	foreach(a; b);
324 }
325 });
326 
327 	test.build(document);
328 	// import std.stdio; stderr.writeln("diagnostics:\n", test.diagnostics);
329 	assert(test.syntaxErrorsAt(Position(0, 0)).length == 0);
330 	assert(test.syntaxErrorsAt(Position(3, 4)).length == 0);
331 	assert(test.syntaxErrorsAt(Position(3, 5)).length == 1);
332 	assert(test.syntaxErrorsAt(Position(3, 5))[0].message == "Expected identifier");
333 	assert(test.syntaxErrorsAt(Position(3, 5))[0].range == TextRange(3, 5, 4, 1));
334 }
335 
336 unittest
337 {
338 	DiagnosticTester test = new DiagnosticTester("test-syntax-issues");
339 	scope (exit) test.shutdown(false);
340 
341 	Document document = Document.nullDocument(q{
342 void main()
343 {
344 	foreach (auto key; value)
345 	{
346 	}
347 }
348 });
349 
350 	test.build(document);
351 	assert(test.diagnosticsAt(Position(0, 0), "workspaced.foreach-auto").length == 0);
352 	auto diag = test.diagnosticsAt(Position(3, 11), "workspaced.foreach-auto");
353 	assert(diag.length == 1);
354 	assert(diag[0].range == TextRange(3, 10, 3, 14));
355 }
356 
357 unittest
358 {
359 	DiagnosticTester test = new DiagnosticTester("test-syntax-issues");
360 	scope (exit) test.shutdown(false);
361 
362 	Document document = Document.nullDocument(q{
363 void main()
364 {
365 	foreach (/* cool */ auto key; value)
366 	{
367 	}
368 }
369 });
370 
371 	test.build(document);
372 	assert(test.diagnosticsAt(Position(0, 0), "workspaced.foreach-auto").length == 0);
373 	auto diag = test.diagnosticsAt(Position(3, 22), "workspaced.foreach-auto");
374 	assert(diag.length == 1);
375 	assert(diag[0].range == TextRange(3, 21, 3, 25));
376 }
377 
378 unittest
379 {
380 	DiagnosticTester test = new DiagnosticTester("test-suspicious-local-imports");
381 	scope (exit) test.shutdown(false);
382 
383 	Document document = Document.nullDocument(q{
384 void main()
385 {
386 	import   imports.stdio;
387 
388 	writeln("hello");
389 }
390 });
391 
392 	test.build(document);
393 	assert(test.diagnosticsAt(Position(0, 0), LocalImportCheckKEY).length == 0);
394 	auto diag = test.diagnosticsAt(Position(3, 11), LocalImportCheckKEY);
395 	assert(diag.length == 1);
396 	assert(diag[0].range == TextRange(3, 1, 3, 24));
397 }