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