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