1 module dietc.complete; 2 3 import dietc.lexer; 4 import dietc.parser; 5 6 import std.algorithm; 7 import std.array; 8 import std.string; 9 import std.uni; 10 import std.meta; 11 12 /// Delegate to provide other files that are being extended 13 alias FileProvider = DietInput delegate(string name); 14 15 enum CompletionType 16 { 17 none, 18 tag, 19 attribute, 20 value, 21 reference, 22 cssName, 23 cssValue, 24 d, 25 meta 26 } 27 28 struct Completion 29 { 30 CompletionType type; 31 string text; 32 string definition; 33 string documentation; 34 35 string referenceFile; 36 size_t[2] referenceRange; 37 bool preselected; 38 39 auto preselect() 40 { 41 preselected = true; 42 return this; 43 } 44 45 static immutable const(Completion)[] completeD = [Completion(CompletionType.d, "", "<d source>")]; 46 } 47 48 struct TagInfo 49 { 50 struct Attribute 51 { 52 string name; 53 CompletionSource completion; 54 } 55 56 string tag; 57 Attribute[] attributes; 58 } 59 60 private const(TagInfo)[] parseTagInfos(string info) 61 { 62 auto ret = appender!(const(TagInfo)[]); 63 auto attributeBase = appender!(TagInfo.Attribute[]); 64 CompletionSource[string] enumCompletions; 65 66 auto parseAttribute(string attr) 67 { 68 auto colon = attr.indexOf(":"); 69 if (colon == -1) 70 throw new Exception("Malformed attribute: " ~ attr); 71 TagInfo.Attribute retAttr; 72 retAttr.name = attr[0 .. colon]; 73 auto value = attr[colon + 1 .. $]; 74 auto dot = value.indexOf('.'); 75 76 if (auto exist = value in enumCompletions) 77 retAttr.completion = *exist; 78 else if (dot != -1) 79 retAttr.completion = new AttributeValueByTagNameComplete(value[0 .. dot], value[dot + 1 .. $]); 80 else 81 throw new Exception("Unknown attribute value " ~ value); 82 83 return cast(const) retAttr; 84 } 85 86 foreach (line; info.lineSplitter) 87 { 88 line = line.strip; 89 if (line.startsWith("//") || !line.length) 90 continue; 91 if (line.startsWith("e ")) 92 { 93 line = line[1 .. $].stripLeft; 94 auto larr = line.indexOf("<"); 95 if (larr == -1) 96 throw new Exception("Malformed enum line: " ~ line); 97 auto name = line[0 .. larr].stripRight; 98 auto values = line[larr + 1 .. $].stripLeft.splitter; 99 enumCompletions[name] = new EnumComplete(values.map!(a => Completion(CompletionType.value, a)).array); 100 } 101 else if (line.startsWith("t ")) 102 { 103 line = line[1 .. $].stripLeft; 104 auto larr = line.indexOf("<"); 105 if (larr == -1) 106 throw new Exception("Malformed enum line: " ~ line); 107 auto name = line[0 .. larr].stripRight; 108 auto attrs = appender(attributeBase.data); 109 foreach (attr; line[larr + 1 .. $].stripLeft.splitter) 110 attrs ~= parseAttribute(attr); 111 ret ~= const TagInfo(name, attrs.data); 112 } 113 else if (line.startsWith("tbase ")) 114 { 115 line = line["tbase".length .. $].stripLeft; 116 attributeBase.clear(); 117 foreach (attr; line.splitter) 118 attributeBase ~= parseAttribute(attr); 119 } 120 else throw new Exception("Invalid line " ~ line); 121 } 122 return ret.data; 123 } 124 125 __gshared const(TagInfo)[] tagInfos; 126 shared static this() 127 { 128 tagInfos = import("html.txt").parseTagInfos; 129 } 130 131 static immutable Completion[] tagCompletions = import("tags.txt").strip 132 .splitLines.map!(a => Completion(CompletionType.tag, a)).array; 133 134 //dfmt off 135 // https://github.com/rejectedsoftware/diet-ng/blob/f65a31def40f40cba2bf03a8f2093821e28a26d3/source/diet/html.d#L428 136 static immutable Completion[] doctypeCompletions = [ 137 Completion(CompletionType.value, "html", `<!DOCTYPE html>`).preselect, 138 Completion(CompletionType.value, "xml", `<?xml version="1.0" encoding="utf-8" ?>`), 139 Completion(CompletionType.value, "transitional", `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">`), 140 Completion(CompletionType.value, "strict", `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">`), 141 Completion(CompletionType.value, "frameset", `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Frameset//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-frameset.dtd">`), 142 Completion(CompletionType.value, "1.1", `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">`), 143 Completion(CompletionType.value, "basic", `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML Basic 1.1//EN" "http://www.w3.org/TR/xhtml-basic/xhtml-basic11.dtd">`), 144 Completion(CompletionType.value, "mobile", `<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN" "http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">`), 145 ]; 146 //dfmt on 147 148 interface CompletionSource 149 { 150 const(Completion)[] complete(string identifier, AST[] context, DietComplete engine, size_t offset); 151 } 152 153 class EnumComplete : CompletionSource 154 { 155 const Completion[] available; 156 157 this(const Completion[] available) 158 { 159 this.available = available; 160 } 161 162 const(Completion)[] complete(string identifier, AST[], DietComplete, size_t) const 163 { 164 return available.filter!(a => a.text.asLowerCase.startsWith(identifier.asLowerCase)).array; 165 } 166 } 167 168 /// Finds attribute values from other tags based on a tag name such as "find id values from table elements". 169 class AttributeValueByTagNameComplete : CompletionSource 170 { 171 string attribute, tag; 172 173 this(string attribute, string tag) 174 { 175 this.attribute = attribute; 176 this.tag = tag; 177 } 178 179 const(Completion)[] complete(string identifier, AST[] context, DietComplete, size_t) const 180 { 181 return null; 182 } 183 } 184 185 class AttributeNameComplete : CompletionSource 186 { 187 const TagInfo[] tagInfos; 188 189 this(in TagInfo[] tagInfos) 190 { 191 this.tagInfos = tagInfos; 192 } 193 194 const(Completion)[] complete(string identifier, AST[] context, DietComplete, size_t) const 195 { 196 TagNode tag; 197 foreach_reverse (node; context) 198 if (cast(TagNode) node) 199 { 200 tag = cast(TagNode) node; 201 break; 202 } 203 204 if (tag) 205 { 206 foreach (info; tagInfos) 207 { 208 if (sicmp(info.tag, tag.name) == 0) 209 { 210 const(Completion)[] completion; 211 foreach (attr; info.attributes) 212 if (attr.name.startsWith(identifier.asLowerCase)) 213 completion ~= Completion(CompletionType.attribute, attr.name); 214 return completion; 215 } 216 } 217 } 218 return null; 219 } 220 } 221 222 class AttributeValueComplete : CompletionSource 223 { 224 const TagInfo[] tagInfos; 225 226 this(in TagInfo[] tagInfos) 227 { 228 this.tagInfos = tagInfos; 229 } 230 231 const(Completion)[] complete(string identifier, AST[] context, DietComplete engine, size_t offset) const 232 { 233 TagNode tag; 234 TagNode.AttributeAST attribute; 235 foreach_reverse (node; context) 236 { 237 if (cast(TagNode.AttributeAST) node) 238 attribute = cast(TagNode.AttributeAST) node; 239 240 if (cast(TagNode) node) 241 { 242 tag = cast(TagNode) node; 243 break; 244 } 245 } 246 247 identifier = identifier.reduceToLastIdentifier; 248 249 if (tag) 250 { 251 foreach (info; tagInfos) 252 { 253 if (sicmp(info.tag, tag.name) == 0) 254 { 255 Completion[] completion; 256 foreach (attr; info.attributes) 257 if (sicmp(attr.name, attribute.name) == 0) 258 completion ~= (cast()attr.completion).complete(identifier, context, engine, offset); 259 return cast(const(Completion)[]) completion.sort!"a.text < b.text".uniq!"a.text == b.text".array; 260 } 261 } 262 } 263 return null; 264 } 265 } 266 267 class DietComplete 268 { 269 FileProvider provider; 270 ASTParser parser; 271 EnumComplete tags; 272 EnumComplete doctypes; 273 AttributeNameComplete attributeNames; 274 AttributeValueComplete attributeValues; 275 276 this(string file) 277 { 278 import std.path : dirName; 279 this(DietInput.fromFile(file), defaultFileProvider(dirName(file))); 280 } 281 282 this(DietInput root, FileProvider provider) 283 { 284 tags = new EnumComplete(tagCompletions); 285 doctypes = new EnumComplete(doctypeCompletions); 286 attributeNames = new AttributeNameComplete(tagInfos); 287 attributeValues = new AttributeValueComplete(tagInfos); 288 289 this.provider = provider; 290 root.reset(); 291 parser = ASTParser(root); 292 parser.parseDocument(); 293 } 294 295 void reparse(string content) 296 { 297 parser.input.code = content; 298 parser.input.reset(); 299 parser.parseDocument(); 300 } 301 302 static FileProvider defaultFileProvider(string dir) 303 { 304 return (name) { 305 import std.file : exists; 306 import std.path : chainPath, withExtension; 307 308 if (exists(chainPath(dir, name.withExtension(".dt")))) 309 return DietInput.fromFile(chainPath(dir, name.withExtension(".dt"))); 310 else 311 return DietInput.init; 312 }; 313 } 314 315 const(Completion)[] completeAt(size_t offset) 316 { 317 auto tree = parser.searchAST(offset); 318 319 auto contentLess = tree; 320 if (cast(Document) contentLess[0]) 321 while (cast(TextLine) contentLess[$ - 1] || cast(TextLine.PartAST) contentLess[$ - 1]) 322 contentLess.length--; 323 assert(contentLess.length >= 1); 324 325 if (cast(DStatement) tree[$ - 1] || cast(Assignment) tree[$ - 1] || cast(RawAssignment) tree[$ - 1]) 326 { 327 auto stmt = cast(IStringContainer) tree[$ - 1]; 328 if (offset.withinRange([stmt.token.range[0] + 1, stmt.token.range[0] + 1 + stmt.content.length])) 329 return Completion.completeD; 330 else 331 return null; // before "-" character or in newline at end 332 } 333 334 if (auto expr = cast(Expression) tree[$ - 1]) 335 { 336 auto code = expr.content; 337 if (!code.all!isNumber && !code.isPlainString) 338 return Completion.completeD; 339 } 340 341 if (tree.length >= 2) 342 { 343 if (auto expr = cast(Expression) tree[$ - 1]) 344 if (auto attr = cast(TagNode.AttributeAST) tree[$ - 2]) 345 { 346 string exprCode = parser.input.read([expr.token.range[0], offset]); 347 return attributeValues.complete(exprCode, tree, this, offset); 348 } 349 } 350 351 if (tree.length >= 3) 352 { 353 if (auto part = cast(TextLine.PartAST) tree[$ - 1]) 354 if (auto line = cast(TextLine) tree[$ - 2]) 355 if (auto tag = cast(TagNode) tree[$ - 3]) 356 if (line._parts.length == 1 && part.part.raw.length) 357 { 358 string text = part.part.raw[0 .. offset - part.token.range[0]]; 359 if (tag.name == "doctype") 360 return doctypes.complete(text, tree, this, offset); 361 } 362 } 363 364 if (auto tag = cast(TagNode) contentLess[$ - 1]) 365 { 366 if (offset.withinRange(tag.tag.range)) 367 { 368 string written = parser.input.read([tag.tag.range[0], offset]); 369 auto ret = tags.complete(written, contentLess, this, offset); 370 if (contentLess.length == 2) // top level element 371 { 372 if ("doctype".startsWith(written.asLowerCase)) 373 ret ~= Completion(CompletionType.meta, "doctype").preselect; 374 if ("html".startsWith(written.asLowerCase)) 375 ret ~= Completion(CompletionType.tag, "html").preselect; 376 } 377 else if (contentLess.length > 2) 378 { 379 if (auto parent = cast(TagNode) contentLess[$ - 2]) 380 { 381 if (parent.name == "html") 382 { 383 if ("head".startsWith(written.asLowerCase)) 384 ret ~= Completion(CompletionType.tag, "head").preselect; 385 if ("body".startsWith(written.asLowerCase)) 386 ret ~= Completion(CompletionType.tag, "body").preselect; 387 } 388 } 389 } 390 return ret; 391 } 392 else if (offset.withinRange(tag.attributesRange)) // somewhere random in attributes but not in name or value 393 return attributeNames.complete("", contentLess, this, offset); 394 } 395 396 if (contentLess.length >= 2) 397 { 398 if (auto tag = cast(TagNode) contentLess[$ - 2]) 399 if (auto attr = cast(TagNode.AttributeAST) contentLess[$ - 1]) 400 if (offset.withinRange(attr.token.range)) 401 return attributeNames.complete(parser.input.read([attr.token.range[0], offset]), contentLess, this, offset); 402 403 if (auto text = cast(TextLine) contentLess[$ - 1]) // must be an empty textline because otherwise a PartAST would be here 404 if (auto tag = cast(TagNode) contentLess[$ - 2]) 405 { 406 if (tag.name == "doctype") 407 return doctypes.complete("", contentLess, this, offset); 408 } 409 } 410 411 if (tree.length == 1) 412 { 413 auto ret = tags.complete("", tree, this, offset); 414 if (cast(Document)tree[0] && (cast(Document)tree[0])._children.length == 0) 415 ret ~= Completion(CompletionType.meta, "doctype").preselect; 416 return ret; 417 } 418 419 return null; 420 } 421 } 422 423 void extractD(DietComplete complete, size_t offset, out string code, out size_t codeOffset, string prefix = null) 424 { 425 return complete.parser.root.extractD(offset, code, codeOffset, prefix); 426 } 427 428 void extractD(AST root, size_t offset, out string code, out size_t codeOffset, string prefix = null) 429 { 430 code = prefix; 431 codeOffset = size_t.max; 432 class CodeVisitorImpl : ASTVisitor 433 { 434 override void visit(DStatement stmt) in (stmt !is null) 435 { 436 if (offset.withinRange(stmt.token.range)) 437 codeOffset = code.length + (offset - (stmt.token.range[0] + stmt.token.content.length)); 438 code ~= stmt.content; 439 if (stmt.children.length) 440 { 441 code ~= "{/*children*/"; 442 stmt.accept(this); 443 code ~= "}"; 444 } 445 } 446 447 static foreach (T; AliasSeq!(Expression, Assignment, RawAssignment)) 448 override void visit(T expr) in (expr !is null) 449 { 450 static if (is(T == Expression)) 451 enum typeOffset = 0; 452 else static if (is(T == Assignment)) 453 enum typeOffset = 1; // offset the equal of tag= 454 else static if (is(T == RawAssignment)) 455 enum typeOffset = 2; // offset the bang-equal of tag!= 456 457 code ~= "__diet_value("; 458 if (offset.withinRange(expr.token.range)) 459 codeOffset = code.length + (offset - typeOffset - expr.token.range[0]); 460 code ~= expr.content; 461 code ~= ");"; 462 } 463 464 override void visit(Comment comment) in (comment !is null) 465 { 466 } 467 468 override void visit(HiddenComment comment) in (comment !is null) 469 { 470 } 471 472 override void visit(Document doc) in (doc !is null) 473 { 474 code ~= "void __diet_document() {"; 475 doc.accept(this); 476 code ~= "}"; 477 } 478 479 static foreach (T; AliasSeq!(TagNode, TagNode.AttributeAST)) 480 override void visit(T ast) in (ast !is null) 481 { 482 code ~= "{"; 483 ast.accept(this); 484 code ~= "}"; 485 } 486 487 alias visit = ASTVisitor.visit; 488 } 489 490 new CodeVisitorImpl().visit(root); 491 } 492 493 bool isPlainString(string code) 494 { 495 if (!code.length) 496 return false; 497 auto quote = code[0]; 498 if (quote != '\'' && quote != '"' && quote != '`') 499 return false; 500 if (code[$ - 1] != quote) 501 return false; 502 // TODO: better string check 503 return true; 504 } 505 506 string reduceToLastIdentifier(string identifier) 507 { 508 foreach_reverse (i, c; identifier) 509 { 510 if (c.isNumber || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' || c == '-') 511 continue; 512 return identifier[i + 1 .. $]; 513 } 514 return identifier; 515 } 516 517 unittest 518 { 519 import std.conv; 520 521 const(Completion)[] testComplete(string text, size_t at, 522 string file = __FILE__ ~ ":" ~ __LINE__.to!string) 523 { 524 DietInput input; 525 input.file = file; 526 input.code = text; 527 return new DietComplete(input, cast(FileProvider)(name) { 528 assert(false, "Can't import " ~ name ~ " in test"); 529 }).completeAt(at); 530 } 531 532 assert(testComplete("a", 0).canFind!(a => a.text == "textarea")); 533 assert(testComplete("t", 1).canFind!(a => a.text == "textarea")); 534 assert(testComplete("", 0).canFind!(a => a.text == "textarea")); 535 assert(testComplete("div\n\t", 5).canFind!(a => a.text == "textarea")); 536 } 537 538 unittest 539 { 540 import std.conv; 541 542 DietInput input; 543 input.file = "stdin"; 544 input.code = `foo 545 - int item = 3; 546 p #{item.foobar} bar 547 a(attr=foo.bar)= foo.bar 548 `; 549 auto c = new DietComplete(input, cast(FileProvider)(name) { 550 assert(false, "Can't import " ~ name ~ " in test"); 551 }); 552 553 assert(c.completeAt(23).canFind!(a => a.text == "pre")); 554 assert(c.completeAt(39).length == 0); 555 assert(c.completeAt(38).length == 0); 556 assert(c.completeAt(24).length == 0); 557 assert(c.completeAt(25).length == 0); 558 559 void checkDBounds(size_t start, size_t end) 560 { 561 assert(c.completeAt(start - 1) !is Completion.completeD); 562 assert(c.completeAt(start) is Completion.completeD); 563 assert(c.completeAt(end) is Completion.completeD); 564 assert(c.completeAt(end + 1) !is Completion.completeD); 565 } 566 567 checkDBounds(6, 20); 568 checkDBounds(26, 37); 569 checkDBounds(51, 58); 570 checkDBounds(60, 68); 571 572 string code; 573 size_t offset; 574 575 c.extractD(28, code, offset); 576 assert(code == `void __diet_document() {{ int item = 3;{__diet_value(item.foobar);}{{__diet_value(foo.bar);}__diet_value( foo.bar);}}}`); 577 assert(offset == 55); 578 579 c.extractD(37, code, offset); 580 assert(offset == 64); 581 582 c.extractD(38, code, offset); 583 assert(offset == size_t.max); 584 585 c.extractD(25, code, offset); 586 assert(offset == size_t.max); 587 588 c.extractD(7, code, offset); 589 assert(offset == 26); 590 591 c.extractD(20, code, offset); 592 assert(offset == 39); 593 594 c.extractD(51, code, offset); 595 assert(offset == 82); 596 597 c.extractD(58, code, offset); 598 assert(offset == 89); 599 600 c.extractD(61, code, offset); 601 assert(offset == 106); 602 603 c.extractD(68, code, offset); 604 assert(offset == 113); 605 }