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 /// At the present moment, interactive playground functionality is disabled on Windows, because of how troublesome it is 6 /// to get it to run. 7 /// 8 /// This module is not enabled, unless additonal dependencies, `fluid-tree-sitter` and `fluid-tree-sitter:d` are also 9 /// compiled in. 10 module fluid.module_view; 11 12 version (Fluid_ModuleView): 13 version (Have_fluid_tree_sitter): 14 version (Have_fluid_tree_sitter_d): 15 16 debug (Fluid_BuildMessages) { 17 pragma(msg, "Fluid: Including moduleView"); 18 } 19 20 import lib_tree_sitter; 21 import bindbc = bindbc.loader; 22 23 import std.conv; 24 import std.range; 25 import std.format; 26 import std.string; 27 import std.algorithm; 28 29 import fluid.node; 30 import fluid.slot; 31 import fluid.rope; 32 import fluid.label; 33 import fluid.space; 34 import fluid.style; 35 import fluid.frame; 36 import fluid.button; 37 import fluid.structs; 38 import fluid.popup_button; 39 import fluid.code_input; 40 import fluid.tree_sitter; 41 import fluid.default_theme; 42 43 // Disable playground functionality under Windows & macOS 44 // Hopefully this can be resolved soon 45 version (Windows) 46 version = Fluid_DisablePlayground; 47 version (OSX) 48 version = Fluid_DisablePlayground; 49 debug (Fluid_DisablePlayground) 50 version = Fluid_DisablePlayground; 51 52 debug (Fluid_BuildMessages) { 53 version (Fluid_DisablePlayground) 54 pragma(msg, "Fluid: moduleView: Disabling interactive playground"); 55 } 56 57 58 @safe: 59 60 61 private { 62 63 bool initialized; 64 TSQuery* documentationQuery; 65 TSQuery* dlangQuery; 66 67 } 68 69 static this() @system { 70 71 // Guard to keep this only running once 72 if (initialized) return; 73 initialized = true; 74 75 TSQueryError error; 76 uint errorOffset; 77 78 auto language = treeSitterLanguage!"d"; 79 auto query = q{ 80 ( 81 (comment)+ @comment 82 ) 83 }; 84 85 documentationQuery = ts_query_new(language, query.ptr, cast(uint) query.length, &errorOffset, &error); 86 assert(documentationQuery, format!"%s at offset %s"(error, errorOffset)); 87 88 dlangQuery = ts_query_new(language, dQuerySource.ptr, cast(uint) dQuerySource.length, &errorOffset, &error); 89 assert(dlangQuery, format!"%s at offset %s"(error, errorOffset)); 90 91 } 92 93 static ~this() @system { 94 95 // Guard to keep this only running once 96 if (!initialized) return; 97 initialized = false; 98 99 ts_query_delete(documentationQuery); 100 ts_query_delete(dlangQuery); 101 102 } 103 104 static immutable string[] fluidImportPaths; 105 static immutable string[] fluidExtraFlags; 106 static immutable string[] fluidExtraFlagsLDC; 107 108 shared static this() { 109 110 import std.path; 111 import std.process; 112 import std.file : thisExePath; 113 114 fluidImportPaths = [ 115 "source", 116 "../source", 117 environment.get("BINDBC_FREETYPE_PACKAGE_DIR").buildPath("source"), 118 environment.get("BINDBC_LOADER_PACKAGE_DIR").buildPath("source"), 119 ]; 120 version (Windows) { 121 fluidExtraFlags = [ 122 // scraped from LDC's linker output 123 "msvcrt.lib", 124 "vcruntime.lib", 125 "oldnames.lib", 126 127 "fluid.lib", 128 "freetype.lib", 129 "raylib.lib", 130 "-L/LIBPATH:" ~ thisExePath.dirName, 131 "-L/LIBPATH:" ~ environment.get("FLUID_LIBPATH", "."), 132 ]; 133 fluidExtraFlagsLDC = fluidExtraFlags ~ "--link-defaultlib-shared"; 134 } 135 else { 136 fluidExtraFlags = []; 137 fluidExtraFlagsLDC = [ 138 "-L-lfluid", 139 "-L-lfreetype", 140 "-L-L" ~ thisExePath.dirName, 141 "-L-L" ~ environment.get("FLUID_LIBPATH", "."), 142 ]; 143 } 144 } 145 146 /// Provides information about the companion D compiler to use for evaluating examples. 147 struct DlangCompiler { 148 149 import std.regex; 150 import std.process; 151 152 enum Type { 153 dmd, 154 ldc, 155 } 156 157 /// Type (vendor) of the compiler. Either DMD or LDC. 158 Type type; 159 160 /// Executable name or path to the compiler executable. If null, no compiler is available. 161 string executable; 162 163 /// DMD frontend version of the compiler. Major version is assumed to be 2. 164 int frontendMinor; 165 166 /// ditto 167 int frontendPatch; 168 169 /// Import paths to pass to the compiler. 170 const(string)[] importPaths; 171 172 /// ditto 173 enum frontendMajor = 2; 174 175 /// Returns true if this entry points to a valid compiler. 176 bool opCast(T : bool)() const { 177 178 return executable !is null 179 && frontendMinor != 0; 180 181 } 182 183 /// Find any suitable in the system. 184 static DlangCompiler findAny() { 185 186 if (auto explicitD = environment.get("DC", null)) { 187 188 if (explicitD.canFind("dmd")) 189 return findDMD(); 190 else 191 return findLDC(); 192 193 } 194 195 return either( 196 findDMD, 197 findLDC, 198 ); 199 200 } 201 202 /// Find DMD in the system. 203 static DlangCompiler findDMD() { 204 205 version (Fluid_DisablePlayground) { 206 return DlangCompiler.init; 207 } 208 else { 209 210 // According to run.dlang.io, this pattern has been used since at least 2.068.2 211 auto pattern = regex(r"D Compiler v2.(\d+).(\d+)"); 212 auto explicitDMD = std.process.environment.get("DMD"); 213 auto candidates = explicitDMD.empty 214 ? ["dmd"] 215 : [explicitDMD]; 216 217 // Test the executables 218 foreach (name; candidates) { 219 220 try { 221 222 auto process = execute([name, "--version"]); 223 224 // Found a compatible compiler 225 if (auto match = process.output.matchFirst(pattern)) { 226 227 return DlangCompiler(Type.dmd, name, match[1].to!int, match[2].to!int, fluidImportPaths); 228 229 } 230 231 } 232 233 catch (ProcessException) { 234 continue; 235 } 236 237 } 238 239 return DlangCompiler.init; 240 241 } 242 243 } 244 245 /// Find LDC in the system. 246 static DlangCompiler findLDC() { 247 248 version (Fluid_DisablePlayground) { 249 return DlangCompiler.init; 250 } 251 else { 252 253 // This pattern appears to be stable as, according to the blame, hasn't changed in at least 5 years 254 auto pattern = regex(r"based on DMD v2\.(\d+)\.(\d+)"); 255 auto explicitLDC = std.process.environment.get("LDC"); 256 auto candidates = explicitLDC.empty 257 ? ["ldc2", "ldc"] 258 : [explicitLDC]; 259 260 // Test the executables 261 foreach (name; candidates) { 262 263 try { 264 265 auto process = execute([name, "--version"]); 266 267 // Found a compatible compiler 268 if (auto match = process.output.matchFirst(pattern)) { 269 270 return DlangCompiler(Type.ldc, name, match[1].to!int, match[2].to!int, fluidImportPaths); 271 272 } 273 274 } 275 276 catch (ProcessException) { 277 continue; 278 } 279 280 } 281 282 return DlangCompiler.init; 283 284 } 285 286 } 287 288 unittest { 289 290 import std.stdio; 291 292 auto dmd = findDMD(); 293 auto ldc = findLDC(); 294 295 // Output search results 296 // Correctness of these tests has to be verified by the CI runner script or the programmer 297 if (dmd) { 298 writefln!"Found DMD (%s) 2.%s.%s"(dmd.executable, dmd.frontendMinor, dmd.frontendPatch); 299 assert(dmd.type == Type.dmd); 300 } 301 else 302 writefln!"DMD wasn't found"; 303 304 if (ldc) { 305 writefln!"Found LDC (%s) compatible with DMD 2.%s.%s"(ldc.executable, ldc.frontendMinor, ldc.frontendPatch); 306 assert(ldc.type == Type.ldc); 307 } 308 else 309 writefln!"LDC wasn't found"; 310 311 // Leading zeros have to be ignored 312 assert("068".to!int == 68); 313 314 // Compare results of the compiler-agnostic and compiler-specific functions 315 if (auto compiler = findAny()) { 316 317 final switch (compiler.type) { 318 319 case Type.dmd: 320 assert(dmd); 321 break; 322 323 case Type.ldc: 324 assert(ldc); 325 break; 326 327 } 328 329 } 330 331 // No compiler found 332 else { 333 334 assert(!dmd); 335 assert(!ldc); 336 337 } 338 339 } 340 341 /// Get the flag for importing from given directory. 342 string importFlag(string directory) const { 343 344 return "-I" ~ directory; 345 346 } 347 348 /// Get the flag for adding all import directories specified in compiler config. 349 string[] importPathsFlag() const { 350 351 return importPaths 352 .map!(a => importFlag(a)) 353 .array; 354 355 } 356 357 /// Get the flag to generate a debug binary for the current compiler. 358 const(string)[] debugFlags() const { 359 360 static immutable flags = ["-g", "-debug"]; 361 362 final switch (type) { 363 364 case Type.dmd: return flags[]; 365 case Type.ldc: return flags[0..1]; 366 367 } 368 369 } 370 371 /// Get the flag to generate a shared library for the given compiler. 372 string sharedLibraryFlag() const { 373 374 final switch (type) { 375 376 case Type.dmd: return "-shared"; 377 case Type.ldc: return "--shared"; 378 379 } 380 381 } 382 383 /// Get the flag to include unittests. 384 string unittestFlag() const { 385 386 final switch (type) { 387 388 case Type.dmd: return "-unittest"; 389 case Type.ldc: return "--unittest"; 390 391 } 392 393 } 394 395 /// Extra, platform-specific flags needed to build a shared library. 396 const(string)[] extraFlags() const { 397 398 final switch (type) { 399 400 case Type.dmd: return fluidExtraFlags; 401 case Type.ldc: return fluidExtraFlagsLDC; 402 403 } 404 405 } 406 407 /// Compile a shared library from given source file. 408 /// 409 /// TODO make this async 410 auto compileSharedLibrary(string source, out string outputPath) const 411 in (this) 412 do { 413 414 import fs = std.file; 415 import random = std.random; 416 import std.path : buildPath, setExtension; 417 418 static string path; 419 420 // Build a path to contain the program's source 421 if (!path) 422 path = fs.tempDir.buildPath("fluid_" ~ random.uniform!uint.to!string ~ ".d"); 423 424 // Write the source 425 fs.write(path, source); 426 427 // Find the correct extension for the system 428 version (Windows) 429 outputPath = path.setExtension(".dll"); 430 else version (OSX) 431 outputPath = path.setExtension(".dylib"); 432 else 433 outputPath = path.setExtension(".so"); 434 435 auto cmdline = [executable, sharedLibraryFlag, unittestFlag, path, "-of=" ~ outputPath] 436 ~ debugFlags 437 ~ importPathsFlag 438 ~ extraFlags; 439 440 debug (Fluid_BuildMessages) { 441 import std.stdio; 442 writefln!"Fluid: compiling $ %s"(escapeShellCommand(cmdline)); 443 } 444 445 // Compile the program 446 auto result = execute(cmdline); 447 448 debug (Fluid_BuildMessages) { 449 import std.stdio; 450 if (result.status == 0) 451 writefln!"Fluid: Compilation succeeded."; 452 else 453 writefln!"Fluid: Compilation failed.\n%s"(result.output.stripRight); 454 } 455 456 return result; 457 458 } 459 460 } 461 462 /// Create an overview display of the given module. 463 Frame moduleViewSource(Params...)(Params params, DlangCompiler compiler, string source, 464 Theme contentTheme = fluidDefaultTheme) @trusted 465 do { 466 467 auto language = treeSitterLanguage!"d"; 468 auto parser = ts_parser_new(); 469 scope (exit) ts_parser_delete(parser); 470 471 ts_parser_set_language(parser, language); 472 473 // Parse the source 474 auto tree = ts_parser_parse_string(parser, null, source.ptr, cast(uint) source.length); 475 scope (exit) ts_tree_delete(tree); 476 auto root = ts_tree_root_node(tree); 477 auto cursor = ts_query_cursor_new(); 478 scope (exit) ts_query_cursor_delete(cursor); 479 480 auto view = ModuleView(compiler, source, contentTheme); 481 auto result = vframe(params); 482 483 if (compiler == compiler.init) { 484 485 version (Fluid_DisablePlayground) { 486 auto message = [ 487 label("Warning: Interactive playground is disabled on this platform. See issue #182 for more details."), 488 button("Open #182 in browser", delegate { 489 import fluid.utils; 490 openURL("https://git.samerion.com/Samerion/Fluid/issues/182"); 491 }), 492 ]; 493 } 494 else { 495 auto message = [ 496 label("Warning: No suitable D compiler could be found; interactive playground is disabled.") 497 ]; 498 } 499 500 result ~= vframe( 501 .layout!"fill", 502 .tags!(FluidTag.warning), 503 message, 504 ); 505 } 506 507 // Perform a query to find possibly relevant comments 508 ts_query_cursor_exec(cursor, documentationQuery, root); 509 TSQueryMatch match; 510 captures: while (ts_query_cursor_next_match(cursor, &match)) { 511 512 auto captures = match.captures[0 .. match.capture_count]; 513 auto node = captures[$-1].node; 514 515 // Load the comment 516 auto docs = readDocs(source, captures) 517 .interpretDocs; 518 519 // Find the symbol the comment is attached to 520 while (true) { 521 522 node = ts_node_next_named_sibling(node); 523 if (ts_node_is_null(node)) break; 524 525 // Once found, annotate and append to result 526 if (auto annotated = view.annotate(docs, node)) { 527 result ~= annotated; 528 continue captures; 529 } 530 531 } 532 533 // Nothing relevant found, paste the documentation as-is 534 result ~= docs; 535 536 } 537 538 return result; 539 540 } 541 542 /// Load the module source from a file. 543 Frame moduleViewFile(Params...)(Params params, DlangCompiler compiler, string filename, 544 Theme contentTheme = fluidDefaultTheme) 545 do { 546 547 import std.file : readText; 548 549 return moduleViewSource!Params(params, compiler, filename.readText, contentTheme); 550 551 } 552 553 private struct ModuleView { 554 555 DlangCompiler compiler; 556 string source; 557 Theme contentTheme; 558 int unittestNumber; 559 560 /// Returns: 561 /// Space to represent the node in the output, or `null` if the given TSNode doesn't correspond to any known valid 562 /// symbol. 563 Space annotate(Space documentation, TSNode node) @trusted { 564 565 const typeC = ts_node_type(node); 566 const type = typeC[0 .. strlen(typeC)]; 567 568 const start = ts_node_start_byte(node); 569 const end = ts_node_end_byte(node); 570 const symbolSource = source[start .. end]; 571 572 switch (type) { 573 574 // unittest 575 case "unittest_declaration": 576 577 // Create the code block 578 auto input = dlangInput(); 579 580 // Find the surrounding context 581 const exampleStart = start + symbolSource.countUntil("{") + 1; 582 const exampleEnd = end - symbolSource.retro.countUntil("}") - 1; 583 584 // Set a constant mangle for the example by injecting it into source 585 const injectSource = Rope(q{ 586 pragma(mangle, "fluid_moduleView_entrypoint") 587 }); 588 589 const prefix = source[0 .. start] ~ injectSource ~ source[start .. exampleStart]; 590 const value = source[exampleStart .. exampleEnd]; 591 const suffix = Rope(source[exampleEnd .. $]); 592 593 // Append code editor to the result 594 documentation.children ~= exampleView(compiler, prefix, value, suffix, contentTheme); 595 return documentation; 596 597 // Declarations that aren't implemented 598 case "module_declaration": 599 case "import_declaration": 600 case "mixin_declaration": 601 case "variable_declaration": 602 case "auto_declaration": 603 case "alias_declaration": 604 case "attribute_declaration": 605 case "pragma_declaration": 606 case "struct_declaration": 607 case "union_declaration": 608 case "invariant_declaration": 609 case "class_declaration": 610 case "interface_declaration": 611 case "enum_declaration": 612 case "anonymous_enum_declaration": 613 case "function_declaration": 614 case "template_declaration": 615 case "mixin_template_declaration": 616 return documentation; 617 618 // Unknown declaration, skip 619 default: 620 return null; 621 622 } 623 624 } 625 626 } 627 628 /// Produce an example. 629 Frame exampleView(DlangCompiler compiler, CodeInput input, Theme contentTheme) { 630 631 auto stdoutLabel = label(.layout!"fill", ""); 632 auto resultCanvas = nodeSlot!Frame(.layout!"fill"); 633 634 /// Wrapper over bindbc.SharedLib to ensure proper library destruction. 635 struct SharedLib { 636 637 bindbc.SharedLib library; 638 alias library this; 639 640 // BUG this crashes the tour on exit 641 // without this, memory is leaked 642 version (none) 643 ~this() { 644 clear(); 645 } 646 647 void clear() @trusted { 648 if (library != bindbc.invalidHandle) { 649 bindbc.unload(library); 650 } 651 } 652 653 } 654 655 auto library = new SharedLib; 656 auto originalValue = input.value; 657 658 /// Compile the program 659 void compileAndRun() @trusted { 660 661 string outputPath; 662 663 // Unload the previous library 664 library.clear(); 665 666 auto result = compiler.compileSharedLibrary(input.sourceValue.to!string, outputPath); 667 668 // Compiled successfully 669 if (result.status == 0) { 670 671 // Prepare the output 672 resultCanvas.value = vframe( 673 .layout!(1, "fill"), 674 contentTheme, 675 ); 676 resultCanvas.show(); 677 stdoutLabel.hide(); 678 679 // Run the snippet and output the results 680 mockRun((node) { 681 resultCanvas.value ~= node; 682 }); 683 scope (exit) mockRun(null); 684 685 // TODO Run the result on a separate thread to prevent the app from locking up 686 library.library = runSharedLibrary(outputPath); 687 resultCanvas.updateSize(); 688 689 } 690 691 // Write compiler output 692 else { 693 694 stdoutLabel.show(); 695 stdoutLabel.text = result.output; 696 resultCanvas.hide(); 697 698 } 699 700 } 701 702 /// Restore original source code. 703 void restoreSource() { 704 705 input.value = originalValue; 706 input.updateSize(); 707 708 } 709 710 // Compile and run the program; do the same when submitted 711 if (compiler) { 712 713 compileAndRun(); 714 input.submitted = &compileAndRun; 715 716 Frame root; 717 718 return root = hframe( 719 .layout!"fill", 720 vspace( 721 .layout!(1, "fill"), 722 hframe( 723 button("Run", () => compileAndRun()), 724 popupButton("Reset", 725 label("Reset original content? Changes\n" 726 ~ "will not be saved."), 727 hspace( 728 .layout!"end", 729 button("Cancel", delegate { 730 731 root.tree.focus = null; 732 733 }), 734 button("Reset content", delegate { 735 736 restoreSource(); 737 root.tree.focus = null; 738 739 }), 740 ), 741 ), 742 ), 743 input, 744 ), 745 vspace( 746 .layout!(1, "fill"), 747 resultCanvas, 748 stdoutLabel, 749 ), 750 ); 751 752 } 753 754 // Disable edits if there's no compiler available 755 else return hframe( 756 .layout!"fill", 757 input.disable(), 758 ); 759 760 } 761 762 /// ditto 763 Frame exampleView(DlangCompiler compiler, Rope prefix, string value, Rope suffix, Theme contentTheme) { 764 765 auto input = dlangInput(); 766 input.prefix = prefix ~ "\n"; 767 input.suffix = "\n" ~ suffix; 768 input.value = value 769 .outdent 770 .strip; 771 772 return exampleView(compiler, input, contentTheme); 773 774 } 775 776 /// Run a Fluid snippet from a shared library. 777 private bindbc.SharedLib runSharedLibrary(string path) @system { 778 779 import core.stdc.stdio; 780 781 // Load the resulting library 782 auto library = bindbc.load(path.toStringz); 783 784 // Failed to load 785 if (library == bindbc.invalidHandle) { 786 787 foreach (error; bindbc.errors) 788 fprintf(stderr, "%s %s\n", error.error, error.message); 789 fflush(stderr); 790 bindbc.resetErrors(); 791 792 return library; 793 794 } 795 796 void function() entrypoint; 797 bindbc.bindSymbol(library, cast(void**) &entrypoint, "fluid_moduleView_entrypoint"); 798 entrypoint(); 799 800 return library; 801 802 } 803 804 @system unittest { 805 806 import std.path; 807 808 auto source = q{ 809 import fluid; 810 811 pragma(mangle, "fluid_moduleView_entrypoint") 812 void entrypoint() { 813 run(label("Hello, World!")); 814 } 815 }; 816 817 // Configure the compiler 818 auto compiler = DlangCompiler.findAny(); 819 820 version (Fluid_DisablePlayground) { 821 assert(compiler == DlangCompiler.init); 822 } 823 824 else { 825 826 // Compile the pogram 827 string outputPath; 828 auto program = compiler.compileSharedLibrary(source, outputPath); 829 assert(program.status == 0, program.output); 830 831 // Prepare the environment 832 Node output; 833 mockRun = delegate (node) { 834 output = node; 835 }; 836 scope (exit) mockRun = null; 837 838 // Make sure it could be loaded 839 auto library = runSharedLibrary(outputPath); 840 assert(library != bindbc.invalidHandle); 841 scope (exit) bindbc.unload(library); 842 843 // And test the output 844 auto result = cast(Label) output; 845 assert(result.text == "Hello, World!"); 846 847 } 848 849 } 850 851 /// Creates a `CodeInput` with D syntax highlighting. 852 CodeInput dlangInput(void delegate() @safe submitted = null) @trusted { 853 854 auto language = treeSitterLanguage!"d"; 855 auto highlighter = new TreeSitterHighlighter(language, dlangQuery); 856 857 return codeInput( 858 .layout!(1, "fill"), 859 highlighter, 860 submitted 861 ); 862 863 } 864 865 private Rope readDocs(string source, TSQueryCapture[] captures) @trusted { 866 867 import std.stdio : writefln; 868 869 const lineFeed = Rope("\n"); 870 871 Rope result; 872 873 // Load all comments 874 foreach (capture; captures) { 875 876 auto start = ts_node_start_byte(capture.node); 877 auto end = ts_node_end_byte(capture.node); 878 auto commentSource = source[start .. end]; 879 880 // TODO multiline comments 881 // Filter 882 if (!commentSource.skipOver("///")) continue; 883 884 result ~= Rope(Rope(commentSource), lineFeed); 885 886 } 887 888 return result; 889 890 } 891 892 private Space interpretDocs(Rope rope) { 893 894 import std.conv : to; 895 import fluid.typeface : Typeface; 896 897 const space = Rope(" "); 898 const lineFeed = Rope("\n"); 899 900 rope = rope.strip; 901 902 // Empty comment, omit 903 if (rope == "") return vspace(); 904 905 // Ditto comment, TODO 906 if (rope == "ditto") return vspace(); 907 908 // TODO DDoc 909 CodeInput lastCode; 910 auto lastParagraph = label(""); 911 auto result = vspace( 912 .layout!"fill", 913 lastParagraph 914 ); 915 916 string preformattedDelimiter; 917 918 // Read line by line 919 foreach (line; Typeface.lineSplitter(rope)) { 920 921 // Regular documentation line 922 if (preformattedDelimiter.empty) { 923 924 line = line.strip(); 925 926 // Start a new paragraph if the line is blank 927 if (line.empty) { 928 if (!lastParagraph.text.empty) 929 result ~= lastParagraph = label(""); 930 } 931 932 // Preformatted line 933 // TODO other delimiters 934 // TODO common space (prefix) 935 else if (line == "---") { 936 preformattedDelimiter = "---"; 937 result ~= lastCode = dlangInput().disable(); 938 } 939 940 // Append text to previous line 941 else { 942 lastParagraph.text ~= Rope(line, space); 943 } 944 945 } 946 947 // Preformatted fragments/code 948 else { 949 950 // Reached the other delimiter, turn preformatted lines off 951 if (line.strip == preformattedDelimiter) { 952 preformattedDelimiter = null; 953 result ~= lastParagraph = label(""); 954 lastCode.value = lastCode.value.to!string.outdent; 955 } 956 957 /// Append text to previous line 958 else { 959 lastCode.push(Rope(line, lineFeed)); 960 } 961 962 } 963 964 } 965 966 return result; 967 968 } 969 970 /// Outdent a rope. 971 /// 972 /// A saner wrapper over `std.string.outdent` that actually does what it should do. 973 private string outdent(string rope) { 974 975 import std.string : outdent; 976 977 return rope.splitLines.outdent.join("\n"); 978 979 }