1 /++
2     A collection of functions used to translate tags in messages into terminal colours.
3 
4     See_Also:
5         [kameloso.terminal],
6         [kameloso.terminal.colours],
7         [kameloso.terminal.colours.defs]
8 
9     Copyright: [JR](https://github.com/zorael)
10     License: [Boost Software License 1.0](https://www.boost.org/users/license.html)
11 
12     Authors:
13         [JR](https://github.com/zorael)
14  +/
15 module kameloso.terminal.colours.tags;
16 
17 private:
18 
19 import kameloso.logger : LogLevel;
20 import std.traits : isSomeString;
21 import std.typecons : Flag, No, Yes;
22 
23 public:
24 
25 
26 // expandTags
27 /++
28     String-replaces `<tags>` in a string with the results from calls to `Tint`.
29     Also works with `dstring`s and `wstring`s.
30 
31     `<tags>` are the lowercase first letter of all
32     [kameloso.logger.LogLevel|LogLevel]s; `<l>`, `<t>`, `<i>`, `<w>`
33     `<e>`, `<c>` and `<f>`. `<a>` is not included.
34 
35     `</>` equals the passed `baseLevel` and is used to terminate colour sequences,
36     returning to a default.
37 
38     Lastly, text between a `<h>` and a `</>` are replaced with the results from
39     a call to [kameloso.terminal.colours.colourByHash|colourByHash].
40 
41     This should hopefully make highlighted strings more readable.
42 
43     Example:
44     ---
45     enum oldPattern = "
46         %1$sYour private authorisation key is: %2$s%3$s%4$s
47         It should be entered as %2$spass%4$s under %2$s[IRCBot]%4$s.
48         ";
49     immutable oldMessage = oldPattern.format(Tint.log, Tint.info, pass, Tint.off);
50 
51     enum newPattern = "
52         <l>Your private authorisation key is: <i>%s</>
53         It should be entered as <i>pass</> under <i>[IRCBot]</>
54         ";
55     immutable newMessage = newPattern
56         .format(pass)
57         .expandTags(LogLevel.off);
58 
59     enum patternWithColouredNickname = "No quotes for nickname <h>%s<h>.";
60 
61     immutable colouredMessage = patternWithColouredNickname
62         .format(event.sender.nickname)
63         .expandTags(LogLevel.off);
64     ---
65 
66     Params:
67         line = A line of text, presumably with `<tags>`.
68         baseLevel = The base [kameloso.logger.LogLevel|LogLevel] to fall back to on `</>` tags.
69         strip = Whether to expand tags or strip them.
70 
71     Returns:
72         The passed `line` but with any `<tags>` replaced with ANSI colour sequences.
73         The original string is passed back if there was nothing to replace.
74  +/
75 auto expandTags(T)(const T line, const LogLevel baseLevel, const Flag!"strip" strip) @safe
76 if (isSomeString!T)
77 {
78     import kameloso.common : logger;
79     import lu.string : contains;
80     import std.array : Appender;
81     import std.range : ElementEncodingType;
82     import std.string : representation;
83     import std.traits : Unqual;
84 
85     static import kameloso.common;
86 
87     alias E = Unqual!(ElementEncodingType!T);
88 
89     if (!line.length || !line.contains('<')) return line;
90 
91     // Without marking this as @trusted, we can't have @safe expandTags...
92     static auto indexOf(H, N)(const H haystack, const N rawNeedle) @trusted
93     {
94         import std.string : indexOf;
95 
96         static if (is(N : ubyte))
97         {
98             immutable needle = cast(char)rawNeedle;
99         }
100         else
101         {
102             alias needle = rawNeedle;
103         }
104 
105         return (cast(T)haystack).indexOf(needle);
106     }
107 
108     Appender!(E[]) sink;
109     bool dirty;
110     bool escaping;
111 
112     // Work around the immutability being lost with -dip1000
113     // The alternative is to use .idup, which is not really desireable here
114     immutable asBytes = () @trusted
115     {
116         return cast(immutable)line.representation;
117     }();
118 
119     immutable toReserve = (asBytes.length + 16);
120 
121     byteloop:
122     for (size_t i; i<asBytes.length; ++i)
123     {
124         immutable c = asBytes[i];
125 
126         switch (c)
127         {
128         case '\\':
129             if (escaping)
130             {
131                 // Always dirty
132                 sink.put('\\');
133             }
134             else
135             {
136                 if (!dirty)
137                 {
138                     sink.reserve(toReserve);
139                     sink.put(asBytes[0..i]);
140                     dirty = true;
141                 }
142             }
143 
144             escaping = !escaping;
145             break;
146 
147         case '<':
148             if (escaping)
149             {
150                 // Always dirty
151                 sink.put('<');
152                 escaping = false;
153             }
154             else
155             {
156                 immutable ptrdiff_t closingBracketPos = indexOf(asBytes[i..$], '>');
157 
158                 if ((closingBracketPos == -1) || (closingBracketPos > 6))
159                 {
160                     if (dirty)
161                     {
162                         sink.put(c);
163                     }
164                 }
165                 else
166                 {
167                     // Valid; dirties now if not already dirty
168 
169                     if (asBytes.length < i+2)
170                     {
171                         // Too close to the end to have a meaningful tag
172                         // Break and return
173 
174                         if (dirty)
175                         {
176                             // Add rest first
177                             sink.put(asBytes[i..$]);
178                         }
179 
180                         break byteloop;
181                     }
182 
183                     if (!dirty)
184                     {
185                         sink.reserve(toReserve);
186                         sink.put(asBytes[0..i]);
187                         dirty = true;
188                     }
189 
190                     immutable slice = asBytes[i+1..i+closingBracketPos];  // mutable
191                     if (slice.length != 1) break;
192 
193                     sliceswitch:
194                     switch (slice[0])
195                     {
196 
197                     version(Colours)
198                     {
199                         case 'l':
200                             if (!strip) sink.put(logger.logtint);
201                             break;
202 
203                         case 't':
204                             if (!strip) sink.put(logger.tracetint);
205                             break;
206 
207                         case 'i':
208                             if (!strip) sink.put(logger.infotint);
209                             break;
210 
211                         case 'w':
212                             if (!strip) sink.put(logger.warningtint);
213                             break;
214 
215                         case 'e':
216                             if (!strip) sink.put(logger.errortint);
217                             break;
218 
219                         case 'c':
220                             if (!strip) sink.put(logger.criticaltint);
221                             break;
222 
223                         case 'f':
224                             if (!strip) sink.put(logger.fataltint);
225                             break;
226 
227                         case 'o':
228                             if (!strip) sink.put(logger.offtint);
229                             break;
230 
231                         case '/':
232                             if (!strip)
233                             {
234                                 with (LogLevel)
235                                 final switch (baseLevel)
236                                 {
237                                 case all:  //log
238                                     //goto case 'l';
239                                     sink.put(logger.logtint);
240                                     break sliceswitch;
241 
242                                 case trace:
243                                     //goto case 't';
244                                     sink.put(logger.tracetint);
245                                     break sliceswitch;
246 
247                                 case info:
248                                     //goto case 'i';
249                                     sink.put(logger.infotint);
250                                     break sliceswitch;
251 
252                                 case warning:
253                                     //goto case 'w';
254                                     sink.put(logger.warningtint);
255                                     break sliceswitch;
256 
257                                 case error:
258                                     //goto case 'e';
259                                     sink.put(logger.errortint);
260                                     break sliceswitch;
261 
262                                 case critical:
263                                     //goto case 'c';
264                                     sink.put(logger.criticaltint);
265                                     break sliceswitch;
266 
267                                 case fatal:
268                                     //goto case 'f';
269                                     sink.put(logger.fataltint);
270                                     break sliceswitch;
271 
272                                 case off:
273                                     //goto case 'o';
274                                     sink.put(logger.offtint);
275                                     break sliceswitch;
276                                 }
277                             }
278                             break;
279                     }
280 
281                     case 'h':
282                         i += 3;  // advance past "<h>".length
283                         immutable closingHashMarkPos = indexOf(asBytes[i..$], "</>");
284 
285                         if (closingHashMarkPos == -1)
286                         {
287                             // Revert advance
288                             i -= 3;
289                             goto default;
290                         }
291                         else
292                         {
293                             immutable word = cast(string)asBytes[i..i+closingHashMarkPos];
294 
295                             version(Colours)
296                             {
297                                 if (!strip)
298                                 {
299                                     import kameloso.terminal.colours : colourByHash;
300 
301                                     sink.put(colourByHash(word, *kameloso.common.settings));
302 
303                                     with (LogLevel)
304                                     final switch (baseLevel)
305                                     {
306                                     case all:  //log
307                                         sink.put(logger.logtint);
308                                         break;
309 
310                                     case trace:
311                                         sink.put(logger.tracetint);
312                                         break;
313 
314                                     case info:
315                                         sink.put(logger.infotint);
316                                         break;
317 
318                                     case warning:
319                                         sink.put(logger.warningtint);
320                                         break;
321 
322                                     case error:
323                                         sink.put(logger.errortint);
324                                         break;
325 
326                                     case critical:
327                                         sink.put(logger.criticaltint);
328                                         break;
329 
330                                     case fatal:
331                                         sink.put(logger.fataltint);
332                                         break;
333 
334                                     case off:
335                                         sink.put(logger.offtint);
336                                         break;
337                                     }
338                                 }
339                                 else
340                                 {
341                                     sink.put(word);
342                                 }
343                             }
344                             else
345                             {
346                                 sink.put(word);
347                             }
348 
349                             // Don't advance the full "<h>".length 3
350                             // because the for-loop ++i will advance one ahead
351                             i += (closingHashMarkPos+2);
352                             continue;  // Not break
353                         }
354 
355                     default:
356                         // Invalid control character, just ignore
357                         break;
358                     }
359 
360                     i += closingBracketPos;
361                 }
362             }
363             break;
364 
365         default:
366             if (escaping)
367             {
368                 escaping = false;
369             }
370 
371             if (dirty)
372             {
373                 sink.put(c);
374             }
375             break;
376         }
377     }
378 
379     return dirty ? sink.data.idup : line;
380 }
381 
382 ///
383 unittest
384 {
385     import kameloso.common : logger;
386     import std.conv : text, to;
387     import std.format : format;
388     import std.typecons : Flag, No, Yes;
389 
390     {
391         immutable line = "This is a <l>log</> line.";
392         immutable replaced = line.expandTags(LogLevel.off, No.strip);
393         immutable expected = text("This is a ", logger.logtint, "log", logger.offtint, " line.");
394         assert((replaced == expected), replaced);
395     }
396     {
397         import std.conv : wtext;
398 
399         immutable line = "This is a <l>log</> line."w;
400         immutable replaced = line.expandTags(LogLevel.off, No.strip);
401         immutable expected = wtext("This is a "w, logger.logtint, "log"w, logger.offtint, " line."w);
402         assert((replaced == expected), replaced.to!string);
403     }
404     {
405         import std.conv : dtext;
406 
407         immutable line = "This is a <l>log</> line."d;
408         immutable replaced = line.expandTags(LogLevel.off, No.strip);
409         immutable expected = dtext("This is a "d, logger.logtint, "log"d, logger.offtint, " line."d);
410         assert((replaced == expected), replaced.to!string);
411     }
412     {
413         immutable line = `<i>info</>nothing<c>critical</>nothing\<w>not warning`;
414         immutable replaced = line.expandTags(LogLevel.off, No.strip);
415         immutable expected = text(logger.infotint, "info", logger.offtint, "nothing",
416             logger.criticaltint, "critical", logger.offtint, "nothing<w>not warning");
417         assert((replaced == expected), replaced);
418     }
419     {
420         immutable line = "This is a line with no tags";
421         immutable replaced = line.expandTags(LogLevel.off, No.strip);
422         assert(line is replaced);
423     }
424     {
425         immutable emptyLine = string.init;
426         immutable replaced = emptyLine.expandTags(LogLevel.off, No.strip);
427         assert(replaced is emptyLine);
428     }
429     {
430         immutable line = "hello<h>kameloso</>hello";
431         immutable replaced = line.expandTags(LogLevel.off, Yes.strip);
432         immutable expected = "hellokamelosohello";
433         assert((replaced == expected), replaced);
434     }
435     {
436         immutable line = "hello<h></>hello";
437         immutable replaced = line.expandTags(LogLevel.off, Yes.strip);
438         immutable expected = "hellohello";
439         assert((replaced == expected), replaced);
440     }
441     {
442         immutable line = `hello\<harbl>kameloso<h>hello</>hi`;
443         immutable replaced = line.expandTags(LogLevel.off, Yes.strip);
444         immutable expected = "hello<harbl>kamelosohellohi";
445         assert((replaced == expected), replaced);
446     }
447     {
448         enum pattern = "Failed to fetch, replay and clear notes for " ~
449             "<l>%s<e> on <l>%s<e>: <l>%s";
450         immutable line = pattern.format("nickname", "<no channel>", "error");
451         immutable replaced = line.expandTags(LogLevel.off, No.strip);
452         immutable expected = "Failed to fetch, replay and clear notes for " ~
453             logger.logtint ~ "nickname" ~ logger.errortint ~ " on " ~ logger.logtint ~
454             "<no channel>" ~ logger.errortint ~ ": " ~ logger.logtint ~ "error";
455         assert((replaced == expected), replaced);
456     }
457     {
458         enum pattern = "Failed to fetch, replay and clear notes for " ~
459             "<l>%s<e> on <l>%s<e>: <l>%s";
460         immutable line = pattern.format("nickname", "<no channel>", "error");
461         immutable replaced = line.expandTags(LogLevel.off, Yes.strip);
462         immutable expected = "Failed to fetch, replay and clear notes for " ~
463             "nickname on <no channel>: error";
464         assert((replaced == expected), replaced);
465     }
466     {
467         enum pattern = "Failed to fetch, replay and clear notes for " ~
468             "<l>%s</> on <l>%s</>: <l>%s";
469         immutable line = pattern.format("nickname", "<no channel>", "error");
470         immutable replaced = line.expandTags(LogLevel.error, No.strip);
471         immutable expected = "Failed to fetch, replay and clear notes for " ~
472             logger.logtint ~ "nickname" ~ logger.errortint ~ " on " ~ logger.logtint ~
473             "<no channel>" ~ logger.errortint ~ ": " ~ logger.logtint ~ "error";
474         assert((replaced == expected), replaced);
475     }
476     {
477         enum pattern = "Failed to fetch, replay and clear notes for " ~
478             "<l>%s</> on <l>%s</>: <l>%s";
479         immutable line = pattern.format("nickname", "<no channel>", "error");
480         immutable replaced = line.expandTags(LogLevel.error, Yes.strip);
481         immutable expected = "Failed to fetch, replay and clear notes for " ~
482             "nickname on <no channel>: error";
483         assert((replaced == expected), replaced);
484     }
485     {
486         enum origPattern = "Could not apply <i>+%s<l> <i>%s<l> in <i>%s<l> " ~
487             "because we are not an operator in the channel.";
488         enum newPattern = "Could not apply <i>+%s</> <i>%s</> in <i>%s</> " ~
489             "because we are not an operator in the channel.";
490         immutable origLine = origPattern.format("o", "nickname", "#channel").expandTags(LogLevel.off, No.strip);
491         immutable newLine = newPattern.format("o", "nickname", "#channel").expandTags(LogLevel.all, No.strip);
492         assert((origLine == newLine), newLine);
493     }
494 
495     version(Colours)
496     {
497         import kameloso.terminal.colours : colourByHash;
498         import kameloso.pods : CoreSettings;
499 
500         CoreSettings brightSettings;
501         CoreSettings darkSettings;
502         brightSettings.brightTerminal = true;
503 
504         {
505             immutable line = "hello<h>kameloso</>hello";
506             immutable replaced = line.expandTags(LogLevel.off, No.strip);
507             immutable expected = text("hello", colourByHash("kameloso",
508                 darkSettings), logger.offtint, "hello");
509             assert((replaced == expected), replaced);
510         }
511         {
512             immutable line = `hello\<harbl>kameloso<h>hello</>hi`;
513             immutable replaced = line.expandTags(LogLevel.off, No.strip);
514             immutable expected = text("hello<harbl>kameloso", colourByHash("hello",
515                 darkSettings), logger.offtint, "hi");
516             assert((replaced == expected), replaced);
517         }
518         {
519             immutable line = "<l>%%APPDATA%%\\\\kameloso</>.";
520             immutable replaced = line.expandTags(LogLevel.off, No.strip);
521             immutable expected = logger.logtint ~ "%%APPDATA%%\\kameloso" ~ logger.offtint ~ ".";
522             assert((replaced == expected), replaced);
523         }
524         {
525             immutable line = "<l>herp\\</>herp\\\\herp\\\\<l>herp</>";
526             immutable replaced = line.expandTags(LogLevel.off, No.strip);
527             immutable expected = logger.logtint ~ "herp</>herp\\herp\\" ~ logger.logtint ~ "herp" ~ logger.offtint;
528             assert((replaced == expected), replaced);
529         }
530         {
531             immutable line = "Added <h>hirrsteff</> as a blacklisted user in #garderoben";
532             immutable replaced = line.expandTags(LogLevel.off, No.strip);
533             immutable expected = "Added " ~
534                 colourByHash("hirrsteff", brightSettings) ~
535                 logger.offtint ~ " as a blacklisted user in #garderoben";
536             assert((replaced == expected), replaced);
537         }
538     }
539 }
540 
541 
542 // expandTags
543 /++
544     String-replaces `<tags>` in a string with the results from calls to
545     [kameloso.logger.KamelosoLogger|KamelosoLogger] `*tint` methods.
546     Also works with `dstring`s and `wstring`s. Overload that does not take a
547     `strip` [std.typecons.Flag|Flag].
548 
549     Params:
550         line = A line of text, presumably with `<tags>`.
551         baseLevel = The base [kameloso.logger.LogLevel|LogLevel] to fall back to on `</>` tags.
552 
553     Returns:
554         The passed `line` but with any `<tags>` replaced with ANSI colour sequences.
555         The original string is passed back if there was nothing to replace.
556  +/
557 auto expandTags(T)(const T line, const LogLevel baseLevel) @safe
558 if (isSomeString!T)
559 {
560     static import kameloso.common;
561     immutable strip = cast(Flag!"strip")kameloso.common.settings.monochrome;
562     return expandTags(line, baseLevel, strip);
563 }
564 
565 ///
566 unittest
567 {
568     import kameloso.common : logger;
569     import std.conv : text, to;
570 
571     {
572         immutable line = "This is a <l>log</> line.";
573         immutable replaced = line.expandTags(LogLevel.off);
574         immutable expected = text("This is a ", logger.logtint, "log", logger.offtint, " line.");
575         assert((replaced == expected), replaced);
576     }
577 }
578 
579 
580 // stripTags
581 /++
582     Removes `<tags>` from a string.
583 
584     Example:
585     ---
586     enum pattern = "
587         <l>Your private authorisation key is: <i>%s</>
588         It should be entered as <i>pass</> under <i>[IRCBot]</>
589         ";
590     immutable newMessage = newPattern
591         .format(pass)
592         .stripTags();
593 
594     enum patternWithColouredNickname = "No quotes for nickname <h>%s<h>.";
595     immutable uncolouredMessage = patternWithColouredNickname
596         .format(event.sender.nickname)
597         .stripTags();
598     ---
599 
600     Params:
601         line = A line of text, presumably with `<tags>` to remove.
602 
603     Returns:
604         The passed `line` with any `<tags>` removed.
605         The original string is passed back if there was nothing to remove.
606  +/
607 auto stripTags(T)(const T line) @safe
608 if (isSomeString!T)
609 {
610     return expandTags(line, LogLevel.off, Yes.strip);
611 }
612 
613 ///
614 unittest
615 {
616     {
617         immutable line = "This is a <l>log</> line.";
618         immutable replaced = line.stripTags();
619         immutable expected = "This is a log line.";
620         assert((replaced == expected), replaced);
621     }
622 }