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