1 module fluid.code_input;
2 
3 import std.range;
4 import std.string;
5 import std.algorithm;
6 
7 import fluid.text;
8 import fluid.input;
9 import fluid.utils;
10 import fluid.style;
11 import fluid.backend;
12 import fluid.text_input;
13 
14 
15 @safe:
16 
17 
18 /// Node parameter for `CodeInput` enabling tabs as the character used for indents.
19 /// Params:
20 ///     width = Indent width; number of character a single tab corresponds to. Optional, if set to 0 or left default,
21 ///         keeps the old/default value.
22 auto useTabs(int width = 0) {
23 
24     struct UseTabs {
25 
26         int width;
27 
28         void apply(CodeInput node) {
29             node.useTabs = true;
30             if (width)
31                 node.indentWidth = width;
32         }
33 
34     }
35 
36     return UseTabs(width);
37 
38 }
39 
40 /// Node parameter for `CodeInput`, setting spaces as the character used for indents.
41 /// Params:
42 ///     width = Indent width; number of spaces a single indent consists of.
43 auto useSpaces(int width) {
44 
45     struct UseSpaces {
46 
47         int width;
48 
49         void apply(CodeInput node) {
50             node.useTabs = false;
51             node.indentWidth = width;
52         }
53 
54     }
55 
56     return UseSpaces(width);
57 
58 }
59 
60 
61 /// A CodeInput is a special variant of `TextInput` that provides syntax highlighting and a gutter (column with line
62 /// numbers).
63 alias codeInput = simpleConstructor!CodeInput;
64 
65 /// ditto
66 class CodeInput : TextInput {
67 
68     mixin enableInputActions;
69 
70     enum maxIndentWidth = 16;
71 
72     public {
73 
74         CodeHighlighter highlighter;
75         CodeIndentor indentor;
76 
77         /// Additional context to pass to the highlighter. Will not be displayed, but can be used to improve syntax
78         /// highlighting and code analysis.
79         Rope prefix;
80 
81         /// ditto
82         Rope suffix;
83 
84         /// Character width of a single indent level.
85         int indentWidth = 4;
86         invariant(indentWidth <= maxIndentWidth);
87 
88         /// If true, uses the tab character for indents.
89         bool useTabs = false;
90 
91     }
92 
93     public {
94 
95         /// Current token type, used for styling individual token types and **only relevant in themes**.
96         const(char)[] token;
97 
98     }
99 
100     private {
101 
102         struct AutomaticFormat {
103 
104             bool pending;
105             int oldTargetIndent;
106 
107             this(int oldTargetIndent) {
108                 this.pending = true;
109                 this.oldTargetIndent = oldTargetIndent;
110             }
111 
112         }
113 
114         /// If automatic reformatting is to take place, `pending` is set to true, with `oldTargetIndent` set to the
115         /// previous value of the indent. This value is compared against the current target, and the reformatter will
116         /// only activate if there was a change.
117         AutomaticFormat _automaticFormat;
118 
119     }
120 
121     this(CodeHighlighter highlighter = null, void delegate() @safe submitted = null) {
122 
123         this.submitted = submitted;
124         this.highlighter = highlighter;
125         this.indentor = cast(CodeIndentor) highlighter;
126         super.contentLabel = new ContentLabel;
127 
128     }
129 
130     inout(ContentLabel) contentLabel() inout {
131 
132         return cast(inout ContentLabel) super.contentLabel;
133 
134     }
135 
136     override bool multiline() const {
137 
138         return true;
139 
140     }
141 
142     class ContentLabel : TextInput.ContentLabel {
143 
144         /// Use our own `Text`.
145         StyledText!CodeHighlighterRange text;
146         Style[256] styles;
147 
148         this() {
149             text = typeof(text)(this, "", CodeHighlighterRange.init);
150             text.hasFastEdits = true;
151         }
152 
153         override void resizeImpl(Vector2 available) {
154 
155             assert(text.hasFastEdits);
156 
157             use(canvasIO);
158 
159             auto typeface = style.getTypeface;
160             typeface.setSize(io.dpi, style.fontSize);
161 
162             this.text.value = super.text.value;
163             text.indentWidth = indentWidth * typeface.advance(' ').x / io.hidpiScale.x;
164             text.resize(canvasIO, available);
165             minSize = text.size;
166 
167         }
168 
169         override void drawImpl(Rectangle outer, Rectangle inner) {
170 
171             const style = pickStyle();
172             text.draw(canvasIO, styles, inner.start);
173 
174         }
175 
176     }
177 
178     /// Get the full value of the text, including context provided via `prefix` and `suffix`.
179     Rope sourceValue() const {
180 
181         // TODO This will allocate. Can it be avoided?
182         return prefix ~ value ~ suffix;
183 
184     }
185 
186     /// Get a rope representing given indent level.
187     Rope indentRope(int indentLevel = 1) const {
188 
189         static tabRope = const Rope("\t");
190         static spaceRope = const Rope("                ");
191 
192         static assert(spaceRope.length == maxIndentWidth);
193 
194         Rope result;
195 
196         // TODO this could be more performant by using as much of a single rope as possible
197 
198         // Insert a tab
199         if (useTabs)
200             foreach (i; 0 .. indentLevel) {
201 
202                 result ~= tabRope;
203 
204             }
205 
206         // Insert a space
207         else foreach (i; 0 .. indentLevel) {
208 
209             result ~= spaceRope[0 .. indentWidth];
210 
211         }
212 
213         return result;
214 
215     }
216 
217     /// `indentRope` outputs tabs if .useTabs is set.
218     @("CodeInput.indentRope outputs tabs")
219     unittest {
220 
221         auto root = codeInput(.useTabs);
222 
223         assert(root.indentRope == "\t");
224         assert(root.indentRope(2) == "\t\t");
225         assert(root.indentRope(3) == "\t\t\t");
226 
227     }
228 
229     /// `indentRope` outputs series of spaces if spaces are used for indents. This is the default behavior.
230     @("CodeInput.indentRope outputs spaces")
231     unittest {
232 
233         auto root = codeInput();
234 
235         assert(root.indentRope == "    ");
236         assert(root.indentRope(2) == "        ");
237         assert(root.indentRope(3) == "            ");
238 
239     }
240 
241     protected void reparse() {
242 
243         const fullValue = sourceValue;
244 
245         // Parse the file
246         if (highlighter) {
247 
248             highlighter.parse(fullValue);
249 
250             // Apply highlighting to the label
251             contentLabel.text.styleMap = highlighter.save(cast(int) prefix.length);
252 
253         }
254 
255         // Pass the file to the indentor
256         if (indentor && cast(Object) indentor !is cast(Object) highlighter) {
257 
258             indentor.parse(fullValue);
259 
260         }
261 
262     }
263 
264     override void resizeImpl(Vector2 vector) @trusted {
265 
266         // Parse changes
267         reparse();
268 
269         // Reformat the line if requested
270         if (_automaticFormat.pending) {
271 
272             const oldTarget = _automaticFormat.oldTargetIndent;
273             const newTarget = targetIndentLevelByIndex(caretIndex);
274 
275             // Reformat only if the target indent changed; don't force "correct" indents on the programmer
276             if (oldTarget != newTarget)
277                 reformatLine();
278 
279             _automaticFormat.pending = false;
280 
281         }
282 
283         // Resize the field
284         super.resizeImpl(vector);
285 
286     }
287 
288     override void drawImpl(Rectangle outer, Rectangle inner) {
289 
290         // Reload token styles
291         contentLabel.styles[0] = pickStyle();
292 
293         if (highlighter) {
294 
295             CodeToken tokenIndex;
296             while (++tokenIndex) {
297 
298                 token = highlighter.nextTokenName(tokenIndex);
299 
300                 if (token is null) break;
301 
302                 contentLabel.styles[tokenIndex] = pickStyle();
303 
304             }
305 
306         }
307 
308         super.drawImpl(outer, inner);
309 
310     }
311 
312     protected override bool keyboardImpl() {
313 
314         auto oldValue = this.value;
315         auto format = AutomaticFormat(targetIndentLevelByIndex(caretIndex));
316 
317         auto keyboardHandled = super.keyboardImpl();
318 
319         // If the value has changed, trigger automatic reformatting
320         if (oldValue !is this.value)
321             _automaticFormat = format;
322 
323         return keyboardHandled;
324 
325     }
326 
327     protected override bool inputActionImpl(InputActionID id, bool active) {
328 
329         // Request format
330         if (active)
331             _automaticFormat = AutomaticFormat(targetIndentLevelByIndex(caretIndex));
332 
333         return false;
334 
335     }
336 
337     /// Returns the index of the first character in a line that is not a space, given index of any character on
338     /// the same line.
339     size_t lineHomeByIndex(size_t index) {
340 
341         const indentWidth = lineByIndex(index)
342             .until!(a => !a.among(' ', '\t'))
343             .walkLength;
344 
345         return lineStartByIndex(index) + indentWidth;
346 
347     }
348 
349     /// Get the column the given index (or caret index) is at, but count tabs as however characters they display as.
350     ptrdiff_t visualColumn(size_t i) {
351 
352         // Select characters on the same before the given index
353         auto indents = lineByIndex(i)[0 .. column!char(i)];
354 
355         return foldIndents(indents);
356 
357     }
358 
359     /// ditto
360     ptrdiff_t visualColumn() {
361 
362         return visualColumn(caretIndex);
363 
364     }
365 
366     /// Get indent count for offset at given index.
367     int indentLevelByIndex(size_t i) {
368 
369         // Select indents on the given line
370         auto indents = lineByIndex(i).byDchar
371             .until!(a => !a.among(' ', '\t'));
372 
373         return cast(int) foldIndents(indents) / indentWidth;
374 
375     }
376 
377     /// Count width of the given text, counting tabs using their visual size, while other characters are of width of 1
378     private auto foldIndents(T)(T input) {
379 
380         return input.fold!(
381             (a, c) => c == '\t'
382                 ? a + indentWidth - (a % indentWidth)
383                 : a + 1)(0);
384 
385     }
386 
387     /// Get suitable indent size for the line at given index, according to information from `indentor`.
388     int targetIndentLevelByIndex(size_t i) {
389 
390         const lineStart = lineStartByIndex(i);
391 
392         // Find the previous line so it can be used as reference.
393         // For the first line, `0` is used.
394         const untilPreviousLine = value[0..lineStart].chomp;
395         const previousLineIndent = lineStart == 0
396             ? 0
397             : indentLevelByIndex(untilPreviousLine.length);
398 
399         // Use the indentor if available
400         if (indentor) {
401 
402             const indentEnd = lineHomeByIndex(i);
403 
404             return max(0, previousLineIndent + indentor.indentDifference(indentEnd + prefix.length));
405 
406         }
407 
408         // Perform basic autoindenting if indentor is not available; keep the same indent at all time
409         else return indentLevelByIndex(i);
410 
411     }
412 
413     @(FluidInputAction.insertTab)
414     void insertTab() {
415 
416         // Indent selection
417         if (isSelecting) indent();
418 
419         // Insert a tab character
420         else if (useTabs) {
421 
422             push('\t');
423 
424         }
425 
426         // Align to tab
427         else {
428 
429             char[maxIndentWidth] insertTab = ' ';
430 
431             const newSpace = indentWidth - (column!dchar % indentWidth);
432 
433             push(insertTab[0 .. newSpace]);
434 
435         }
436 
437     }
438 
439     @(FluidInputAction.indent)
440     void indent() {
441 
442         indent(1);
443 
444     }
445 
446     void indent(int indentCount, bool includeEmptyLines = false) {
447 
448         // Write an undo/redo history entry
449         auto shot = snapshot();
450         scope (success) pushSnapshot(shot);
451 
452         // Indent every selected line
453         foreach (ref line; eachSelectedLine) {
454 
455             // Skip empty lines
456             if (!includeEmptyLines && line == "") continue;
457 
458             // Prepend the indent
459             line = indentRope(indentCount) ~ line;
460 
461         }
462 
463     }
464 
465     @(FluidInputAction.outdent)
466     void outdent() {
467 
468         outdent(1);
469 
470     }
471 
472     void outdent(int i) {
473 
474         // Write an undo/redo history entry
475         auto shot = snapshot();
476         scope (success) pushSnapshot(shot);
477 
478         // Outdent every selected line
479         foreach (ref line; eachSelectedLine) {
480 
481             // Do it for each indent
482             foreach (j; 0..i) {
483 
484                 const leadingWidth = line.take(indentWidth)
485                     .until!(a => !a.among(' ', '\t'))
486                     .until("\t", No.openRight)
487                     .walkLength;
488 
489                 // Remove the tab
490                 line = line[leadingWidth .. $];
491 
492             }
493 
494         }
495 
496     }
497 
498     override void chop(bool forward = false) {
499 
500         // Make it possible to backspace space-based indents
501         if (!forward && !isSelecting) {
502 
503             const lineStart = lineStartByIndex(caretIndex);
504             const lineHome = lineHomeByIndex(caretIndex);
505             const isIndent = caretIndex > lineStart && caretIndex <= lineHome;
506 
507             // This is an indent
508             if (isIndent) {
509 
510                 const line = caretLine;
511                 const col = column!char;
512                 const tabWidth = either(visualColumn % indentWidth, indentWidth);
513                 const tabStart = max(0, col - tabWidth);
514                 const allSpaces = line[tabStart .. col].all!(a => a == ' ');
515 
516                 // Remove spaces as if they were tabs
517                 if (allSpaces) {
518 
519                     const oldCaretIndex = caretIndex;
520 
521                     // Write an undo/redo history entry
522                     auto shot = snapshot();
523                     scope (success) pushSnapshot(shot);
524 
525                     caretLine = line[0 .. tabStart] ~ line[col .. $];
526                     caretIndex = oldCaretIndex - tabWidth;
527 
528                     return;
529 
530                 }
531 
532             }
533 
534         }
535 
536         super.chop(forward);
537 
538     }
539 
540     @(FluidInputAction.breakLine)
541     override bool breakLine() {
542 
543         const currentIndent = indentLevelByIndex(caretIndex);
544 
545         // Break the line
546         if (super.breakLine()) {
547 
548             // Copy indent from the previous line
549             // Enable continuous input to merge the indent with the line break in the history
550             _isContinuous = true;
551             push(indentRope(currentIndent));
552             reparse();
553 
554             // Ask the autoindentor to complete the job
555             reformatLine();
556             _isContinuous = false;
557 
558             return true;
559 
560         }
561 
562         return false;
563 
564     }
565 
566     /// Reformat a line by index of any character it contains.
567     void reformatLineByIndex(size_t index) {
568 
569         import std.math;
570 
571         // TODO Implement reformatLine for selections
572         if (isSelecting) return;
573 
574         const newIndentLevel = targetIndentLevelByIndex(index);
575 
576         const line = lineByIndex(index);
577         const lineStart = lineStartByIndex(index);
578         const lineHome = lineHomeByIndex(index);
579         const lineEnd = lineEndByIndex(index);
580         const newIndent = indentRope(newIndentLevel);
581         const oldIndentLength = lineHome - lineStart;
582 
583         // Ignore if indent is the same
584         if (newIndent.length == oldIndentLength) return;
585 
586         const oldCaretIndex = caretIndex;
587         const newLine = newIndent ~ line[oldIndentLength .. $];
588 
589         // Write the new indent, replacing the old one
590         lineByIndex(index, newLine);
591 
592         // Update caret index
593         if (oldCaretIndex >= lineStart && oldCaretIndex <= lineEnd)
594             caretIndex = clamp(oldCaretIndex + newIndent.length - oldIndentLength,
595                 lineStart + newIndent.length,
596                 lineStart + newLine.length);
597 
598         // Parse again
599         reparse();
600 
601     }
602 
603     /// Reformat the current line.
604     void reformatLine() {
605 
606         reformatLineByIndex(caretIndex);
607 
608     }
609 
610     /// CodeInput moves `toLineStart` action handler to `toggleHome`
611     override void caretToLineStart() {
612 
613         super.caretToLineStart();
614 
615     }
616 
617     /// Move the caret to the "home" position of the line, see `lineHomeByIndex`.
618     void caretToLineHome() {
619 
620         caretIndex = lineHomeByIndex(caretIndex);
621         updateCaretPosition(true);
622         moveOrClearSelection();
623         horizontalAnchor = caretPosition.x;
624 
625     }
626 
627     /// Move the caret to the "home" position of the line — or if the caret is already at that position, move it to
628     /// line start. This function perceives the line visually, so if the text wraps, it will go to the beginning of the
629     /// visible line, instead of the hard line break or the home.
630     ///
631     /// See_Also: `caretToLineHome` and `lineHomeByIndex`
632     @(FluidInputAction.toLineStart)
633     void toggleHome() {
634 
635         const home = lineHomeByIndex(caretIndex);
636         const oldIndex = caretIndex;
637 
638         // Move to visual start of line
639         caretToLineStart();
640 
641         const shouldMove = caretIndex < home
642             || caretIndex == oldIndex;
643 
644         // Unless the caret was already at home, or it didn't move to start, navigate home
645         if (oldIndex != home && shouldMove) {
646 
647             caretToLineHome();
648 
649         }
650 
651     }
652 
653     @(FluidInputAction.paste)
654     override void paste() {
655 
656         import std.array : Appender;
657 
658         if (clipboardIO) {
659 
660             char[1024] buffer;
661             Appender!(char[]) content;
662             int offset;
663 
664             // Read text from the clipboard and into the buffer
665             // This is not the most optimal, but pasting is completely reworked in the next release anyway
666             while (true) {
667                 if (auto text = clipboardIO.readClipboard(buffer, offset)) {
668                     content ~= text;
669                 }
670                 else break;
671             }
672 
673             paste(content[]);
674 
675         }
676         else {
677             paste(io.clipboard);
678         }
679 
680     }
681 
682     void paste(const char[] clipboard) {
683 
684         import fluid.typeface : Typeface;
685 
686         // Write an undo/redo history entry
687         auto shot = snapshot();
688         scope (success) forcePushSnapshot(shot);
689 
690         const pasteStart = selectionLowIndex;
691         auto indentLevel = indentLevelByIndex(pasteStart);
692 
693         // Find the smallest indent in the clipboard
694         // Skip the first line because it's likely to be without indent when copy-pasting
695         auto lines = Typeface.lineSplitter(clipboard).drop(1);
696 
697         // Count indents on each line, skip blank lines
698         auto significantIndents = lines
699             .map!(a => a
700                 .countUntil!(a => !a.among(' ', '\t')))
701             .filter!(a => a != -1);
702 
703         // Test blank lines only if all lines are blank
704         const commonIndent
705             = !significantIndents.empty ? significantIndents.minElement()
706             : !lines.empty ? lines.front.length
707             : 0;
708 
709         // Remove the common indent
710         auto outdentedClipboard = Typeface.lineSplitter!(Yes.keepTerminator)(clipboard)
711             .map!((a) {
712                 const localIndent = a
713                     .until!(a => !a.among(' ', '\t'))
714                     .walkLength;
715 
716                 return a.drop(min(commonIndent, localIndent));
717             })
718             .map!(a => Rope(a))
719             .array;
720 
721         // Push the clipboard
722         push(Rope.merge(outdentedClipboard));
723 
724         reparse();
725 
726         const pasteEnd = caretIndex;
727 
728         // Reformat each line
729         foreach (index, ref line; eachLineByIndex(pasteStart, pasteEnd)) {
730 
731             // Save indent of the first line, but don't reformat
732             // `min` is used in case text is pasted inside the indent
733             if (index <= pasteStart) {
734                 indentLevel = min(indentLevel, indentLevelByIndex(pasteStart));
735                 continue;
736             }
737 
738             // Use the reformatter if available
739             if (indentor) {
740                 reformatLineByIndex(index);
741                 line = lineByIndex(index);
742             }
743 
744             // If not, prepend the indent
745             else {
746                 line = indentRope(indentLevel) ~ line;
747             }
748 
749         }
750 
751         // Make sure the input is parsed completely
752         reparse();
753 
754     }
755     @("CodeInput calls parse only once if Highlighter and Indentor are the same")
756     unittest {
757 
758         import std.typecons;
759 
760         static abstract class Highlighter : CodeHighlighter {
761 
762             int highlightCount;
763 
764             void parse(Rope) {
765                 highlightCount++;
766             }
767 
768         }
769 
770         static abstract class Indentor : CodeIndentor {
771 
772             int indentCount;
773 
774             void parse(Rope) {
775                 indentCount++;
776             }
777 
778         }
779 
780         auto highlighter = new BlackHole!Highlighter;
781         auto root = codeInput(highlighter);
782         root.reparse();
783 
784         assert(highlighter.highlightCount == 1);
785 
786         auto indentor = new BlackHole!Indentor;
787         root.indentor = indentor;
788         root.reparse();
789 
790         // Parse called once for each
791         assert(highlighter.highlightCount == 2);
792         assert(indentor.indentCount == 1);
793 
794         static abstract class FullHighlighter : CodeHighlighter, CodeIndentor {
795 
796             int highlightCount;
797             int indentCount;
798 
799             void parse(Rope) {
800                 highlightCount++;
801                 indentCount++;
802             }
803 
804         }
805 
806         auto fullHighlighter = new BlackHole!FullHighlighter;
807         root = codeInput(fullHighlighter);
808         root.reparse();
809 
810         // Parse should be called once for the whole class
811         assert(fullHighlighter.highlightCount == 1);
812         assert(fullHighlighter.indentCount == 1);
813 
814     }
815 
816     @("Legacy: CodeInput.paste creates a history entry (migrated)")
817     unittest {
818 
819         auto io = new HeadlessBackend;
820         auto root = codeInput(.useSpaces(2));
821         root.io = io;
822 
823         io.clipboard = "World";
824         root.push("  Hello,");
825         root.runInputAction!(FluidInputAction.breakLine);
826         root.paste();
827         assert(!root._isContinuous);
828         root.push("!");
829         assert(root.value == "  Hello,\n  World!");
830 
831         // Undo the exclamation mark
832         root.undo();
833         assert(root.value == "  Hello,\n  World");
834 
835         // Undo moves before pasting
836         root.undo();
837         assert(root.value == "  Hello,\n  ");
838         assert(root.valueBeforeCaret == root.value);
839 
840         // Next undo moves before line break
841         root.undo();
842         assert(root.value == "  Hello,");
843 
844         // Next undo clears all changes
845         root.undo();
846         assert(root.value == "");
847 
848         // No change
849         root.undo();
850         assert(root.value == "");
851 
852         // It can all be redone
853         root.redo();
854         assert(root.value == "  Hello,");
855         assert(root.valueBeforeCaret == root.value);
856         root.redo();
857         assert(root.value == "  Hello,\n  ");
858         assert(root.valueBeforeCaret == root.value);
859         root.redo();
860         assert(root.value == "  Hello,\n  World");
861         assert(root.valueBeforeCaret == root.value);
862         root.redo();
863         assert(root.value == "  Hello,\n  World!");
864         assert(root.valueBeforeCaret == root.value);
865         root.redo();
866         assert(root.value == "  Hello,\n  World!");
867 
868     }
869 
870 }
871 
872 ///
873 unittest {
874 
875     // Start a code editor
876     codeInput();
877 
878     // Start a code editor that uses tabs
879     codeInput(
880         .useTabs
881     );
882 
883     // Or, 2 spaces, if you prefer — the default is 4 spaces
884     codeInput(
885         .useSpaces(2)
886     );
887 
888 }
889 
890 alias CodeToken = ubyte;
891 alias CodeSlice = TextStyleSlice;
892 
893 // Note: This was originally a member of CodeHighlighter, but it broke the vtable sometimes...? I wasn't able to
894 // produce a minimal example to open a bug ticket, sorry.
895 alias CodeHighlighterRange = typeof(CodeHighlighter.save());
896 
897 /// Implements syntax highlighting for `CodeInput`.
898 /// Warning: This API is unstable and might change without warning.
899 interface CodeHighlighter {
900 
901     /// Get a name for the token at given index. Returns null if there isn't a token at given index. Indices must be
902     /// sequential. Starts at 1.
903     const(char)[] nextTokenName(CodeToken index);
904 
905     /// Parse the given text to use with other functions in the highlighter.
906     void parse(Rope text);
907 
908     /// Find the next important range starting with the byte at given index.
909     ///
910     /// Tip: Query is likely to be called again with `byteIndex` set to the value of `range.end`.
911     ///
912     /// Returns:
913     ///     The next relevant code range. Parts with no highlighting should be ignored. If there is nothing left to
914     ///     highlight, should return `init`.
915     CodeSlice query(size_t byteIndex)
916     in (byteIndex != size_t.max, "Invalid byte index (-1)")
917     out (r; r.end != byteIndex, "query() must not return empty ranges");
918 
919     /// Produce a TextStyleSlice range using the result.
920     /// Params:
921     ///     offset = Number of bytes to skip. Apply the offset to all resulting items.
922     /// Returns: `CodeHighlighterRange` suitable for use as a `Text` style map.
923     final save(int offset = 0) {
924 
925         static struct HighlighterRange {
926 
927             CodeHighlighter highlighter;
928             TextStyleSlice front;
929             int offset;
930 
931             bool empty() const {
932 
933                 return front is front.init;
934 
935             }
936 
937             // Continue where the last token ended
938             void popFront() {
939 
940                 do front = highlighter.query(front.end + offset).offset(-offset);
941 
942                 // Pop again if got a null token
943                 while (front.styleIndex == 0 && front !is front.init);
944 
945             }
946 
947             HighlighterRange save() {
948 
949                 return this;
950 
951             }
952 
953         }
954 
955         return HighlighterRange(this, query(offset).offset(-offset), offset);
956 
957     }
958 
959 }
960 
961 interface CodeIndentor {
962 
963     /// Parse the given text.
964     void parse(Rope text);
965 
966     /// Get indent level for the given offset, relative to the previous line.
967     ///
968     /// `CodeInput` will use the first non-white character on a line as a reference for reformatting.
969     int indentDifference(ptrdiff_t offset);
970 
971 }