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 }