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).setFromList;
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.utility : 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         ctx = FileCtx.make(original, fr.id);
116         ctx.processFile = fr.file;
117         ctx.out_ = File(out_path, "w");
118         ctx.span = Spanner(tokenize(fio.getOutputDir, fr.file));
119 
120         return this;
121     }
122 
123     override void fileMutantEvent(const ref FileMutantRow fr) {
124         import dextool.plugin.mutate.backend.generate_mutant : makeMutationText;
125 
126         // TODO unnecessary to create the mutation text here.
127         // Move it to endFileEvent. This is inefficient.
128 
129         // the mutation text has been found to contain '\0' characters when the
130         // mutant span multiple lines. These null characters render badly in
131         // the html report.
132         static string cleanup(const(char)[] raw) @safe nothrow {
133             import std.algorithm : filter;
134             import std.array : array;
135             import std.utf : byChar;
136 
137             return raw.byChar.filter!(a => a != '\0').array.idup;
138         }
139 
140         auto fin = fio.makeInput(AbsolutePath(ctx.processFile, DirName(fio.getOutputDir)));
141         auto txt = makeMutationText(fin, fr.mutationPoint.offset, fr.mutation.kind, fr.lang);
142         ctx.span.put(FileMutant(fr.id, fr.mutationPoint.offset,
143                 cleanup(txt.original), cleanup(txt.mutation), fr.mutation));
144     }
145 
146     override void endFileEvent(ref Database db) @trusted {
147         import std.algorithm : max, each, map, min, canFind;
148         import std.array : appender;
149         import std.conv : to;
150         import std.range : repeat;
151         import std.traits : EnumMembers;
152         import dextool.plugin.mutate.backend.database.type : MutantMetaData;
153 
154         static struct MData {
155             MutationId id;
156             FileMutant.Text txt;
157             Mutation mut;
158             MutantMetaData metaData;
159         }
160 
161         static string styleHover(MutationId this_mut, const(FileMutant) m) {
162             if (this_mut == m.id)
163                 return format(`<b class="%s">%s</b>`, pickColor(m).toHover, m.mut.kind);
164             return format(`<span class="%s">%s</span>`, pickColor(m).toHover, m.mut.kind);
165         }
166 
167         Set!MutationId ids;
168         auto muts = appender!(MData[])();
169         // this is the last location. It is used to calculate the num of
170         // newlines, detect when a line changes etc.
171         auto lastLoc = SourceLoc(1, 1);
172 
173         auto root = ctx.doc.mainBody;
174         auto lines = root.addChild("table").setAttribute("id", "locs");
175         auto line = lines.addChild("tr").addChild("td").setAttribute("id", "loc-1");
176         line.addClass("loc");
177 
178         line.addChild("span", "1:").addClass("line_nr");
179         foreach (const s; ctx.span.toRange) {
180             if (s.tok.loc.line > lastLoc.line) {
181                 lastLoc.column = 1;
182             }
183             auto meta = MetaSpan(s.muts);
184 
185             foreach (const i; 0 .. max(0, s.tok.loc.line - lastLoc.line)) {
186                 // force a newline in the generated html to improve readability
187                 root.appendText("\n");
188                 with (line = lines.addChild("tr").addChild("td")) {
189                     setAttribute("id", format("%s-%s", "loc", lastLoc.line + i + 1));
190                     addClass("loc");
191                     addChild("span", format("%s:", lastLoc.line + i + 1)).addClass("line_nr");
192                 }
193 
194             }
195             const spaces = max(0, s.tok.loc.column - lastLoc.column);
196             line.addChild(new RawSource(ctx.doc, format("%-(%s%)", "&nbsp;".repeat(spaces))));
197             auto d0 = line.addChild("div").setAttribute("style", "display: inline;");
198             with (d0.addChild("span", s.tok.spelling)) {
199                 addClass("original");
200                 addClass(s.tok.toName);
201                 if (auto v = meta.status.toVisible)
202                     addClass(v);
203                 if (s.muts.length != 0)
204                     addClass(format("%(mutid%s %)", s.muts.map!(a => a.id)));
205                 if (meta.onClick.length != 0)
206                     setAttribute("onclick", meta.onClick);
207             }
208             foreach (m; s.muts) {
209                 if (!ids.contains(m.id)) {
210                     ids.add(m.id);
211                     muts.put(MData(m.id, m.txt, m.mut, db.getMutantationMetaData(m.id)));
212                     const inside_fly = format(`%-(%s %)`, s.muts.map!(a => styleHover(m.id, a)))
213                         .toJson;
214                     const fly = format(`fly(event, %s)`, inside_fly);
215                     with (d0.addChild("span", m.mutation)) {
216                         addClass("mutant");
217                         addClass(s.tok.toName);
218                         setAttribute("id", m.id.to!string);
219                         setAttribute("onmouseenter", fly);
220                         setAttribute("onmouseleave", fly);
221                     }
222                     d0.addChild("a").setAttribute("href", "#" ~ m.id.to!string);
223                 }
224             }
225 
226             lastLoc = s.tok.locEnd;
227         }
228         with (root.addChild("script")) {
229             import dextool.plugin.mutate.backend.report.utility : window;
230 
231             // force a newline in the generated html to improve readability
232             appendText("\n");
233             addChild(new RawSource(ctx.doc, format("var g_mutids = [%(%s,%)];",
234                     muts.data.map!(a => a.id))));
235             appendText("\n");
236             addChild(new RawSource(ctx.doc, format("var g_muts_orgs = [%(%s,%)];",
237                     muts.data.map!(a => window(a.txt.original)))));
238             appendText("\n");
239             addChild(new RawSource(ctx.doc, format("var g_mut_st_map = [%('%s',%)'];",
240                     [EnumMembers!(Mutation.Status)])));
241             appendText("\n");
242             addChild(new RawSource(ctx.doc, format("var g_muts_muts = [%(%s,%)];",
243                     muts.data.map!(a => window(a.txt.mutation)))));
244             appendText("\n");
245             addChild(new RawSource(ctx.doc, format("var g_muts_st = [%(%s,%)];",
246                     muts.data.map!(a => a.mut.status.to!ubyte))));
247             appendText("\n");
248             addChild(new RawSource(ctx.doc, format("var g_muts_meta = [%(%s,%)];",
249                     muts.data.map!(a => a.metaData.kindToString))));
250             appendText("\n");
251         }
252 
253         try {
254             ctx.out_.write(ctx.doc.toString);
255         } catch (Exception e) {
256             logger.error(e.msg).collectException;
257             logger.error("Unable to generate a HTML report for ", ctx.processFile).collectException;
258         }
259     }
260 
261     override void postProcessEvent(ref Database db) @trusted {
262         import std.datetime : Clock;
263         import std.path : buildPath, baseName;
264         import dextool.plugin.mutate.backend.report.html.page_long_term_view;
265         import dextool.plugin.mutate.backend.report.html.page_minimal_set;
266         import dextool.plugin.mutate.backend.report.html.page_nomut;
267         import dextool.plugin.mutate.backend.report.html.page_short_term_view;
268         import dextool.plugin.mutate.backend.report.html.page_stats;
269         import dextool.plugin.mutate.backend.report.html.page_test_case_similarity;
270         import dextool.plugin.mutate.backend.report.html.page_test_group_similarity;
271         import dextool.plugin.mutate.backend.report.html.page_test_groups;
272 
273         auto index = tmplBasicPage;
274         index.title = format("Mutation Testing Report %(%s %) %s",
275                 humanReadableKinds, Clock.currTime);
276 
277         //There's probably a more appropriate place to do this
278         auto s = index.root.childElements("head")[0].addChild("script");
279         s.addChild(new RawSource(index, js_index));
280 
281         void addSubPage(Fn)(Fn fn, string name, string link_txt) {
282             import std.functional : unaryFun;
283 
284             const fname = buildPath(logDir, name ~ htmlExt);
285             index.mainBody.addChild("p").addChild("a", link_txt).href = fname.baseName;
286             logger.infof("Generating %s (%s)", link_txt, name);
287             File(fname, "w").write(fn());
288         }
289 
290         addSubPage(() => makeStats(db, conf, humanReadableKinds, kinds), "stats", "Statistics");
291         if (!diff.empty) {
292             addSubPage(() => makeStats(db, conf, humanReadableKinds, kinds),
293                     "short_term_view", "Short Term View");
294         }
295         addSubPage(() => makeLongTermView(db, conf, humanReadableKinds, kinds),
296                 "long_term_view", "Long Term View");
297         if (ReportSection.tc_groups in sections)
298             addSubPage(() => makeTestGroups(db, conf, humanReadableKinds,
299                     kinds), "test_groups", "Test Groups");
300         addSubPage(() => makeNomut(db, conf, humanReadableKinds, kinds), "nomut", "NoMut Details");
301         if (ReportSection.tc_min_set in sections)
302             addSubPage(() => makeMinimalSetAnalyse(db, conf, humanReadableKinds,
303                     kinds), "minimal_set", "Minimal Test Set");
304         if (ReportSection.tc_similarity in sections)
305             addSubPage(() => makeTestCaseSimilarityAnalyse(db, conf, humanReadableKinds,
306                     kinds), "test_case_similarity", "Test Case Similarity");
307         if (ReportSection.tc_groups_similarity in sections)
308             addSubPage(() => makeTestGroupSimilarityAnalyse(db, conf, humanReadableKinds,
309                     kinds), "test_group_similarity", "Test Group Similarity");
310 
311         files.data.toIndex(index.mainBody, htmlFileDir);
312         File(buildPath(logDir, "index" ~ htmlExt), "w").write(index.toPrettyString);
313     }
314 
315     override void endEvent(ref Database) {
316     }
317 }
318 
319 @safe:
320 private:
321 
322 string toJson(string s) {
323     import std.json : JSONValue;
324 
325     return JSONValue(s).toString;
326 }
327 
328 struct FileCtx {
329     import std.stdio : File;
330     import dextool.plugin.mutate.backend.database : FileId;
331 
332     Path processFile;
333     File out_;
334 
335     Spanner span;
336 
337     Document doc;
338 
339     /// Database ID for this file.
340     FileId fileId;
341 
342     static FileCtx make(string title, FileId id) @trusted {
343         import dextool.plugin.mutate.backend.report.html.js;
344         import dextool.plugin.mutate.backend.report.html.tmpl;
345 
346         auto r = FileCtx.init;
347         r.doc = tmplBasicPage;
348         r.doc.title = title;
349         r.doc.mainBody.setAttribute("onload", "javascript:init();");
350 
351         auto s = r.doc.root.childElements("head")[0].addChild("style");
352         s.addChild(new RawSource(r.doc, tmplIndexStyle));
353 
354         s = r.doc.root.childElements("head")[0].addChild("script");
355         s.addChild(new RawSource(r.doc, js_source));
356 
357         r.doc.mainBody.appendHtml(tmplIndexBody);
358 
359         r.fileId = id;
360 
361         return r;
362     }
363 }
364 
365 auto tokenize(AbsolutePath base_dir, Path f) @trusted {
366     import std.path : buildPath;
367     import std.typecons : Yes;
368     import cpptooling.analyzer.clang.context;
369     static import dextool.plugin.mutate.backend.utility;
370 
371     const fpath = buildPath(base_dir, f).Path.AbsolutePath;
372     auto ctx = ClangContext(Yes.useInternalHeaders, Yes.prependParamSyntaxOnly);
373     return dextool.plugin.mutate.backend.utility.tokenize(ctx, fpath);
374 }
375 
376 struct FileMutant {
377 nothrow:
378     static struct Text {
379         /// the original text that covers the offset.
380         string original;
381         /// The mutation text that covers the offset.
382         string mutation;
383     }
384 
385     MutationId id;
386     Offset offset;
387     Text txt;
388     Mutation mut;
389 
390     this(MutationId id, Offset offset, string original, string mutation, Mutation mut) {
391         import std.utf : validate;
392         import dextool.plugin.mutate.backend.type : invalidUtf8;
393 
394         this.id = id;
395         this.offset = offset;
396         this.mut = mut;
397 
398         try {
399             validate(original);
400             this.txt.original = original;
401         } catch (Exception e) {
402             this.txt.original = invalidUtf8;
403         }
404 
405         try {
406             validate(mutation);
407             // users prefer being able to see what has been removed.
408             if (mutation.length == 0)
409                 this.txt.mutation = "/* " ~ this.txt.original ~ " */";
410             else
411                 this.txt.mutation = mutation;
412         } catch (Exception e) {
413             this.txt.mutation = invalidUtf8;
414         }
415     }
416 
417     this(MutationId id, Offset offset, string original) {
418         this(id, offset, original, null, Mutation.init);
419     }
420 
421     string original() @safe pure nothrow const @nogc scope {
422         return txt.original;
423     }
424 
425     string mutation() @safe pure nothrow const @nogc scope {
426         return txt.mutation;
427     }
428 
429     int opCmp(ref const typeof(this) s) const @safe {
430         if (offset.begin > s.offset.begin)
431             return 1;
432         if (offset.begin < s.offset.begin)
433             return -1;
434         if (offset.end > s.offset.end)
435             return 1;
436         if (offset.end < s.offset.end)
437             return -1;
438         return 0;
439     }
440 }
441 
442 @("shall be possible to construct a FileMutant in @safe")
443 @safe unittest {
444     auto fmut = FileMutant(MutationId(1), Offset(1, 2), "smurf");
445 }
446 
447 /*
448 I get a mutant that have a start/end offset.
449 I have all tokens.
450 I can't write the html before I have all mutants for the offset.
451 Hmm potentially this mean that I can't write any html until I have analyzed all mutants for the file.
452 This must be so....
453 
454 How to do it?
455 
456 From reading https://stackoverflow.com/questions/11389627/span-overlapping-strings-in-a-paragraph
457 it seems that generating a <span..> for each token with multiple classes in them. A class for each mutant.
458 then they can be toggled on/off.
459 
460 a <href> tag to the beginning to jump to the mutant.
461 */
462 
463 /** Provide an interface to travers the tokens and get the overlapping mutants.
464  */
465 struct Spanner {
466     import std.container : RedBlackTree, redBlackTree;
467     import std.range : isOutputRange;
468 
469     alias BTree(T) = RedBlackTree!(T, "a < b", true);
470 
471     BTree!Token tokens;
472     BTree!FileMutant muts;
473 
474     this(Token[] tokens) @trusted {
475         this.tokens = new typeof(this.tokens);
476         this.muts = new typeof(this.muts)();
477 
478         this.tokens.insert(tokens);
479     }
480 
481     void put(const FileMutant fm) {
482         muts.insert(fm);
483     }
484 
485     SpannerRange toRange() @safe {
486         return SpannerRange(tokens, muts);
487     }
488 
489     string toString() @safe pure const {
490         import std.array : appender;
491 
492         auto buf = appender!string;
493         this.toString(buf);
494         return buf.data;
495     }
496 
497     void toString(Writer)(ref Writer w) const if (isOutputRange!(Writer, char)) {
498         import std.format : formattedWrite;
499         import std.range : zip, StoppingPolicy;
500         import std.string;
501         import std.algorithm : max;
502         import std.traits : Unqual;
503 
504         ulong sz;
505 
506         foreach (ref const t; zip(StoppingPolicy.longest, tokens[], muts[])) {
507             auto c0 = format("%s", cast(Unqual!(typeof(t[0]))) t[0]);
508             string c1;
509             if (t[1] != typeof(t[1]).init)
510                 c1 = format("%s", cast(Unqual!(typeof(t[1]))) t[1]);
511             sz = max(sz, c0.length, c1.length);
512             formattedWrite(w, "%s | %s\n", c0.rightJustify(sz), c1);
513         }
514     }
515 }
516 
517 @("shall be possible to construct a Spanner in @safe")
518 @safe unittest {
519     import std.algorithm;
520     import std.conv;
521     import std.range;
522     import clang.c.Index : CXTokenKind;
523 
524     auto toks = zip(iota(10), iota(10, 20)).map!(a => Token(CXTokenKind.comment,
525             Offset(a[0], a[1]), SourceLoc.init, SourceLoc.init, a[0].to!string)).retro.array;
526     auto span = Spanner(toks);
527 
528     span.put(FileMutant(MutationId(1), Offset(1, 10), "smurf"));
529     span.put(FileMutant(MutationId(1), Offset(9, 15), "donkey"));
530 
531     // TODO add checks
532 }
533 
534 /**
535  *
536  * # Overlap Cases
537  * 1. Perfekt overlap
538  * |--T--|
539  * |--M--|
540  *
541  * 2. Token enclosing mutant
542  * |---T--|
543  *   |-M-|
544  *
545  * 3. Mutant beginning inside a token
546  * |---T--|
547  *   |-M----|
548  *
549  * 4. Mutant overlapping multiple tokens.
550  * |--T--|--T--|
551  * |--M--------|
552  */
553 struct SpannerRange {
554     alias BTree = Spanner.BTree;
555 
556     BTree!Token tokens;
557     BTree!FileMutant muts;
558 
559     this(BTree!Token tokens, BTree!FileMutant muts) @safe {
560         this.tokens = tokens;
561         this.muts = muts;
562         dropMutants;
563     }
564 
565     Span front() @safe pure nothrow {
566         import std.array : appender;
567 
568         assert(!empty, "Can't get front of an empty range");
569         auto t = tokens.front;
570         if (muts.empty)
571             return Span(t);
572 
573         auto app = appender!(FileMutant[])();
574         foreach (m; muts) {
575             if (m.offset.begin < t.offset.end)
576                 app.put(m);
577             else
578                 break;
579         }
580 
581         return Span(t, app.data);
582     }
583 
584     void popFront() @safe {
585         assert(!empty, "Can't pop front of an empty range");
586         tokens.removeFront;
587         dropMutants;
588     }
589 
590     bool empty() @safe pure nothrow @nogc {
591         return tokens.empty;
592     }
593 
594     private void dropMutants() @safe {
595         import std.algorithm : filter;
596         import std.array : array;
597 
598         if (tokens.empty)
599             return;
600 
601         // removing mutants that the tokens have "passed by"
602         const t = tokens.front;
603         auto r = muts[].filter!(a => a.offset.end <= t.offset.begin).array;
604         muts.removeKey(r);
605     }
606 }
607 
608 struct Span {
609     import std.range : isOutputRange;
610 
611     Token tok;
612     FileMutant[] muts;
613 
614     string toString() @safe pure const {
615         import std.array : appender;
616 
617         auto buf = appender!string;
618         toString(buf);
619         return buf.data;
620     }
621 
622     void toString(Writer)(ref Writer w) const if (isOutputRange!(Writer, char)) {
623         import std.format : formattedWrite;
624         import std.range : put;
625 
626         formattedWrite(w, "%s|%(%s %)", tok, muts);
627     }
628 }
629 
630 @("shall return a range grouping mutants by the tokens they overlap")
631 @safe unittest {
632     import std.algorithm;
633     import std.array : array;
634     import std.conv;
635     import std.range;
636     import clang.c.Index : CXTokenKind;
637 
638     auto offsets = zip(iota(0, 150, 10), iota(10, 160, 10)).map!(a => Offset(a[0], a[1])).array;
639 
640     auto toks = offsets.map!(a => Token(CXTokenKind.comment, a, SourceLoc.init,
641             SourceLoc.init, a.begin.to!string)).retro.array;
642     auto span = Spanner(toks);
643 
644     span.put(FileMutant(MutationId(2), Offset(11, 15), "token enclosing mutant"));
645     span.put(FileMutant(MutationId(3), Offset(31, 42), "mutant beginning inside a token"));
646     span.put(FileMutant(MutationId(4), Offset(50, 80), "mutant overlapping multiple tokens"));
647 
648     span.put(FileMutant(MutationId(5), Offset(90, 100), "1 multiple mutants for a token"));
649     span.put(FileMutant(MutationId(6), Offset(90, 110), "2 multiple mutants for a token"));
650     span.put(FileMutant(MutationId(1), Offset(120, 130), "perfect overlap"));
651 
652     auto res = span.toRange.array;
653     //logger.tracef("%(%s\n%)", res);
654     res[1].muts[0].id.shouldEqual(2);
655     res[2].muts.length.shouldEqual(0);
656     res[3].muts[0].id.shouldEqual(3);
657     res[4].muts[0].id.shouldEqual(3);
658     res[5].muts[0].id.shouldEqual(4);
659     res[6].muts[0].id.shouldEqual(4);
660     res[7].muts[0].id.shouldEqual(4);
661     res[8].muts.length.shouldEqual(0);
662     res[9].muts.length.shouldEqual(2);
663     res[9].muts[0].id.shouldEqual(5);
664     res[9].muts[1].id.shouldEqual(6);
665     res[10].muts[0].id.shouldEqual(6);
666     res[11].muts.length.shouldEqual(0);
667     res[12].muts[0].id.shouldEqual(1);
668     res[13].muts.length.shouldEqual(0);
669 }
670 
671 void toIndex(FileIndex[] files, Element root, string htmlFileDir) @trusted {
672     import std.algorithm : sort, filter;
673     import std.conv : to;
674     import std.path : buildPath;
675 
676     auto tbl = tmplDefaultTable(root, [
677             "Path", "Score", "Alive", "NoMut", "Total"
678             ]);
679 
680     // Users are not interested that files that contains zero mutants are shown
681     // in the list. It is especially annoying when they are marked with dark
682     // green.
683     bool has_suppressed;
684     foreach (f; files.sort!((a, b) => a.path < b.path)
685             .filter!(a => a.totalMutants != 0)) {
686         auto r = tbl.appendRow();
687         r.addChild("td").addChild("a", f.display).href = buildPath(htmlFileDir, f.path);
688 
689         const score = () {
690             if (f.totalMutants == 0)
691                 return 1.0;
692             return cast(double) f.killedMutants / cast(double) f.totalMutants;
693         }();
694         const style = () {
695             if (f.killedMutants == f.totalMutants)
696                 return "background-color: green";
697             if (score < 0.3)
698                 return "background-color: red";
699             if (score < 0.5)
700                 return "background-color: salmon";
701             if (score < 0.8)
702                 return "background-color: lightyellow";
703             if (score < 1.0)
704                 return "background-color: lightgreen";
705             return null;
706         }();
707 
708         r.addChild("td", format("%.3s", score)).style = style;
709         r.addChild("td", f.aliveMutants.to!string).style = style;
710         r.addChild("td", f.aliveNoMut.to!string).style = style;
711         r.addChild("td", f.totalMutants.to!string).style = style;
712 
713         has_suppressed = has_suppressed || f.aliveNoMut != 0;
714     }
715 
716     root.addChild("p", "NoMut is the number of alive mutants in the file that are ignored.")
717         .appendText(" This increases the score.");
718     root.setAttribute("onload", "init()");
719 }
720 
721 /** Metadata about the span to be used to e.g. color it.
722  *
723  * Each span has a mutant that becomes activated when the user click on the
724  * span. The user most likely is interested in seeing **a** mutant that has
725  * survived on that point becomes the color is red.
726  *
727  * This is why the algorithm uses the same prio as the one for choosing
728  * color. These two are strongly correlated with each other.
729  */
730 struct MetaSpan {
731     // ordered in priority
732     enum StatusColor {
733         alive,
734         killed,
735         timeout,
736         killedByCompiler,
737         unknown,
738         none,
739     }
740 
741     StatusColor status;
742     string onClick;
743 
744     this(const(FileMutant)[] muts) {
745         immutable click_fmt2 = "ui_set_mut(%s)";
746         status = StatusColor.none;
747 
748         foreach (ref const m; muts) {
749             status = pickColor(m, status);
750             if (onClick.length == 0 && m.mut.status == Mutation.Status.alive) {
751                 onClick = format(click_fmt2, m.id);
752             }
753         }
754 
755         if (onClick.length == 0 && muts.length != 0) {
756             onClick = format(click_fmt2, muts[0].id);
757         }
758     }
759 }
760 
761 /// Choose a color for a mutant span by prioritizing alive mutants above all.
762 MetaSpan.StatusColor pickColor(const FileMutant m,
763         MetaSpan.StatusColor status = MetaSpan.StatusColor.none) {
764     final switch (m.mut.status) {
765     case Mutation.Status.alive:
766         status = MetaSpan.StatusColor.alive;
767         break;
768     case Mutation.Status.killed:
769         if (status > MetaSpan.StatusColor.killed)
770             status = MetaSpan.StatusColor.killed;
771         break;
772     case Mutation.Status.killedByCompiler:
773         if (status > MetaSpan.StatusColor.killedByCompiler)
774             status = MetaSpan.StatusColor.killedByCompiler;
775         break;
776     case Mutation.Status.timeout:
777         if (status > MetaSpan.StatusColor.timeout)
778             status = MetaSpan.StatusColor.timeout;
779         break;
780     case Mutation.Status.unknown:
781         if (status > MetaSpan.StatusColor.unknown)
782             status = MetaSpan.StatusColor.unknown;
783         break;
784     }
785     return status;
786 }
787 
788 string toVisible(MetaSpan.StatusColor s) {
789     if (s == MetaSpan.StatusColor.none)
790         return null;
791     return format("status_%s", s);
792 }
793 
794 string toHover(MetaSpan.StatusColor s) {
795     if (s == MetaSpan.StatusColor.none)
796         return null;
797     return format("hover_%s", s);
798 }