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