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 }