1 module served.commands.code_actions;
2 
3 import served.extension;
4 import served.types;
5 import served.utils.fibermanager;
6 
7 import workspaced.api;
8 import workspaced.com.dcdext;
9 import workspaced.com.importer;
10 import workspaced.coms;
11 
12 import served.commands.format : generateDfmtArgs;
13 
14 import served.linters.dscanner : DScannerDiagnosticSource, SyntaxHintDiagnosticSource;
15 import served.linters.dub : DubDiagnosticSource;
16 
17 import std.algorithm : canFind, map, min, sort, startsWith, uniq;
18 import std.array : array;
19 import std.conv : to;
20 import std.experimental.logger;
21 import std.format : format;
22 import std.path : buildNormalizedPath, isAbsolute;
23 import std.regex : matchFirst, regex, replaceAll;
24 import std.string : indexOf, indexOfAny, join, replace, strip;
25 
26 import fs = std.file;
27 import io = std.stdio;
28 
29 package auto importRegex = regex(`import\s+(?:[a-zA-Z_]+\s*=\s*)?([a-zA-Z_]\w*(?:\.\w*[a-zA-Z_]\w*)*)?(\s*\:\s*(?:[a-zA-Z_,\s=]*(?://.*?[\r\n]|/\*.*?\*/|/\+.*?\+/)?)+)?;?`);
30 package static immutable regexQuoteChars = "['\"`]?";
31 package auto undefinedIdentifier = regex(`^undefined identifier ` ~ regexQuoteChars ~ `(\w+)`
32 		~ regexQuoteChars ~ `(?:, did you mean .*? ` ~ regexQuoteChars ~ `(\w+)`
33 		~ regexQuoteChars ~ `\?)?$`);
34 package auto undefinedTemplate = regex(
35 		`template ` ~ regexQuoteChars ~ `(\w+)` ~ regexQuoteChars ~ ` is not defined`);
36 package auto noProperty = regex(`^no property ` ~ regexQuoteChars ~ `(\w+)`
37 		~ regexQuoteChars ~ `(?: for type ` ~ regexQuoteChars ~ `.*?` ~ regexQuoteChars ~ `)?$`);
38 package auto moduleRegex = regex(
39 		`(?<!//.*)\bmodule\s+([a-zA-Z_]\w*\s*(?:\s*\.\s*[a-zA-Z_]\w*)*)\s*;`);
40 package auto whitespace = regex(`\s*`);
41 
42 @protocolMethod("textDocument/codeAction")
43 CodeAction[] provideCodeActions(CodeActionParams params)
44 {
45 	auto document = documents[params.textDocument.uri];
46 	auto instance = activeInstance = backend.getBestInstance(document.uri.uriToFile);
47 	if (document.getLanguageId != "d" || !instance)
48 		return [];
49 
50 	// eagerly load DCD in opened files which request code actions
51 	if (instance.has!DCDComponent)
52 		instance.get!DCDComponent();
53 
54 	CodeAction[] ret;
55 	if (instance.has!DCDExtComponent) // check if extends
56 	{
57 		scope codeText = document.rawText.idup;
58 		auto startIndex = document.positionToBytes(params.range.start);
59 		ptrdiff_t idx = min(cast(ptrdiff_t) startIndex, cast(ptrdiff_t) codeText.length - 1);
60 		while (idx > 0)
61 		{
62 			if (codeText[idx] == ':')
63 			{
64 				// probably extends
65 				if (instance.get!DCDExtComponent.implementAll(codeText,
66 						cast(int) startIndex).getYield.length > 0)
67 				{
68 					Command cmd = {
69 						title: "Implement base classes/interfaces",
70 						command: "code-d.implementMethods",
71 						arguments: [
72 							JsonValue(document.positionToOffset(params.range.start))
73 						]
74 					};
75 					ret ~= CodeAction(cmd);
76 				}
77 				break;
78 			}
79 			if (codeText[idx] == ';' || codeText[idx] == '{' || codeText[idx] == '}')
80 				break;
81 			idx--;
82 		}
83 	}
84 	foreach (diagnostic; params.context.diagnostics)
85 	{
86 		if (diagnostic.source == DubDiagnosticSource)
87 		{
88 			addDubDiagnostics(ret, instance, document, diagnostic, params);
89 		}
90 		else if (diagnostic.source == DScannerDiagnosticSource)
91 		{
92 			addDScannerDiagnostics(ret, instance, document, diagnostic, params);
93 		}
94 		else if (diagnostic.source == SyntaxHintDiagnosticSource)
95 		{
96 			addSyntaxDiagnostics(ret, instance, document, diagnostic, params);
97 		}
98 	}
99 	return ret;
100 }
101 
102 void addDubDiagnostics(ref CodeAction[] ret, WorkspaceD.Instance instance,
103 	Document document, Diagnostic diagnostic, CodeActionParams params)
104 {
105 	auto match = diagnostic.message.matchFirst(importRegex);
106 	if (diagnostic.message.canFind("import ") && match)
107 	{
108 		ret ~= CodeAction(Command("Import " ~ match[1], "code-d.addImport",
109 			[
110 				JsonValue(match[1]),
111 				JsonValue(document.positionToOffset(params.range[0]))
112 			]));
113 	}
114 	if (cast(bool)(match = diagnostic.message.matchFirst(undefinedIdentifier))
115 			|| cast(bool)(match = diagnostic.message.matchFirst(undefinedTemplate))
116 			|| cast(bool)(match = diagnostic.message.matchFirst(noProperty)))
117 	{
118 		string[] files;
119 		string[] modules;
120 		int lineNo;
121 		joinAll({
122 			files ~= instance.get!DscannerComponent.findSymbol(match[1])
123 				.getYield.map!"a.file".array;
124 		}, {
125 			if (instance.has!DCDComponent)
126 				files ~= instance.get!DCDComponent.searchSymbol(match[1]).getYield.map!"a.file".array;
127 		} /*, {
128 			struct Symbol
129 			{
130 				string project, package_;
131 			}
132 
133 			StopWatch sw;
134 			bool got;
135 			Symbol[] symbols;
136 			sw.start();
137 			info("asking the interwebs for ", match[1]);
138 			new Thread({
139 				import std.net.curl : get;
140 				import std.uri : encodeComponent;
141 
142 				auto str = get(
143 				"https://symbols.webfreak.org/symbols?limit=60&identifier=" ~ encodeComponent(match[1]));
144 				foreach (symbol; parseJSON(str).array)
145 					symbols ~= Symbol(symbol["project"].str, symbol["package"].str);
146 				got = true;
147 			}).start();
148 			while (sw.peek < 3.seconds && !got)
149 				Fiber.yield();
150 			foreach (v; symbols.sort!"a.project < b.project"
151 				.uniq!"a.project == b.project")
152 				ret ~= Command("Import " ~ v.package_ ~ " from dub package " ~ v.project);
153 		}*/
154 		);
155 		info("Files: ", files);
156 		foreach (file; files.sort().uniq)
157 		{
158 			if (!isAbsolute(file))
159 				file = buildNormalizedPath(instance.cwd, file);
160 			if (!fs.exists(file))
161 				continue;
162 			lineNo = 0;
163 			foreach (line; io.File(file).byLine)
164 			{
165 				if (++lineNo >= 100)
166 					break;
167 				auto match2 = line.matchFirst(moduleRegex);
168 				if (match2)
169 				{
170 					modules ~= match2[1].replaceAll(whitespace, "").idup;
171 					break;
172 				}
173 			}
174 		}
175 		foreach (mod; modules.sort().uniq)
176 			ret ~= CodeAction(Command("Import " ~ mod, "code-d.addImport", [
177 				JsonValue(mod),
178 				JsonValue(document.positionToOffset(params.range[0]))
179 			]));
180 	}
181 
182 	if (diagnostic.message.startsWith("use `is` instead of `==`",
183 			"use `!is` instead of `!=`")
184 		&& diagnostic.range.end.character - diagnostic.range.start.character == 2)
185 	{
186 		auto b = document.positionToBytes(diagnostic.range.start);
187 		auto text = document.rawText[b .. $];
188 
189 		string target = diagnostic.message[5] == '!' ? "!=" : "==";
190 		string replacement = diagnostic.message[5] == '!' ? "!is" : "is";
191 
192 		if (text.startsWith(target))
193 		{
194 			string title = format!"Change '%s' to '%s'"(target, replacement);
195 			TextEditCollection[DocumentUri] changes;
196 			changes[document.uri] = [TextEdit(diagnostic.range, replacement)];
197 			auto action = CodeAction(title, WorkspaceEdit(changes));
198 			action.isPreferred = true;
199 			action.diagnostics = [diagnostic];
200 			action.kind = CodeActionKind.quickfix;
201 			ret ~= action;
202 		}
203 	}
204 }
205 
206 void addDScannerDiagnostics(ref CodeAction[] ret, WorkspaceD.Instance instance,
207 	Document document, Diagnostic diagnostic, CodeActionParams params)
208 {
209 	import dscanner.analysis.imports_sortedness : ImportSortednessCheck;
210 
211 	string key = diagnostic.code.orDefault.match!((string s) => s, _ => cast(string)(null));
212 
213 	info("Diagnostic: ", diagnostic);
214 
215 	if (key == ImportSortednessCheck.KEY)
216 	{
217 		ret ~= CodeAction(Command("Sort imports", "code-d.sortImports",
218 				[JsonValue(document.positionToOffset(params.range[0]))]));
219 	}
220 
221 	if (key.length)
222 	{
223 		JsonValue code = diagnostic.code.match!(() => JsonValue(null), j => j);
224 		if (key.startsWith("dscanner."))
225 			key = key["dscanner.".length .. $];
226 		ret ~= CodeAction(Command("Ignore " ~ key ~ " warnings (this line)",
227 			"code-d.ignoreDscannerKey", [code, JsonValue("line")]));
228 		ret ~= CodeAction(Command("Ignore " ~ key ~ " warnings",
229 			"code-d.ignoreDscannerKey", [code]));
230 	}
231 }
232 
233 void addSyntaxDiagnostics(ref CodeAction[] ret, WorkspaceD.Instance instance,
234 	Document document, Diagnostic diagnostic, CodeActionParams params)
235 {
236 	string key = diagnostic.code.orDefault.match!((string s) => s, _ => cast(string)(null));
237 	switch (key)
238 	{
239 	case "workspaced.foreach-auto":
240 		auto b = document.positionToBytes(diagnostic.range.start);
241 		auto text = document.rawText[b .. $];
242 
243 		if (text.startsWith("auto"))
244 		{
245 			auto range = diagnostic.range;
246 			size_t offset = 4;
247 			foreach (i, dchar c; text[4 .. $])
248 			{
249 				offset = 4 + i;
250 				if (!isDIdentifierSeparatingChar(c))
251 					break;
252 			}
253 			range.end = range.start;
254 			range.end.character += offset;
255 			string title = "Remove 'auto' to fix syntax";
256 			TextEditCollection[DocumentUri] changes;
257 			changes[document.uri] = [TextEdit(range, "")];
258 			auto action = CodeAction(title, WorkspaceEdit(changes));
259 			action.isPreferred = true;
260 			action.diagnostics = [diagnostic];
261 			action.kind = CodeActionKind.quickfix;
262 			ret ~= action;
263 		}
264 		break;
265 	default:
266 		warning("No diagnostic fix for our own diagnostic: ", diagnostic);
267 		break;
268 	}
269 }
270 
271 /// Command to sort all user imports in a block at a given position in given code. Returns a list of changes to apply. (Replaces the whole block currently if anything changed, otherwise empty)
272 @protocolMethod("served/sortImports")
273 TextEdit[] sortImports(SortImportsParams params)
274 {
275 	auto document = documents[params.textDocument.uri];
276 	TextEdit[] ret;
277 	auto sorted = backend.get!ImporterComponent.sortImports(document.rawText,
278 			cast(int) document.offsetToBytes(params.location));
279 	if (sorted == ImportBlock.init)
280 		return ret;
281 	auto start = document.bytesToPosition(sorted.start);
282 	auto end = document.movePositionBytes(start, sorted.start, sorted.end);
283 	auto lines = sorted.imports.to!(string[]);
284 	if (!lines.length)
285 		return null;
286 	foreach (ref line; lines[1 .. $])
287 		line = sorted.indentation ~ line;
288 	string code = lines.join(document.eolAt(0).toString);
289 	return [TextEdit(TextRange(start, end), code)];
290 }
291 
292 /// Flag to make dcdext.implementAll return snippets
293 __gshared bool implementInterfaceSnippets;
294 
295 /// Implements the interfaces or abstract classes of a specified class/interface. The given position must be on/inside the identifier of any subclass after the colon (`:`) in a class definition.
296 @protocolMethod("served/implementMethods")
297 TextEdit[] implementMethods(ImplementMethodsParams params)
298 {
299 	import std.ascii : isWhite;
300 
301 	auto document = documents[params.textDocument.uri];
302 	string file = document.uri.uriToFile;
303 	auto config = workspace(params.textDocument.uri).config;
304 	TextEdit[] ret;
305 	auto location = document.offsetToBytes(params.location);
306 	scope codeText = document.rawText.idup;
307 
308 	if (gFormattingOptionsApplyOn != params.textDocument.uri)
309 		tryFindFormattingSettings(config, document);
310 
311 	auto eol = document.eolAt(0);
312 	auto eolStr = eol.toString;
313 	auto toImplement = backend.best!DCDExtComponent(file).implementAll(codeText, cast(int) location,
314 			config.d.enableFormatting, generateDfmtArgs(config, eol), implementInterfaceSnippets)
315 		.getYield;
316 	if (!toImplement.length)
317 		return ret;
318 
319 	string formatCode(ImplementedMethod method, bool needsIndent = false)
320 	{
321 		if (needsIndent)
322 		{
323 			// start/end of block where it's not intended
324 			return "\t" ~ method.code.replace("\n", "\n\t");
325 		}
326 		else
327 		{
328 			// cool! snippets handle indentation and new lines automatically so we just keep it as is
329 			return method.code;
330 		}
331 	}
332 
333 	auto existing = backend.best!DCDExtComponent(file).getInterfaceDetails(file,
334 			codeText, cast(int) location);
335 	if (existing == InterfaceDetails.init || existing.isEmpty)
336 	{
337 		// insert at start (could not parse class properly or class is empty)
338 		auto brace = codeText.indexOf('{', location);
339 		if (brace == -1)
340 			brace = codeText.length;
341 		brace++;
342 		auto pos = document.bytesToPosition(brace);
343 		return [
344 			TextEdit(TextRange(pos, pos),
345 					eolStr
346 					~ toImplement
347 						.map!(a => formatCode(a, true))
348 						.join(eolStr ~ eolStr))
349 		];
350 	}
351 	else if (existing.methods.length == 0)
352 	{
353 		// insert at end (no methods in class)
354 		auto end = document.bytesToPosition(existing.blockRange[1] - 1);
355 		return [
356 			TextEdit(TextRange(end, end),
357 				eolStr
358 				~ toImplement
359 					.map!(a => formatCode(a, true))
360 					.join(eolStr ~ eolStr)
361 				~ eolStr)
362 		];
363 	}
364 	else
365 	{
366 		// simply insert at the end of methods, maybe we want to add sorting?
367 		// ... ofc that would need a configuration flag because once this is in for a while at least one user will have get used to this and wants to continue having it.
368 		auto end = document.bytesToPosition(existing.methods[$ - 1].blockRange[1]);
369 		return [
370 			TextEdit(TextRange(end, end),
371 					eolStr
372 					~ eolStr
373 					~ toImplement
374 						.map!(a => formatCode(a, false))
375 						.join(eolStr ~ eolStr)
376 					~ eolStr)
377 		];
378 	}
379 }