1 /// `moduleView` is a work-in-progress component used to display an overview of a module. 2 /// 3 /// Warning: This module is unstable. Significant changes may be made without prior warning. 4 /// 5 /// This module is not enabled, unless additonal dependencies, `fluid-tree-sitter` and `fluid-tree-sitter:d` are also 6 /// compiled in. 7 module fluid.module_view; 8 9 version (Fluid_ModuleView): 10 version (Have_fluid_tree_sitter): 11 version (Have_fluid_tree_sitter_d): 12 13 debug (Fluid_BuildMessages) { 14 pragma(msg, "Fluid: Including moduleView"); 15 } 16 17 import lib_tree_sitter; 18 import bindbc = bindbc.loader; 19 20 import std.conv; 21 import std.range; 22 import std.format; 23 import std.string; 24 import std.algorithm; 25 26 import fluid.node; 27 import fluid.slot; 28 import fluid.rope; 29 import fluid.label; 30 import fluid.space; 31 import fluid.style; 32 import fluid.frame; 33 import fluid.button; 34 import fluid.structs; 35 import fluid.popup_button; 36 import fluid.code_input; 37 import fluid.tree_sitter; 38 39 40 @safe: 41 42 43 private { 44 45 bool initialized; 46 TSQuery* documentationQuery; 47 TSQuery* dlangQuery; 48 49 } 50 51 static this() @system { 52 53 // Guard to keep this only running once 54 if (initialized) return; 55 initialized = true; 56 57 TSQueryError error; 58 uint errorOffset; 59 60 auto language = treeSitterLanguage!"d"; 61 auto query = q{ 62 ( 63 (comment)+ @comment 64 ) 65 }; 66 67 documentationQuery = ts_query_new(language, query.ptr, cast(uint) query.length, &errorOffset, &error); 68 assert(documentationQuery, format!"%s at offset %s"(error, errorOffset)); 69 70 dlangQuery = ts_query_new(language, dQuerySource.ptr, cast(uint) dQuerySource.length, &errorOffset, &error); 71 assert(dlangQuery, format!"%s at offset %s"(error, errorOffset)); 72 73 } 74 75 static ~this() @system { 76 77 // Guard to keep this only running once 78 if (!initialized) return; 79 initialized = false; 80 81 ts_query_delete(documentationQuery); 82 ts_query_delete(dlangQuery); 83 84 } 85 86 /// Provides information about the companion D compiler to use for evaluating examples. 87 struct DlangCompiler { 88 89 import std.regex; 90 import std.process; 91 92 enum Type { 93 dmd, 94 ldc, 95 } 96 97 /// Type (vendor) of the compiler. Either DMD or LDC. 98 Type type; 99 100 /// Executable name or path to the compiler executable. If null, no compiler is available. 101 string executable; 102 103 /// DMD frontend version of the compiler. Major version is assumed to be 2. 104 int frontendMinor; 105 106 /// ditto 107 int frontendPatch; 108 109 /// ditto 110 enum frontendMajor = 2; 111 112 /// Import paths to pass to the compiler. 113 string[] importPaths; 114 115 /// Returns true if this entry points to a valid compiler. 116 bool opCast(T : bool)() const { 117 118 return executable !is null 119 && frontendMinor != 0; 120 121 } 122 123 /// Find any suitable in the system. 124 static DlangCompiler findAny() { 125 126 return either( 127 findDMD, 128 findLDC, 129 ); 130 131 } 132 133 /// Find DMD in the system. 134 static DlangCompiler findDMD() { 135 136 // According to run.dlang.io, this pattern has been used since at least 2.068.2 137 auto pattern = regex(r"D Compiler v2.(\d+).(\d+)"); 138 auto explicitDMD = std.process.environment.get("DMD"); 139 auto candidates = explicitDMD.empty 140 ? ["dmd"] 141 : [explicitDMD]; 142 143 // Test the executables 144 foreach (name; candidates) { 145 146 auto process = execute([name, "--version"]); 147 148 // Found a compatible compiler 149 if (auto match = process.output.matchFirst(pattern)) { 150 151 return DlangCompiler(Type.dmd, name, match[1].to!int, match[2].to!int); 152 153 } 154 155 } 156 157 return DlangCompiler.init; 158 159 } 160 161 /// Find LDC in the system. 162 static DlangCompiler findLDC() { 163 164 // This pattern appears to be stable as, according to the blame, hasn't changed in at least 5 years 165 auto pattern = regex(r"based on DMD v2\.(\d+)\.(\d+)"); 166 auto explicitLDC = std.process.environment.get("LDC"); 167 auto candidates = explicitLDC.empty 168 ? ["ldc2", "ldc"] 169 : [explicitLDC]; 170 171 // Test the executables 172 foreach (name; candidates) { 173 174 auto process = execute([name, "--version"]); 175 176 // Found a compatible compiler 177 if (auto match = process.output.matchFirst(pattern)) { 178 179 return DlangCompiler(Type.ldc, name, match[1].to!int, match[2].to!int); 180 181 } 182 183 } 184 185 return DlangCompiler.init; 186 187 } 188 189 unittest { 190 191 import std.stdio; 192 193 auto dmd = findDMD(); 194 auto ldc = findLDC(); 195 196 // Output search results 197 // Correctness of these tests has to be verified by the CI runner script or the programmer 198 if (dmd) { 199 writefln!"Found DMD (%s) 2.%s.%s"(dmd.executable, dmd.frontendMinor, dmd.frontendPatch); 200 assert(dmd.type == Type.dmd); 201 } 202 else 203 writefln!"DMD wasn't found"; 204 205 if (ldc) { 206 writefln!"Found LDC (%s) compatible with DMD 2.%s.%s"(ldc.executable, ldc.frontendMinor, ldc.frontendPatch); 207 assert(ldc.type == Type.ldc); 208 } 209 else 210 writefln!"LDC wasn't found"; 211 212 // Leading zeros have to be ignored 213 assert("068".to!int == 68); 214 215 // Compare results of the compiler-agnostic and compiler-specific functions 216 if (auto compiler = findAny()) { 217 218 final switch (compiler.type) { 219 220 case Type.dmd: 221 assert(dmd); 222 break; 223 224 case Type.ldc: 225 assert(ldc); 226 break; 227 228 } 229 230 } 231 232 // No compiler found 233 else { 234 235 assert(!dmd); 236 assert(!ldc); 237 238 } 239 240 } 241 242 /// Get the flag for importing from given directory. 243 string importFlag(string directory) const { 244 245 return "-I" ~ directory; 246 247 } 248 249 /// Get the flag for adding all import directories specified in compiler config. 250 string[] importPathsFlag() const { 251 252 return importPaths 253 .map!(a => importFlag(a)) 254 .array; 255 256 } 257 258 /// Get the flag to generate a shared library for the given compiler. 259 string sharedLibraryFlag() const { 260 261 final switch (type) { 262 263 case Type.dmd: return "-shared"; 264 case Type.ldc: return "--shared"; 265 266 } 267 268 } 269 270 /// Get the flag to include unittests. 271 string unittestFlag() const { 272 273 final switch (type) { 274 275 case Type.dmd: return "-unittest"; 276 case Type.ldc: return "--unittest"; 277 278 } 279 280 } 281 282 /// Compile a shared library from given source file. 283 /// 284 /// TODO make this async 285 auto compileSharedLibrary(string source, out string outputPath) const 286 in (this) 287 do { 288 289 import fs = std.file; 290 import random = std.random; 291 import std.path : buildPath, setExtension; 292 293 static string path; 294 295 // Build a path to contain the program's source 296 if (!path) 297 path = fs.tempDir.buildPath("fluid_" ~ random.uniform!uint.to!string ~ ".d"); 298 299 // Write the source 300 fs.write(path, source); 301 302 // Find the correct extension for the system 303 version (Windows) 304 outputPath = path.setExtension(".dll"); 305 else version (OSX) 306 outputPath = path.setExtension(".dylib"); 307 else 308 outputPath = path.setExtension(".so"); 309 310 auto cmdline = [executable, sharedLibraryFlag, unittestFlag, path, "-of=" ~ outputPath] ~ importPathsFlag; 311 312 debug (Fluid_BuildMessages) { 313 import std.stdio; 314 writefln!"Fluid: compiling $ %s"(escapeShellCommand(cmdline)); 315 } 316 317 // Compile the program 318 return execute(cmdline); 319 320 } 321 322 } 323 324 /// Create an overview display of the given module. 325 Frame moduleViewSource(Params...)(Params params, DlangCompiler compiler, string source, 326 Theme contentTheme = fluidDefaultTheme) @trusted 327 do { 328 329 auto language = treeSitterLanguage!"d"; 330 auto parser = ts_parser_new(); 331 scope (exit) ts_parser_delete(parser); 332 333 ts_parser_set_language(parser, language); 334 335 // Parse the source 336 auto tree = ts_parser_parse_string(parser, null, source.ptr, cast(uint) source.length); 337 scope (exit) ts_tree_delete(tree); 338 auto root = ts_tree_root_node(tree); 339 auto cursor = ts_query_cursor_new(); 340 scope (exit) ts_query_cursor_delete(cursor); 341 342 auto view = ModuleView(compiler, source, contentTheme); 343 auto result = vframe(params); 344 345 // Perform a query to find possibly relevant comments 346 ts_query_cursor_exec(cursor, documentationQuery, root); 347 TSQueryMatch match; 348 captures: while (ts_query_cursor_next_match(cursor, &match)) { 349 350 auto captures = match.captures[0 .. match.capture_count]; 351 auto node = captures[$-1].node; 352 353 // Load the comment 354 auto docs = readDocs(source, captures) 355 .interpretDocs; 356 357 // Find the symbol the comment is attached to 358 while (true) { 359 360 node = ts_node_next_named_sibling(node); 361 if (ts_node_is_null(node)) break; 362 363 // Once found, annotate and append to result 364 if (auto annotated = view.annotate(docs, node)) { 365 result ~= annotated; 366 continue captures; 367 } 368 369 } 370 371 // Nothing relevant found, paste the documentation as-is 372 result ~= docs; 373 374 } 375 376 return result; 377 378 } 379 380 /// Load the module source from a file. 381 Frame moduleViewFile(Params...)(Params params, DlangCompiler compiler, string filename, 382 Theme contentTheme = fluidDefaultTheme) 383 do { 384 385 import std.file : readText; 386 387 return moduleViewSource!Params(params, compiler, filename.readText, contentTheme); 388 389 } 390 391 private struct ModuleView { 392 393 DlangCompiler compiler; 394 string source; 395 Theme contentTheme; 396 int unittestNumber; 397 398 /// Returns: 399 /// Space to represent the node in the output, or `null` if the given TSNode doesn't correspond to any known valid 400 /// symbol. 401 Space annotate(Space documentation, TSNode node) @trusted { 402 403 const typeC = ts_node_type(node); 404 const type = typeC[0 .. strlen(typeC)]; 405 406 const start = ts_node_start_byte(node); 407 const end = ts_node_end_byte(node); 408 const symbolSource = source[start .. end]; 409 410 switch (type) { 411 412 // unittest 413 case "unittest_declaration": 414 415 // Create the code block 416 auto input = dlangInput(); 417 418 // Find the surrounding context 419 const exampleStart = start + symbolSource.countUntil("{") + 1; 420 const exampleEnd = end - symbolSource.retro.countUntil("}") - 1; 421 422 // Set a constant mangle for the example by injecting it into source 423 const injectSource = Rope(q{ 424 pragma(mangle, "fluid_moduleView_entrypoint") 425 }); 426 427 const prefix = source[0 .. start] ~ injectSource ~ source[start .. exampleStart]; 428 const value = source[exampleStart .. exampleEnd]; 429 const suffix = Rope(source[exampleEnd .. $]); 430 431 // Append code editor to the result 432 documentation.children ~= exampleView(compiler, prefix, value, suffix, contentTheme); 433 return documentation; 434 435 // Declarations that aren't implemented 436 case "module_declaration": 437 case "import_declaration": 438 case "mixin_declaration": 439 case "variable_declaration": 440 case "auto_declaration": 441 case "alias_declaration": 442 case "attribute_declaration": 443 case "pragma_declaration": 444 case "struct_declaration": 445 case "union_declaration": 446 case "invariant_declaration": 447 case "class_declaration": 448 case "interface_declaration": 449 case "enum_declaration": 450 case "anonymous_enum_declaration": 451 case "function_declaration": 452 case "template_declaration": 453 case "mixin_template_declaration": 454 return documentation; 455 456 // Unknown declaration, skip 457 default: 458 return null; 459 460 } 461 462 } 463 464 } 465 466 /// Produce an example. 467 Frame exampleView(DlangCompiler compiler, CodeInput input, Theme contentTheme) { 468 469 auto stdoutLabel = label(.layout!"fill", ""); 470 auto resultCanvas = nodeSlot!Frame(.layout!"fill"); 471 472 /// Wrapper over bindbc.SharedLib to ensure proper library destruction. 473 struct SharedLib { 474 475 bindbc.SharedLib library; 476 alias library this; 477 478 ~this() { 479 clear(); 480 } 481 482 void clear() @trusted { 483 if (library != bindbc.invalidHandle) { 484 bindbc.unload(library); 485 } 486 } 487 488 } 489 490 auto library = new SharedLib; 491 auto originalValue = input.value; 492 493 /// Compile the program 494 void compileAndRun() { 495 496 string outputPath; 497 498 // Unload the previous library 499 library.clear(); 500 501 auto result = compiler.compileSharedLibrary(input.sourceValue.to!string, outputPath); 502 503 // Compiled successfully 504 if (result.status == 0) { 505 506 // Prepare the output 507 resultCanvas.value = vframe( 508 .layout!(1, "fill"), 509 contentTheme, 510 ); 511 resultCanvas.show(); 512 stdoutLabel.hide(); 513 514 // Run the snippet and output the results 515 mockRun((node) { 516 resultCanvas.value ~= node; 517 }); 518 scope (exit) mockRun(null); 519 520 // TODO Run the result on a separate thread to prevent the app from locking up 521 library.library = runSharedLibrary(outputPath); 522 resultCanvas.updateSize(); 523 524 } 525 526 // Write compiler output 527 else { 528 529 stdoutLabel.show(); 530 stdoutLabel.text = result.output; 531 resultCanvas.hide(); 532 533 } 534 535 } 536 537 /// Restore original source code. 538 void restoreSource() { 539 540 input.value = originalValue; 541 input.updateSize(); 542 543 } 544 545 // Compile and run the program; do the same when submitted 546 if (compiler) { 547 548 compileAndRun(); 549 input.submitted = &compileAndRun; 550 551 Frame root; 552 553 return root = hframe( 554 .layout!"fill", 555 vspace( 556 .layout!(1, "fill"), 557 hframe( 558 button("Run", () => compileAndRun()), 559 popupButton("Reset", 560 label("Reset original content? Changes\n" 561 ~ "will not be saved."), 562 hspace( 563 .layout!"end", 564 button("Cancel", delegate { 565 566 root.tree.focus = null; 567 568 }), 569 button("Reset content", delegate { 570 571 restoreSource(); 572 root.tree.focus = null; 573 574 }), 575 ), 576 ), 577 ), 578 input, 579 ), 580 vspace( 581 .layout!(1, "fill"), 582 resultCanvas, 583 stdoutLabel, 584 ), 585 ); 586 587 } 588 589 // Disable edits if there's no compiler available 590 else return hframe( 591 .layout!"fill", 592 input.disable(), 593 ); 594 595 } 596 597 /// ditto 598 Frame exampleView(DlangCompiler compiler, Rope prefix, string value, Rope suffix, Theme contentTheme) { 599 600 auto input = dlangInput(); 601 input.prefix = prefix ~ "\n"; 602 input.suffix = "\n" ~ suffix; 603 input.value = value 604 .outdent 605 .strip; 606 607 return exampleView(compiler, input, contentTheme); 608 609 } 610 611 /// Run a Fluid snippet from a shared library. 612 private bindbc.SharedLib runSharedLibrary(string path) @trusted { 613 614 // Load the resulting library 615 auto library = bindbc.load(path.toStringz); 616 617 // Failed to load 618 if (library == bindbc.invalidHandle) { 619 620 foreach (error; bindbc.errors) 621 printf("%s %s", error.error, error.message); 622 623 return library; 624 625 } 626 627 void function() entrypoint; 628 bindbc.bindSymbol(library, cast(void**) &entrypoint, "fluid_moduleView_entrypoint"); 629 entrypoint(); 630 631 return library; 632 633 } 634 635 /// Creates a `CodeInput` with D syntax highlighting. 636 CodeInput dlangInput(void delegate() @safe submitted = null) @trusted { 637 638 auto language = treeSitterLanguage!"d"; 639 auto highlighter = new TreeSitterHighlighter(language, dlangQuery); 640 641 return codeInput( 642 .layout!"fill", 643 highlighter, 644 submitted 645 ); 646 647 } 648 649 private Rope readDocs(string source, TSQueryCapture[] captures) @trusted { 650 651 import std.stdio : writefln; 652 653 const lineFeed = Rope("\n"); 654 655 Rope result; 656 657 // Load all comments 658 foreach (capture; captures) { 659 660 auto start = ts_node_start_byte(capture.node); 661 auto end = ts_node_end_byte(capture.node); 662 auto commentSource = source[start .. end]; 663 664 // TODO multiline comments 665 // Filter 666 if (!commentSource.skipOver("///")) continue; 667 668 result ~= Rope(Rope(commentSource), lineFeed); 669 670 } 671 672 return result; 673 674 } 675 676 private Space interpretDocs(Rope rope) { 677 678 import std.conv : to; 679 import fluid.typeface : Typeface; 680 681 const space = Rope(" "); 682 const lineFeed = Rope("\n"); 683 684 rope = rope.strip; 685 686 // Empty comment, omit 687 if (rope == "") return vspace(); 688 689 // Ditto comment, TODO 690 if (rope == "ditto") return vspace(); 691 692 // TODO DDoc 693 CodeInput lastCode; 694 auto lastParagraph = label(""); 695 auto result = vspace( 696 .layout!"fill", 697 lastParagraph 698 ); 699 700 string preformattedDelimiter; 701 702 // Read line by line 703 foreach (line; Typeface.lineSplitter(rope)) { 704 705 // Regular documentation line 706 if (preformattedDelimiter.empty) { 707 708 line = line.strip(); 709 710 // Start a new paragraph if the line is blank 711 if (line.empty) { 712 if (!lastParagraph.text.empty) 713 result ~= lastParagraph = label(""); 714 } 715 716 // Preformatted line 717 // TODO other delimiters 718 // TODO common space (prefix) 719 else if (line == "---") { 720 preformattedDelimiter = "---"; 721 result ~= lastCode = dlangInput().disable(); 722 } 723 724 // Append text to previous line 725 else { 726 lastParagraph.text ~= Rope(line, space); 727 } 728 729 } 730 731 // Preformatted fragments/code 732 else { 733 734 // Reached the other delimiter, turn preformatted lines off 735 if (line.strip == preformattedDelimiter) { 736 preformattedDelimiter = null; 737 result ~= lastParagraph = label(""); 738 lastCode.value = lastCode.value.to!string.outdent; 739 } 740 741 /// Append text to previous line 742 else { 743 lastCode.push(Rope(line, lineFeed)); 744 } 745 746 } 747 748 } 749 750 return result; 751 752 } 753 754 /// Outdent a rope. 755 /// 756 /// A saner wrapper over `std.string.outdent` that actually does what it should do. 757 private string outdent(string rope) { 758 759 import std.string : outdent; 760 761 return rope.splitLines.outdent.join("\n"); 762 763 }