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