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