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 }