1 /**
2 Copyright: Copyright (c) 2017, Joakim Brännström. All rights reserved.
3 License: MPL-2
4 Author: Joakim Brännström (joakim.brannstrom@gmx.com)
5 
6 This Source Code Form is subject to the terms of the Mozilla Public License,
7 v.2.0. If a copy of the MPL was not distributed with this file, You can obtain
8 one at http://mozilla.org/MPL/2.0/.
9 
10 This module is responsible for converting the users CLI arguments to
11 configuration of how the mutation plugin should behave.
12 */
13 module dextool.plugin.mutate.frontend.argparser;
14 
15 import core.time : dur;
16 import logger = std.experimental.logger;
17 import std.algorithm : joiner, sort, map, filter;
18 import std.array : empty, array, appender;
19 import std.exception : collectException;
20 import std.traits : EnumMembers;
21 
22 import toml : TOMLDocument;
23 
24 public import dextool.plugin.mutate.backend : Mutation;
25 public import dextool.plugin.mutate.type;
26 import dextool.plugin.mutate.config;
27 import dextool.type : AbsolutePath, Path, ExitStatusType, DirName;
28 
29 version (unittest) {
30     import unit_threaded.assertions;
31 }
32 
33 @safe:
34 
35 /// Extract and cleanup user input from the command line.
36 struct ArgParser {
37     import std.typecons : Nullable;
38     import std.conv : ConvException;
39     import std.getopt : GetoptResult, getopt, defaultGetoptPrinter;
40     import dextool.type : FileName;
41 
42     /// Minimal data needed to bootstrap the configuration.
43     MiniConfig miniConf;
44 
45     ConfigAdmin admin;
46     ConfigAnalyze analyze;
47     ConfigCompileDb compileDb;
48     ConfigCompiler compiler;
49     ConfigMutationTest mutationTest;
50     ConfigReport report;
51     ConfigWorkArea workArea;
52     ConfigGenerate generate;
53 
54     struct Data {
55         string[] inFiles;
56 
57         AbsolutePath db;
58 
59         bool help;
60         ExitStatusType exitStatus = ExitStatusType.Ok;
61 
62         MutationKind[] mutation;
63 
64         ToolMode toolMode;
65 
66         Mutation.Status to_status;
67     }
68 
69     Data data;
70     alias data this;
71 
72     private GetoptResult help_info;
73 
74     alias GroupF = void delegate(string[]) @system;
75     GroupF[string] groups;
76 
77     /// Returns: a config object with default values.
78     static ArgParser make() @safe {
79         import dextool.compilation_db : defaultCompilerFlagFilter, CompileCommandFilter;
80 
81         ArgParser r;
82         r.compileDb.flagFilter = CompileCommandFilter(defaultCompilerFlagFilter, 0);
83         return r;
84     }
85 
86     /// Convert the configuration to a TOML file.
87     string toTOML() @trusted {
88         import std.ascii : newline;
89         import std.conv : to;
90         import std.format : format;
91         import std.utf : toUTF8;
92 
93         auto app = appender!(string[])();
94 
95         app.put("[workarea]");
96         app.put("# path used as the root for accessing files");
97         app.put(
98                 "# dextool will not modify files outside the root when it perform mutation testing");
99         app.put(`# root = "."`);
100         app.put("# restrict analysis to files in this directory tree");
101         app.put("# this make it possible to only mutate certain parts of an application");
102         app.put("# use relative paths that are inside the root");
103         app.put("# restrict = []");
104         app.put(null);
105 
106         app.put("[analyze]");
107         app.put("# exclude files in these directory tree(s) from analysis");
108         app.put("# relative paths are relative to the directory dextool is executed in");
109         app.put("# exclude = []");
110         app.put(null);
111 
112         app.put("[database]");
113         app.put("# path to where to store the sqlite3 database");
114         app.put(`# db = "dextool_mutate.sqlite3"`);
115         app.put(null);
116 
117         app.put("[compiler]");
118         app.put("# extra flags to pass on to the compiler such as the C++ standard");
119         app.put(format(`# extra_flags = [%(%s, %)]`, compiler.extraFlags));
120         app.put("# toggle this to force system include paths to use -I instead of -isystem");
121         app.put("# force_system_includes = true");
122         app.put(
123                 "# use this compilers system includes instead of the one used in the compile_commands.json");
124         app.put(format(`# use_compiler_system_includes = "%s"`, compiler.useCompilerSystemIncludes.length == 0
125                 ? "/path/to/c++" : compiler.useCompilerSystemIncludes.value));
126         app.put(null);
127 
128         app.put("[compile_commands]");
129         app.put("# search for compile_commands.json in this paths");
130         if (compileDb.dbs.length == 0)
131             app.put(`# search_paths = ["./compile_commands.json"]`);
132         else
133             app.put(format("search_paths = %s", compileDb.rawDbs));
134         app.put("# flags to remove when analyzing a file in the DB");
135         app.put(format("# filter = [%(%s, %)]", compileDb.flagFilter.filter));
136         app.put("# compiler arguments to skip from the beginning. Needed when the first argument is NOT a compiler but rather a wrapper");
137         app.put(format("# skip_compiler_args = %s", compileDb.flagFilter.skipCompilerArgs));
138         app.put(null);
139 
140         app.put("[mutant_test]");
141         app.put("# (required) program used to run the test suite");
142         app.put(`# the arguments for test_cmd can be an array of multiple test commands`);
143         app.put(`# 1. ["test1.sh", "test2.sh"]`);
144         app.put(`# 2. [["test1.sh", "-x"], "test2.sh"]`);
145         app.put(`test_cmd = "test.sh"`);
146         app.put("# timeout to use for the test suite (msecs)");
147         app.put("# test_cmd_timeout = 1000");
148         app.put("# (required) program used to build the application");
149         app.put(`# the arguments for build_cmd can be an array: ["build.sh", "-x"]`);
150         app.put(`build_cmd = "build.sh"`);
151         app.put(
152                 "# program used to analyze the output from the test suite for test cases that killed the mutant");
153         app.put(`# analyze_cmd = "analyze.sh"`);
154         app.put("# builtin analyzer of output from testing frameworks to find failing test cases");
155         app.put(format("# analyze_using_builtin = [%(%s, %)]",
156                 [EnumMembers!TestCaseAnalyzeBuiltin].map!(a => a.to!string)));
157         app.put("# determine in what order mutations are chosen");
158         app.put(format("# order = %(%s|%)", [EnumMembers!MutationOrder].map!(a => a.to!string)));
159         app.put("# how to behave when new test cases are found");
160         app.put(format("# detected_new_test_case = %(%s|%)",
161                 [EnumMembers!(ConfigMutationTest.NewTestCases)].map!(a => a.to!string)));
162         app.put("# how to behave when test cases are detected as having been removed");
163         app.put("# should the test and the gathered statistics be remove too?");
164         app.put(format("# detected_dropped_test_case = %(%s|%)",
165                 [EnumMembers!(ConfigMutationTest.RemovedTestCases)].map!(a => a.to!string)));
166         app.put("# how the oldest mutants should be treated.");
167         app.put("# It is recommended to test them again.");
168         app.put("# Because you may have changed the test suite so mutants that where previously killed by the test suite now survive.");
169         app.put(format("# oldest_mutants = %(%s|%)",
170                 [EnumMembers!(ConfigMutationTest.OldMutant)].map!(a => a.to!string)));
171         app.put("# How many of the oldest mutants to do the above with");
172         app.put("# oldest_mutants_nr = 10");
173         app.put(null);
174 
175         app.put("[report]");
176         app.put("# default style to use");
177         app.put(format("# style = %(%s|%)", [EnumMembers!ReportKind].map!(a => a.to!string)));
178         app.put(null);
179 
180         app.put("[test_group]");
181         app.put("# subgroups with a description and pattern. Example:");
182         app.put("# [test_group.uc1]");
183         app.put(`# description = "use case 1"`);
184         app.put(`# pattern = "uc_1.*"`);
185         app.put(`# see for regex syntax: http://dlang.org/phobos/std_regex.html`);
186         app.put(null);
187 
188         return app.data.joiner(newline).toUTF8;
189     }
190 
191     void parse(string[] args) {
192         import std.format : format;
193 
194         static import std.getopt;
195 
196         const db_help = "sqlite3 database to use (default: dextool_mutate.sqlite3)";
197         const restrict_help = "restrict mutation to the files in this directory tree (default: .)";
198         const out_help = "path used as the root for mutation/reporting of files (default: .)";
199         const conf_help = "load configuration (default: .dextool_mutate.toml)";
200 
201         // not used but need to be here. The one used is in MiniConfig.
202         string conf_file;
203         string db;
204 
205         void analyzerG(string[] args) {
206             string[] compile_dbs;
207             string[] exclude_files;
208 
209             data.toolMode = ToolMode.analyzer;
210             // dfmt off
211             help_info = getopt(args, std.getopt.config.keepEndOfOptions,
212                    "compile-db", "Retrieve compilation parameters from the file", &compile_dbs,
213                    "c|config", conf_help, &conf_file,
214                    "db", db_help, &db,
215                    "in", "Input file to parse (default: all files in the compilation database)", &data.inFiles,
216                    "out", out_help, &workArea.rawRoot,
217                    "restrict", restrict_help, &workArea.rawRestrict,
218                    "file-exclude", "exclude files in these directory tree from the analysis (default: none)", &exclude_files,
219                    );
220             // dfmt on
221 
222             updateCompileDb(compileDb, compile_dbs);
223             if (!exclude_files.empty)
224                 analyze.rawExclude = exclude_files;
225         }
226 
227         void generateMutantG(string[] args) {
228             data.toolMode = ToolMode.generate_mutant;
229             // dfmt off
230             help_info = getopt(args, std.getopt.config.keepEndOfOptions,
231                    "c|config", conf_help, &conf_file,
232                    "db", db_help, &db,
233                    "out", out_help, &workArea.rawRoot,
234                    "restrict", restrict_help, &workArea.rawRestrict,
235                    std.getopt.config.required, "id", "mutate the source code as mutant ID", &generate.mutationId,
236                    );
237             // dfmt on
238         }
239 
240         void testMutantsG(string[] args) {
241             string[] mutationTester;
242             string mutationCompile;
243             string mutationTestCaseAnalyze;
244             long mutationTesterRuntime;
245             string maxRuntime;
246             string[] testConstraint;
247             int maxAlive = -1;
248 
249             data.toolMode = ToolMode.test_mutants;
250             // dfmt off
251             help_info = getopt(args, std.getopt.config.keepEndOfOptions,
252                    "L", "restrict testing to the requested files and lines (<file>:<start>-<end>)", &testConstraint,
253                    "build-cmd", "program used to build the application", &mutationCompile,
254                    "c|config", conf_help, &conf_file,
255                    "db", db_help, &db,
256                    "diff-from-stdin", "restrict testing to the mutants in the diff", &mutationTest.unifiedDiffFromStdin,
257                    "dry-run", "do not write data to the filesystem", &mutationTest.dryRun,
258                    "max-alive", "stop after NR alive mutants is found (only effective with -L or --diff-from-stdin)", &maxAlive,
259                    "max-runtime", format("max time to run the mutation testing for (default: %s)", mutationTest.maxRuntime), &maxRuntime,
260                    "mutant", "kind of mutation to test " ~ format("[%(%s|%)]", [EnumMembers!MutationKind]), &data.mutation,
261                    "order", "determine in what order mutants are chosen " ~ format("[%(%s|%)]", [EnumMembers!MutationOrder]), &mutationTest.mutationOrder,
262                    "out", out_help, &workArea.rawRoot,
263                    "restrict", restrict_help, &workArea.rawRestrict,
264                    "test-case-analyze-builtin", "builtin analyzer of output from testing frameworks to find failing test cases", &mutationTest.mutationTestCaseBuiltin,
265                    "test-case-analyze-cmd", "program used to find what test cases killed the mutant", &mutationTestCaseAnalyze,
266                    "test-cmd", "program used to run the test suite", &mutationTester,
267                    "test-timeout", "timeout to use for the test suite (msecs)", &mutationTesterRuntime,
268                    );
269             // dfmt on
270 
271             if (maxAlive > 0)
272                 mutationTest.maxAlive = maxAlive;
273             if (mutationTester.length != 0)
274                 mutationTest.mutationTester = mutationTester.map!(a => ShellCommand.fromString(a))
275                     .array;
276             if (mutationCompile.length != 0)
277                 mutationTest.mutationCompile = ShellCommand.fromString(mutationCompile);
278             if (mutationTestCaseAnalyze.length != 0)
279                 mutationTest.mutationTestCaseAnalyze = ShellCommand.fromString(
280                         mutationTestCaseAnalyze);
281             if (mutationTesterRuntime != 0)
282                 mutationTest.mutationTesterRuntime = mutationTesterRuntime.dur!"msecs";
283             if (!maxRuntime.empty)
284                 mutationTest.maxRuntime = parseDuration(maxRuntime);
285             mutationTest.constraint = parseUserTestConstraint(testConstraint);
286         }
287 
288         void reportG(string[] args) {
289             string[] compile_dbs;
290             string logDir;
291 
292             data.toolMode = ToolMode.report;
293             // dfmt off
294             help_info = getopt(args, std.getopt.config.keepEndOfOptions,
295                    "compile-db", "Retrieve compilation parameters from the file", &compile_dbs,
296                    "c|config", conf_help, &conf_file,
297                    "db", db_help, &db,
298                    "diff-from-stdin", "report alive mutants in the areas indicated as changed in the diff", &report.unifiedDiff,
299                    "level", "the report level of the mutation data " ~ format("[%(%s|%)]", [EnumMembers!ReportLevel]), &report.reportLevel,
300                    "logdir", "Directory to write log files to (default: .)", &logDir,
301                    "mutant", "kind of mutation to report " ~ format("[%(%s|%)]", [EnumMembers!MutationKind]), &data.mutation,
302                    "out", out_help, &workArea.rawRoot,
303                    "restrict", restrict_help, &workArea.rawRestrict,
304                    "section", "sections to include in the report " ~ format("[%(%s|%)]", [EnumMembers!ReportSection]), &report.reportSection,
305                    "section-tc_stat-num", "number of test cases to report", &report.tcKillSortNum,
306                    "section-tc_stat-sort", "sort order when reporting test case kill stat " ~ format("[%(%s|%)]", [EnumMembers!ReportKillSortOrder]), &report.tcKillSortOrder,
307                    "style", "kind of report to generate " ~ format("[%(%s|%)]", [EnumMembers!ReportKind]), &report.reportKind,
308                    );
309             // dfmt on
310 
311             if (report.reportSection.length != 0 && report.reportLevel != ReportLevel.summary) {
312                 logger.error("Combining --section and --level is not supported");
313                 help_info.helpWanted = true;
314             }
315 
316             if (logDir.empty)
317                 logDir = ".";
318             report.logDir = logDir.Path.AbsolutePath;
319 
320             updateCompileDb(compileDb, compile_dbs);
321         }
322 
323         void adminG(string[] args) {
324             bool dump_conf;
325             bool init_conf;
326             data.toolMode = ToolMode.admin;
327             // dfmt off
328             help_info = getopt(args, std.getopt.config.keepEndOfOptions,
329                 "c|config", conf_help, &conf_file,
330                 "db", db_help, &db,
331                 "dump-config", "dump the detailed configuration used", &dump_conf,
332                 "init", "create an initial config to use", &init_conf,
333                 "mutant", "mutants to operate on " ~ format("[%(%s|%)]", [EnumMembers!MutationKind]), &data.mutation,
334                 "operation", "administrative operation to perform " ~ format("[%(%s|%)]", [EnumMembers!AdminOperation]), &admin.adminOp,
335                 "test-case-regex", "regex to use when removing test cases", &admin.testCaseRegex,
336                 "status", "change mutants with this state to the value specified by --to-status " ~ format("[%(%s|%)]", [EnumMembers!(Mutation.Status)]), &admin.mutantStatus,
337                 "to-status", "reset mutants to state (default: unknown) " ~ format("[%(%s|%)]", [EnumMembers!(Mutation.Status)]), &admin.mutantToStatus,
338                 "id", "specify mutant by Id", &admin.mutationId,
339                 "rationale", "rationale for marking mutant", &admin.mutantRationale,
340                 "out", out_help, &workArea.rawRoot,
341                 );
342             // dfmt on
343 
344             if (dump_conf)
345                 data.toolMode = ToolMode.dumpConfig;
346             else if (init_conf)
347                 data.toolMode = ToolMode.initConfig;
348         }
349 
350         groups["analyze"] = &analyzerG;
351         groups["generate"] = &generateMutantG;
352         groups["test"] = &testMutantsG;
353         groups["report"] = &reportG;
354         groups["admin"] = &adminG;
355 
356         if (args.length < 2) {
357             logger.error("Missing command");
358             help = true;
359             exitStatus = ExitStatusType.Errors;
360             return;
361         }
362 
363         const string cg = args[1];
364         string[] subargs = args[0 .. 1];
365         if (args.length > 2)
366             subargs ~= args[2 .. $];
367 
368         if (auto f = cg in groups) {
369             try {
370                 // trusted: not any external input.
371                 () @trusted { (*f)(subargs); }();
372                 help = help_info.helpWanted;
373             } catch (std.getopt.GetOptException ex) {
374                 logger.error(ex.msg);
375                 help = true;
376                 exitStatus = ExitStatusType.Errors;
377             } catch (Exception ex) {
378                 logger.error(ex.msg);
379                 help = true;
380                 exitStatus = ExitStatusType.Errors;
381             }
382         } else {
383             logger.error("Unknown command: ", cg);
384             help = true;
385             exitStatus = ExitStatusType.Errors;
386             return;
387         }
388 
389         import std.algorithm : find;
390         import std.range : drop;
391 
392         if (db.length != 0)
393             data.db = AbsolutePath(FileName(db));
394         else if (data.db.length == 0)
395             data.db = "dextool_mutate.sqlite3".Path.AbsolutePath;
396 
397         if (workArea.rawRoot.empty) {
398             workArea.rawRoot = ".";
399         }
400         workArea.outputDirectory = workArea.rawRoot.Path.AbsolutePath;
401 
402         if (workArea.rawRestrict.empty) {
403             workArea.rawRestrict = [workArea.rawRoot];
404         }
405         workArea.restrictDir = workArea.rawRestrict.map!(a => AbsolutePath(FileName(a),
406                 DirName(workArea.outputDirectory))).array;
407 
408         analyze.exclude = analyze.rawExclude.map!(a => AbsolutePath(FileName(a),
409                 DirName(workArea.outputDirectory))).array;
410 
411         compiler.extraFlags = compiler.extraFlags ~ args.find("--").drop(1).array();
412     }
413 
414     /**
415      * Trusted:
416      * The only input is a static string and data derived from getopt itselt.
417      * Assuming that getopt in phobos behave well.
418      */
419     void printHelp() @trusted {
420         import std.ascii : newline;
421         import std.stdio : writeln;
422 
423         string base_help = "Usage: dextool mutate COMMAND [options]";
424 
425         switch (toolMode) with (ToolMode) {
426         case none:
427             writeln("commands: ", newline,
428                     groups.byKey.array.sort.map!(a => "  " ~ a).joiner(newline));
429             break;
430         case analyzer:
431             base_help = "Usage: dextool mutate analyze [options] [-- CFLAGS...]";
432             break;
433         case generate_mutant:
434             break;
435         case test_mutants:
436             logger.infof("--test-case-analyze-builtin possible values: %(%s|%)",
437                     [EnumMembers!TestCaseAnalyzeBuiltin]);
438             logger.infof(
439                     "--max-runtime supported units are [weeks, days, hours, minutes, seconds, msecs]");
440             logger.infof(`example: --max-runtime "1 hours 30 minutes"`);
441             break;
442         case report:
443             break;
444         case admin:
445             break;
446         default:
447             break;
448         }
449 
450         defaultGetoptPrinter(base_help, help_info.options);
451     }
452 }
453 
454 /// Update the config from the users input.
455 void updateCompileDb(ref ConfigCompileDb db, string[] compile_dbs) {
456     if (compile_dbs.length != 0)
457         db.rawDbs = compile_dbs;
458     db.dbs = db.rawDbs
459         .filter!(a => a.length != 0)
460         .map!(a => Path(a).AbsolutePath)
461         .array;
462 }
463 
464 /** Print a help message conveying how files in the compilation database will
465  * be analyzed.
466  *
467  * It must be enough information that the user can adjust `--out` and `--restrict`.
468  */
469 void printFileAnalyzeHelp(ref ArgParser ap) @safe {
470     static void printPath(string user, AbsolutePath real_path) {
471         logger.info("  User input: ", user);
472         logger.info("  Real path: ", real_path);
473     }
474 
475     logger.infof("Reading compilation database:\n%-(%s\n%)", ap.compileDb.dbs);
476 
477     logger.info(
478             "Analyze and mutation of files will only be done on those inside this directory root");
479     printPath(ap.workArea.rawRoot, ap.workArea.outputDirectory);
480     logger.info(ap.workArea.rawRestrict.length != 0,
481             "Restricting mutation to files in the following directory tree(s)");
482 
483     assert(ap.workArea.rawRestrict.length == ap.workArea.restrictDir.length);
484     foreach (idx; 0 .. ap.workArea.rawRestrict.length) {
485         if (ap.workArea.rawRestrict[idx] == ap.workArea.rawRoot)
486             continue;
487         printPath(ap.workArea.rawRestrict[idx], ap.workArea.restrictDir[idx]);
488     }
489 
490     logger.info(!ap.analyze.exclude.empty,
491             "Excluding files inside the following directory tree(s) from analysis");
492     foreach (idx; 0 .. ap.analyze.exclude.length) {
493         printPath(ap.analyze.rawExclude[idx], ap.analyze.exclude[idx]);
494     }
495 }
496 
497 /** Load the configuration from file.
498  *
499  * Example of a TOML configuration
500  * ---
501  * [defaults]
502  * check_name_standard = true
503  * ---
504  */
505 void loadConfig(ref ArgParser rval) @trusted {
506     import std.file : exists, readText;
507     import toml;
508 
509     if (!exists(rval.miniConf.confFile))
510         return;
511 
512     static auto tryLoading(string configFile) {
513         auto txt = readText(configFile);
514         auto doc = parseTOML(txt);
515         return doc;
516     }
517 
518     TOMLDocument doc;
519     try {
520         doc = tryLoading(rval.miniConf.confFile);
521     } catch (Exception e) {
522         logger.warning("Unable to read the configuration from ", rval.miniConf.confFile);
523         logger.warning(e.msg);
524         rval.data.exitStatus = ExitStatusType.Errors;
525         return;
526     }
527 
528     rval = loadConfig(rval, doc);
529 }
530 
531 ArgParser loadConfig(ArgParser rval, ref TOMLDocument doc) @trusted {
532     import std.conv : to;
533     import std.path : dirName, buildPath;
534     import toml;
535 
536     alias Fn = void delegate(ref ArgParser c, ref TOMLValue v);
537     Fn[string] callbacks;
538 
539     static ShellCommand toShellCommand(ref TOMLValue v, string errorMsg) {
540         if (v.type == TOML_TYPE.STRING) {
541             return ShellCommand.fromString(v.str);
542         } else if (v.type == TOML_TYPE.ARRAY) {
543             return ShellCommand(v.array.map!(a => a.str).array);
544         }
545         logger.warning(errorMsg);
546         return ShellCommand.init;
547     }
548 
549     static ShellCommand[] toShellCommands(ref TOMLValue v, string errorMsg) {
550         import std.format : format;
551 
552         if (v.type == TOML_TYPE.STRING) {
553             return [ShellCommand.fromString(v.str)];
554         } else if (v.type == TOML_TYPE.ARRAY) {
555             return v.array.map!(a => toShellCommand(a,
556                     format!"%s: failed to parse as an array"(errorMsg))).array;
557         }
558         logger.warning(errorMsg);
559         return ShellCommand[].init;
560     }
561 
562     callbacks["analyze.exclude"] = (ref ArgParser c, ref TOMLValue v) {
563         c.analyze.rawExclude = v.array.map!(a => a.str).array;
564     };
565 
566     callbacks["workarea.root"] = (ref ArgParser c, ref TOMLValue v) {
567         c.workArea.rawRoot = v.str;
568     };
569     callbacks["workarea.restrict"] = (ref ArgParser c, ref TOMLValue v) {
570         c.workArea.rawRestrict = v.array.map!(a => a.str).array;
571     };
572 
573     callbacks["database.db"] = (ref ArgParser c, ref TOMLValue v) {
574         c.db = v.str.Path.AbsolutePath;
575     };
576 
577     callbacks["compile_commands.search_paths"] = (ref ArgParser c, ref TOMLValue v) {
578         c.compileDb.rawDbs = v.array.map!"a.str".array;
579     };
580     callbacks["compile_commands.filter"] = (ref ArgParser c, ref TOMLValue v) {
581         import dextool.type : FilterClangFlag;
582 
583         c.compileDb.flagFilter.filter = v.array.map!(a => FilterClangFlag(a.str)).array;
584     };
585     callbacks["compile_commands.skip_compiler_args"] = (ref ArgParser c, ref TOMLValue v) {
586         c.compileDb.flagFilter.skipCompilerArgs = cast(int) v.integer;
587     };
588 
589     callbacks["compiler.extra_flags"] = (ref ArgParser c, ref TOMLValue v) {
590         c.compiler.extraFlags = v.array.map!(a => a.str).array;
591     };
592     callbacks["compiler.force_system_includes"] = (ref ArgParser c, ref TOMLValue v) {
593         c.compiler.forceSystemIncludes = v == true;
594     };
595     callbacks["compiler.use_compiler_system_includes"] = (ref ArgParser c, ref TOMLValue v) {
596         c.compiler.useCompilerSystemIncludes = v.str;
597     };
598 
599     callbacks["mutant_test.test_cmd"] = (ref ArgParser c, ref TOMLValue v) {
600         c.mutationTest.mutationTester = toShellCommands(v,
601                 "config: failed to parse mutant_test.test_cmd");
602     };
603     callbacks["mutant_test.test_cmd_timeout"] = (ref ArgParser c, ref TOMLValue v) {
604         c.mutationTest.mutationTesterRuntime = v.integer.dur!"msecs";
605     };
606     callbacks["mutant_test.build_cmd"] = (ref ArgParser c, ref TOMLValue v) {
607         c.mutationTest.mutationCompile = toShellCommand(v,
608                 "config: failed to parse mutant_test.build_cmd");
609     };
610     callbacks["mutant_test.analyze_cmd"] = (ref ArgParser c, ref TOMLValue v) {
611         c.mutationTest.mutationTestCaseAnalyze = toShellCommand(v,
612                 "config: failed to parse mutant_test.analyze_cmd");
613     };
614     callbacks["mutant_test.analyze_using_builtin"] = (ref ArgParser c, ref TOMLValue v) {
615         c.mutationTest.mutationTestCaseBuiltin = v.array.map!(
616                 a => a.str.to!TestCaseAnalyzeBuiltin).array;
617     };
618     callbacks["mutant_test.order"] = (ref ArgParser c, ref TOMLValue v) {
619         c.mutationTest.mutationOrder = v.str.to!MutationOrder;
620     };
621     callbacks["mutant_test.detected_new_test_case"] = (ref ArgParser c, ref TOMLValue v) {
622         try {
623             c.mutationTest.onNewTestCases = v.str.to!(ConfigMutationTest.NewTestCases);
624         } catch (Exception e) {
625             logger.info("Available alternatives: ",
626                     [EnumMembers!(ConfigMutationTest.NewTestCases)]);
627         }
628     };
629     callbacks["mutant_test.detected_dropped_test_case"] = (ref ArgParser c, ref TOMLValue v) {
630         try {
631             c.mutationTest.onRemovedTestCases = v.str.to!(ConfigMutationTest.RemovedTestCases);
632         } catch (Exception e) {
633             logger.info("Available alternatives: ",
634                     [EnumMembers!(ConfigMutationTest.RemovedTestCases)]);
635         }
636     };
637     callbacks["mutant_test.oldest_mutants"] = (ref ArgParser c, ref TOMLValue v) {
638         try {
639             c.mutationTest.onOldMutants = v.str.to!(ConfigMutationTest.OldMutant);
640         } catch (Exception e) {
641             logger.info("Available alternatives: ", [
642                     EnumMembers!(ConfigMutationTest.OldMutant)
643                     ]);
644         }
645     };
646     callbacks["mutant_test.oldest_mutants_nr"] = (ref ArgParser c, ref TOMLValue v) {
647         c.mutationTest.oldMutantsNr = v.integer;
648     };
649     callbacks["report.style"] = (ref ArgParser c, ref TOMLValue v) {
650         c.report.reportKind = v.str.to!ReportKind;
651     };
652 
653     void iterSection(ref ArgParser c, string sectionName) {
654         if (auto section = sectionName in doc) {
655             // specific configuration from section members
656             foreach (k, v; *section) {
657                 if (auto cb = (sectionName ~ "." ~ k) in callbacks) {
658                     try {
659                         (*cb)(c, v);
660                     } catch (Exception e) {
661                         logger.error(e.msg).collectException;
662                     }
663                 } else {
664                     logger.infof("Unknown key '%s' in configuration section '%s'", k, sectionName);
665                 }
666             }
667         }
668     }
669 
670     iterSection(rval, "analyze");
671     iterSection(rval, "workarea");
672     iterSection(rval, "database");
673     iterSection(rval, "compiler");
674     iterSection(rval, "compile_commands");
675     iterSection(rval, "mutant_test");
676     iterSection(rval, "report");
677 
678     parseTestGroups(rval, doc);
679 
680     return rval;
681 }
682 
683 void parseTestGroups(ref ArgParser c, ref TOMLDocument doc) @trusted {
684     import toml;
685 
686     if ("test_group" !in doc)
687         return;
688 
689     foreach (k, s; *("test_group" in doc)) {
690         if (s.type != TOML_TYPE.TABLE)
691             continue;
692 
693         string desc;
694         if (auto v = "description" in s)
695             desc = v.str;
696         if (auto v = "pattern" in s) {
697             string re = v.str;
698             c.report.testGroups ~= TestGroup(k, desc, re);
699         }
700     }
701 }
702 
703 @("shall populate the test, build and analyze command of an ArgParser from a TOML document")
704 @system unittest {
705     import std.format : format;
706     import toml : parseTOML;
707 
708     immutable txt = `
709 [mutant_test]
710 test_cmd = %s
711 build_cmd = %s
712 analyze_cmd = %s
713 `;
714 
715     {
716         auto doc = parseTOML(format!txt(`"test.sh"`, `"build.sh"`, `"analyze.sh"`));
717         auto ap = loadConfig(ArgParser.init, doc);
718         ap.mutationTest.mutationTester.shouldEqual([ShellCommand(["test.sh"])]);
719         ap.mutationTest.mutationCompile.shouldEqual(ShellCommand(["build.sh"]));
720         ap.mutationTest.mutationTestCaseAnalyze.shouldEqual(ShellCommand([
721                     "analyze.sh"
722                 ]));
723     }
724 
725     {
726         auto doc = parseTOML(format!txt(`[["test1.sh"], ["test2.sh"]]`,
727                 `["build.sh", "-y"]`, `["analyze.sh", "-z"]`));
728         auto ap = loadConfig(ArgParser.init, doc);
729         ap.mutationTest.mutationTester.shouldEqual([
730                 ShellCommand(["test1.sh"]), ShellCommand(["test2.sh"])
731                 ]);
732         ap.mutationTest.mutationCompile.shouldEqual(ShellCommand([
733                     "build.sh", "-y"
734                 ]));
735         ap.mutationTest.mutationTestCaseAnalyze.shouldEqual(ShellCommand([
736                     "analyze.sh", "-z"
737                 ]));
738     }
739 
740     {
741         auto doc = parseTOML(format!txt(`[["test1.sh", "-x"], ["test2.sh", "-y"]]`,
742                 `"build.sh"`, `"analyze.sh"`));
743         auto ap = loadConfig(ArgParser.init, doc);
744         ap.mutationTest.mutationTester.shouldEqual([
745                 ShellCommand(["test1.sh", "-x"]), ShellCommand([
746                         "test2.sh", "-y"
747                     ])
748                 ]);
749     }
750 }
751 
752 /// Minimal config to setup path to config file.
753 struct MiniConfig {
754     /// Value from the user via CLI, unmodified.
755     string rawConfFile;
756 
757     /// The configuration file that has been loaded
758     AbsolutePath confFile;
759 
760     bool shortPluginHelp;
761 }
762 
763 /// Returns: minimal config to load settings and setup working directory.
764 MiniConfig cliToMiniConfig(string[] args) @trusted nothrow {
765     import std.file : exists;
766     static import std.getopt;
767 
768     immutable default_conf = ".dextool_mutate.toml";
769 
770     MiniConfig conf;
771 
772     try {
773         std.getopt.getopt(args, std.getopt.config.keepEndOfOptions, std.getopt.config.passThrough,
774                 "c|config", "none not visible to the user", &conf.rawConfFile,
775                 "short-plugin-help", "not visible to the user", &conf.shortPluginHelp);
776         if (conf.rawConfFile.length == 0)
777             conf.rawConfFile = default_conf;
778         conf.confFile = Path(conf.rawConfFile).AbsolutePath;
779     } catch (Exception e) {
780         logger.trace(conf).collectException;
781         logger.error(e.msg).collectException;
782     }
783 
784     return conf;
785 }
786 
787 auto parseDuration(string timeSpec) {
788     import std.conv : to;
789     import std.string : split;
790     import std.datetime : Duration, dur;
791     import std.range : chunks;
792 
793     Duration d;
794     const parts = timeSpec.split;
795 
796     if (parts.length % 2 != 0) {
797         logger.warning("Invalid time specification because either the number or unit is missing");
798         return d;
799     }
800 
801     foreach (const p; parts.chunks(2)) {
802         const nr = p[0].to!long;
803         bool validUnit;
804         immutable Units = [
805             "msecs", "seconds", "minutes", "hours", "days", "weeks"
806         ];
807         static foreach (Unit; Units) {
808             if (p[1] == Unit) {
809                 d += nr.dur!Unit;
810                 validUnit = true;
811             }
812         }
813         if (!validUnit) {
814             logger.warningf("Invalid unit '%s'. Valid are %-(%s, %).", p[1], Units);
815             return d;
816         }
817     }
818 
819     return d;
820 }
821 
822 @("shall parse a string to a duration")
823 unittest {
824     const expected = 1.dur!"weeks" + 1.dur!"days" + 3.dur!"hours"
825         + 2.dur!"minutes" + 5.dur!"seconds" + 9.dur!"msecs";
826     const d = parseDuration("1 weeks 1 days 3 hours 2 minutes 5 seconds 9 msecs");
827     d.should == expected;
828 }
829 
830 auto parseUserTestConstraint(string[] raw) {
831     import std.conv : to;
832     import std.regex : regex, matchFirst;
833     import std.typecons : tuple;
834     import dextool.plugin.mutate.type : TestConstraint;
835 
836     TestConstraint rval;
837     const re = regex(`(?P<file>.*):(?P<start>\d*)-(?P<end>\d*)`);
838 
839     foreach (r; raw.map!(a => tuple!("user", "match")(a, matchFirst(a, re)))) {
840         if (r.match.empty) {
841             logger.warning("Unable to parse ", r.user);
842             continue;
843         }
844 
845         const start = r.match["start"].to!uint;
846         const end = r.match["end"].to!uint + 1;
847 
848         if (start > end) {
849             logger.warningf("Unable to parse %s because start (%s) must be less than end (%s)",
850                     r.user, r.match["start"], r.match["end"]);
851             continue;
852         }
853 
854         foreach (const l; start .. end)
855             rval.value[Path(r.match["file"])] ~= Line(l);
856     }
857 
858     return rval;
859 }
860 
861 @("shall parse a test restriction")
862 unittest {
863     const r = parseUserTestConstraint([
864             "foo/bar:1-10", "smurf bar/i oknen:ea,ting:33-45"
865             ]);
866 
867     Path("foo/bar").shouldBeIn(r.value);
868     r.value[Path("foo/bar")][0].should == Line(1);
869     r.value[Path("foo/bar")][9].should == Line(10);
870 
871     Path("smurf bar/i oknen:ea,ting").shouldBeIn(r.value);
872     r.value[Path("smurf bar/i oknen:ea,ting")][0].should == Line(33);
873     r.value[Path("smurf bar/i oknen:ea,ting")][12].should == Line(45);
874 }