1 /++
2     Functions related to IRC colouring and formatting; mapping it to ANSI
3     terminal such, stripping it, etc.
4 
5     IRC colours are not in the standard per se, but there is a de-facto standard
6     based on the mIRC coluring syntax of `\3fg,bg...\3`, where '\3' is byte 3,
7     `fg` is a foreground colour number (of [IRCColour]) and `bg` is a similar
8     background colour number.
9 
10     Example:
11     ---
12     immutable nameInColour = "kameloso".ircColour(IRCColour.red);
13     immutable nameInHashedColour = "kameloso".ircColourByHash(Yes.extendedOutgoingColours);
14     immutable nameInBold = "kameloso".ircBold;
15     ---
16 
17     See_Also:
18         [kameloso.terminal.colours]
19 
20     Copyright: [JR](https://github.com/zorael)
21     License: [Boost Software License 1.0](https://www.boost.org/users/license.html)
22 
23     Authors:
24         [JR](https://github.com/zorael)
25  +/
26 module kameloso.irccolours;
27 
28 private:
29 
30 import kameloso.terminal.colours.defs : TerminalBackground, TerminalForeground,
31     TerminalFormat, TerminalReset;
32 import dialect.common : IRCControlCharacter;
33 import std.range.primitives : isOutputRange;
34 import std.typecons : Flag, No, Yes;
35 
36 
37 public:
38 
39 @safe:
40 
41 /++
42     Official mIRC colour table.
43  +/
44 enum IRCColour
45 {
46     unset       = -1,  /// Unset
47     white       = 0,   /// White
48     black       = 1,   /// Black
49     blue        = 2,   /// Blue
50     green       = 3,   /// Green
51     red         = 4,   /// Red
52     brown       = 5,   /// Brown
53     magenta     = 6,   /// Magenta
54     orange      = 7,   /// Orange
55     yellow      = 8,   /// Yellow
56     lightgreen  = 9,   /// Light green
57     cyan        = 10,  /// Cyan
58     lightcyan   = 11,  /// Light cyan
59     lightblue   = 12,  /// Light blue
60     pink        = 13,  /// Pink
61     grey        = 14,  /// Grey
62     lightgrey   = 15,  /// Light grey
63     transparent = 99,  /// "Transparent"
64 }
65 
66 
67 // ircANSIColourMap
68 /++
69     Map of IRC colour values above 16 to ANSI terminal colours, as per ircdocs.
70 
71     See_Also:
72         https://modern.ircdocs.horse/formatting.html#colors-16-98.
73  +/
74 immutable uint[99] ircANSIColourMap =
75 [
76      0 : TerminalForeground.default_,
77      1 : TerminalForeground.white,  // replace with .black on bright terminals
78      2 : TerminalForeground.red,
79      3 : TerminalForeground.green,
80      4 : TerminalForeground.yellow,
81      5 : TerminalForeground.blue,
82      6 : TerminalForeground.magenta,
83      7 : TerminalForeground.cyan,
84      8 : TerminalForeground.lightgrey,
85      9 : TerminalForeground.darkgrey,
86     10 : TerminalForeground.lightred,
87     11 : TerminalForeground.lightgreen,
88     12 : TerminalForeground.lightyellow,
89     13 : TerminalForeground.lightblue,
90     14 : TerminalForeground.lightmagenta,
91     15 : TerminalForeground.lightcyan,
92     16 : 52,
93     17 : 94,
94     18 : 100,
95     19 : 58,
96     20 : 22,
97     21 : 29,
98     22 : 23,
99     23 : 24,
100     24 : 17,
101     25 : 54,
102     26 : 53,
103     27 : 89,
104     28 : 88,
105     29 : 130,
106     30 : 142,
107     31 : 64,
108     32 : 28,
109     33 : 35,
110     34 : 30,
111     35 : 25,
112     36 : 18,
113     37 : 91,
114     38 : 90,
115     39 : 125,
116     40 : 124,
117     41 : 166,
118     42 : 184,
119     43 : 106,
120     44 : 34,
121     45 : 49,
122     46 : 37,
123     47 : 33,
124     48 : 19,
125     49 : 129,
126     50 : 127,
127     51 : 161,
128     52 : 196,
129     53 : 208,
130     54 : 226,
131     55 : 154,
132     56 : 46,
133     57 : 86,
134     58 : 51,
135     59 : 75,
136     60 : 21,
137     61 : 171,
138     62 : 201,
139     63 : 198,
140     64 : 203,
141     65 : 215,
142     66 : 227,
143     67 : 191,
144     68 : 83,
145     69 : 122,
146     70 : 87,
147     71 : 111,
148     72 : 63,
149     73 : 177,
150     74 : 207,
151     75 : 205,
152     76 : 217,
153     77 : 223,
154     78 : 229,
155     79 : 193,
156     80 : 157,
157     81 : 158,
158     82 : 159,
159     83 : 153,
160     84 : 147,
161     85 : 183,
162     86 : 219,
163     87 : 212,
164     88 : 16,
165     89 : 233,
166     90 : 235,
167     91 : 237,
168     92 : 239,
169     93 : 241,
170     94 : 244,
171     95 : 247,
172     96 : 250,
173     97 : 254,
174     98 : 231,
175 ];
176 
177 
178 // ircColourInto
179 /++
180     Colour-codes the passed string with mIRC colouring, foreground and background.
181     Takes an output range sink and writes to it instead of allocating a new string.
182 
183     Params:
184         line = Line to tint.
185         sink = Output range sink to fill with the function's output.
186         fg = Foreground [IRCColour] integer.
187         bg = Optional background [IRCColour] integer.
188  +/
189 void ircColourInto(Sink)
190     (const string line,
191     auto ref Sink sink,
192     const int fg,
193     const int bg = IRCColour.unset)
194 if (isOutputRange!(Sink, char[]))
195 in (line.length, "Tried to apply IRC colours to a string but no string was given")
196 {
197     import lu.conv : toAlphaInto;
198 
199     sink.put(cast(char)IRCControlCharacter.colour);
200     (cast(int)fg).toAlphaInto!(2, 2)(sink);  // So far the highest colour seems to be 99; two digits
201 
202     if (bg != IRCColour.unset)
203     {
204         sink.put(',');
205         (cast(int)bg).toAlphaInto!(2, 2)(sink);
206     }
207 
208     sink.put(line);
209     sink.put(cast(char)IRCControlCharacter.colour);
210 }
211 
212 ///
213 unittest
214 {
215     import std.array : Appender;
216 
217     alias I = IRCControlCharacter;
218     Appender!(char[]) sink;
219 
220     "kameloso".ircColourInto(sink, IRCColour.red, IRCColour.white);
221     assert((sink.data == I.colour ~ "04,00kameloso" ~ I.colour), sink.data);
222     sink.clear();
223 
224     "harbl".ircColourInto(sink, IRCColour.green);
225     assert((sink.data == I.colour ~ "03harbl" ~ I.colour), sink.data);
226 }
227 
228 
229 // ircColour
230 /++
231     Colour-codes the passed string with mIRC colouring, foreground and background.
232     Direct overload that leverages the output range version to colour an internal
233     [std.array.Appender|Appender], and returns the resulting string.
234 
235     Params:
236         line = Line to tint.
237         fg = Foreground [IRCColour] integer.
238         bg = Optional background [IRCColour] integer.
239 
240     Returns:
241         The passed line, encased within IRC colour tags.
242  +/
243 string ircColour(
244     const string line,
245     const int fg,
246     const int bg = IRCColour.unset) pure
247 in (line.length, "Tried to apply IRC colours to a string but no string was given")
248 {
249     import std.array : Appender;
250 
251     if (!line.length) return string.init;
252 
253     Appender!(char[]) sink;
254 
255     sink.reserve(line.length + 7);  // Two colour tokens, four colour numbers and a comma
256     line.ircColourInto(sink, fg, bg);
257     return sink.data;
258 }
259 
260 ///
261 unittest
262 {
263     alias I = IRCControlCharacter;
264 
265     immutable redwhite = "kameloso".ircColour(IRCColour.red, IRCColour.white);
266     assert((redwhite == I.colour ~ "04,00kameloso" ~ I.colour), redwhite);
267 
268     immutable green = "harbl".ircColour(IRCColour.green);
269     assert((green == I.colour ~ "03harbl" ~ I.colour), green);
270 }
271 
272 
273 // ircColour
274 /++
275     Returns a mIRC colour code for the passed foreground and background colour.
276     Overload that doesn't take a string to tint, only the [IRCColour]s to
277     produce a colour code from.
278 
279     Params:
280         fg = Foreground [IRCColour].
281         bg = Optional background [IRCColour].
282 
283     Returns:
284         An opening IRC colour token with the passed colours.
285  +/
286 string ircColour(const IRCColour fg, const IRCColour bg = IRCColour.unset) pure
287 {
288     import lu.conv : toAlphaInto;
289     import std.array : Appender;
290 
291     Appender!(char[]) sink;
292     sink.reserve(6);
293 
294     sink.put(cast(char)IRCControlCharacter.colour);
295     (cast(int)fg).toAlphaInto!(2, 2)(sink);
296 
297     if (bg != IRCColour.unset)
298     {
299         sink.put(',');
300         (cast(int)bg).toAlphaInto!(2, 2)(sink);
301     }
302 
303     return sink.data;
304 }
305 
306 ///
307 unittest
308 {
309     alias I = IRCControlCharacter;
310 
311     with (IRCColour)
312     {
313         {
314             immutable line = "abcdefg".ircColour(white);
315             immutable expected = I.colour ~ "00abcdefg" ~ I.colour;
316             assert((line == expected), line);
317         }
318         {
319             immutable line = "abcdefg".ircBold;
320             immutable expected = I.bold ~ "abcdefg" ~ I.bold;
321             assert((line == expected), line);
322         }
323         {
324             immutable line = ircColour(white) ~ "abcdefg" ~ I.reset;
325             immutable expected = I.colour ~ "00abcdefg" ~ I.reset;
326             assert((line == expected), line);
327         }
328         {
329             immutable line = "" ~ I.bold ~ I.underlined ~ ircColour(green) ~
330                 "abcdef" ~ "ghijkl".ircColour(red) ~ I.reset;
331             immutable expected = "" ~ I.bold ~ I.underlined ~ I.colour ~ "03abcdef" ~
332                 I.colour ~ "04ghijkl" ~ I.colour ~ I.reset;
333             assert((line == expected), line);
334 
335             immutable expressedDifferently = ircBold(ircUnderlined("abcdef".ircColour(green) ~
336                 "ghijkl".ircColour(red)));
337             immutable expectedDifferently = "" ~ I.bold ~ I.underlined ~ I.colour ~
338                 "03abcdef" ~ I.colour ~ I.colour ~ "04ghijkl" ~ I.colour ~
339                 I.underlined ~ I.bold;
340             assert((expressedDifferently == expectedDifferently), expressedDifferently);
341         }
342         {
343             immutable account = "kameloso";
344             immutable authorised = "not authorised";
345             immutable line = "Account " ~ ircBold(account) ~ ": " ~ ircUnderlined(authorised) ~ "!";
346             immutable expected = "Account " ~ I.bold ~ "kameloso" ~ I.bold ~ ": " ~
347                 I.underlined ~ "not authorised" ~ I.underlined ~ "!";
348             assert((line == expected), line);
349         }
350     }
351 }
352 
353 
354 // ircColourByHash
355 /++
356     Returns the passed string coloured with an IRC colour depending on the hash
357     of the string, making for good "random" (uniformly distributed) nick colours
358     in IRC messages.
359 
360     Params:
361         word = String to tint.
362         extendedOutgoingColours = Whether or not to use extended colours (16-98).
363 
364     Returns:
365         The passed string encased within IRC colour coding.
366  +/
367 string ircColourByHash(
368     const string word,
369     const Flag!"extendedOutgoingColours" extendedOutgoingColours) pure
370 in (word.length, "Tried to apply IRC colours by hash to a string but no string was given")
371 {
372     import lu.conv : toAlphaInto;
373     import std.array : Appender;
374 
375     if (!word.length) return string.init;
376 
377     Appender!(char[]) sink;
378     sink.reserve(word.length + 4);  // colour, index, word, colour
379 
380     immutable modulo = extendedOutgoingColours ? ircANSIColourMap.length : 16;
381     immutable colourInteger = (hashOf(word) % modulo);
382 
383     sink.put(cast(char)IRCControlCharacter.colour);
384     colourInteger.toAlphaInto!(2, 2)(sink);
385     sink.put(word);
386     sink.put(cast(char)IRCControlCharacter.colour);
387 
388     return sink.data;
389 }
390 
391 ///
392 unittest
393 {
394     alias I = IRCControlCharacter;
395 
396     // Colour based on hash
397 
398     {
399         immutable actual = "kameloso".ircColourByHash(Yes.extendedOutgoingColours);
400         immutable expected = I.colour ~ "23kameloso" ~ I.colour;
401         assert((actual == expected), actual);
402     }
403     {
404         immutable actual = "kameloso^".ircColourByHash(Yes.extendedOutgoingColours);
405         immutable expected = I.colour ~ "56kameloso^" ~ I.colour;
406         assert((actual == expected), actual);
407     }
408     {
409         immutable actual = "kameloso^11".ircColourByHash(Yes.extendedOutgoingColours);
410         immutable expected = I.colour ~ "91kameloso^11" ~ I.colour;
411         assert((actual == expected), actual);
412     }
413     {
414         immutable actual = "flerrp".ircColourByHash(Yes.extendedOutgoingColours);
415         immutable expected = I.colour ~ "90flerrp" ~ I.colour;
416         assert((actual == expected), actual);
417     }
418 }
419 
420 
421 // ircBold
422 /++
423     Returns the passed something wrapped in between IRC bold control characters.
424 
425     Params:
426         something = Something [std.conv.to]-convertible to enwrap in bold.
427 
428     Returns:
429         The passed something, as a string, in IRC bold.
430  +/
431 auto ircBold(T)(T something) //pure nothrow
432 {
433     import std.conv : text;
434 
435     alias I = IRCControlCharacter;
436     return text(cast(char)I.bold, something, cast(char)I.bold);
437 }
438 
439 ///
440 unittest
441 {
442     import std.conv : to;
443     alias I = IRCControlCharacter;
444 
445     {
446         immutable line = "kameloso: " ~ ircBold("kameloso");
447         immutable expected = "kameloso: " ~ I.bold ~ "kameloso" ~ I.bold;
448         assert((line == expected), line);
449     }
450     {
451         immutable number = 1234;
452         immutable line = number.ircBold;
453         immutable expected = I.bold ~ number.to!string ~ I.bold;
454         assert((line == expected), line);
455     }
456     {
457         immutable b = true;
458         immutable line = b.ircBold;
459         immutable expected = I.bold ~ "true" ~ I.bold;
460         assert((line == expected), line);
461     }
462 }
463 
464 
465 // ircItalics
466 /++
467     Returns the passed something wrapped in between IRC italics control characters.
468 
469     Params:
470         something = Something [std.conv.to]-convertible to enwrap in italics.
471 
472     Returns:
473         The passed something, as a string, in IRC italics.
474  +/
475 auto ircItalics(T)(T something) //pure nothrow
476 {
477     import std.conv : text;
478 
479     alias I = IRCControlCharacter;
480     return text(cast(char)I.italics, something, cast(char)I.italics);
481 }
482 
483 ///
484 unittest
485 {
486     import std.conv : to;
487     alias I = IRCControlCharacter;
488 
489     {
490         immutable line = "kameloso: " ~ ircItalics("kameloso");
491         immutable expected = "kameloso: " ~ I.italics ~ "kameloso" ~ I.italics;
492         assert((line == expected), line);
493     }
494     {
495         immutable number = 1234;
496         immutable line = number.ircItalics;
497         immutable expected = I.italics ~ number.to!string ~ I.italics;
498         assert((line == expected), line);
499     }
500     {
501         immutable b = true;
502         immutable line = b.ircItalics;
503         immutable expected = I.italics ~ "true" ~ I.italics;
504         assert((line == expected), line);
505     }
506 }
507 
508 
509 // ircUnderlined
510 /++
511     Returns the passed something wrapped in between IRC underlined control characters.
512 
513     Params:
514         something = Something [std.conv.to]-convertible to enwrap in underlined.
515 
516     Returns:
517         The passed something, as a string, in IRC underlined.
518  +/
519 auto ircUnderlined(T)(T something) //pure nothrow
520 {
521     import std.conv : text;
522 
523     alias I = IRCControlCharacter;
524     return text(cast(char)I.underlined, something, cast(char)I.underlined);
525 }
526 
527 ///
528 unittest
529 {
530     import std.conv : to;
531     alias I = IRCControlCharacter;
532 
533     {
534         immutable line = "kameloso: " ~ ircUnderlined("kameloso");
535         immutable expected = "kameloso: " ~ I.underlined ~ "kameloso" ~ I.underlined;
536         assert((line == expected), line);
537     }
538     {
539         immutable number = 1234;
540         immutable line = number.ircUnderlined;
541         immutable expected = I.underlined ~ number.to!string ~ I.underlined;
542         assert((line == expected), line);
543     }
544     {
545         immutable b = true;
546         immutable line = b.ircUnderlined;
547         immutable expected = I.underlined ~ "true" ~ I.underlined;
548         assert((line == expected), line);
549     }
550 }
551 
552 
553 // ircReset
554 /++
555     Returns an IRC formatting reset token.
556 
557     Returns:
558         An IRC colour/formatting reset token.
559  +/
560 auto ircReset() @nogc pure nothrow
561 {
562     return cast(char)IRCControlCharacter.reset;
563 }
564 
565 
566 // mapEffects
567 /++
568     Maps mIRC effect tokens (colour, bold, italics, underlined) to terminal ones.
569 
570     Example:
571     ---
572     string mIRCEffectString = "...";
573     string TerminalFormatString = mapEffects(mIRCEffectString);
574     ---
575 
576     Params:
577         origLine = String line to map effects of.
578         fgBase = Optional foreground base code to reset to after end colour tags.
579         bgBase = Optional background base code to reset to after end colour tags.
580 
581     Returns:
582         A new string based on `origLine` with mIRC tokens mapped to terminal ones.
583  +/
584 version(Colours)
585 auto mapEffects(
586     const string origLine,
587     const TerminalForeground fgBase = TerminalForeground.default_,
588     const TerminalBackground bgBase = TerminalBackground.default_) pure nothrow
589 {
590     import lu.string : contains;
591 
592     alias I = IRCControlCharacter;
593     alias TF = TerminalFormat;
594 
595     if (!origLine.length) return string.init;
596 
597     string line = origLine;  // mutable
598 
599     if (line.contains(I.colour))
600     {
601         // Colour is mIRC 3
602         line = mapColours(line, fgBase, bgBase);
603     }
604 
605     if (line.contains(I.bold))
606     {
607         // Bold is terminal 1, mIRC 2
608         line = mapEffectsImpl!(No.strip, I.bold, TF.bold)(line);
609     }
610 
611     if (line.contains(I.italics))
612     {
613         // Italics is terminal 3 (not really), mIRC 29
614         line = mapEffectsImpl!(No.strip, I.italics, TF.italics)(line);
615     }
616 
617     if (line.contains(I.underlined))
618     {
619         // Underlined is terminal 4, mIRC 31
620         line = mapEffectsImpl!(No.strip, I.underlined, TF.underlined)(line);
621     }
622 
623     return line;
624 }
625 
626 ///
627 version(Colours)
628 unittest
629 {
630     import kameloso.terminal : TerminalToken;
631     import lu.conv : toAlpha;
632 
633     alias I = IRCControlCharacter;
634 
635     enum bBold = TerminalToken.format ~ "[" ~ TerminalFormat.bold.toAlpha ~ "m";
636     enum bReset = TerminalToken.format ~ "[22m";
637     //enum bResetAll = TerminalToken.format ~ "[0m";
638 
639     immutable line1 = "ABC"~I.bold~"DEF"~I.bold~"GHI"~I.bold~"JKL"~I.bold~"MNO";
640     immutable line2 = "ABC"~bBold~"DEF"~bReset~"GHI"~bBold~"JKL"~bReset~"MNO";//~bResetAll;
641     immutable mapped = mapEffects(line1);
642 
643     assert((mapped == line2), mapped);
644 }
645 
646 
647 // stripEffects
648 /++
649     Removes all form of mIRC formatting (colours, bold, italics, underlined)
650     from a string.
651 
652     Params:
653         line = String to strip effects from.
654 
655     Returns:
656         A string devoid of effects.
657  +/
658 auto stripEffects(const string line) pure nothrow
659 {
660     if (!line.length) return line;
661 
662     alias I = IRCControlCharacter;
663 
664     return line
665         .stripColours
666         .mapEffectsImpl!(Yes.strip, I.bold, TerminalFormat.unset)
667         .mapEffectsImpl!(Yes.strip, I.italics, TerminalFormat.unset)
668         .mapEffectsImpl!(Yes.strip, I.underlined, TerminalFormat.unset);
669 }
670 
671 ///
672 unittest
673 {
674     alias I = IRCControlCharacter;
675 
676     enum boldCode = "" ~ I.bold;
677     enum italicsCode = "" ~ I.italics;
678 
679     {
680         immutable withTags = "This is " ~ boldCode ~ "riddled" ~ boldCode ~ " with " ~
681             italicsCode ~ "tags" ~ italicsCode;
682         immutable without = stripEffects(withTags);
683         assert((without == "This is riddled with tags"), without);
684     }
685     {
686         immutable withTags = "This line has no tags.";
687         immutable without = stripEffects(withTags);
688         assert((without == withTags), without);
689     }
690     {
691         string withTags;
692         immutable without = stripEffects(withTags);
693         assert(!without.length, without);
694     }
695 }
696 
697 
698 // mapColours
699 /++
700     Maps mIRC effect colour tokens to terminal ones.
701 
702     Merely calls [mapColoursImpl] with `No.strip`.
703 
704     Params:
705         line = String line with IRC colours to translate.
706         fgFallback = Foreground code to reset to after colour-default tokens.
707         bgFallback = Background code to reset to after colour-default tokens.
708 
709     Returns:
710         The passed `line`, now with terminal colouring.
711  +/
712 version(Colours)
713 auto mapColours(
714     const string line,
715     const TerminalForeground fgFallback,
716     const TerminalBackground bgFallback) pure nothrow
717 {
718     if (!line.length) return line;
719     return mapColoursImpl!(No.strip)(line, fgFallback, bgFallback);
720 }
721 
722 
723 // mapColoursImpl
724 /++
725     Maps mIRC effect colour tokens to terminal ones, or strip them entirely.
726     Now with less regex.
727 
728     Pass `Yes.strip` as `strip` to map colours to nothing, removing colouring.
729 
730     This function requires version `Colours` to map colours, but doesn't if
731     just to strip.
732 
733     Params:
734         strip = Whether or not to strip colours or to map them.
735         line = String line with IRC colours to translate.
736         fgFallback = Foreground code to reset to after colour-default tokens.
737         bgFallback = Background code to reset to after colour-default tokens.
738 
739     Returns:
740         The passed `line`, now with terminal colouring, or completely without.
741  +/
742 private string mapColoursImpl(Flag!"strip" strip = No.strip)
743     (const string line,
744     const TerminalForeground fgFallback,
745     const TerminalBackground bgFallback) pure nothrow
746 {
747     import lu.conv : toAlphaInto;
748     import std.array : Appender;
749     import std.string : indexOf;
750 
751     version(Colours) {}
752     else
753     {
754         static if (!strip)
755         {
756             static assert(0, "Tried to `mapColoursImpl!(No.strip)` outside of version `Colours`");
757         }
758     }
759 
760     static struct Segment
761     {
762         string pre;
763         int fg;
764         int bg;
765         bool hasBackground;
766         bool isReset;
767     }
768 
769     string slice = line;  // mutable
770 
771     ptrdiff_t pos = slice.indexOf(IRCControlCharacter.colour);
772 
773     if (pos == -1) return line;  // Return line as is, don't allocate a new one
774 
775     Segment[] segments;
776     segments.reserve(8);  // Guesstimate
777 
778     while (pos != -1)
779     {
780         immutable segmentIndex = segments.length;  // snapshot
781         segments ~= Segment.init;
782         Segment* segment = &segments[segmentIndex];
783 
784         segment.pre = slice[0..pos];
785         if (slice.length == pos) break;
786         slice = slice[pos+1..$];
787 
788         if (!slice.length)
789         {
790             segment.isReset = true;
791             break;
792         }
793 
794         int c = slice[0] - '0';
795 
796         if ((c >= 0) && (c <= 9))
797         {
798             int fg1;
799             int fg2;
800             bool hasFg2;
801 
802             fg1 = c;
803             if (slice.length < 2) break;
804             slice = slice[1..$];
805 
806             c = slice[0] - '0';
807 
808             if ((c >= 0) && (c <= 9))
809             {
810                 fg2 = c;
811                 hasFg2 = true;
812                 if (slice.length < 2) break;
813                 slice = slice[1..$];
814             }
815 
816             int fg = hasFg2 ? (10*fg1 + fg2) : fg1;
817 
818             if (fg > 15)
819             {
820                 fg %= 16;
821             }
822 
823             segment.fg = fg;
824 
825             if (slice[0] == ',')
826             {
827                 if (!slice.length) break;
828                 slice = slice[1..$];
829 
830                 c = slice[0] - '0';
831 
832                 if ((c >= 0) && (c <= 9))
833                 {
834                     segment.hasBackground = true;
835 
836                     int bg1;
837                     int bg2;
838                     bool hasBg2;
839 
840                     bg1 = c;
841                     if (slice.length < 2) break;
842                     slice = slice[1..$];
843 
844                     c = slice[0] - '0';
845 
846                     if ((c >= 0) && (c <= 9))
847                     {
848                         bg2 = c;
849                         hasBg2 = true;
850                         if (!slice.length) break;
851                         slice = slice[1..$];
852                     }
853 
854                     uint bg = hasBg2 ? (10*bg1 + bg2) : bg1;
855 
856                     if (bg > 15)
857                     {
858                         bg %= 16;
859                     }
860 
861                     segment.bg = bg;
862                 }
863             }
864         }
865         else
866         {
867             segment.isReset = true;
868         }
869 
870         pos = slice.indexOf(IRCControlCharacter.colour);
871     }
872 
873     immutable tail = slice;
874 
875     Appender!(char[]) sink;
876     sink.reserve(line.length + segments.length * 8);
877 
878     static if (strip)
879     {
880         foreach (segment; segments)
881         {
882             sink.put(segment.pre);
883         }
884     }
885     else
886     {
887         version(Colours)
888         {
889             alias F = TerminalForeground;
890             alias B = TerminalBackground;
891 
892             static immutable TerminalForeground[16] weechatForegroundMap =
893             [
894                  0 : F.white,
895                  1 : F.darkgrey,
896                  2 : F.blue,
897                  3 : F.green,
898                  4 : F.lightred,
899                  5 : F.red,
900                  6 : F.magenta,
901                  7 : F.yellow,
902                  8 : F.lightyellow,
903                  9 : F.lightgreen,
904                 10 : F.cyan,
905                 11 : F.lightcyan,
906                 12 : F.lightblue,
907                 13 : F.lightmagenta,
908                 14 : F.darkgrey,
909                 15 : F.lightgrey,
910             ];
911 
912             static immutable TerminalBackground[16] weechatBackgroundMap =
913             [
914                  0 : B.white,
915                  1 : B.black,
916                  2 : B.blue,
917                  3 : B.green,
918                  4 : B.red,
919                  5 : B.red,
920                  6 : B.magenta,
921                  7 : B.yellow,
922                  8 : B.yellow,
923                  9 : B.green,
924                 10 : B.cyan,
925                 11 : B.cyan,
926                 12 : B.blue,
927                 13 : B.magenta,
928                 14 : B.black,
929                 15 : B.lightgrey,
930             ];
931 
932             bool open;
933 
934             foreach (segment; segments)
935             {
936                 open = true;
937                 sink.put(segment.pre);
938                 sink.put("\033[");
939 
940                 if (segment.isReset)
941                 {
942                     fgFallback.toAlphaInto(sink);
943                     sink.put(';');
944                     bgFallback.toAlphaInto(sink);
945                     sink.put('m');
946                     open = false;
947                     continue;
948                 }
949 
950                 (cast(uint)weechatForegroundMap[segment.fg]).toAlphaInto(sink);
951 
952                 if (segment.hasBackground)
953                 {
954                     sink.put(';');
955                     (cast(uint)weechatBackgroundMap[segment.bg]).toAlphaInto(sink);
956                 }
957 
958                 sink.put("m");
959             }
960         }
961         else
962         {
963             //static assert(0);
964         }
965     }
966 
967     sink.put(tail);
968 
969     version(Colours)
970     {
971         static if (!strip)
972         {
973             if (open)
974             {
975                 if ((fgFallback == 39) && (bgFallback == 49))
976                 {
977                     // Shortcut
978                     sink.put("\033[39;49m");
979                 }
980                 else
981                 {
982                     sink.put("\033[");
983                     fgFallback.toAlphaInto(sink);
984                     sink.put(';');
985                     bgFallback.toAlphaInto(sink);
986                     sink.put('m');
987                 }
988             }
989         }
990     }
991 
992     return sink.data;
993 }
994 
995 ///
996 version(Colours)
997 unittest
998 {
999     alias I = IRCControlCharacter;
1000     alias TF = TerminalForeground;
1001     alias TB = TerminalBackground;
1002 
1003     {
1004         immutable line = "This is " ~ I.colour ~ "4all red!" ~ I.colour ~ " while this is not.";
1005         immutable mapped = mapColours(line, TF.default_, TB.default_);
1006         assert((mapped == "This is \033[91mall red!\033[39;49m while this is not."), mapped);
1007     }
1008     {
1009         immutable line = "This time there's" ~ I.colour ~ "6 no ending token, only magenta.";
1010         immutable mapped = mapColours(line, TF.default_, TB.default_);
1011         assert((mapped == "This time there's\033[35m no ending token, only magenta.\033[39;49m"), mapped);
1012     }
1013     {
1014         immutable line = I.colour ~ "1,0You" ~ I.colour ~ "0,4Tube" ~ I.colour ~ " asdf";
1015         immutable mapped = mapColours(line, TF.default_, TB.default_);
1016         assert((mapped == "\033[90;107mYou\033[97;41mTube\033[39;49m asdf"), mapped);
1017     }
1018     {
1019         immutable line = I.colour ~ "17,0You" ~ I.colour ~ "0,21Tube" ~ I.colour ~ " asdf";
1020         immutable mapped = mapColours(line, TF.default_, TB.default_);
1021         assert((mapped == "\033[90;107mYou\033[97;41mTube\033[39;49m asdf"), mapped);
1022     }
1023     {
1024         immutable line = I.colour ~ "17,0You" ~ I.colour ~ "0,2" ~ I.colour;
1025         immutable mapped = mapColours(line, TF.default_, TB.default_);
1026         assert((mapped == "\033[90;107mYou\033[97;44m\033[39;49m"), mapped);
1027     }
1028     {
1029         immutable line = I.colour ~ "";
1030         immutable mapped = mapColours(line, TF.default_, TB.default_);
1031         assert((mapped == "\033[39;49m"), mapped);
1032     }
1033 }
1034 
1035 
1036 // stripColours
1037 /++
1038     Removes IRC colouring from a passed string.
1039 
1040     Merely calls [mapColours] with a `Yes.strip` template parameter.
1041 
1042     Params:
1043         line = String to strip of IRC colour tags.
1044 
1045     Returns:
1046         The passed `line`, now stripped of IRC colours.
1047  +/
1048 auto stripColours(const string line) pure nothrow
1049 {
1050     if (!line.length) return line;
1051     return mapColoursImpl!(Yes.strip)(line, TerminalForeground.default_, TerminalBackground.default_);
1052 }
1053 
1054 ///
1055 unittest
1056 {
1057     alias I = IRCControlCharacter;
1058 
1059     {
1060         immutable line = "This is " ~ I.colour ~ "4all red!" ~ I.colour ~ " while this is not.";
1061         immutable stripped = line.stripColours();
1062         assert((stripped == "This is all red! while this is not."), stripped);
1063     }
1064     {
1065         immutable line = "This time there's" ~ I.colour ~ "6 no ending token, only magenta.";
1066         immutable stripped = line.stripColours();
1067         assert((stripped == "This time there's no ending token, only magenta."), stripped);
1068     }
1069     {
1070         immutable line = "This time there's" ~ I.colour ~ "6 no ending " ~ I.colour ~
1071             "6token, only " ~ I.colour ~ "magenta.";
1072         immutable stripped = line.stripColours();
1073         assert((stripped == "This time there's no ending token, only magenta."), stripped);
1074     }
1075 }
1076 
1077 
1078 // mapEffectsImpl
1079 /++
1080     Replaces mIRC tokens with terminal effect codes, in an alternating fashion
1081     so as to support repeated effects toggling behaviour. Now with less regex.
1082 
1083     It seems to be the case that a token for bold text will trigger bold text up
1084     until the next bold token. If we only naïvely replace all mIRC tokens for
1085     bold text then, we'll get lines that start off bold and continue as such
1086     until the very end.
1087 
1088     Instead we iterate all occcurences of the passed `mircToken`, toggling the
1089     effect on and off.
1090 
1091     Params:
1092         strip = Whether or not to strip effects or map them.
1093         mircToken = mIRC token for a particular text effect.
1094         terminalFormatCode = Terminal equivalent of the mircToken effect.
1095         line = The mIRC-formatted string to translate.
1096 
1097     Returns:
1098         The passed `line`, now with terminal formatting.
1099  +/
1100 private string mapEffectsImpl(Flag!"strip" strip, IRCControlCharacter mircToken,
1101     TerminalFormat terminalFormatCode)
1102     (const string line) pure
1103 {
1104     import lu.conv : toAlpha;
1105     import std.array : Appender;
1106     import std.string : indexOf;
1107 
1108     version(Colours) {}
1109     else
1110     {
1111         static if (!strip)
1112         {
1113             static assert(0, "Tried to call `mapEffectsImpl!(No.strip)` outside of version `Colours`");
1114         }
1115     }
1116 
1117     string slice = line;  // mutable
1118     ptrdiff_t pos = slice.indexOf(mircToken);
1119     if (pos == -1) return line;  // As is
1120 
1121     Appender!(char[]) sink;
1122 
1123     static if (!strip)
1124     {
1125         import kameloso.terminal : TerminalToken;
1126         import kameloso.terminal.colours : applyANSI;
1127 
1128         enum terminalToken = TerminalToken.format ~ "[" ~ toAlpha(terminalFormatCode) ~ "m";
1129         sink.reserve(cast(size_t)(line.length * 1.5));
1130         bool open;
1131     }
1132     else
1133     {
1134         sink.reserve(line.length);
1135     }
1136 
1137     while (pos != -1)
1138     {
1139         sink.put(slice[0..pos]);
1140 
1141         if (slice.length == pos)
1142         {
1143             // Slice away the end so it isn't added as the tail afterwards
1144             slice = slice[pos..$];
1145             break;
1146         }
1147 
1148         slice = slice[pos+1..$];
1149 
1150         static if (!strip)
1151         {
1152             if (!open)
1153             {
1154                 sink.put(terminalToken);
1155                 open = true;
1156             }
1157             else
1158             {
1159                 static if ((terminalFormatCode == 1) || (terminalFormatCode == 2))
1160                 {
1161                     // Both 1 and 2 seem to be reset by 22?
1162                     enum tokenstring = TerminalToken.format ~ "[22m";
1163                     sink.put(tokenstring);
1164                 }
1165                 else static if ((terminalFormatCode >= 3) && (terminalFormatCode <= 5))
1166                 {
1167                     enum tokenstring = TerminalToken.format ~ "[2" ~ terminalFormatCode.toAlpha ~ "m";
1168                     sink.put(tokenstring);
1169                 }
1170                 else
1171                 {
1172                     //logger.warning("Unknown terminal effect code: ", TerminalFormatCode);
1173                     sink.applyANSI(TerminalReset.all);
1174                 }
1175 
1176                 open = false;
1177             }
1178         }
1179 
1180         pos = slice.indexOf(mircToken);
1181     }
1182 
1183     alias tail = slice;
1184     sink.put(tail);
1185 
1186     static if (!strip)
1187     {
1188         if (open) sink.applyANSI(TerminalReset.all);
1189     }
1190 
1191     return sink.data;
1192 }
1193 
1194 ///
1195 version(Colours)
1196 unittest
1197 {
1198     import kameloso.terminal : TerminalToken;
1199     import lu.conv : toAlpha;
1200 
1201     alias I = IRCControlCharacter;
1202     alias TF = TerminalFormat;
1203 
1204     enum bBold = TerminalToken.format ~ "[" ~ TF.bold.toAlpha ~ "m";
1205     enum bReset = TerminalToken.format ~ "[22m";
1206 
1207     {
1208         enum line = "derp " ~ I.bold ~ "herp derp" ~ I.bold ~ "der dper";
1209         immutable mapped = mapEffectsImpl!(No.strip, I.bold, TF.bold)(line);
1210         assert((mapped == "derp " ~ bBold ~ "herp derp" ~ bReset ~ "der dper"), mapped);
1211     }
1212 }
1213 
1214 
1215 // expandIRCTags
1216 /++
1217     Slightly more complicated, but essentially string-replaces `<tags>` in an
1218     outgoing IRC string with correlating formatting using
1219     [dialect.common.IRCControlCharacter|IRCControlCharacter]s in their syntax.
1220     Overload that takes an explicit `strip` [std.typecons.Flag|Flag].
1221 
1222     Params:
1223         line = String line to expand IRC tags of.
1224         extendedOutgoingColours = Whether or not to use extended colours (16-99).
1225         strip = Whether to expand tags or strip them from the input line.
1226 
1227     Returns:
1228         The passed `line` but with tags expanded to formatting and colouring.
1229  +/
1230 T expandIRCTags(T)
1231     (const T line,
1232     const Flag!"extendedOutgoingColours" extendedOutgoingColours,
1233     const Flag!"strip" strip) @system
1234 {
1235     import std.encoding : sanitize;
1236     import std.utf : UTFException;
1237     import core.exception : UnicodeException;
1238 
1239     try
1240     {
1241         return expandIRCTagsImpl(line, extendedOutgoingColours, strip);
1242     }
1243     catch (UTFException _)
1244     {
1245         return expandIRCTagsImpl(sanitize(line), extendedOutgoingColours, strip);
1246     }
1247     catch (UnicodeException _)
1248     {
1249         return expandIRCTagsImpl(sanitize(line), extendedOutgoingColours, strip);
1250     }
1251 }
1252 
1253 ///
1254 @system unittest
1255 {
1256     import std.typecons : Flag, No, Yes;
1257 
1258     // See unittests of other overloads for more No.strip tests
1259 
1260     {
1261         immutable line = "hello<b>hello<b>hello";
1262         immutable expanded = line.expandIRCTags(Yes.extendedOutgoingColours, Yes.strip);
1263         immutable expected = "hellohellohello";
1264         assert((expanded == expected), expanded);
1265     }
1266     {
1267         immutable line = "hello<99,99<b>hiho</>";
1268         immutable expanded = line.expandIRCTags(Yes.extendedOutgoingColours, Yes.strip);
1269         immutable expected = "hello<99,99hiho";
1270         assert((expanded == expected), expanded);
1271     }
1272     {
1273         immutable line = "hello<1>hellohello";
1274         immutable expanded = line.expandIRCTags(Yes.extendedOutgoingColours, Yes.strip);
1275         immutable expected = "hellohellohello";
1276         assert((expanded == expected), expanded);
1277     }
1278     {
1279         immutable line = `hello\<h>hello<h>hello<h>hello`;
1280         immutable expanded = line.expandIRCTags(Yes.extendedOutgoingColours, Yes.strip);
1281         immutable expected = "hello<h>hellohellohello";
1282         assert((expanded == expected), expanded);
1283     }
1284 }
1285 
1286 
1287 // expandIRCTags
1288 /++
1289     Slightly more complicated, but essentially string-replaces `<tags>` in an
1290     outgoing IRC string with correlating formatting using
1291     [dialect.common.IRCControlCharacter|IRCControlCharacter]s in their syntax.
1292     Overload that does not take a `strip` [std.typecons.Flag|Flag].
1293 
1294     `<tags>` are the lowercase first letter of all
1295     [dialect.common.IRCControlCharacter|IRCControlCharacter] members;
1296     `<b>` for [dialect.common.IRCControlCharacter.bold|IRCControlCharacter.bold],
1297     `<c>` for [dialect.common.IRCControlCharacter.colour|IRCControlCharacter.colour],
1298     `<i>` for [dialect.common.IRCControlCharacter.italics|IRCControlCharacter.italics],
1299     `<u>` for [dialect.common.IRCControlCharacter.underlined|IRCControlCharacter.underlined],
1300     and the magic `</>` for [dialect.common.IRCControlCharacter.reset|IRCControlCharacter.reset],
1301 
1302     An additional `<h>` tag is also introduced, which invokes [ircColourByHash]
1303     on the content between two of them.
1304 
1305     If the line is not valid UTF, it is sanitised and the expansion retried.
1306 
1307     Example:
1308     ---
1309     // Old
1310     enum pattern = "Quote %s #%s saved.";
1311     immutable message = plugin.state.settings.colouredOutgoing ?
1312         pattern.format(id.ircColourByHash(Yes.extendedOutgoingColours), index.ircBold) :
1313         pattern.format(id, index);
1314     privmsg(plugin.state, event.channel, event.sender.nickname. message);
1315 
1316     // New
1317     enum newPattern = "Quote <h>%s<h> #<b>%d<b> saved.";
1318     immutable newMessage = newPattern.format(id, index);
1319     privmsg(plugin.state, event.channel, event.sender.nickname, newMessage);
1320     ---
1321 
1322     Params:
1323         line = String line to expand IRC tags of.
1324 
1325     Returns:
1326         The passed `line` but with tags expanded to formatting and colouring.
1327  +/
1328 T expandIRCTags(T)(const T line) @system
1329 {
1330     static import kameloso.common;
1331 
1332     debug
1333     {
1334         if (kameloso.common.settings is null)
1335         {
1336             import std.stdio : stdout, writefln;
1337 
1338             // We're likely threading and forgot to initialise global settings
1339             kameloso.common.settings = new typeof(*kameloso.common.settings);
1340 
1341             writefln("-- Warning: attempted to expand IRC tags by relying on " ~
1342                 "global `kameloso.common.settings`, and it was null");
1343             stdout.flush();
1344         }
1345     }
1346 
1347     immutable extendedOutgoingColours =
1348         cast(Flag!"extendedOutgoingColours")kameloso.common.settings.extendedOutgoingColours;
1349     immutable strip = cast(Flag!"strip")!kameloso.common.settings.colouredOutgoing;
1350     return expandIRCTags(line, extendedOutgoingColours, strip);
1351 }
1352 
1353 ///
1354 @system unittest
1355 {
1356     import dialect.common : I = IRCControlCharacter;
1357     import std.conv : text, to;
1358     import std.format : format;
1359 
1360     {
1361         immutable line = "hello";
1362         immutable expanded = line.expandIRCTags;
1363         assert((expanded is line), expanded);
1364     }
1365     {
1366         immutable line = string.init;
1367         immutable expanded = line.expandIRCTags;
1368         assert(expanded is null);
1369     }
1370     {
1371         immutable line = "hello<b>hello<b>hello";
1372         immutable expanded = line.expandIRCTags;
1373         immutable expected = "hello" ~ I.bold ~ "hello" ~ I.bold ~ "hello";
1374         assert((expanded == expected), expanded);
1375     }
1376     {
1377         immutable line = "hello<1>hello<c>hello";
1378         immutable expanded = line.expandIRCTags;
1379         immutable expected = "hello" ~ I.colour ~ "01hello" ~ I.colour ~ "hello";
1380         assert((expanded == expected), expanded);
1381     }
1382     {
1383         immutable line = "hello<3,4>hello<c>hello";
1384         immutable expanded = line.expandIRCTags;
1385         immutable expected = "hello" ~ I.colour ~ "03,04hello" ~ I.colour ~ "hello";
1386         assert((expanded == expected), expanded);
1387     }
1388     {
1389         immutable line = "hello<99,99<b>hiho</>";
1390         immutable expanded = line.expandIRCTags;
1391         immutable expected = "hello<99,99" ~ I.bold ~ "hiho" ~ I.reset;
1392         assert((expanded == expected), expanded);
1393     }
1394     {
1395         immutable line = "hello<99,99><b>hiho</>";
1396         immutable expanded = line.expandIRCTags;
1397         immutable expected = "hello" ~ I.colour ~ "99,99" ~ I.bold ~ "hiho" ~ I.reset;
1398         assert((expanded == expected), expanded);
1399     }
1400     {
1401         immutable line = "hello<99,999><b>hiho</>hey";
1402         immutable expanded = line.expandIRCTags;
1403         immutable expected = "hello<99,999>" ~ I.bold ~ "hiho" ~ I.reset ~ "hey";
1404         assert((expanded == expected), expanded);
1405     }
1406     {
1407         immutable line = `hello\<1,2>hiho`;
1408         immutable expanded = line.expandIRCTags;
1409         immutable expected = `hello<1,2>hiho`;
1410         assert((expanded == expected), expanded);
1411     }
1412     {
1413         immutable line = `hello\\<1,2>hiho`;
1414         immutable expanded = line.expandIRCTags;
1415         immutable expected = `hello\` ~ I.colour ~ "01,02hiho";
1416         assert((expanded == expected), expanded);
1417     }
1418     {
1419         immutable line = "hello<";
1420         immutable expanded = line.expandIRCTags;
1421         assert((expanded is line), expanded);
1422     }
1423     {
1424         immutable line = "hello<<<<";
1425         immutable expanded = line.expandIRCTags;
1426         assert((expanded is line), expanded);
1427     }
1428     {
1429         immutable line = "hello<x>hello<z>";
1430         immutable expanded = line.expandIRCTags;
1431         immutable expected = "hellohello";
1432         assert((expanded == expected), expanded);
1433     }
1434     {
1435         immutable line = "hello<h>kameloso<h>hello";
1436         immutable expanded = line.expandIRCTags;
1437         immutable expected = "hello" ~ I.colour ~ "23kameloso" ~ I.colour ~ "hello";
1438         assert((expanded == expected), expanded);
1439     }
1440     {
1441         immutable line = "hello<h>kameloso";
1442         immutable expanded = line.expandIRCTags;
1443         immutable expected = "hellokameloso";
1444         assert((expanded == expected), expanded);
1445     }
1446     {
1447         immutable line = "hello<3,4>hello<c>hello"d;
1448         immutable expanded = line.expandIRCTags;
1449         immutable expected = "hello"d ~ I.colour ~ "03,04hello"d ~ I.colour ~ "hello"d;
1450         assert((expanded == expected), expanded.to!string);
1451     }
1452     /*{
1453         immutable line = "hello<h>kameloso<h>hello"w;
1454         immutable expanded = line.expandIRCTags;
1455         immutable expected = "hello"w ~ I.colour ~ "01kameloso"w ~ I.colour ~ "hello"w;
1456         assert((expanded == expected), expanded.to!string);
1457     }*/
1458     {
1459         immutable line = "Quote <h>zorael<h> #<b>5<b> saved.";
1460         immutable expanded = line.expandIRCTags;
1461         enum pattern = "Quote %s #%s saved.";
1462         immutable expected = pattern.format(
1463             "zorael".ircColourByHash(Yes.extendedOutgoingColours),
1464             "5".ircBold);
1465         assert((expanded == expected), expanded);
1466     }
1467     {
1468         immutable line = "Stopwatch stopped after <b>5 seconds<b>.";
1469         immutable expanded = line.expandIRCTags;
1470         enum pattern = "Stopwatch stopped after %s.";
1471         immutable expected = pattern.format("5 seconds".ircBold);
1472         assert((expanded == expected), expanded);
1473     }
1474     {
1475         immutable line = "<h>hirrsteff<h> was already <b>whitelist<b> in #garderoben.";
1476         immutable expanded = line.expandIRCTags;
1477         enum pattern = "%s was already %s in #garderoben.";
1478         immutable expected = pattern.format(
1479             "hirrsteff".ircColourByHash(Yes.extendedOutgoingColours),
1480             "whitelist".ircBold);
1481         assert((expanded == expected), expanded);
1482     }
1483     {
1484         immutable line = `hello\<h>hello<h>hello<h>hello`;
1485         immutable expanded = line.expandIRCTags;
1486         immutable expected = text(
1487             "hello<h>hello",
1488             "hello".ircColourByHash(Yes.extendedOutgoingColours),
1489             "hello");
1490         assert((expanded == expected), expanded);
1491     }
1492 }
1493 
1494 
1495 // stripIRCTags
1496 /++
1497     Removes `<tags>` in an outgoing IRC string where the tags correlate to formatting
1498     using [dialect.common.IRCControlCharacter|IRCControlCharacter]s.
1499 
1500     Params:
1501         line = String line to remove IRC tags from.
1502 
1503     Returns:
1504         The passed `line` but with tags removed.
1505  +/
1506 T stripIRCTags(T)(const T line) @system
1507 {
1508     return expandIRCTags(line, No.extendedOutgoingColours, Yes.strip);
1509 }
1510 
1511 ///
1512 @system unittest
1513 {
1514     import std.typecons : Flag, No, Yes;
1515 
1516     {
1517         immutable line = "hello<b>hello<b>hello";
1518         immutable expanded = line.stripIRCTags();
1519         immutable expected = "hellohellohello";
1520         assert((expanded == expected), expanded);
1521     }
1522     {
1523         immutable line = "hello<99,99<b>hiho</>";
1524         immutable expanded = line.stripIRCTags();
1525         immutable expected = "hello<99,99hiho";
1526         assert((expanded == expected), expanded);
1527     }
1528     {
1529         immutable line = "hello<1>hellohello";
1530         immutable expanded = line.stripIRCTags();
1531         immutable expected = "hellohellohello";
1532         assert((expanded == expected), expanded);
1533     }
1534     {
1535         immutable line = `hello\<h>hello<h>hello<h>hello`;
1536         immutable expanded = line.stripIRCTags();
1537         immutable expected = "hello<h>hellohellohello";
1538         assert((expanded == expected), expanded);
1539     }
1540 }
1541 
1542 
1543 // expandIRCTagsImpl
1544 /++
1545     Implementation function for [expandIRCTags]. Kept separate so that
1546     [std.utf.UTFException|UTFException] can be neatly caught.
1547 
1548     Params:
1549         line = String line to expand IRC tags of.
1550         extendedOutgoingColours = Whether or not to use extended colours (16-99).
1551         strip = Whether to expand tags or strip them from the input line.
1552 
1553     Returns:
1554         The passed `line` but with tags expanded to formatting and colouring.
1555 
1556     Throws:
1557         [std.string.indexOf] (used internally) throws [std.utf.UTFException|UTFException]
1558         if the starting index of a lookup doesn't represent a well-formed codepoint.
1559  +/
1560 private T expandIRCTagsImpl(T)
1561     (const T line,
1562     const Flag!"extendedOutgoingColours" extendedOutgoingColours,
1563     const Flag!"strip" strip = No.strip) pure
1564 {
1565     import dialect.common : IRCControlCharacter;
1566     import lu.string : contains;
1567     import std.array : Appender;
1568     import std.range : ElementEncodingType;
1569     import std.string : representation;
1570     import std.traits : Unqual;
1571 
1572     alias E = Unqual!(ElementEncodingType!T);
1573 
1574     if (!line.length || !line.contains('<')) return line;
1575 
1576     Appender!(E[]) sink;
1577     bool dirty;
1578     bool escaping;
1579 
1580     immutable asBytes = line.representation;
1581     immutable toReserve = (asBytes.length + 16);
1582 
1583     byteloop:
1584     for (size_t i; i<asBytes.length; ++i)
1585     {
1586         immutable c = asBytes[i];
1587 
1588         switch (c)
1589         {
1590         case '\\':
1591             if (escaping)
1592             {
1593                 // Always dirty
1594                 sink.put('\\');
1595             }
1596             else
1597             {
1598                 if (!dirty)
1599                 {
1600                     sink.reserve(toReserve);
1601                     sink.put(asBytes[0..i]);
1602                     dirty = true;
1603                 }
1604             }
1605 
1606             escaping = !escaping;
1607             break;
1608 
1609         case '<':
1610             if (escaping)
1611             {
1612                 // Always dirty
1613                 sink.put('<');
1614                 escaping = false;
1615             }
1616             else
1617             {
1618                 import std.string : indexOf;
1619 
1620                 immutable ptrdiff_t closingBracketPos = (cast(T)asBytes[i..$]).indexOf('>');
1621 
1622                 if ((closingBracketPos == -1) || (closingBracketPos > 6))
1623                 {
1624                     if (dirty)
1625                     {
1626                         sink.put(c);
1627                     }
1628                 }
1629                 else
1630                 {
1631                     // Valid; dirties now if not already dirty
1632 
1633                     if (asBytes.length < i+2)
1634                     {
1635                         // Too close to the end to have a meaningful tag
1636                         // Break and return
1637 
1638                         if (dirty)
1639                         {
1640                             // Add rest first
1641                             sink.put(asBytes[i..$]);
1642                         }
1643 
1644                         break byteloop;
1645                     }
1646 
1647                     if (!dirty)
1648                     {
1649                         sink.reserve(toReserve);
1650                         sink.put(asBytes[0..i]);
1651                         dirty = true;
1652                     }
1653 
1654                     immutable slice = asBytes[i+1..i+closingBracketPos];  // mutable
1655 
1656                     if ((slice[0] >= '0') && (slice[0] <= '9'))
1657                     {
1658                         if (!strip)
1659                         {
1660                             static auto getColourChars(S)(S slice)
1661                             {
1662                                 static struct Result
1663                                 {
1664                                     immutable S fg;
1665                                     immutable S bg;
1666                                 }
1667 
1668                                 immutable commaPos = (cast(T)slice).indexOf(',');
1669 
1670                                 if (commaPos != -1)
1671                                 {
1672                                     return Result(slice[0..commaPos], slice[commaPos+1..$]);
1673                                 }
1674                                 else
1675                                 {
1676                                     return Result(slice);
1677                                 }
1678                             }
1679 
1680                             immutable colours = getColourChars(slice);
1681 
1682                             sink.put(cast(char)IRCControlCharacter.colour);
1683                             if (colours.fg.length == 1) sink.put('0');
1684                             sink.put(colours.fg);
1685 
1686                             if (colours.bg.length)
1687                             {
1688                                 sink.put(',');
1689                                 if (colours.bg.length == 1) sink.put('0');
1690                                 sink.put(colours.bg);
1691                             }
1692                         }
1693                     }
1694                     else
1695                     {
1696                         if (slice.length != 1) break;
1697 
1698                         switch (slice[0])
1699                         {
1700                         case 'b':
1701                             if (!strip) sink.put(cast(char)IRCControlCharacter.bold);
1702                             break;
1703 
1704                         case 'c':
1705                             if (!strip) sink.put(cast(char)IRCControlCharacter.colour);
1706                             break;
1707 
1708                         case 'i':
1709                             if (!strip) sink.put(cast(char)IRCControlCharacter.italics);
1710                             break;
1711 
1712                         case 'u':
1713                             if (!strip) sink.put(cast(char)IRCControlCharacter.underlined);
1714                             break;
1715 
1716                         case '/':
1717                             if (!strip) sink.put(cast(char)IRCControlCharacter.reset);
1718                             break;
1719 
1720                         case 'h':
1721                             i += 3;  // advance past "<h>".length
1722                             immutable closingHashMarkPos = (cast(T)asBytes[i..$]).indexOf("<h>");
1723 
1724                             if (closingHashMarkPos == -1)
1725                             {
1726                                 // Revert advance
1727                                 i -= 3;
1728                                 goto default;
1729                             }
1730                             else
1731                             {
1732                                 if (!strip)
1733                                 {
1734                                     sink.put(ircColourByHash(
1735                                         cast(string)asBytes[i..i+closingHashMarkPos],
1736                                         extendedOutgoingColours));
1737                                 }
1738                                 else
1739                                 {
1740                                     sink.put(cast(string)asBytes[i..i+closingHashMarkPos]);
1741                                 }
1742 
1743                                 // Don't advance the full "<h>".length 3
1744                                 // because the for-loop ++i will advance one ahead
1745                                 i += (closingHashMarkPos+2);
1746                                 continue;  // Not break
1747                             }
1748 
1749                         default:
1750                             // Invalid control character, just ignore
1751                             break;
1752                         }
1753                     }
1754 
1755                     i += closingBracketPos;
1756                 }
1757             }
1758             break;
1759 
1760         default:
1761             if (dirty)
1762             {
1763                 sink.put(c);
1764             }
1765             break;
1766         }
1767     }
1768 
1769     return dirty ? sink.data.idup : line;
1770 }