1 /++
2     Interactive assert statement generation from raw IRC strings, for use in the
3     source code `unittest` blocks.
4 
5     Example:
6 
7     $(CONSOLE
8     $ dub run :assertgen
9     (...)
10 
11     // Paste a raw event string and hit Enter to generate an assert block. Ctrl+C to exit.
12 
13     $(I @badge-info=subscriber/15;badges=subscriber/12;color=;display-name=tayk47_mom;emotes=;flags=;id=d6729804-2bf3-495d-80ce-a2fe8ed00a26;login=tayk47_mom;mod=0;msg-id=submysterygift;msg-param-mass-gift-count=1;msg-param-origin-id=49\s9d\s3e\s68\sca\s26\se9\s2a\s6e\s44\sd4\s60\s9b\s3d\saa\sb9\s4c\sad\s43\s5c;msg-param-sender-count=4;msg-param-sub-plan=1000;room-id=71092938;subscriber=1;system-msg=tayk47_mom\sis\sgifting\s1\sTier\s1\sSubs\sto\sxQcOW's\scommunity!\sThey've\sgifted\sa\stotal\sof\s4\sin\sthe\schannel!;tmi-sent-ts=1569013433362;user-id=224578549;user-type= :tmi.twitch.tv USERNOTICE #xqcow)
14 
15     {
16         immutable event = parser.toIRCEvent("@badge-info=subscriber/15;badges=subscriber/12;color=;display-name=tayk47_mom;emotes=;flags=;id=d6729804-2bf3-495d-80ce-a2fe8ed00a26;login=tayk47_mom;mod=0;msg-id=submysterygift;msg-param-mass-gift-count=1;msg-param-origin-id=49\\s9d\\s3e\\s68\\sca\\s26\\se9\\s2a\\s6e\\s44\\sd4\\s60\\s9b\\s3d\\saa\\sb9\\s4c\\sad\\s43\\s5c;msg-param-sender-count=4;msg-param-sub-plan=1000;room-id=71092938;subscriber=1;system-msg=tayk47_mom\\sis\\sgifting\\s1\\sTier\\s1\\sSubs\\sto\\sxQcOW's\\scommunity!\\sThey've\\sgifted\\sa\\stotal\\sof\\s4\\sin\\sthe\\schannel!;tmi-sent-ts=1569013433362;user-id=224578549;user-type= :tmi.twitch.tv USERNOTICE #xqcow");
17         with (event)
18         {
19             assert((type == IRCEvent.Type.TWITCH_BULKGIFT), Enum!(IRCEvent.Type).toString(type));
20             assert((sender.nickname == "tayk47_mom"), sender.nickname);
21             assert((sender.displayName == "tayk47_mom"), sender.displayName);
22             assert((sender.account == "tayk47_mom"), sender.account);
23             assert((sender.badges == "subscriber/12"), sender.badges);
24             assert((channel == "#xqcow"), channel);
25             assert((content == "tayk47_mom is gifting 1 Tier 1 Subs to xQcOW's community! They've gifted a total of 4 in the channel!"), content);
26             assert((aux == "1000"), aux);
27             assert((tags == "badge-info=subscriber/15;badges=subscriber/12;color=;display-name=tayk47_mom;emotes=;flags=;id=d6729804-2bf3-495d-80ce-a2fe8ed00a26;login=tayk47_mom;mod=0;msg-
28     id=submysterygift;msg-param-mass-gift-count=1;msg-param-origin-id=49\\s9d\\s3e\\s68\\sca\\s26\\se9\\s2a\\s6e\\s44\\sd4\\s60\\s9b\\s3d\\saa\\sb9\\s4c\\sad\\s43\\s5c;msg-param-sender-cou
29     nt=4;msg-param-sub-plan=1000;room-id=71092938;subscriber=1;system-msg=tayk47_mom\\sis\\sgifting\\s1\\sTier\\s1\\sSubs\\sto\\sxQcOW's\\scommunity!\\sThey've\\sgifted\\sa\\stotal\\sof\\s
30     4\\sin\\sthe\\schannel!;tmi-sent-ts=1569013433362;user-id=224578549;user-type="), tags);
31             assert((count == 1), count.to!string);
32             assert((altcount == 4), altcount.to!string);
33             assert((id == "d6729804-2bf3-495d-80ce-a2fe8ed00a26"), id);
34         }
35     }
36     )
37 
38     These can be directly copy/pasted into the appropriate files in `/tests`.
39     They only carry state from the events pasted before it, but the changes made
40     are also expressed as asserts.
41 
42     Example:
43 
44     $(CONSOLE
45     $ dub run :assertgen
46     (...)
47 
48     // Paste a raw event string and hit Enter to generate an assert block. Ctrl+C to exit.
49 
50     $(I @badge-info=;badges=;color=#5F9EA0;display-name=Zorael;emote-sets=0,185411,771823,1511983;user-id=22216721;user-type= :tmi.twitch.tv GLOBALUSERSTATE)
51 
52     {
53         immutable event = parser.toIRCEvent("@badge-info=;badges=;color=#5F9EA0;display-name=Zorael;emote-sets=0,185411,771823,1511983;user-id=22216721;user-type= :tmi.twitch.tv GLOBALUSERSTATE");
54         with (event)
55         {
56             assert((type == IRCEvent.Type.GLOBALUSERSTATE), Enum!(IRCEvent.Type).toString(type));
57             assert((sender.address == "tmi.twitch.tv"), sender.address);
58             assert((sender.class_ == IRCUser.Class.special), Enum!(IRCUser.Class).toString(sender.class_));
59             assert((target.nickname == "zorael"), target.nickname);
60             assert((target.displayName == "Zorael"), target.displayName);
61             assert((target.class_ == IRCUser.Class.admin), Enum!(IRCUser.Class).toString(target.class_));
62             assert((target.badges == "*"), target.badges);
63             assert((target.colour == "5F9EA0"), target.colour);
64             assert((tags == "badge-info=;badges=;color=#5F9EA0;display-name=Zorael;emote-sets=0,185411,771823,1511983;user-id=22216721;user-type="), tags);
65         }
66     }
67 
68     with (parser.client)
69     {
70         assert((displayName == "Zorael"), displayName);
71     }
72     )
73 
74     This makes it easy to generate tests that verify wanted side-effects
75     incurred by events.
76  +/
77 module dialect.assertgen;
78 
79 version(AssertGeneration):
80 
81 private:
82 
83 import dialect.defs;
84 import dialect.parsing : IRCParser;
85 import std.range.primitives : isOutputRange;
86 import std.typecons : Flag, No, Yes;
87 
88 
89 // formatClientAssignment
90 /++
91     Constructs statement lines for each changed field of an
92     [dialect.defs.IRCClient|IRCClient], including instantiating a fresh one.
93 
94     Example:
95     ---
96     IRCClient client;
97     IRCServer server;
98     Appender!(char[]) sink;
99 
100     sink.formatClientAssignment(client, server);
101     ---
102 
103     Params:
104         sink = Output buffer to write to.
105         client = [dialect.defs.IRCClient|IRCClient] to simulate the assignment of.
106         server = [dialect.defs.IRCServer|IRCServer] to simulate the assignment of.
107  +/
108 void formatClientAssignment(Sink)
109     (auto ref Sink sink,
110     const IRCClient client,
111     const IRCServer server) pure @safe
112 if (isOutputRange!(Sink, char[]))
113 {
114     import lu.deltastrings : formatDeltaInto;
115 
116     sink.put("IRCParser parser;\n\n");
117     sink.put("with (parser)\n");
118     sink.put("{\n");
119     sink.formatDeltaInto(IRCClient.init, client, 1, "client");
120     sink.formatDeltaInto(IRCServer.init, server, 1, "server");
121     sink.put('}');
122 
123     static if (!__traits(hasMember, Sink, "data"))
124     {
125         sink.put('\n');
126     }
127 }
128 
129 ///
130 pure @safe unittest
131 {
132     import std.array : Appender;
133 
134     Appender!(char[]) sink;
135     sink.reserve(128);
136 
137     IRCClient client;
138     IRCServer server;
139 
140     with (client)
141     {
142         nickname = "NICKNAME";
143         user = "UUUUUSER";
144         server.address = "something.freenode.net";
145         server.port = 6667;
146         server.daemon = IRCServer.Daemon.unreal;
147         server.aModes = "eIbq";
148     }
149 
150     sink.formatClientAssignment(client, server);
151 
152     assert(sink.data ==
153 `IRCParser parser;
154 
155 with (parser)
156 {
157     client.nickname = "NICKNAME";
158     client.user = "UUUUUSER";
159     server.address = "something.freenode.net";
160     server.port = 6667;
161     server.daemon = IRCServer.Daemon.unreal;
162     server.aModes = "eIbq";
163 }`, '\n' ~ sink.data);
164 }
165 
166 
167 // formatEventAssertBlock
168 /++
169     Constructs assert statement blocks for each changed field of an
170     [dialect.defs.IRCEvent|IRCEvent].
171 
172     Example:
173     ---
174     IRCEvent event;
175     Appender!(char[]) sink;
176     sink.formatEventAssertBlock(event);
177     ---
178 
179     Params:
180         sink = Output buffer to write to.
181         event = [dialect.defs.IRCEvent|IRCEvent] to construct assert statements for.
182  +/
183 void formatEventAssertBlock(Sink)
184     (auto ref Sink sink,
185     const IRCEvent event) pure @safe
186 if (isOutputRange!(Sink, char[]))
187 {
188     import lu.deltastrings : formatDeltaInto;
189     import lu.string : tabs;
190     import std.array : replace;
191     import std.format : format, formattedWrite;
192 
193     immutable raw = event.tags.length ?
194         "@%s %s".format(event.tags, event.raw) : event.raw;
195 
196     immutable escaped = raw
197         .replace('\\', `\\`)
198         .replace('"', `\"`);
199 
200     sink.put("{\n");
201     if (escaped != raw) sink.formattedWrite("%s// %s\n", 1.tabs, raw);
202     sink.formattedWrite("%simmutable event = parser.toIRCEvent(\"%s\");\n", 1.tabs, escaped);
203     sink.formattedWrite("%swith (event)\n", 1.tabs);
204     sink.formattedWrite("%s{\n", 1.tabs);
205     sink.formatDeltaInto!(Yes.asserts)(IRCEvent.init, event, 2);
206     sink.formattedWrite("%s}\n", 1.tabs);
207     sink.put("}");
208 
209     static if (!__traits(hasMember, Sink, "data"))
210     {
211         sink.put('\n');
212     }
213 }
214 
215 ///
216 unittest
217 {
218     import lu.deltastrings : formatDeltaInto;
219     import lu.string : tabs;
220     import std.array : Appender;
221     import std.format : formattedWrite;
222 
223     Appender!(char[]) sink;
224     sink.reserve(1024);
225 
226     IRCClient client;
227     IRCServer server;
228     auto parser = IRCParser(client, server);
229 
230     immutable event = parser.toIRCEvent(":zorael!~NaN@2001:41d0:2:80b4:: PRIVMSG #flerrp :kameloso: 8ball");
231 
232     // copy/paste the above
233     sink.put("{\n");
234     sink.formattedWrite("%simmutable event = parser.toIRCEvent(\"%s\");\n", 1.tabs, event.raw);
235     sink.formattedWrite("%swith (event)\n", 1.tabs);
236     sink.formattedWrite("%s{\n", 1.tabs);
237     sink.formatDeltaInto!(Yes.asserts)(IRCEvent.init, event, 2);
238     sink.formattedWrite("%s}\n", 1.tabs);
239     sink.put("}");
240 
241     assert(sink.data ==
242 `{
243     immutable event = parser.toIRCEvent(":zorael!~NaN@2001:41d0:2:80b4:: PRIVMSG #flerrp :kameloso: 8ball");
244     with (event)
245     {
246         assert((type == IRCEvent.Type.CHAN), Enum!(IRCEvent.Type).toString(type));
247         assert((sender.nickname == "zorael"), sender.nickname);
248         assert((sender.ident == "~NaN"), sender.ident);
249         assert((sender.address == "2001:41d0:2:80b4::"), sender.address);
250         assert((channel == "#flerrp"), channel);
251         assert((content == "kameloso: 8ball"), content);
252     }
253 }`, '\n' ~ sink.data);
254 }
255 
256 
257 // inputServerInformation
258 /++
259     Asks the user to input server information via standard input.
260 
261     Params:
262         parser = [dialect.parsing.IRCParser] to populate with information.
263  +/
264 void inputServerInformation(ref IRCParser parser) @system
265 {
266     import dialect.common : typenumsOf;
267     import lu.conv : Enum;
268     import lu.string : advancePast, stripped;
269     import std.range : chunks, only;
270     import std.traits : EnumMembers;
271     import std.stdio : readln, stdin, stdout, write, writefln, writeln;
272     import std.uni : toLower;
273 
274     writeln("-- Available daemons --");
275     writefln("%(%(%-14s%)\n%)", EnumMembers!(IRCServer.Daemon).only.chunks(3));
276     writeln();
277 
278     write("Enter daemon [optional daemon literal] (solanum): ");
279     stdout.flush();
280     stdin.flush();
281     string slice = readln().stripped;  // mutable so we can advancePast it
282     immutable daemonstring = slice.advancePast(' ', Yes.inherit).toLower;
283     immutable daemonLiteral = slice.length ? slice : daemonstring;
284 
285     parser.server.daemon = daemonstring.length ?
286         Enum!(IRCServer.Daemon).fromString(daemonstring) : IRCServer.Daemon.solanum;
287     parser.typenums = typenumsOf(parser.server.daemon);
288     parser.server.daemonstring = daemonLiteral;
289 
290     write("Enter network (Libera.Chat): ");
291     stdout.flush();
292     stdin.flush();
293     parser.server.network = readln().stripped;
294     if (!parser.server.network.length) parser.server.network = "Libera.Chat";
295 
296     write("Enter server address (irc.libera.chat): ");
297     stdout.flush();
298     stdin.flush();
299     parser.server.address = readln().stripped;
300     if (!parser.server.address.length) parser.server.address = "irc.libera.chat";
301 }
302 
303 
304 public:
305 
306 
307 // main
308 /++
309     Entry point when compiling the `assertgen` dub configuration.
310 
311     Reads raw server strings from `stdin`, parses them into
312     [dialect.defs.IRCEvent|IRCEvent]s and constructs assert blocks of their contents.
313  +/
314 version(unittest) {}
315 else
316 int main(string[] args) @system
317 {
318     import dialect.defs : IRCServer;
319     import lu.deltastrings : formatDeltaInto;
320     import lu.string : strippedLeft;
321     import std.array : Appender;
322     import std.getopt : GetOptException, config, getopt;
323     import std.stdio : File, readln, stdin, stdout, write, writefln, writeln;
324     import std.string : chomp;
325 
326     enum defaultOutputFilename = "unittest.log";
327 
328     string nicknameOverride;
329     string userOverride;
330     string identOverride;
331     string outputFile = defaultOutputFilename;
332     bool overwrite;
333     bool twitch;
334 
335     try
336     {
337         version(TwitchSupport)
338         {
339             enum twitchString = "Shortcut to Twitch input";
340         }
341         else
342         {
343             enum twitchString = "(Only available when compiled with Twitch support)";
344         }
345 
346         auto results = getopt(args,
347             config.caseSensitive,
348             config.bundling,
349             "n|nickname",
350                 "Override initial nickname",
351                 &nicknameOverride,
352             "u|user",
353                 "Override initial user",
354                 &userOverride,
355             "i|ident",
356                 "Override initial ident",
357                 &identOverride,
358             "o|output",
359                 "Output file (specify '-' to disable) [" ~ defaultOutputFilename ~ "]",
360                 &outputFile,
361             "O|overwrite",
362                 "Overwrite file instead of appending to it",
363                 &overwrite,
364             "twitch",
365                 twitchString,
366                 &twitch,
367         );
368 
369         if (results.helpWanted)
370         {
371             import std.getopt : defaultGetoptPrinter;
372             defaultGetoptPrinter("Available flags:\n", results.options);
373             return 0;
374         }
375     }
376     catch (GetOptException e)
377     {
378         writeln(e.msg);
379         return 1;
380     }
381 
382     IRCParser parser;
383     parser.initPostprocessors();  // Normally done in IRCParser(IRCClient) constructor
384 
385     Appender!(char[]) buffer;
386     buffer.reserve(2048);
387 
388     if (outputFile == "-")
389     {
390         outputFile = string.init;
391     }
392 
393     if (outputFile.length)
394     {
395         writefln("Writing output to %s, overwrite:%s", outputFile, overwrite);
396     }
397 
398     version (TwitchSupport)
399     {
400         if (twitch)
401         {
402             import dialect.common : typenumsOf;
403 
404             parser.server.daemon = IRCServer.Daemon.twitch;
405             parser.typenums = typenumsOf(IRCServer.Daemon.twitch);
406             parser.server.network = "Twitch";
407             parser.server.daemonstring = "twitch";
408             parser.server.address = "irc.chat.twitch.tv";
409             parser.server.maxNickLength = 25;
410 
411             // Provide skeletal user defaults.
412             with (parser.client)
413             {
414                 // nickname, user and ident are always identical
415                 nickname = nicknameOverride.length ? nicknameOverride : "kameloso";
416                 user = nickname;
417                 ident = nickname;
418                 //realName = "kameloso IRC bot";  // Not used on Twitch
419             }
420 
421             writeln("Server set to Twitch as per command-line argument.");
422         }
423     }
424 
425     if (!twitch)
426     {
427         import std.conv : ConvException;
428 
429         try
430         {
431             inputServerInformation(parser);
432         }
433         catch (ConvException e)
434         {
435             writeln();
436             writeln("-- Conversion exception caught when parsing daemon: ", e.msg);
437             version(PrintStacktraces) writeln(e.info);
438             stdout.flush();
439             return 1;
440         }
441 
442         // Provide skeletal user defaults.
443         with (parser.client)
444         {
445             nickname = nicknameOverride.length ? nicknameOverride : "kameloso";
446             user = userOverride.length ? userOverride : "kameloso";
447             ident = identOverride.length ? identOverride : "~kameloso";
448             realName = "kameloso IRC bot";
449         }
450 
451         // Provide Libera.Chat defaults here, now that they're no longer in IRCServer.init
452         // If we need different values we'll have to provide a RPL_MYINFO event.
453         with (parser.server)
454         {
455             aModes = "eIbq";
456             bModes = "k";
457             cModes = "flj";
458             dModes = "CFLMPQScgimnprstuz";
459             prefixes = "ov";
460             prefixchars = [ 'o' : '@', 'v' : '+' ];
461         }
462     }
463 
464     enum scissors = "// 8<  --  8<  --  8<  --  8<  --  8<  --  8<  --  8<  --  8<  --  8<\n";
465 
466     buffer.formatClientAssignment(parser.client, parser.server);
467     buffer.put("\n\nparser.typenums = typenumsOf(parser.server.daemon);\n");
468 
469     writeln();
470     writeln(scissors);
471     writeln(buffer.data);
472     writeln(scissors);
473     writeln("// Paste a raw event string and hit Enter to generate an assert block. " ~
474         "Ctrl+C to exit.");
475     writeln();
476     stdout.flush();
477 
478     File file;
479 
480     if (outputFile.length)
481     {
482         import std.datetime.systime : Clock;
483         import std.file : exists;
484         import core.time : msecs;
485 
486         if (overwrite)
487         {
488             file = File(outputFile, "w");
489         }
490         else
491         {
492             immutable shouldPad = outputFile.exists;
493 
494             file = File(outputFile, "a");
495 
496             if (shouldPad)
497             {
498                 file.writeln('\n');
499             }
500         }
501 
502         auto now = Clock.currTime;
503         now.fracSecs = 0.msecs;
504         file.writeln("// ========== ", args[0], ": ", now, '\n');
505         file.writeln(buffer.data);
506         file.flush();
507     }
508 
509     buffer.clear();
510 
511     IRCClient oldClient = parser.client;
512     IRCServer oldServer = parser.server;
513     string input;
514 
515     while ((input = readln()) !is null)
516     {
517         import dialect.common : IRCParseException;
518         import lu.string : unquoted;
519         import std.format : formattedWrite;
520 
521         scope(exit)
522         {
523             // Reset input so double enter doesn't display the same event
524             input = string.init;
525             stdin.flush();
526             stdout.flush();
527         }
528 
529         input = input
530             .strippedLeft(" /")  // Remove indents and commentating slashes
531             .chomp //strippedRight;
532             .unquoted;
533 
534         if (input.length && (input[$-1] == '$')) input = input[0..$-1];
535         if (!input.length) continue;
536 
537         try
538         {
539             IRCEvent event = parser.toIRCEvent(input);
540 
541             buffer.formatEventAssertBlock(event);
542 
543             if (parser.updates != IRCParser.Update.nothing)
544             {
545                 buffer.put("\n\nwith (parser)\n{\n");
546                 buffer.formatDeltaInto!(Yes.asserts)(oldClient, parser.client, 1, "client");
547                 buffer.formatDeltaInto!(Yes.asserts)(oldServer, parser.server, 1, "server");
548                 buffer.put("}\n");
549 
550                 oldClient = parser.client;
551                 oldServer = parser.server;
552                 parser.updates = IRCParser.Update.nothing;
553             }
554         }
555         catch (IRCParseException e)
556         {
557             buffer.formattedWrite("\n// IRC Parse Exception at %s:%d: %s\n", e.file, e.line, e.msg);
558 
559             version(PrintStacktraces)
560             {
561                 import std.conv : text;
562 
563                 buffer.put("/*\n");
564                 buffer.put(e.info.text);
565                 buffer.put("\n*/\n");
566             }
567         }
568         catch (Exception e)
569         {
570             buffer.formattedWrite("\n// Exception at %s:%d: %s\n", e.file, e.line, e.msg);
571 
572             version(PrintStacktraces)
573             {
574                 buffer.put("/*\n");
575                 buffer.put(e.toString);
576                 buffer.put("\n*/\n");
577             }
578         }
579 
580         if (outputFile.length)
581         {
582             file.writeln(buffer.data);
583             file.flush();
584         }
585 
586         writeln();
587         writeln(buffer.data);
588         writeln();
589         buffer.clear();
590     }
591 
592     return 0;
593 }