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 const(TagInfo)[] ret; 63 const(TagInfo.Attribute)[] attributeBase; 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 ret; 72 ret.name = attr[0 .. colon]; 73 auto value = attr[colon + 1 .. $]; 74 auto dot = value.indexOf('.'); 75 76 if (auto exist = value in enumCompletions) 77 ret.completion = *exist; 78 else if (dot != -1) 79 ret.completion = new AttributeValueByTagNameComplete(value[0 .. dot], value[dot + 1 .. $]); 80 else 81 throw new Exception("Unknown attribute value " ~ value); 82 83 return cast(const) ret; 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 = attributeBase; 109 foreach (attr; line[larr + 1 .. $].stripLeft.splitter) 110 attrs ~= parseAttribute(attr); 111 ret ~= const TagInfo(name, attrs); 112 } 113 else if (line.startsWith("tbase ")) 114 { 115 line = line["tbase".length .. $].stripLeft; 116 attributeBase = null; 117 foreach (attr; line.splitter) 118 attributeBase ~= parseAttribute(attr); 119 } 120 else throw new Exception("Invalid line " ~ line); 121 } 122 return ret; 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 return tags.complete(parser.input.read([tag.tag.range[0], offset]), contentLess, this, offset); 368 else if (offset.withinRange(tag.attributesRange)) // somewhere random in attributes but not in name or value 369 return attributeNames.complete("", contentLess, this, offset); 370 } 371 372 if (contentLess.length >= 2) 373 { 374 if (auto tag = cast(TagNode) contentLess[$ - 2]) 375 if (auto attr = cast(TagNode.AttributeAST) contentLess[$ - 1]) 376 if (offset.withinRange(attr.token.range)) 377 return attributeNames.complete(parser.input.read([attr.token.range[0], offset]), contentLess, this, offset); 378 379 if (auto text = cast(TextLine) contentLess[$ - 1]) // must be an empty textline because otherwise a PartAST would be here 380 if (auto tag = cast(TagNode) contentLess[$ - 2]) 381 { 382 if (tag.name == "doctype") 383 return doctypes.complete("", contentLess, this, offset); 384 } 385 } 386 387 if (tree.length == 1) 388 { 389 auto ret = tags.complete("", tree, this, offset); 390 if (cast(Document)tree[0] && (cast(Document)tree[0])._children.length == 0) 391 ret ~= Completion(CompletionType.meta, "doctype").preselect; 392 return ret; 393 } 394 395 return null; 396 } 397 } 398 399 void extractD(DietComplete complete, size_t offset, out string code, out size_t codeOffset) 400 { 401 return complete.parser.root.extractD(offset, code, codeOffset); 402 } 403 404 void extractD(AST root, size_t offset, out string code, out size_t codeOffset) 405 { 406 codeOffset = size_t.max; 407 class CodeVisitorImpl : ASTVisitor 408 { 409 override void visit(DStatement stmt) in (stmt !is null) 410 { 411 if (offset.withinRange(stmt.token.range)) 412 codeOffset = code.length + (offset - (stmt.token.range[0] + stmt.token.content.length)); 413 code ~= stmt.content; 414 if (stmt.children.length) 415 { 416 code ~= "{/*children*/"; 417 stmt.accept(this); 418 code ~= "}"; 419 } 420 } 421 422 static foreach (T; AliasSeq!(Expression, Assignment, RawAssignment)) 423 override void visit(T expr) in (expr !is null) 424 { 425 code ~= "__diet_value("; 426 if (offset.withinRange(expr.token.range)) 427 codeOffset = code.length + (offset - expr.token.range[0] - expr.token.content.length); 428 code ~= expr.content; 429 code ~= ");"; 430 } 431 432 override void visit(Comment comment) in (comment !is null) 433 { 434 } 435 436 override void visit(HiddenComment comment) in (comment !is null) 437 { 438 } 439 440 override void visit(Document doc) in (doc !is null) 441 { 442 code ~= "void __diet_document() {"; 443 doc.accept(this); 444 code ~= "}"; 445 } 446 447 static foreach (T; AliasSeq!(TagNode, TagNode.AttributeAST)) 448 override void visit(T ast) in (ast !is null) 449 { 450 code ~= "{"; 451 ast.accept(this); 452 code ~= "}"; 453 } 454 455 alias visit = ASTVisitor.visit; 456 } 457 458 new CodeVisitorImpl().visit(root); 459 } 460 461 bool isPlainString(string code) 462 { 463 if (!code.length) 464 return false; 465 auto quote = code[0]; 466 if (quote != '\'' && quote != '"' && quote != '`') 467 return false; 468 if (code[$ - 1] != quote) 469 return false; 470 // TODO: better string check 471 return true; 472 } 473 474 string reduceToLastIdentifier(string identifier) 475 { 476 foreach_reverse (i, c; identifier) 477 { 478 if (c.isNumber || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' || c == '-') 479 continue; 480 return identifier[i + 1 .. $]; 481 } 482 return identifier; 483 } 484 485 unittest 486 { 487 import std.conv; 488 489 const(Completion)[] testComplete(string text, size_t at, 490 string file = __FILE__ ~ ":" ~ __LINE__.to!string) 491 { 492 DietInput input; 493 input.file = file; 494 input.code = text; 495 return new DietComplete(input, cast(FileProvider)(name) { 496 assert(false, "Can't import " ~ name ~ " in test"); 497 }).completeAt(at); 498 } 499 500 assert(testComplete("a", 0).canFind!(a => a.text == "textarea")); 501 assert(testComplete("t", 1).canFind!(a => a.text == "textarea")); 502 assert(testComplete("", 0).canFind!(a => a.text == "textarea")); 503 assert(testComplete("div\n\t", 5).canFind!(a => a.text == "textarea")); 504 }