1 /++
2     Implementation of Printer plugin functionality that concerns formatting.
3     For internal use.
4 
5     The [dialect.defs.IRCEvent|IRCEvent]-annotated handlers must be in the same module
6     as the [kameloso.plugins.printer.base.PrinterPlugin|PrinterPlugin],
7     but these implementation functions can be offloaded here to limit module size a bit.
8 
9     See_Also:
10         [kameloso.plugins.printer.base],
11         [kameloso.plugins.printer.logging]
12 
13     Copyright: [JR](https://github.com/zorael)
14     License: [Boost Software License 1.0](https://www.boost.org/users/license.html)
15 
16     Authors:
17         [JR](https://github.com/zorael)
18  +/
19 module kameloso.plugins.printer.formatting;
20 
21 version(WithPrinterPlugin):
22 
23 private:
24 
25 import kameloso.plugins.printer.base;
26 
27 import kameloso.pods : CoreSettings;
28 import dialect.defs;
29 import std.range.primitives : isOutputRange;
30 import std.typecons : Flag, No, Yes;
31 
32 version(Colours) import kameloso.terminal.colours.defs : TerminalForeground;
33 
34 package:
35 
36 @safe:
37 
38 version(Colours)
39 {
40     alias TF = TerminalForeground;
41 
42     /++
43         Default colours for printing events on a dark terminal background.
44      +/
45     enum EventPrintingDark : TerminalForeground
46     {
47         type      = TF.lightblue,
48         error     = TF.lightred,
49         sender    = TF.lightgreen,
50         target    = TF.cyan,
51         channel   = TF.yellow,
52         content   = TF.default_,
53         aux       = TF.darkgrey,
54         count     = TF.green,
55         num       = TF.darkgrey,
56         badge     = TF.white,
57         emote     = TF.cyan,
58         highlight = TF.white,
59         query     = TF.lightgreen,
60     }
61 
62     /++
63         Default colours for printing events on a bright terminal background.
64      +/
65     enum EventPrintingBright : TerminalForeground
66     {
67         type      = TF.blue,
68         error     = TF.red,
69         sender    = TF.green,
70         target    = TF.cyan,
71         channel   = TF.yellow,
72         content   = TF.default_,
73         aux       = TF.default_,
74         count     = TF.lightgreen,
75         num       = TF.default_,
76         badge     = TF.black,
77         emote     = TF.lightcyan,
78         highlight = TF.black,
79         query     = TF.green,
80     }
81 }
82 
83 
84 // put
85 /++
86     Puts a variadic list of values into an output range sink.
87 
88     Params:
89         colours = Whether or not to accept terminal colour tokens and use
90             them to tint the text.
91         sink = Output range to sink items into.
92         args = Variadic list of things to put into the output range.
93  +/
94 void put(Flag!"colours" colours = No.colours, Sink, Args...)
95     (auto ref Sink sink, Args args)
96 if (isOutputRange!(Sink, char[]))
97 {
98     foreach (arg; args)
99     {
100         alias T = typeof(arg);
101 
102         static if (__traits(compiles, sink.put(T.init)) && !is(T : bool))
103         {
104             sink.put(arg);
105         }
106         else static if (is(T == enum))
107         {
108             import lu.conv : Enum;
109             import std.traits : Unqual;
110 
111             sink.put(Enum!(Unqual!T).toString(arg));
112         }
113         else static if (is(T : bool))
114         {
115             sink.put(arg ? "true" : "false");
116         }
117         else static if (is(T : long))
118         {
119             import lu.conv : toAlphaInto;
120             arg.toAlphaInto(sink);
121         }
122         else
123         {
124             import std.conv : to;
125             sink.put(arg.to!string);
126         }
127     }
128 }
129 
130 ///
131 unittest
132 {
133     import std.array : Appender;
134 
135     Appender!(char[]) sink;
136 
137     .put(sink, "abc", long.min, "def", 456, true);
138     assert((sink.data == "abc-9223372036854775808def456true"), sink.data);
139 }
140 
141 
142 // formatMessageMonochrome
143 /++
144     Formats an [dialect.defs.IRCEvent|IRCEvent] into an output range sink, in monochrome.
145 
146     It formats the timestamp, the type of the event, the sender or sender alias,
147     the channel or target, the content body, as well as auxiliary information.
148 
149     Params:
150         plugin = Current [kameloso.plugins.printer.base.PrinterPlugin|PrinterPlugin].
151         sink = Output range to format the [dialect.defs.IRCEvent|IRCEvent] into.
152         event = The [dialect.defs.IRCEvent|IRCEvent] that is to be formatted.
153         bellOnMention = Whether or not to emit a terminal bell when the bot's
154             nickname is mentioned in chat.
155         bellOnError = Whether or not to emit a terminal bell when an error occurred.
156  +/
157 void formatMessageMonochrome(Sink)
158     (PrinterPlugin plugin,
159     auto ref Sink sink,
160     const ref IRCEvent event,
161     const Flag!"bellOnMention" bellOnMention,
162     const Flag!"bellOnError" bellOnError)
163 if (isOutputRange!(Sink, char[]))
164 {
165     import kameloso.irccolours : stripEffects;
166     import lu.conv : Enum;
167     import std.algorithm.comparison : equal;
168     import std.algorithm.iteration : filter;
169     import std.datetime : DateTime;
170     import std.datetime.systime : SysTime;
171     import std.format : formattedWrite;
172     import std.uni : asLowerCase;
173 
174     immutable typestring = Enum!(IRCEvent.Type).toString(event.type).withoutTypePrefix;
175     string content = stripEffects(event.content);  // mutable
176     bool shouldBell;
177 
178     static if (!__traits(hasMember, Sink, "data"))
179     {
180         scope(exit)
181         {
182             sink.put('\n');
183         }
184     }
185 
186     void putSender()
187     {
188         if (event.sender.isServer)
189         {
190             sink.put(event.sender.address);
191             return;
192         }
193 
194         bool putDisplayName;
195 
196         version(TwitchSupport)
197         {
198             if (event.sender.displayName.length)
199             {
200                 sink.put(event.sender.displayName);
201                 putDisplayName = true;
202 
203                 if ((event.sender.displayName != event.sender.nickname) &&
204                     !event.sender.displayName.asLowerCase.equal(event.sender.nickname))
205                 {
206                     .put(sink, " (", event.sender.nickname, ')');
207                 }
208             }
209         }
210 
211         if (!putDisplayName && event.sender.nickname.length)
212         {
213             // Can be no-nick special: [PING] *2716423853
214             sink.put(event.sender.nickname);
215         }
216 
217         version(PrintClassNamesToo)
218         {
219             .put(sink, ':', event.sender.class_);
220         }
221 
222         version(PrintAccountNamesToo)
223         {
224             // No need to check for nickname.length, I think
225             if ((plugin.state.server.daemon != IRCServer.Daemon.twitch) &&
226                 event.sender.account.length)
227             {
228                 .put(sink, '(', event.sender.account, ')');
229             }
230         }
231 
232         version(TwitchSupport)
233         {
234             if ((plugin.state.server.daemon == IRCServer.Daemon.twitch) &&
235                 plugin.printerSettings.twitchBadges && event.sender.badges.length)
236             {
237                 with (IRCEvent.Type)
238                 switch (event.type)
239                 {
240                 case JOIN:
241                 case SELFJOIN:
242                 case PART:
243                 case SELFPART:
244                 case QUERY:
245                 //case SELFQUERY:  // Doesn't seem to happen
246                     break;
247 
248                 default:
249                     .put(sink, " [", event.sender.badges, ']');
250                     break;
251                 }
252             }
253         }
254     }
255 
256     void putTarget()
257     {
258         bool putArrow;
259         bool putDisplayName;
260 
261         version(TwitchSupport)
262         {
263             with (IRCEvent.Type)
264             switch (event.type)
265             {
266             case TWITCH_GIFTCHAIN:
267                 // Add more as they become apparent
268                 sink.put(" <- ");
269                 break;
270 
271             default:
272                 sink.put(" -> ");
273                 break;
274             }
275 
276             putArrow = true;
277 
278             if (event.target.displayName.length)
279             {
280                 sink.put(event.target.displayName);
281                 putDisplayName = true;
282 
283                 if ((event.target.displayName != event.target.nickname) &&
284                     !event.target.displayName.asLowerCase.equal(event.target.nickname))
285                 {
286                     .put(sink, " (", event.target.nickname, ')');
287                 }
288             }
289         }
290 
291         if (!putArrow)
292         {
293             sink.put(" -> ");
294         }
295 
296         if (!putDisplayName)
297         {
298             sink.put(event.target.nickname);
299         }
300 
301         version(PrintClassNamesToo)
302         {
303             .put(sink, ':', event.target.class_);
304         }
305 
306         version(PrintAccountNamesToo)
307         {
308             // No need to check for nickname.length, I think
309             if ((plugin.state.server.daemon != IRCServer.Daemon.twitch) &&
310                 event.target.account.length)
311             {
312                 .put(sink, '(', event.target.account, ')');
313             }
314         }
315 
316         version(TwitchSupport)
317         {
318             if ((plugin.state.server.daemon == IRCServer.Daemon.twitch) &&
319                 plugin.printerSettings.twitchBadges && event.target.badges.length)
320             {
321                 .put(sink, " [", event.target.badges, ']');
322             }
323         }
324     }
325 
326     void putContent()
327     {
328         if (event.sender.isServer || event.sender.nickname.length)
329         {
330             immutable isEmote = (event.type == IRCEvent.Type.EMOTE) ||
331                 (event.type == IRCEvent.Type.SELFEMOTE);
332 
333             if (isEmote)
334             {
335                 sink.put(' ');
336             }
337             else
338             {
339                 sink.put(`: "`);
340             }
341 
342             with (IRCEvent.Type)
343             switch (event.type)
344             {
345             case CHAN:
346             case EMOTE:
347             case TWITCH_SUBGIFT:
348                 if (plugin.state.client.nickname.length &&
349                     content.containsNickname(plugin.state.client.nickname))
350                 {
351                     // Nick was mentioned (certain)
352                     shouldBell = bellOnMention;
353                 }
354                 break;
355 
356             default:
357                 break;
358             }
359 
360             sink.put(content);
361             if (!isEmote) sink.put('"');
362         }
363         else
364         {
365             // PING or ERROR likely
366             sink.put(content);  // No need for indenting space
367         }
368     }
369 
370     sink.put('[');
371 
372     (cast(DateTime)SysTime
373         .fromUnixTime(event.time))
374         .timeOfDay
375         .toString(sink);
376 
377     sink.put("] [");
378 
379     if (plugin.printerSettings.uppercaseTypes)
380     {
381         sink.put(typestring);
382     }
383     else
384     {
385         sink.put(typestring.asLowerCase);
386     }
387 
388     sink.put("] ");
389 
390     if (event.channel.length) .put(sink, '[', event.channel, "] ");
391 
392     putSender();
393 
394     bool putQuotedTwitchMessage;
395     auto auxRange = event.aux[].filter!(s => s.length);
396 
397     version(TwitchSupport)
398     {
399         if (((event.type == IRCEvent.Type.CHAN) ||
400              (event.type == IRCEvent.Type.SELFCHAN) ||
401              (event.type == IRCEvent.Type.EMOTE)) &&
402             event.target.nickname.length &&
403             event.aux[0].length)
404         {
405             /*if (content.length)*/ putContent();
406             putTarget();
407             .put(sink, `: "`, event.aux[0], '"');
408 
409             putQuotedTwitchMessage = true;
410             auxRange.popFront();
411         }
412     }
413 
414     if (!putQuotedTwitchMessage)
415     {
416         if (event.target.nickname.length) putTarget();
417         if (content.length) putContent();
418     }
419 
420     if (!auxRange.empty)
421     {
422         enum pattern = " (%-(%s%|) (%))";
423 
424         static if ((__VERSION__ == 2101L) || (__VERSION__ == 2102L))
425         {
426             import std.array : array;
427             // "Deprecation: scope variable `aux` assigned to non-scope parameter `_param_2` calling `formattedWrite"
428             // Seemingly only on 2.101 and 2.102
429             sink.formattedWrite(pattern, auxRange.array.dup);
430         }
431         else
432         {
433             sink.formattedWrite(pattern, auxRange);
434         }
435     }
436 
437     auto countRange = event.count[].filter!(n => !n.isNull);
438 
439     if (!countRange.empty)
440     {
441         enum pattern = " {%-(%s%|} {%)}";
442         sink.formattedWrite(pattern, countRange);
443     }
444 
445     if (event.num > 0)
446     {
447         import lu.conv : toAlphaInto;
448 
449         sink.put(" [#");
450         event.num.toAlphaInto!(3, 3)(sink);
451         sink.put(']');
452     }
453 
454     if (event.errors.length)
455     {
456         .put(sink, " ! ", event.errors, " !");
457     }
458 
459     shouldBell = shouldBell ||
460         ((event.type == IRCEvent.Type.QUERY) && bellOnMention) ||
461         (event.errors.length && bellOnError);
462 
463     if (shouldBell) sink.put(plugin.bell);
464 }
465 
466 ///
467 @system unittest
468 {
469     import kameloso.plugins.common.core : IRCPluginState;
470     import std.array : Appender;
471 
472     Appender!(char[]) sink;
473 
474     IRCPluginState state;
475     state.server.daemon = IRCServer.Daemon.twitch;
476     PrinterPlugin plugin = new PrinterPlugin(state);
477 
478     IRCEvent event;
479 
480     with (event.sender)
481     {
482         nickname = "nickname";
483         address = "127.0.0.1";
484         version(TwitchSupport) displayName = "Nickname";
485         //account = "n1ckn4m3";
486         class_ = IRCUser.Class.whitelist;
487     }
488 
489     event.type = IRCEvent.Type.JOIN;
490     event.channel = "#channel";
491 
492     formatMessageMonochrome(plugin, sink, event, No.bellOnMention, No.bellOnError);
493     immutable joinLine = sink.data[11..$].idup;
494     version(TwitchSupport) assert((joinLine == "[join] [#channel] Nickname"), joinLine);
495     else assert((joinLine == "[join] [#channel] nickname"), joinLine);
496     sink.clear();
497 
498     event.type = IRCEvent.Type.CHAN;
499     event.content = "Harbl snarbl";
500 
501     formatMessageMonochrome(plugin, sink, event, No.bellOnMention, No.bellOnError);
502     immutable chanLine = sink.data[11..$].idup;
503     version(TwitchSupport) assert((chanLine == `[chan] [#channel] Nickname: "Harbl snarbl"`), chanLine);
504     else assert((chanLine == `[chan] [#channel] nickname: "Harbl snarbl"`), chanLine);
505     sink.clear();
506 
507     version(TwitchSupport)
508     {
509         event.sender.badges = "broadcaster/0,moderator/1,subscriber/9";
510         //colour = "#3c507d";
511 
512         formatMessageMonochrome(plugin, sink, event, No.bellOnMention, No.bellOnError);
513         immutable twitchLine = sink.data[11..$].idup;
514         assert((twitchLine == `[chan] [#channel] Nickname [broadcaster/0,moderator/1,subscriber/9]: "Harbl snarbl"`),
515             twitchLine);
516         sink.clear();
517         event.sender.badges = string.init;
518     }
519 
520     event.type = IRCEvent.Type.ACCOUNT;
521     event.channel = string.init;
522     event.content = string.init;
523     event.sender.account = "n1ckn4m3";
524     event.aux[0] = "n1ckn4m3";
525 
526     formatMessageMonochrome(plugin, sink, event, No.bellOnMention, No.bellOnError);
527     immutable accountLine = sink.data[11..$].idup;
528     version(TwitchSupport) assert((accountLine == "[account] Nickname (n1ckn4m3)"), accountLine);
529     else assert((accountLine == "[account] nickname (n1ckn4m3)"), accountLine);
530     sink.clear();
531 
532     event.errors = "DANGER WILL ROBINSON";
533     event.content = "Blah balah";
534     event.num = 666;
535     event.count[0] = -42;
536     event.count[1] = 123;
537     event.count[5] = 420;
538     event.aux[0] = string.init;
539     event.aux[1] = "aux1";
540     event.aux[4] = "aux5";
541     event.type = IRCEvent.Type.ERROR;
542 
543     formatMessageMonochrome(plugin, sink, event, No.bellOnMention, No.bellOnError);
544     immutable errorLine = sink.data[11..$].idup;
545     version(TwitchSupport)
546     {
547         enum expected = `[error] Nickname: "Blah balah" (aux1) (aux5) ` ~
548             "{-42} {123} {420} [#666] ! DANGER WILL ROBINSON !";
549         assert((errorLine == expected), errorLine);
550     }
551     else
552     {
553         enum expected = `[error] nickname: "Blah balah" (aux1) (aux5) ` ~
554             "{-42} {123} {420} [#666] ! DANGER WILL ROBINSON !";
555         assert((errorLine == expected), errorLine);
556     }
557     //sink.clear();
558 }
559 
560 
561 // formatMessageColoured
562 /++
563     Formats an [dialect.defs.IRCEvent|IRCEvent] into an output range sink, coloured.
564 
565     It formats the timestamp, the type of the event, the sender or the sender's
566     display name, the channel or target, the content body, as well as auxiliary
567     information and numbers.
568 
569     Params:
570         plugin = Current [kameloso.plugins.printer.base.PrinterPlugin|PrinterPlugin].
571         sink = Output range to format the [dialect.defs.IRCEvent|IRCEvent] into.
572         event = The [dialect.defs.IRCEvent|IRCEvent] that is to be formatted.
573         bellOnMention = Whether or not to emit a terminal bell when the bot's
574             nickname is mentioned in chat.
575         bellOnError = Whether or not to emit a terminal bell when an error occurred.
576  +/
577 version(Colours)
578 void formatMessageColoured(Sink)
579     (PrinterPlugin plugin,
580     auto ref Sink sink,
581     const ref IRCEvent event,
582     const Flag!"bellOnMention" bellOnMention,
583     const Flag!"bellOnError" bellOnError)
584 if (isOutputRange!(Sink, char[]))
585 {
586     import kameloso.constants : DefaultColours;
587     import kameloso.terminal.colours.defs : ANSICodeType, TerminalReset;
588     import kameloso.terminal.colours : applyANSI;
589     import lu.conv : Enum;
590     import std.algorithm.iteration : filter;
591     import std.datetime : DateTime;
592     import std.datetime.systime : SysTime;
593     import std.format : formattedWrite;
594     import std.uni : asLowerCase;
595 
596     alias Bright = EventPrintingBright;
597     alias Dark = EventPrintingDark;
598     alias Timestamp = DefaultColours.TimestampColour;
599 
600     immutable rawTypestring = Enum!(IRCEvent.Type).toString(event.type);
601     immutable typestring = rawTypestring.withoutTypePrefix;
602     string content = event.content;  // mutable, don't strip
603     bool shouldBell;
604 
605     immutable bright = cast(Flag!"brightTerminal")plugin.state.settings.brightTerminal;
606 
607     version(TwitchSupport)
608     {
609         immutable normalise = cast(Flag!"normalise")plugin.printerSettings.normaliseTruecolour;
610     }
611 
612     /++
613         Outputs a terminal ANSI colour token based on the hash of the passed
614         nickname.
615 
616         It gives each user a random yet consistent colour to their name.
617      +/
618     uint colourByHash(const string nickname)
619     {
620         import kameloso.irccolours : ircANSIColourMap;
621         import kameloso.terminal.colours : getColourByHash;
622 
623         if (!plugin.printerSettings.colourfulNicknames)
624         {
625             // Don't differentiate between sender and target? Consistency?
626             return plugin.state.settings.brightTerminal ? Bright.sender : Dark.sender;
627         }
628 
629         return getColourByHash(nickname, plugin.state.settings);
630     }
631 
632     /++
633         Outputs a terminal truecolour token based on the #RRGGBB value stored in
634         `user.colour`.
635 
636         This is for Twitch servers that assign such values to users' messages.
637         By catching it we can honour the setting by tinting users accordingly.
638      +/
639     void colourUserTruecolour(const IRCUser user)
640     {
641         bool coloured;
642 
643         version(TwitchSupport)
644         {
645             if (!user.isServer &&
646                 user.colour.length &&
647                 plugin.printerSettings.truecolour &&
648                 plugin.state.settings.extendedColours)
649             {
650                 import kameloso.terminal.colours : applyTruecolour;
651                 import lu.conv : rgbFromHex;
652 
653                 auto rgb = rgbFromHex(user.colour);
654                 sink.applyTruecolour(rgb.r, rgb.g, rgb.b, bright, normalise);
655                 coloured = true;
656             }
657         }
658 
659         if (!coloured)
660         {
661             immutable name = user.isServer ?
662                 user.address :
663                 ((user.account.length && plugin.printerSettings.colourByAccount) ?
664                     user.account :
665                     user.nickname);
666 
667             sink.applyANSI(colourByHash(name), ANSICodeType.foreground);
668         }
669     }
670 
671     static if (!__traits(hasMember, Sink, "data"))
672     {
673         scope(exit)
674         {
675             sink.put('\n');
676         }
677     }
678 
679     void putSender()
680     {
681         scope(exit) sink.applyANSI(TerminalReset.all);
682 
683         colourUserTruecolour(event.sender);
684 
685         if (event.sender.isServer)
686         {
687             sink.put(event.sender.address);
688             return;
689         }
690 
691         bool putDisplayName;
692 
693         version(TwitchSupport)
694         {
695             if (event.sender.displayName.length)
696             {
697                 sink.put(event.sender.displayName);
698                 putDisplayName = true;
699 
700                 import std.algorithm.comparison : equal;
701                 import std.uni : asLowerCase;
702 
703                 if ((event.sender.displayName != event.sender.nickname) &&
704                     !event.sender.displayName.asLowerCase.equal(event.sender.nickname))
705                 {
706                     sink.applyANSI(TerminalReset.all);
707                     sink.put(" (");
708                     colourUserTruecolour(event.sender);
709                     sink.put(event.sender.nickname);
710                     sink.applyANSI(TerminalReset.all);
711                     sink.put(')');
712                 }
713             }
714         }
715 
716         if (!putDisplayName && event.sender.nickname.length)
717         {
718             // Can be no-nick special: [PING] *2716423853
719             sink.put(event.sender.nickname);
720         }
721 
722         version(PrintClassNamesToo)
723         {
724             sink.applyANSI(TerminalReset.all);
725             .put(sink, ':', event.sender.class_);
726         }
727 
728         version(PrintAccountNamesToo)
729         {
730             // No need to check for nickname.length, I think
731             if ((plugin.state.server.daemon != IRCServer.Daemon.twitch) &&
732                 event.sender.account.length)
733             {
734                 sink.applyANSI(TerminalReset.all);
735                 .put(sink, '(', event.sender.account, ')');
736             }
737         }
738 
739         version(TwitchSupport)
740         {
741             if ((plugin.state.server.daemon == IRCServer.Daemon.twitch) &&
742                 plugin.printerSettings.twitchBadges &&
743                 event.sender.badges.length)
744             {
745                 with (IRCEvent.Type)
746                 switch (event.type)
747                 {
748                 case JOIN:
749                 case SELFJOIN:
750                 case PART:
751                 case SELFPART:
752                     break;
753 
754                 default:
755                     immutable code = bright ? Bright.badge : Dark.badge;
756                     sink.applyANSI(TerminalReset.all);
757                     sink.applyANSI(code, ANSICodeType.foreground);
758                     .put(sink, " [", event.sender.badges, ']');
759                     break;
760                 }
761             }
762         }
763     }
764 
765     void putTarget()
766     {
767         scope(exit) sink.applyANSI(TerminalReset.all, ANSICodeType.reset);
768 
769         bool putArrow;
770         bool putDisplayName;
771 
772         version(TwitchSupport)
773         {
774             with (IRCEvent.Type)
775             switch (event.type)
776             {
777             case TWITCH_GIFTCHAIN:
778                 // Add more as they become apparent
779                 sink.applyANSI(TerminalReset.all);
780                 sink.put(" <- ");
781                 break;
782 
783             default:
784                 sink.applyANSI(TerminalReset.all);
785                 sink.put(" -> ");
786                 break;
787             }
788 
789             colourUserTruecolour(event.target);
790             putArrow = true;
791 
792             if (event.target.displayName.length)
793             {
794                 sink.put(event.target.displayName);
795                 putDisplayName = true;
796 
797                 import std.algorithm.comparison : equal;
798                 import std.uni : asLowerCase;
799 
800                 if ((event.target.displayName != event.target.nickname) &&
801                     !event.target.displayName.asLowerCase.equal(event.target.nickname))
802                 {
803                     sink.put(" (");
804                     colourUserTruecolour(event.target);
805                     sink.put(event.target.nickname);
806                     sink.applyANSI(TerminalReset.all);
807                     sink.put(')');
808                 }
809             }
810         }
811 
812         if (!putArrow)
813         {
814             // No need to check isServer; target is never server
815             sink.applyANSI(TerminalReset.all);
816             sink.put(" -> ");
817             colourUserTruecolour(event.target);
818         }
819 
820         if (!putDisplayName)
821         {
822             sink.put(event.target.nickname);
823         }
824 
825         version(PrintClassNamesToo)
826         {
827             sink.applyANSI(TerminalReset.all);
828             .put(sink, ':', event.target.class_);
829         }
830 
831         version(PrintAccountNamesToo)
832         {
833             // No need to check for nickname.length, I think
834             if ((plugin.state.server.daemon != IRCServer.Daemon.twitch) &&
835                 event.target.account.length)
836             {
837                 sink.applyANSI(TerminalReset.all);
838                 sink.put('(', event.target.account, ')');
839             }
840         }
841 
842         version(TwitchSupport)
843         {
844             if ((plugin.state.server.daemon == IRCServer.Daemon.twitch) &&
845                 plugin.printerSettings.twitchBadges &&
846                 event.target.badges.length)
847             {
848                 immutable code = bright ? Bright.badge : Dark.badge;
849                 sink.applyANSI(TerminalReset.all);
850                 sink.applyANSI(code, ANSICodeType.foreground);
851                 .put(sink, " [", event.target.badges, ']');
852             }
853         }
854     }
855 
856     void putContent()
857     {
858         import kameloso.terminal.colours.defs : ANSICodeType, TerminalBackground, TerminalForeground;
859         import kameloso.terminal.colours : applyANSI;
860 
861         scope(exit) sink.applyANSI(TerminalReset.all);
862 
863         immutable TerminalForeground contentFgBase = bright ? Bright.content : Dark.content;
864         immutable TerminalForeground emoteFgBase = bright ? Bright.emote : Dark.emote;
865         immutable isEmote =
866             (event.type == IRCEvent.Type.EMOTE) ||
867             (event.type == IRCEvent.Type.SELFEMOTE);
868         immutable fgBase = isEmote ? emoteFgBase : contentFgBase;
869 
870         sink.applyANSI(fgBase, ANSICodeType.foreground);  // Always grey colon and SASL +, prepare for emote
871 
872         if (!event.sender.isServer && !event.sender.nickname.length)
873         {
874             // PING or ERROR likely
875             sink.put(content);  // No need for delimiter space
876             return;
877         }
878 
879         if (isEmote)
880         {
881             sink.put(' ');
882         }
883         else
884         {
885             sink.put(`: "`);
886         }
887 
888         if (plugin.state.server.daemon != IRCServer.Daemon.twitch)
889         {
890             import kameloso.irccolours : mapEffects;
891             // Twitch chat has no colours or effects, only emotes
892             content = mapEffects(content, fgBase);
893         }
894         else
895         {
896             version(TwitchSupport)
897             {
898                 content = highlightEmotes(
899                     event,
900                     cast(Flag!"colourful")plugin.printerSettings.colourfulEmotes,
901                     plugin.state.settings);
902             }
903         }
904 
905         with (IRCEvent.Type)
906         switch (event.type)
907         {
908         case CHAN:
909         case EMOTE:
910         case TWITCH_SUBGIFT:
911         //case SELFCHAN:
912             import kameloso.terminal.colours : invert;
913 
914             /// Nick was mentioned (certain)
915             bool match;
916             string inverted = content;
917 
918             if (content.containsNickname(plugin.state.client.nickname))
919             {
920                 inverted = content.invert(plugin.state.client.nickname);
921                 match = true;
922             }
923 
924             version(TwitchSupport)
925             {
926                 // If available, also highlight the display name alias
927                 if (plugin.state.client.displayName.length &&
928                     (plugin.state.client.nickname != plugin.state.client.displayName) &&
929                     content.containsNickname(plugin.state.client.displayName))
930                 {
931                     inverted = inverted.invert(plugin.state.client.displayName);
932                     match = true;
933                 }
934             }
935 
936             if (!match) goto default;
937 
938             sink.put(inverted);
939             shouldBell = bellOnMention;
940             break;
941 
942         default:
943             // Normal non-highlighting channel message
944             sink.put(content);
945             break;
946         }
947 
948         // Reset the background to ward off bad backgrounds bleeding out
949         sink.applyANSI(fgBase, ANSICodeType.foreground); //, TerminalBackground.default_);
950         sink.applyANSI(TerminalBackground.default_);
951         if (!isEmote) sink.put('"');
952     }
953 
954     immutable timestampCode = bright ? Timestamp.bright : Timestamp.dark;
955     sink.applyANSI(timestampCode, ANSICodeType.foreground);
956     sink.put('[');
957 
958     (cast(DateTime)SysTime
959         .fromUnixTime(event.time))
960         .timeOfDay
961         .toString(sink);
962 
963     sink.put(']');
964 
965     import lu.string : beginsWith;
966 
967     if ((event.type == IRCEvent.Type.ERROR) ||
968         (event.type == IRCEvent.Type.TWITCH_ERROR) ||
969         rawTypestring.beginsWith("ERR_"))
970     {
971         sink.applyANSI(bright ? Bright.error : Dark.error);
972     }
973     else
974     {
975         if (bright)
976         {
977             immutable code = (event.type == IRCEvent.Type.QUERY) ? Bright.query : Bright.type;
978             sink.applyANSI(code, ANSICodeType.foreground);
979         }
980         else
981         {
982             immutable code = (event.type == IRCEvent.Type.QUERY) ? Dark.query : Dark.type;
983             sink.applyANSI(code, ANSICodeType.foreground);
984         }
985     }
986 
987     sink.put(" [");
988 
989     if (plugin.printerSettings.uppercaseTypes)
990     {
991         sink.put(typestring);
992     }
993     else
994     {
995         sink.put(typestring.asLowerCase);
996     }
997 
998     sink.put("] ");
999 
1000     if (event.channel.length)
1001     {
1002         immutable code = bright ? Bright.channel : Dark.channel;
1003         sink.applyANSI(code, ANSICodeType.foreground);
1004         .put(sink, '[', event.channel, "] ");
1005     }
1006 
1007     putSender();
1008 
1009     bool putQuotedTwitchMessage;
1010     auto auxRange = event.aux[].filter!(s => s.length);
1011 
1012     version(TwitchSupport)
1013     {
1014         if (((event.type == IRCEvent.Type.CHAN) ||
1015              (event.type == IRCEvent.Type.SELFCHAN) ||
1016              (event.type == IRCEvent.Type.EMOTE)) &&
1017             event.target.nickname.length &&
1018             event.aux[0].length)
1019         {
1020             /*if (content.length)*/ putContent();
1021             putTarget();
1022             immutable code = bright ? Bright.content : Dark.content;
1023             sink.applyANSI(code, ANSICodeType.foreground);
1024             .put(sink, `: "`, event.aux[0], '"');
1025 
1026             putQuotedTwitchMessage = true;
1027             auxRange.popFront();
1028         }
1029     }
1030 
1031     if (!putQuotedTwitchMessage)
1032     {
1033         if (event.target.nickname.length) putTarget();
1034         if (content.length) putContent();
1035     }
1036 
1037     if (!auxRange.empty)
1038     {
1039         enum pattern = " (%-(%s%|) (%))";
1040         sink.applyANSI(bright ? Bright.aux : Dark.aux);
1041 
1042         static if ((__VERSION__ == 2101L) || (__VERSION__ == 2102L))
1043         {
1044             import std.array : array;
1045             // "Deprecation: scope variable `aux` assigned to non-scope parameter `_param_2` calling `formattedWrite"
1046             // Seemingly only on 2.101 and 2.102
1047             sink.formattedWrite(pattern, auxRange.array.dup);
1048         }
1049         else
1050         {
1051             sink.formattedWrite(pattern, auxRange);
1052         }
1053     }
1054 
1055     auto countRange = event.count[].filter!(n => !n.isNull);
1056 
1057     if (!countRange.empty)
1058     {
1059         enum pattern = " {%-(%s%|} {%)}";
1060         sink.applyANSI(bright ? Bright.count : Dark.count);
1061         sink.formattedWrite(pattern, countRange);
1062     }
1063 
1064     if (event.num > 0)
1065     {
1066         import lu.conv : toAlphaInto;
1067 
1068         sink.applyANSI(bright ? Bright.num : Dark.num);
1069         sink.put(" [#");
1070         event.num.toAlphaInto!(3, 3)(sink);
1071         sink.put(']');
1072     }
1073 
1074     if (event.errors.length)
1075     {
1076         immutable code = bright ? Bright.error : Dark.error;
1077         sink.applyANSI(code, ANSICodeType.foreground);
1078         .put(sink, " ! ", event.errors, " !");
1079     }
1080 
1081     sink.applyANSI(TerminalReset.all);
1082 
1083     shouldBell = shouldBell ||
1084         ((event.type == IRCEvent.Type.QUERY) && bellOnMention) ||
1085         (event.errors.length && bellOnError);
1086 
1087     if (shouldBell) sink.put(plugin.bell);
1088 }
1089 
1090 
1091 // withoutTypePrefix
1092 /++
1093     Slices away any type prefixes from the string of a
1094     [dialect.defs.IRCEvent.Type|IRCEvent.Type].
1095 
1096     Only for shared use in [formatMessageMonochrome] and [formatMessageColoured].
1097 
1098     Example:
1099     ---
1100     immutable typestring1 = "PRIVMSG".withoutTypePrefix;
1101     assert((typestring1 == "PRIVMSG"), typestring1);  // passed through
1102 
1103     immutable typestring2 = "ERR_NOSUCHNICK".withoutTypePrefix;
1104     assert((typestring2 == "NOSUCHNICK"), typestring2);
1105 
1106     immutable typestring3 = "RPL_LIST".withoutTypePrefix;
1107     assert((typestring3 == "LIST"), typestring3);
1108     ---
1109 
1110     Params:
1111         typestring = The string form of a [dialect.defs.IRCEvent.Type|IRCEvent.Type].
1112 
1113     Returns:
1114         A slice of the passed `typestring`, excluding any prefixes if present.
1115  +/
1116 auto withoutTypePrefix(const string typestring) @safe pure nothrow @nogc @property
1117 {
1118     import lu.string : beginsWith;
1119 
1120     if (typestring.beginsWith("RPL_") || typestring.beginsWith("ERR_"))
1121     {
1122         return typestring[4..$];
1123     }
1124     else
1125     {
1126         version(TwitchSupport)
1127         {
1128             if (typestring.beginsWith("TWITCH_"))
1129             {
1130                 return typestring[7..$];
1131             }
1132         }
1133     }
1134 
1135     return typestring;  // as is
1136 }
1137 
1138 ///
1139 unittest
1140 {
1141     {
1142         immutable typestring = "RPL_ENDOFMOTD";
1143         immutable without = typestring.withoutTypePrefix;
1144         assert((without == "ENDOFMOTD"), without);
1145     }
1146     {
1147         immutable typestring = "ERR_CHANOPRIVSNEEDED";
1148         immutable without = typestring.withoutTypePrefix;
1149         assert((without == "CHANOPRIVSNEEDED"), without);
1150     }
1151     version(TwitchSupport)
1152     {{
1153         immutable typestring = "TWITCH_USERSTATE";
1154         immutable without = typestring.withoutTypePrefix;
1155         assert((without == "USERSTATE"), without);
1156     }}
1157     {
1158         immutable typestring = "PRIVMSG";
1159         immutable without = typestring.withoutTypePrefix;
1160         assert((without == "PRIVMSG"), without);
1161     }
1162 }
1163 
1164 
1165 // highlightEmotes
1166 /++
1167     Tints emote strings and highlights Twitch emotes in a ref
1168     [dialect.defs.IRCEvent|IRCEvent]'s `content` member.
1169 
1170     Wraps [highlightEmotesImpl].
1171 
1172     Params:
1173         event = [dialect.defs.IRCEvent|IRCEvent] whose content text to highlight.
1174         colourful = Whether or not emotes should be highlit in colours.
1175         brightTerminal = Whether or not the terminal has a bright background
1176             and colours should be adapted to suit.
1177 
1178     Returns:
1179         A new string of the passed [dialect.defs.IRCEvent|IRCEvent]'s `content` member
1180         with any emotes highlighted, or said `content` member as-is if there weren't any.
1181  +/
1182 version(Colours)
1183 version(TwitchSupport)
1184 auto highlightEmotes(
1185     const ref IRCEvent event,
1186     const Flag!"colourful" colourful,
1187     const CoreSettings settings)
1188 {
1189     import kameloso.constants : DefaultColours;
1190     import kameloso.terminal.colours : applyANSI;
1191     import lu.string : contains;
1192     import std.array : Appender;
1193 
1194     alias Bright = EventPrintingBright;
1195     alias Dark = EventPrintingDark;
1196 
1197     if (!event.emotes.length) return event.content;
1198 
1199     static Appender!(char[]) sink;
1200     scope(exit) sink.clear();
1201     sink.reserve(event.content.length + 60);  // mostly +10
1202 
1203     immutable TerminalForeground highlight = settings.brightTerminal ?
1204         Bright.highlight :
1205         Dark.highlight;
1206     immutable isEmoteOnly = !colourful && event.tags.contains("emote-only=1");
1207 
1208     with (IRCEvent.Type)
1209     switch (event.type)
1210     {
1211     case EMOTE:
1212     case SELFEMOTE:
1213         if (isEmoteOnly)
1214         {
1215             // Just highlight the whole line, don't worry about resetting to fgBase
1216             sink.applyANSI(highlight);
1217             sink.put(event.content);
1218             break;
1219         }
1220 
1221         // Emote but mixed text and emotes OR we're doing colourful emotes
1222         immutable TerminalForeground emoteFgBase = settings.brightTerminal ?
1223             Bright.emote :
1224             Dark.emote;
1225 
1226         sink.highlightEmotesImpl(
1227             event.content,
1228             event.emotes,
1229             highlight,
1230             emoteFgBase,
1231             colourful,
1232             settings);
1233         break;
1234 
1235     default:
1236         if (isEmoteOnly)
1237         {
1238             // / Emote only channel message, treat the same as an emote-only emote?
1239             goto case EMOTE;
1240         }
1241 
1242         // Normal content, normal text, normal emotes
1243         immutable TerminalForeground contentFgBase = settings.brightTerminal ?
1244             Bright.content :
1245             Dark.content;
1246 
1247         sink.highlightEmotesImpl(
1248             event.content,
1249             event.emotes,
1250             highlight,
1251             contentFgBase,
1252             colourful,
1253             settings);
1254         break;
1255     }
1256 
1257     return sink.data.idup;
1258 }
1259 
1260 
1261 // highlightEmotesImpl
1262 /++
1263     Highlights Twitch emotes in the chat by tinting them a different colour,
1264     saving the results into a passed output range sink.
1265 
1266     Params:
1267         sink = Output range to put the results into.
1268         line = Content line whose containing emotes should be highlit.
1269         emotes = The list of emotes and their positions as divined from the
1270             IRCv3 tags of an event.
1271         pre = Terminal foreground tint to colour the emotes with.
1272         post = Terminal foreground tint to reset to after colouring an emote.
1273         colourful = Whether or not emotes should be highlit in colours.
1274         brightTerminal = Whether or not the terminal has a bright background
1275             and colours should be adapted to suit.
1276  +/
1277 version(Colours)
1278 version(TwitchSupport)
1279 void highlightEmotesImpl(Sink)
1280     (auto ref Sink sink,
1281     const string line,
1282     const string emotes,
1283     const TerminalForeground pre,
1284     const TerminalForeground post,
1285     const Flag!"colourful" colourful,
1286     const CoreSettings settings)
1287 if (isOutputRange!(Sink, char[]))
1288 {
1289     import std.algorithm.iteration : splitter, uniq;
1290     import std.algorithm.sorting : sort;
1291     import std.array : Appender;
1292     import std.conv : to;
1293 
1294     static struct Highlight
1295     {
1296         string id;
1297         size_t start;
1298         size_t end;
1299     }
1300 
1301     // max encountered emotes so far: 46
1302     // Severely pathological let's-crash-the-bot case: max possible ~161 emotes
1303     // That is a standard PRIVMSG line with ":) " repeated until 512 chars.
1304     //enum maxHighlights = 162;
1305 
1306     static Appender!(Highlight[]) highlights;
1307 
1308     scope(exit)
1309     {
1310         if (highlights.data.length)
1311         {
1312             highlights.clear();
1313         }
1314     }
1315 
1316     if (highlights.capacity == 0)
1317     {
1318         highlights.reserve(64);  // guesstimate
1319     }
1320 
1321     size_t pos;
1322 
1323     foreach (/*const*/ emote; emotes.splitter('/'))
1324     {
1325         import lu.string : nom;
1326 
1327         immutable emoteID = emote.nom(':');
1328 
1329         foreach (immutable location; emote.splitter(','))
1330         {
1331             import std.string : indexOf;
1332 
1333             immutable dashPos = location.indexOf('-');
1334             immutable start = location[0..dashPos].to!size_t;
1335             immutable end = location[dashPos+1..$].to!size_t + 1;  // inclusive
1336 
1337             highlights.put(Highlight(emoteID, start, end));
1338         }
1339     }
1340 
1341     /+
1342         We need to use uniq since sometimes there will be custom emotes for which
1343         there are already official ones. Example:
1344 
1345             content: Hey Dist, what’s up? distPls distRoll
1346             emotes:  emotesv2_1e80339255a84a4ebbd0129851b90aa0:21-27/emotesv2_744f13dfe4a345c5be4becdeb05343ee:29-36/distPls:21-27
1347 
1348         The first and the last are duplicates.
1349      +/
1350     auto sortedHighlights = highlights.data
1351         .dup
1352         .sort!((a, b) => (a.start < b.start))
1353         .uniq!((a, b) => (a.start == b.start)); // && (a.end == b.end));
1354 
1355     // We need a dstring since we're slicing something that isn't necessarily ASCII
1356     // Without this highlights become offset a few characters depending on the text
1357     immutable dline = line.to!dstring;
1358 
1359     foreach (const highlight; sortedHighlights)
1360     {
1361         import kameloso.terminal.colours.defs : ANSICodeType;
1362         import kameloso.terminal.colours : applyANSI, getColourByHash;
1363 
1364         immutable colour = colourful ?
1365             getColourByHash(highlight.id, settings) :
1366             pre;
1367 
1368         sink.put(dline[pos..highlight.start]);
1369         sink.applyANSI(colour, ANSICodeType.foreground);
1370         sink.put(dline[highlight.start..highlight.end]);
1371         sink.applyANSI(post, ANSICodeType.foreground);
1372         pos = highlight.end;
1373     }
1374 
1375     // Add the remaining tail from after the last emote
1376     sink.put(dline[pos..$]);
1377 }
1378 
1379 ///
1380 version(Colours)
1381 version(TwitchSupport)
1382 unittest
1383 {
1384     import std.array : Appender;
1385 
1386     Appender!(char[]) sink;
1387 
1388     CoreSettings brightSettings;
1389     CoreSettings darkSettings;
1390     brightSettings.brightTerminal = true;
1391 
1392     {
1393         immutable emotes = "212612:14-22/75828:24-29";
1394         immutable line = "Moody the god pownyFine pownyL";
1395         sink.highlightEmotesImpl(line, emotes, TerminalForeground.white,
1396             TerminalForeground.default_, No.colourful, darkSettings);
1397         assert((sink.data == "Moody the god \033[97mpownyFine\033[39m \033[97mpownyL\033[39m"), sink.data);
1398     }
1399     {
1400         sink.clear();
1401         immutable emotes = "25:41-45";
1402         immutable line = "whoever plays nintendo switch whisper me Kappa";
1403         sink.highlightEmotesImpl(line, emotes, TerminalForeground.white,
1404             TerminalForeground.default_, No.colourful, darkSettings);
1405         assert((sink.data == "whoever plays nintendo switch whisper me \033[97mKappa\033[39m"), sink.data);
1406     }
1407     {
1408         sink.clear();
1409         immutable emotes = "877671:8-17,19-28,30-39";
1410         immutable line = "NOOOOOO camillsCry camillsCry camillsCry";
1411         sink.highlightEmotesImpl(line, emotes, TerminalForeground.white,
1412             TerminalForeground.default_, No.colourful, darkSettings);
1413         assert((sink.data == "NOOOOOO \033[97mcamillsCry\033[39m " ~
1414             "\033[97mcamillsCry\033[39m \033[97mcamillsCry\033[39m"), sink.data);
1415     }
1416     {
1417         sink.clear();
1418         immutable emotes = "822112:0-6,8-14,16-22";
1419         immutable line = "FortOne FortOne FortOne";
1420         sink.highlightEmotesImpl(line, emotes, TerminalForeground.white,
1421             TerminalForeground.default_, No.colourful, darkSettings);
1422         assert((sink.data == "\033[97mFortOne\033[39m \033[97mFortOne\033[39m " ~
1423             "\033[97mFortOne\033[39m"), sink.data);
1424     }
1425     {
1426         sink.clear();
1427         immutable emotes = "141844:17-24,26-33,35-42/141073:9-15";
1428         immutable line = "@mugs123 cohhWow cohhBoop cohhBoop cohhBoop";
1429         sink.highlightEmotesImpl(line, emotes, TerminalForeground.white,
1430             TerminalForeground.default_, No.colourful, darkSettings);
1431         assert((sink.data == "@mugs123 \033[97mcohhWow\033[39m \033[97mcohhBoop\033[39m " ~
1432             "\033[97mcohhBoop\033[39m \033[97mcohhBoop\033[39m"), sink.data);
1433     }
1434     {
1435         sink.clear();
1436         immutable emotes = "12345:81-91,93-103";
1437         immutable line = "Link Amazon Prime to your Twitch account and get a " ~
1438             "FREE SUBSCRIPTION every month courageHYPE courageHYPE " ~
1439             "twitch.amazon.com/prime | Click subscribe now to check if a " ~
1440             "free prime sub is available to use!";
1441         immutable highlitLine = "Link Amazon Prime to your Twitch account and get a " ~
1442             "FREE SUBSCRIPTION every month \033[97mcourageHYPE\033[39m \033[97mcourageHYPE\033[39m " ~
1443             "twitch.amazon.com/prime | Click subscribe now to check if a " ~
1444             "free prime sub is available to use!";
1445         sink.highlightEmotesImpl(line, emotes, TerminalForeground.white,
1446             TerminalForeground.default_, No.colourful, brightSettings);
1447         assert((sink.data == highlitLine), sink.data);
1448     }
1449     {
1450         sink.clear();
1451         immutable emotes = "25:32-36";
1452         immutable line = "@kiwiskool but you’re a sub too Kappa";
1453         sink.highlightEmotesImpl(line, emotes, TerminalForeground.white,
1454             TerminalForeground.default_, No.colourful, brightSettings);
1455         assert((sink.data == "@kiwiskool but you’re a sub too \033[97mKappa\033[39m"), sink.data);
1456     }
1457     {
1458         sink.clear();
1459         immutable emotes = "425618:6-8,16-18/1:20-21";
1460         immutable line = "高所恐怖症 LUL なにぬねの LUL :)";
1461         sink.highlightEmotesImpl(line, emotes, TerminalForeground.white,
1462             TerminalForeground.default_, No.colourful, brightSettings);
1463         assert((sink.data == "高所恐怖症 \033[97mLUL\033[39m なにぬねの " ~
1464             "\033[97mLUL\033[39m \033[97m:)\033[39m"), sink.data);
1465     }
1466     {
1467         sink.clear();
1468         immutable emotes = "425618:6-8,16-18/1:20-21";
1469         immutable line = "高所恐怖症 LUL なにぬねの LUL :)";
1470         sink.highlightEmotesImpl(line, emotes, TerminalForeground.white,
1471             TerminalForeground.default_, Yes.colourful, brightSettings);
1472         assert((sink.data == "高所恐怖症 \033[38;5;171mLUL\033[39m なにぬねの " ~
1473             "\033[38;5;171mLUL\033[39m \033[35m:)\033[39m"), sink.data);
1474     }
1475     {
1476         sink.clear();
1477         immutable emotes = "212612:14-22/75828:24-29";
1478         immutable line = "Moody the god pownyFine pownyL";
1479         sink.highlightEmotesImpl(line, emotes, TerminalForeground.white,
1480             TerminalForeground.default_, Yes.colourful, brightSettings);
1481         assert((sink.data == "Moody the god \033[38;5;237mpownyFine\033[39m \033[38;5;159mpownyL\033[39m"), sink.data);
1482     }
1483     {
1484         sink.clear();
1485         immutable emotes = "25:41-45";
1486         immutable line = "whoever plays nintendo switch whisper me Kappa";
1487         sink.highlightEmotesImpl(line, emotes, TerminalForeground.white,
1488             TerminalForeground.default_, Yes.colourful, brightSettings);
1489         assert((sink.data == "whoever plays nintendo switch whisper me \033[38;5;49mKappa\033[39m"), sink.data);
1490     }
1491     {
1492         sink.clear();
1493         immutable emotes = "877671:8-17,19-28,30-39";
1494         immutable line = "NOOOOOO camillsCry camillsCry camillsCry";
1495         sink.highlightEmotesImpl(line, emotes, TerminalForeground.white,
1496             TerminalForeground.default_, Yes.colourful, brightSettings);
1497         assert((sink.data == "NOOOOOO \033[38;5;166mcamillsCry\033[39m " ~
1498             "\033[38;5;166mcamillsCry\033[39m \033[38;5;166mcamillsCry\033[39m"), sink.data);
1499     }
1500 }
1501 
1502 
1503 // containsNickname
1504 /++
1505     Searches a string for a substring that isn't surrounded by characters that
1506     can be part of a nickname. This can detect a nickname in a string without
1507     getting false positives from similar nicknames.
1508 
1509     Tries to detect nicknames enclosed in terminal formatting. As such, call this
1510     *after* having translated IRC-to-terminal such with
1511     [kameloso.irccolours.mapEffects].
1512 
1513     Uses [std.string.indexOf|indexOf] internally with hopes of being more resilient to
1514     weird UTF-8.
1515 
1516     Params:
1517         haystack = A string to search for the substring nickname.
1518         needle = The nickname substring to find in `haystack`.
1519 
1520     Returns:
1521         True if `haystack` contains `needle` in such a way that it is guaranteed
1522         to not be a different nickname.
1523  +/
1524 auto containsNickname(const string haystack, const string needle) pure nothrow @nogc
1525 in (needle.length, "Tried to determine whether an empty nickname was in a string")
1526 {
1527     import kameloso.terminal : TerminalToken;
1528     import dialect.common : isValidNicknameCharacter;
1529     import std.string : indexOf;
1530 
1531     if ((haystack.length == needle.length) && (haystack == needle)) return true;
1532 
1533     immutable pos = haystack.indexOf(needle);
1534     if (pos == -1) return false;
1535 
1536     if (pos > 0)
1537     {
1538         bool match;
1539 
1540         version(Colours)
1541         {
1542             if ((pos >= 4) && (haystack[pos-1] == 'm'))
1543             {
1544                 import std.algorithm.comparison : min;
1545                 import std.ascii : isDigit;
1546 
1547                 bool previousWasNumber;
1548                 bool previousWasBracket;
1549 
1550                 foreach_reverse (immutable i, immutable c; haystack[pos-min(8, pos)..pos-1])
1551                 {
1552                     if (c.isDigit)
1553                     {
1554                         if (previousWasBracket) return false;
1555                         previousWasNumber = true;
1556                     }
1557                     else if (c == ';')
1558                     {
1559                         if (!previousWasNumber) return false;
1560                         previousWasNumber = false;
1561                     }
1562                     else if (c == '[')
1563                     {
1564                         if (!previousWasNumber) return false;
1565                         previousWasNumber = false;
1566                         previousWasBracket = true;
1567                     }
1568                     else if (c == TerminalToken.format)
1569                     {
1570                         if (!previousWasBracket) return false;
1571 
1572                         // Seems valid, drop down
1573                         match = true;
1574                         break;
1575                     }
1576                     else
1577                     {
1578                         // Invalid character
1579                         return false;
1580                     }
1581                 }
1582             }
1583         }
1584 
1585         if (match)
1586         {
1587             // The above found a formatted nickname
1588         }
1589         else if (haystack[pos-1] == '@')
1590         {
1591             // "@kameloso"
1592         }
1593         else if (haystack[pos-1].isValidNicknameCharacter ||
1594             (haystack[pos-1] == '.') ||
1595             (haystack[pos-1] == '/'))
1596         {
1597             // URL or run-on word
1598             return false;
1599         }
1600     }
1601 
1602     immutable end = pos + needle.length;
1603 
1604     if (end > haystack.length)
1605     {
1606         return false;
1607     }
1608     else if (end == haystack.length)
1609     {
1610         return true;
1611     }
1612 
1613     if (haystack[end] == TerminalToken.format)
1614     {
1615         // Run-on formatted word
1616         return true;
1617     }
1618     else
1619     {
1620         return !haystack[end].isValidNicknameCharacter;
1621     }
1622 }
1623 
1624 ///
1625 unittest
1626 {
1627     assert("kameloso".containsNickname("kameloso"));
1628     assert(" kameloso ".containsNickname("kameloso"));
1629     assert(!"kam".containsNickname("kameloso"));
1630     assert(!"kameloso^".containsNickname("kameloso"));
1631     assert(!string.init.containsNickname("kameloso"));
1632     //assert(!"kameloso".containsNickname(""));  // For now let this be false.
1633     assert("@kameloso".containsNickname("kameloso"));
1634     assert(!"www.kameloso.com".containsNickname("kameloso"));
1635     assert("kameloso.".containsNickname("kameloso"));
1636     assert("kameloso/".containsNickname("kameloso"));
1637     assert(!"/kameloso/".containsNickname("kameloso"));
1638     assert(!"kamelosoooo".containsNickname("kameloso"));
1639     assert(!"".containsNickname("kameloso"));
1640 
1641     version(Colours)
1642     {
1643         assert("\033[1mkameloso".containsNickname("kameloso"));
1644         assert("\033[2;3mkameloso".containsNickname("kameloso"));
1645         assert("\033[12;34mkameloso".containsNickname("kameloso"));
1646         assert(!"\033[0m0mkameloso".containsNickname("kameloso"));
1647         assert(!"\033[kameloso".containsNickname("kameloso"));
1648         assert(!"\033[mkameloso".containsNickname("kameloso"));
1649         assert(!"\033[0kameloso".containsNickname("kameloso"));
1650         assert(!"\033[0mmkameloso".containsNickname("kameloso"));
1651         assert(!"\033[0;mkameloso".containsNickname("kameloso"));
1652         assert("\033[12mkameloso\033[1mjoe".containsNickname("kameloso"));
1653         assert(!"0mkameloso".containsNickname("kameloso"));
1654     }
1655 }