1 /**
2 Copyright: Copyright (c) 2018, 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 module dextool.plugin.mutate.backend.report.html;
11 
12 import logger = std.experimental.logger;
13 import std.exception : collectException;
14 import std.format : format;
15 import std.stdio : File;
16 
17 import arsd.dom : Document, Element, require, Table, RawSource;
18 
19 import dextool.plugin.mutate.backend.database : Database, FileRow, FileMutantRow, MutationId;
20 import dextool.plugin.mutate.backend.diff_parser : Diff;
21 import dextool.plugin.mutate.backend.interface_ : FilesysIO;
22 import dextool.plugin.mutate.backend.report.type : FileReport, FilesReporter;
23 import dextool.plugin.mutate.backend.report.utility : toSections;
24 import dextool.plugin.mutate.backend.type : Mutation, Offset, SourceLoc, Token;
25 import dextool.plugin.mutate.config : ConfigReport;
26 import dextool.plugin.mutate.type : MutationKind, ReportKind, ReportLevel, ReportSection;
27 import dextool.type : AbsolutePath, Path;
28 
29 import dextool.plugin.mutate.backend.report.html.constants;
30 import dextool.plugin.mutate.backend.report.html.js;
31 import dextool.plugin.mutate.backend.report.html.tmpl;
32 
33 version (unittest) {
34     import unit_threaded : shouldEqual;
35 }
36 
37 struct FileIndex {
38     Path path;
39     string display;
40 
41     long aliveMutants;
42     long killedMutants;
43     long totalMutants;
44     // Nr of mutants that are alive but tagged with nomut.
45     long aliveNoMut;
46 }
47 
48 @safe final class ReportHtml : FileReport, FilesReporter {
49     import std.array : Appender;
50     import std.stdio : File, writefln, writeln;
51     import std.xml : encode;
52     import my.set;
53 
54     const Mutation.Kind[] kinds;
55     const ConfigReport conf;
56 
57     /// The base directory of logdirs
58     const AbsolutePath logDir;
59     /// Reports for each file
60     const AbsolutePath logFilesDir;
61 
62     /// What the user configured.
63     MutationKind[] humanReadableKinds;
64     Set!ReportSection sections;
65 
66     FilesysIO fio;
67 
68     // all files that have been produced.
69     Appender!(FileIndex[]) files;
70 
71     // the context for the file that is currently being processed.
72     FileCtx ctx;
73 
74     // Report alive mutants in this section
75     Diff diff;
76 
77     this(const(Mutation.Kind)[] kinds, const ConfigReport conf, FilesysIO fio, ref Diff diff) {
78         import std.path : buildPath;
79 
80         this.kinds = kinds;
81         this.fio = fio;
82         this.conf = conf;
83         this.logDir = buildPath(conf.logDir, htmlDir).Path.AbsolutePath;
84         this.logFilesDir = buildPath(this.logDir, htmlFileDir).Path.AbsolutePath;
85         this.diff = diff;
86 
87         sections = (conf.reportSection.length == 0 ? conf.reportLevel.toSections
88                 : conf.reportSection.dup).toSet;
89     }
90 
91     override void mutationKindEvent(const MutationKind[] k) {
92         import std.file : mkdirRecurse;
93 
94         humanReadableKinds = k.dup;
95         mkdirRecurse(this.logDir);
96         mkdirRecurse(this.logFilesDir);
97     }
98 
99     override FileReport getFileReportEvent(ref Database db, const ref FileRow fr) {
100         import std.path : buildPath;
101         import std.stdio : File;
102         import dextool.plugin.mutate.backend.report.html.page_files;
103         import dextool.plugin.mutate.backend.report.analyzers : reportStatistics;
104 
105         const original = fr.file.dup.pathToHtml;
106         const report = (original ~ htmlExt).Path;
107 
108         auto stat = reportStatistics(db, kinds, fr.file);
109 
110         files.put(FileIndex(report, fr.file, stat.alive,
111                 stat.killed + stat.timeout + stat.aliveNoMut, stat.total, stat.aliveNoMut));
112 
113         const out_path = buildPath(logFilesDir, report).Path.AbsolutePath;
114 
115         auto raw = fio.makeInput(AbsolutePath(buildPath(fio.getOutputDir, fr.file)));
116 
117         auto tc_info = db.getAllTestCaseInfo2(fr.id, kinds);
118 
119         ctx = FileCtx.make(original, fr.id, raw, tc_info);
120         ctx.processFile = fr.file;
121         ctx.out_ = File(out_path, "w");
122         ctx.span = Spanner(tokenize(fio.getOutputDir, fr.file));
123 
124         return this;
125     }
126 
127     override void fileMutantEvent(const ref FileMutantRow fr) {
128         import dextool.plugin.mutate.backend.generate_mutant : makeMutationText;
129 
130         // TODO unnecessary to create the mutation text here.
131         // Move it to endFileEvent. This is inefficient.
132 
133         // the mutation text has been found to contain '\0' characters when the
134         // mutant span multiple lines. These null characters render badly in
135         // the html report.
136         static string cleanup(const(char)[] raw) @safe nothrow {
137             import std.algorithm : filter;
138             import std.array : array;
139             import std.utf : byChar;
140 
141             return raw.byChar.filter!(a => a != '\0').array.idup;
142         }
143 
144         auto txt = makeMutationText(ctx.raw, fr.mutationPoint.offset, fr.mutation.kind, fr.lang);
145         ctx.span.put(FileMutant(fr.id, fr.mutationPoint.offset,
146                 cleanup(txt.original), cleanup(txt.mutation), fr.mutation));
147     }
148 
149     override void endFileEvent(ref Database db) @trusted {
150         import std.algorithm : max, each, map, min, canFind, sort, filter;
151         import std.array : appender, empty;
152         import std.conv : to;
153         import std.range : repeat, enumerate;
154         import std.traits : EnumMembers;
155         import dextool.plugin.mutate.type : MutationKind;
156         import dextool.plugin.mutate.backend.database.type : MutantMetaData;
157         import dextool.plugin.mutate.backend.report.utility : window;
158         import dextool.plugin.mutate.backend.mutation_type : toUser;
159 
160         static struct MData {
161             MutationId id;
162             FileMutant.Text txt;
163             Mutation mut;
164             MutantMetaData metaData;
165         }
166 
167         auto root = ctx.doc.mainBody;
168         auto lines = root.addChild("table").setAttribute("id", "locs");
169         auto line = lines.addChild("tr").addChild("td").setAttribute("id", "loc-1");
170         line.addClass("loc");
171 
172         line.addChild("span", "1:").addClass("line_nr");
173         auto mut_data = "var g_muts_data = {};\n";
174         mut_data ~= "g_muts_data[-1] = {'kind' : null, 'status' : null, 'testCases' : null, 'orgText' : null, 'mutText' : null, 'meta' : null};\n";
175 
176         // used to make sure that metadata about a mutant is only written onces
177         // to the global arrays.
178         Set!MutationId ids;
179         auto muts = appender!(MData[])();
180 
181         // this is the last location. It is used to calculate the num of
182         // newlines, detect when a line changes etc.
183         auto lastLoc = SourceLoc(1, 1);
184 
185         foreach (const s; ctx.span.toRange) {
186             if (s.tok.loc.line > lastLoc.line) {
187                 lastLoc.column = 1;
188             }
189             auto meta = MetaSpan(s.muts);
190 
191             foreach (const i; 0 .. max(0, s.tok.loc.line - lastLoc.line)) {
192                 with (line = lines.addChild("tr").addChild("td")) {
193                     setAttribute("id", format("%s-%s", "loc", lastLoc.line + i + 1));
194                     addClass("loc");
195                     addChild("span", format("%s:", lastLoc.line + i + 1)).addClass("line_nr");
196                 }
197                 // force a newline in the generated html to improve readability
198                 lines.appendText("\n");
199             }
200 
201             const spaces = max(0, s.tok.loc.column - lastLoc.column);
202             line.addChild(new RawSource(ctx.doc, format("%-(%s%)", " ".repeat(spaces))));
203             auto d0 = line.addChild("div").setAttribute("style", "display: inline;");
204             with (d0.addChild("span", s.tok.spelling)) {
205                 addClass("original");
206                 addClass(s.tok.toName);
207                 if (auto v = meta.status.toVisible)
208                     addClass(v);
209                 if (s.muts.length != 0)
210                     addClass(format("%(mutid%s %)", s.muts.map!(a => a.id)));
211                 if (meta.onClick.length != 0)
212                     setAttribute("onclick", meta.onClick);
213             }
214 
215             foreach (m; s.muts.filter!(m => !ids.contains(m.id))) {
216                 ids.add(m.id);
217 
218                 const metadata = db.getMutantationMetaData(m.id);
219 
220                 muts.put(MData(m.id, m.txt, m.mut, metadata));
221                 with (d0.addChild("span", m.mutation)) {
222                     addClass("mutant");
223                     addClass(s.tok.toName);
224                     setAttribute("id", m.id.to!string);
225                 }
226                 d0.addChild("a").setAttribute("href", "#" ~ m.id.to!string);
227 
228                 auto testCases = ctx.getTestCaseInfo(m.id);
229                 if (testCases.empty) {
230                     mut_data ~= format("g_muts_data[%s] = {'kind' : %s, 'kindGroup' : %s, 'status' : %s, 'testCases' : null, 'orgText' : '%s', 'mutText' : '%s', 'meta' : '%s'};\n",
231                             m.id, m.mut.kind.to!int, toUser(m.mut.kind)
232                             .to!int, m.mut.status.to!ubyte, window(m.txt.original),
233                             window(m.txt.mutation), metadata.kindToString);
234                 } else {
235                     mut_data ~= format("g_muts_data[%s] = {'kind' : %s, 'kindGroup' : %s, 'status' : %s, 'testCases' : [%('%s',%)'], 'orgText' : '%s', 'mutText' : '%s', 'meta' : '%s'};\n",
236                             m.id, m.mut.kind.to!int, toUser(m.mut.kind)
237                             .to!int, m.mut.status.to!ubyte,
238                             testCases.map!(a => a.name), window(m.txt.original),
239                             window(m.txt.mutation), metadata.kindToString);
240                 }
241             }
242             lastLoc = s.tok.locEnd;
243         }
244 
245         // make sure there is a newline before the script start to improve
246         // readability of the html document source.
247         root.appendText("\n");
248 
249         with (root.addChild("script")) {
250             // force a newline in the generated html to improve readability
251             appendText("\n");
252             addChild(new RawSource(ctx.doc, format("const MAX_NUM_TESTCASES = %s;",
253                     db.getDetectedTestCases.length)));
254             appendText("\n");
255             addChild(new RawSource(ctx.doc, format("const g_mutids = [%(%s,%)];",
256                     muts.data.map!(a => a.id))));
257             appendText("\n");
258             addChild(new RawSource(ctx.doc, format("const g_mut_st_map = [%('%s',%)'];",
259                     [EnumMembers!(Mutation.Status)])));
260             appendText("\n");
261             addChild(new RawSource(ctx.doc, format("const g_mut_kind_map = [%('%s',%)'];",
262                     [EnumMembers!(Mutation.Kind)])));
263             appendText("\n");
264             addChild(new RawSource(ctx.doc, format("const g_mut_kindGroup_map = [%('%s',%)'];",
265                     [EnumMembers!(MutationKind)])));
266             appendText("\n");
267 
268             // Creates a list of number of kills per testcase.
269             appendChild(new RawSource(ctx.doc, "var g_testcases_kills = {}"));
270             appendText("\n");
271             foreach (tc; ctx.testCases) {
272                 appendChild(new RawSource(ctx.doc,
273                         format("g_testcases_kills['%s'] = [%s];", tc.name, tc.killed)));
274                 appendText("\n");
275             }
276             appendChild(new RawSource(ctx.doc, mut_data));
277             appendText("\n");
278         }
279 
280         try {
281             ctx.out_.write(ctx.doc.toString);
282         } catch (Exception e) {
283             logger.error(e.msg).collectException;
284             logger.error("Unable to generate a HTML report for ", ctx.processFile).collectException;
285         }
286     }
287 
288     override void postProcessEvent(ref Database db) @trusted {
289         import std.datetime : Clock;
290         import std.path : buildPath, baseName;
291         import dextool.plugin.mutate.backend.report.html.page_diff;
292         import dextool.plugin.mutate.backend.report.html.page_long_term_view;
293         import dextool.plugin.mutate.backend.report.html.page_minimal_set;
294         import dextool.plugin.mutate.backend.report.html.page_nomut;
295         import dextool.plugin.mutate.backend.report.html.page_stats;
296         import dextool.plugin.mutate.backend.report.html.page_test_case_similarity;
297         import dextool.plugin.mutate.backend.report.html.page_test_case_stat;
298         import dextool.plugin.mutate.backend.report.html.page_test_case_unique;
299         import dextool.plugin.mutate.backend.report.html.page_test_group_similarity;
300         import dextool.plugin.mutate.backend.report.html.page_test_groups;
301         import dextool.plugin.mutate.backend.report.html.page_tree_map;
302 
303         auto index = tmplBasicPage;
304         index.title = format("Mutation Testing Report %(%s %) %s",
305                 humanReadableKinds, Clock.currTime);
306         auto s = index.root.childElements("head")[0].addChild("script");
307         s.addChild(new RawSource(index, js_index));
308 
309         void addSubPage(Fn)(Fn fn, string name, string link_txt) {
310             import std.functional : unaryFun;
311 
312             const fname = buildPath(logDir, name ~ htmlExt);
313             index.mainBody.addChild("p").addChild("a", link_txt).href = fname.baseName;
314             logger.infof("Generating %s (%s)", link_txt, name);
315             File(fname, "w").write(fn());
316         }
317 
318         addSubPage(() => makeStats(db, conf, humanReadableKinds, kinds), "stats", "Statistics");
319         if (!diff.empty) {
320             addSubPage(() => makeDiffView(db, conf, humanReadableKinds, kinds,
321                     diff, fio.getOutputDir), "diff_view", "Diff View");
322         }
323         addSubPage(() => makeLongTermView(db, conf, humanReadableKinds, kinds),
324                 "long_term_view", "Long Term View");
325         if (ReportSection.treemap in sections) {
326             addSubPage(() => makeTreeMapPage(files.data), "tree_map", "Treemap");
327         }
328         if (ReportSection.tc_stat in sections) {
329             addSubPage(() => makeTestCaseStats(db, conf, humanReadableKinds,
330                     kinds), "test_case_stat", "Test Case Statistics");
331         }
332         if (ReportSection.tc_groups in sections) {
333             addSubPage(() => makeTestGroups(db, conf, humanReadableKinds,
334                     kinds), "test_groups", "Test Groups");
335         }
336         addSubPage(() => makeNomut(db, conf, humanReadableKinds, kinds), "nomut", "NoMut Details");
337         if (ReportSection.tc_min_set in sections) {
338             addSubPage(() => makeMinimalSetAnalyse(db, conf, humanReadableKinds,
339                     kinds), "minimal_set", "Minimal Test Set");
340         }
341         if (ReportSection.tc_similarity in sections) {
342             addSubPage(() => makeTestCaseSimilarityAnalyse(db, conf, humanReadableKinds,
343                     kinds), "test_case_similarity", "Test Case Similarity");
344         }
345         if (ReportSection.tc_groups_similarity in sections) {
346             addSubPage(() => makeTestGroupSimilarityAnalyse(db, conf, humanReadableKinds,
347                     kinds), "test_group_similarity", "Test Group Similarity");
348         }
349         if (ReportSection.tc_unique in sections) {
350             addSubPage(() => makeTestCaseUnique(db, conf, humanReadableKinds,
351                     kinds), "test_case_unique", "Test Case Uniqueness");
352         }
353 
354         files.data.toIndex(index.mainBody, htmlFileDir);
355         File(buildPath(logDir, "index" ~ htmlExt), "w").write(index.toPrettyString);
356     }
357 
358     override void endEvent(ref Database) {
359     }
360 }
361 
362 @safe:
363 private:
364 
365 string toJson(string s) {
366     import std.json : JSONValue;
367 
368     return JSONValue(s).toString;
369 }
370 
371 struct FileCtx {
372     import std.stdio : File;
373     import blob_model : Blob;
374     import dextool.plugin.mutate.backend.database : FileId, TestCaseInfo2;
375 
376     Path processFile;
377     File out_;
378 
379     Spanner span;
380 
381     Document doc;
382 
383     // The text of the current file that is being processed.
384     Blob raw;
385 
386     /// Database ID for this file.
387     FileId fileId;
388 
389     /// Find the test cases that killed a mutant. They are sorted by most killed -> least killed.
390     TestCaseInfo[][MutationId] tcKilledMutant;
391 
392     /// All test cases in the file.
393     TestCaseInfo[] testCases;
394 
395     static FileCtx make(string title, FileId id, Blob raw, TestCaseInfo2[] tc_info) @trusted {
396         import std.algorithm : sort;
397         import std.array : array;
398         import dextool.plugin.mutate.backend.report.html.js;
399         import dextool.plugin.mutate.backend.report.html.tmpl;
400 
401         auto r = FileCtx.init;
402         r.doc = tmplBasicPage;
403         r.doc.title = title;
404         r.doc.mainBody.setAttribute("onload", "javascript:init();");
405 
406         auto s = r.doc.root.childElements("head")[0].addChild("style");
407         s.addChild(new RawSource(r.doc, tmplIndexStyle));
408 
409         s = r.doc.root.childElements("head")[0].addChild("script");
410         s.addChild(new RawSource(r.doc, js_source));
411 
412         r.doc.mainBody.appendHtml(tmplIndexBody);
413 
414         r.fileId = id;
415 
416         r.raw = raw;
417 
418         typeof(tcKilledMutant) tmp;
419         foreach (a; tc_info) {
420             foreach (mut; a.killed) {
421                 if (auto v = mut in tmp) {
422                     *v ~= TestCaseInfo(a.name, a.killed.length);
423                 } else {
424                     tmp[mut] = [TestCaseInfo(a.name, a.killed.length)];
425                 }
426             }
427             r.testCases ~= TestCaseInfo(a.name, a.killed.length);
428         }
429         foreach (kv; tmp.byKeyValue) {
430             r.tcKilledMutant[kv.key] = kv.value.sort.array;
431         }
432 
433         return r;
434     }
435 
436     TestCaseInfo[] getTestCaseInfo(MutationId mutationId) @safe pure nothrow {
437         if (auto v = mutationId in tcKilledMutant)
438             return *v;
439         return null;
440     }
441 
442     static struct TestCaseInfo {
443         import dextool.plugin.mutate.backend.type : TestCase;
444 
445         TestCase name;
446         long killed;
447 
448         int opCmp(ref const typeof(this) s) @safe pure nothrow const @nogc scope {
449             if (killed < s.killed)
450                 return -1;
451             else if (killed > s.killed)
452                 return 1;
453             else if (name < s.name)
454                 return -1;
455             else if (name > s.name)
456                 return 1;
457             return 0;
458         }
459 
460         bool opEquals(ref const typeof(this) s) @safe pure nothrow const @nogc scope {
461             return name == s.name;
462         }
463 
464         size_t toHash() @safe nothrow const {
465             return name.toHash;
466         }
467     }
468 }
469 
470 auto tokenize(AbsolutePath base_dir, Path f) @trusted {
471     import std.path : buildPath;
472     import std.typecons : Yes;
473     import cpptooling.analyzer.clang.context;
474     static import dextool.plugin.mutate.backend.utility;
475 
476     const fpath = buildPath(base_dir, f).Path.AbsolutePath;
477     auto ctx = ClangContext(Yes.useInternalHeaders, Yes.prependParamSyntaxOnly);
478     return dextool.plugin.mutate.backend.utility.tokenize!(Yes.splitMultiLineTokens)(ctx, fpath);
479 }
480 
481 struct FileMutant {
482 nothrow:
483     static struct Text {
484         /// the original text that covers the offset.
485         string original;
486         /// The mutation text that covers the offset.
487         string mutation;
488     }
489 
490     MutationId id;
491     Offset offset;
492     Text txt;
493     Mutation mut;
494 
495     this(MutationId id, Offset offset, string original, string mutation, Mutation mut) {
496         import std.utf : validate;
497         import dextool.plugin.mutate.backend.type : invalidUtf8;
498 
499         this.id = id;
500         this.offset = offset;
501         this.mut = mut;
502 
503         try {
504             validate(original);
505             this.txt.original = original;
506         } catch (Exception e) {
507             this.txt.original = invalidUtf8;
508         }
509 
510         try {
511             validate(mutation);
512             // users prefer being able to see what has been removed.
513             if (mutation.length == 0)
514                 this.txt.mutation = "/* " ~ this.txt.original ~ " */";
515             else
516                 this.txt.mutation = mutation;
517         } catch (Exception e) {
518             this.txt.mutation = invalidUtf8;
519         }
520     }
521 
522     this(MutationId id, Offset offset, string original) {
523         this(id, offset, original, null, Mutation.init);
524     }
525 
526     string original() @safe pure nothrow const @nogc {
527         return txt.original;
528     }
529 
530     string mutation() @safe pure nothrow const @nogc {
531         return txt.mutation;
532     }
533 
534     int opCmp(ref const typeof(this) s) const @safe {
535         if (offset.begin > s.offset.begin)
536             return 1;
537         if (offset.begin < s.offset.begin)
538             return -1;
539         if (offset.end > s.offset.end)
540             return 1;
541         if (offset.end < s.offset.end)
542             return -1;
543         return 0;
544     }
545 }
546 
547 @("shall be possible to construct a FileMutant in @safe")
548 @safe unittest {
549     auto fmut = FileMutant(MutationId(1), Offset(1, 2), "smurf");
550 }
551 
552 /*
553 I get a mutant that have a start/end offset.
554 I have all tokens.
555 I can't write the html before I have all mutants for the offset.
556 Hmm potentially this mean that I can't write any html until I have analyzed all mutants for the file.
557 This must be so....
558 
559 How to do it?
560 
561 From reading https://stackoverflow.com/questions/11389627/span-overlapping-strings-in-a-paragraph
562 it seems that generating a <span..> for each token with multiple classes in them. A class for each mutant.
563 then they can be toggled on/off.
564 
565 a <href> tag to the beginning to jump to the mutant.
566 */
567 
568 /** Provide an interface to travers the tokens and get the overlapping mutants.
569  */
570 struct Spanner {
571     import std.container : RedBlackTree, redBlackTree;
572     import std.range : isOutputRange;
573 
574     alias BTree(T) = RedBlackTree!(T, "a < b", true);
575 
576     BTree!Token tokens;
577     BTree!FileMutant muts;
578 
579     this(Token[] tokens) @trusted {
580         this.tokens = new typeof(this.tokens);
581         this.muts = new typeof(this.muts)();
582 
583         this.tokens.insert(tokens);
584     }
585 
586     void put(const FileMutant fm) @trusted {
587         muts.insert(fm);
588     }
589 
590     SpannerRange toRange() @safe {
591         return SpannerRange(tokens, muts);
592     }
593 
594     string toString() @safe pure const {
595         import std.array : appender;
596 
597         auto buf = appender!string;
598         this.toString(buf);
599         return buf.data;
600     }
601 
602     void toString(Writer)(ref Writer w) const if (isOutputRange!(Writer, char)) {
603         import std.format : formattedWrite;
604         import std.range : zip, StoppingPolicy;
605         import std.string;
606         import std.algorithm : max;
607         import std.traits : Unqual;
608 
609         ulong sz;
610 
611         foreach (ref const t; zip(StoppingPolicy.longest, tokens[], muts[])) {
612             auto c0 = format("%s", cast(Unqual!(typeof(t[0]))) t[0]);
613             string c1;
614             if (t[1] != typeof(t[1]).init)
615                 c1 = format("%s", cast(Unqual!(typeof(t[1]))) t[1]);
616             sz = max(sz, c0.length, c1.length);
617             formattedWrite(w, "%s | %s\n", c0.rightJustify(sz), c1);
618         }
619     }
620 }
621 
622 @("shall be possible to construct a Spanner in @safe")
623 @safe unittest {
624     import std.algorithm;
625     import std.conv;
626     import std.range;
627     import clang.c.Index : CXTokenKind;
628 
629     auto toks = zip(iota(10), iota(10, 20)).map!(a => Token(CXTokenKind.comment,
630             Offset(a[0], a[1]), SourceLoc.init, SourceLoc.init, a[0].to!string)).retro.array;
631     auto span = Spanner(toks);
632 
633     span.put(FileMutant(MutationId(1), Offset(1, 10), "smurf"));
634     span.put(FileMutant(MutationId(1), Offset(9, 15), "donkey"));
635 
636     // TODO add checks
637 }
638 
639 /**
640  *
641  * # Overlap Cases
642  * 1. Perfekt overlap
643  * |--T--|
644  * |--M--|
645  *
646  * 2. Token enclosing mutant
647  * |---T--|
648  *   |-M-|
649  *
650  * 3. Mutant beginning inside a token
651  * |---T--|
652  *   |-M----|
653  *
654  * 4. Mutant overlapping multiple tokens.
655  * |--T--|--T--|
656  * |--M--------|
657  */
658 struct SpannerRange {
659     alias BTree = Spanner.BTree;
660 
661     BTree!Token tokens;
662     BTree!FileMutant muts;
663 
664     this(BTree!Token tokens, BTree!FileMutant muts) @safe {
665         this.tokens = tokens;
666         this.muts = muts;
667         dropMutants;
668     }
669 
670     Span front() @safe pure nothrow {
671         import std.array : appender;
672 
673         assert(!empty, "Can't get front of an empty range");
674         auto t = tokens.front;
675         if (muts.empty)
676             return Span(t);
677 
678         auto app = appender!(FileMutant[])();
679         foreach (m; muts) {
680             if (m.offset.begin < t.offset.end)
681                 app.put(m);
682             else
683                 break;
684         }
685 
686         return Span(t, app.data);
687     }
688 
689     void popFront() @safe {
690         assert(!empty, "Can't pop front of an empty range");
691         tokens.removeFront;
692         dropMutants;
693     }
694 
695     bool empty() @safe pure nothrow @nogc {
696         return tokens.empty;
697     }
698 
699     private void dropMutants() @safe {
700         import std.algorithm : filter;
701         import std.array : array;
702 
703         if (tokens.empty)
704             return;
705 
706         // removing mutants that the tokens have "passed by"
707         const t = tokens.front;
708         auto r = muts[].filter!(a => a.offset.end <= t.offset.begin).array;
709         muts.removeKey(r);
710     }
711 }
712 
713 struct Span {
714     import std.range : isOutputRange;
715 
716     Token tok;
717     FileMutant[] muts;
718 
719     string toString() @safe pure const {
720         import std.array : appender;
721 
722         auto buf = appender!string;
723         toString(buf);
724         return buf.data;
725     }
726 
727     void toString(Writer)(ref Writer w) const if (isOutputRange!(Writer, char)) {
728         import std.format : formattedWrite;
729         import std.range : put;
730 
731         formattedWrite(w, "%s|%(%s %)", tok, muts);
732     }
733 }
734 
735 @("shall return a range grouping mutants by the tokens they overlap")
736 @safe unittest {
737     import std.algorithm;
738     import std.array : array;
739     import std.conv;
740     import std.range;
741     import clang.c.Index : CXTokenKind;
742 
743     auto offsets = zip(iota(0, 150, 10), iota(10, 160, 10)).map!(a => Offset(a[0], a[1])).array;
744 
745     auto toks = offsets.map!(a => Token(CXTokenKind.comment, a, SourceLoc.init,
746             SourceLoc.init, a.begin.to!string)).retro.array;
747     auto span = Spanner(toks);
748 
749     span.put(FileMutant(MutationId(2), Offset(11, 15), "token enclosing mutant"));
750     span.put(FileMutant(MutationId(3), Offset(31, 42), "mutant beginning inside a token"));
751     span.put(FileMutant(MutationId(4), Offset(50, 80), "mutant overlapping multiple tokens"));
752 
753     span.put(FileMutant(MutationId(5), Offset(90, 100), "1 multiple mutants for a token"));
754     span.put(FileMutant(MutationId(6), Offset(90, 110), "2 multiple mutants for a token"));
755     span.put(FileMutant(MutationId(1), Offset(120, 130), "perfect overlap"));
756 
757     auto res = span.toRange.array;
758     //logger.tracef("%(%s\n%)", res);
759     res[1].muts[0].id.shouldEqual(2);
760     res[2].muts.length.shouldEqual(0);
761     res[3].muts[0].id.shouldEqual(3);
762     res[4].muts[0].id.shouldEqual(3);
763     res[5].muts[0].id.shouldEqual(4);
764     res[6].muts[0].id.shouldEqual(4);
765     res[7].muts[0].id.shouldEqual(4);
766     res[8].muts.length.shouldEqual(0);
767     res[9].muts.length.shouldEqual(2);
768     res[9].muts[0].id.shouldEqual(5);
769     res[9].muts[1].id.shouldEqual(6);
770     res[10].muts[0].id.shouldEqual(6);
771     res[11].muts.length.shouldEqual(0);
772     res[12].muts[0].id.shouldEqual(1);
773     res[13].muts.length.shouldEqual(0);
774 }
775 
776 void toIndex(FileIndex[] files, Element root, string htmlFileDir) @trusted {
777     import std.algorithm : sort, filter;
778     import std.conv : to;
779     import std.path : buildPath;
780 
781     auto tbl_container = root.addChild("div").addClass("tbl_container");
782     auto tbl = tmplDefaultTable(tbl_container, [
783             "Path", "Score", "Alive", "NoMut", "Total"
784             ]);
785 
786     // Users are not interested that files that contains zero mutants are shown
787     // in the list. It is especially annoying when they are marked with dark
788     // green.
789     bool has_suppressed;
790     foreach (f; files.sort!((a, b) => a.path < b.path)
791             .filter!(a => a.totalMutants != 0)) {
792         auto r = tbl.appendRow();
793         r.addChild("td").addChild("a", f.display).href = buildPath(htmlFileDir, f.path);
794 
795         const score = () {
796             if (f.totalMutants == 0)
797                 return 1.0;
798             return cast(double) f.killedMutants / cast(double) f.totalMutants;
799         }();
800         const style = () {
801             if (f.killedMutants == f.totalMutants)
802                 return "background-color: green";
803             if (score < 0.3)
804                 return "background-color: red";
805             if (score < 0.5)
806                 return "background-color: salmon";
807             if (score < 0.8)
808                 return "background-color: lightyellow";
809             if (score < 1.0)
810                 return "background-color: lightgreen";
811             return null;
812         }();
813 
814         r.addChild("td", format("%.3s", score)).style = style;
815         r.addChild("td", f.aliveMutants.to!string).style = style;
816         r.addChild("td", f.aliveNoMut.to!string).style = style;
817         r.addChild("td", f.totalMutants.to!string).style = style;
818 
819         has_suppressed = has_suppressed || f.aliveNoMut != 0;
820     }
821 
822     root.addChild("p", "NoMut is the number of alive mutants in the file that are ignored.")
823         .appendText(" This increases the score.");
824     root.setAttribute("onload", "init()");
825 }
826 
827 /** Metadata about the span to be used to e.g. color it.
828  *
829  * Each span has a mutant that becomes activated when the user click on the
830  * span. The user most likely is interested in seeing **a** mutant that has
831  * survived on that point becomes the color is red.
832  *
833  * This is why the algorithm uses the same prio as the one for choosing
834  * color. These two are strongly correlated with each other.
835  */
836 struct MetaSpan {
837     // ordered in priority
838     enum StatusColor {
839         alive,
840         killed,
841         timeout,
842         killedByCompiler,
843         unknown,
844         none,
845     }
846 
847     StatusColor status;
848     string onClick;
849 
850     this(const(FileMutant)[] muts) {
851         immutable click_fmt2 = "ui_set_mut(%s)";
852         status = StatusColor.none;
853 
854         foreach (ref const m; muts) {
855             status = pickColor(m, status);
856             if (onClick.length == 0 && m.mut.status == Mutation.Status.alive) {
857                 onClick = format(click_fmt2, m.id);
858             }
859         }
860 
861         if (onClick.length == 0 && muts.length != 0) {
862             onClick = format(click_fmt2, muts[0].id);
863         }
864     }
865 }
866 
867 /// Choose a color for a mutant span by prioritizing alive mutants above all.
868 MetaSpan.StatusColor pickColor(const FileMutant m,
869         MetaSpan.StatusColor status = MetaSpan.StatusColor.none) {
870     final switch (m.mut.status) {
871     case Mutation.Status.alive:
872         status = MetaSpan.StatusColor.alive;
873         break;
874     case Mutation.Status.killed:
875         if (status > MetaSpan.StatusColor.killed)
876             status = MetaSpan.StatusColor.killed;
877         break;
878     case Mutation.Status.killedByCompiler:
879         if (status > MetaSpan.StatusColor.killedByCompiler)
880             status = MetaSpan.StatusColor.killedByCompiler;
881         break;
882     case Mutation.Status.timeout:
883         if (status > MetaSpan.StatusColor.timeout)
884             status = MetaSpan.StatusColor.timeout;
885         break;
886     case Mutation.Status.unknown:
887         if (status > MetaSpan.StatusColor.unknown)
888             status = MetaSpan.StatusColor.unknown;
889         break;
890     }
891     return status;
892 }
893 
894 string toVisible(MetaSpan.StatusColor s) {
895     if (s == MetaSpan.StatusColor.none)
896         return null;
897     return format("status_%s", s);
898 }
899 
900 string toHover(MetaSpan.StatusColor s) {
901     if (s == MetaSpan.StatusColor.none)
902         return null;
903     return format("hover_%s", s);
904 }