1 /++ 2 A module containing the logic for parsing and analysing the code coverage 3 4 Copyright: © 2017 Szabo Bogdan 5 License: Subject to the terms of the MIT license, as written in the included LICENSE.txt file. 6 Authors: Szabo Bogdan 7 +/ 8 module trial.coverage; 9 10 import std.algorithm; 11 import std.range; 12 import std..string; 13 import std.stdio; 14 import std.conv; 15 import std.exception; 16 import std.file; 17 import std.path; 18 import std.math; 19 20 import trial.discovery.code; 21 22 version(D_Coverage) { 23 shared static this() { 24 import core.runtime; 25 26 if(exists("coverage")) { 27 writeln("Creating coverage folder..."); 28 rmdirRecurse("coverage"); 29 } 30 31 auto destination = buildPath("coverage", "raw").asAbsolutePath.array.idup.to!string; 32 mkdirRecurse(destination); 33 34 dmd_coverSetMerge(false); 35 //dmd_coverSourcePath(thisExePath); 36 dmd_coverDestPath(destination); 37 } 38 } 39 40 /// Converts coverage lst files to html 41 double convertLstFiles(string source, string destination, string packagePath, string packageName) { 42 auto htmlPath = buildPath(destination, "html"); 43 if(!source.exists) { 44 return 0; 45 } 46 47 if(!htmlPath.exists) { 48 htmlPath.mkdirRecurse; 49 } 50 51 std.file.write(buildPath(htmlPath, "coverage.css"), import("templates/coverage.css")); 52 53 auto coverageData = 54 dirEntries(buildPath("coverage", "raw"), SpanMode.shallow) 55 .filter!(f => f.name.endsWith(".lst")) 56 .filter!(f => f.isFile) 57 .map!(a => readText(a.name)) 58 .map!(a => a.toCoverageFile(packagePath)).array; 59 60 std.file.write(buildPath(htmlPath, "coverage-shield.svg"), coverageShield(coverageData.filter!"a.isInCurrentProject".array.coveragePercent.to!int.to!string)); 61 std.file.write(buildPath(htmlPath, "index.html"), coverageData.toHtmlIndex(packageName)); 62 63 foreach (data; coverageData) { 64 auto htmlFile = data.path.toCoverageHtmlFileName; 65 66 std.file.write(buildPath(htmlPath, htmlFile), data.toHtml); 67 } 68 69 return coverageData.coveragePercent; 70 } 71 72 string toCoverageHtmlFileName(string fileName) { 73 return fileName.replace("/", "-").replace("\\", "-") ~ ".html"; 74 } 75 76 /// Get the line that contains the coverage summary 77 auto getCoverageSummary(string fileContent) { 78 auto lines = fileContent.splitLines.array; 79 80 std.algorithm.reverse(lines); 81 82 return lines 83 .filter!(a => a.indexOf('|') == -1 || a.indexOf('|') > 9) 84 .map!(a => a.strip) 85 .filter!(a => a != ""); 86 } 87 88 /// It should get the coverage summary from the .lst file with no coverage 89 unittest { 90 " 91 | double threadUsage(uint index); 92 |} 93 core/cloud/source/cloud/system.d has no code 94 ".getCoverageSummary.front.should.equal("core/cloud/source/cloud/system.d has no code"); 95 } 96 97 /// It should get the coverage summary from the .lst file with missing data 98 unittest { 99 " 100 101 ".getCoverageSummary.empty.should.equal(true); 102 } 103 104 /// It should get the coverage summary from the .lst file with percentage 105 unittest { 106 " 107 2| statusList[0].properties[\"thread1\"].value.should.startWith(\"1;\"); 108 |} 109 core/cloud/source/cloud/system.d is 88% covered 110 ".getCoverageSummary.front.should.equal("core/cloud/source/cloud/system.d is 88% covered"); 111 } 112 113 /// Get the filename from the coverage summary 114 string getFileName(string fileContent) { 115 auto r = fileContent.getCoverageSummary; 116 117 if(r.empty) { 118 return ""; 119 } 120 121 auto pos = r.front.lastIndexOf(".d"); 122 123 return r.front[0..pos + 2]; 124 } 125 126 version(unittest) { 127 version(Have_fluent_asserts) { 128 import fluent.asserts; 129 } 130 } 131 132 /// It should get the filename from the .lst file with no coverage 133 unittest { 134 " 135 | double threadUsage(uint index); 136 |} 137 core/cloud/source/cloud/system.d has no code 138 ".getFileName.should.equal("core/cloud/source/cloud/system.d"); 139 } 140 141 /// It should get the filename from the .lst file with no code 142 unittest { 143 " 144 145 146 ".getFileName.should.equal(""); 147 } 148 149 /// It should get the filename from the .lst file with percentage 150 unittest { 151 " 152 2| statusList[0].properties[\"thread1\"].value.should.startWith(\"1;\"); 153 |} 154 core/cloud/source/cloud/system.d is 88% covered 155 ".getFileName.should.equal("core/cloud/source/cloud/system.d"); 156 } 157 158 /// Get the percentage from the covered summary 159 double getCoveragePercent(string fileContent) { 160 auto r = fileContent.getCoverageSummary; 161 162 if(r.empty) { 163 return 100; 164 } 165 166 auto pos = r.front.lastIndexOf('%'); 167 168 if(pos == -1) { 169 return 100; 170 } 171 172 auto pos2 = r.front[0..pos].lastIndexOf(' ') + 1; 173 174 return r.front[pos2..pos].to!double; 175 } 176 177 /// It should get the filename from the .lst file with no coverage 178 unittest { 179 " 180 | double threadUsage(uint index); 181 |} 182 core/cloud/source/cloud/system.d has no code 183 ".getCoveragePercent.should.equal(100); 184 } 185 186 /// It should get the filename from the .lst file with no code 187 unittest { 188 " 189 190 191 ".getCoveragePercent.should.equal(100); 192 } 193 194 /// It should get the filename from the .lst file with percentage 195 unittest { 196 " 197 2| statusList[0].properties[\"thread1\"].value.should.startWith(\"1;\"); 198 |} 199 core/cloud/source/cloud/system.d is 88% covered 200 ".getCoveragePercent.should.equal(88); 201 } 202 203 /// The representation of a line from the .lst file 204 struct LineCoverage { 205 206 /// 207 string code; 208 209 /// 210 size_t hits; 211 212 /// 213 bool hasCode; 214 215 @disable this(); 216 217 /// 218 this(string line) { 219 enforce(line.indexOf("\n") == -1, "You should provide a line"); 220 line = line.strip; 221 auto column = line.indexOf("|"); 222 223 if(column == -1) { 224 code = line; 225 } else if(column == 0) { 226 code = line[1..$]; 227 } else { 228 hits = line[0..column].strip.to!size_t; 229 hasCode = true; 230 code = line[column + 1..$]; 231 } 232 } 233 } 234 235 /// It should parse an empty line 236 unittest 237 { 238 auto lineCoverage = LineCoverage(` |`); 239 lineCoverage.code.should.equal(""); 240 lineCoverage.hits.should.equal(0); 241 lineCoverage.hasCode.should.equal(false); 242 } 243 244 /// Parse the file lines 245 auto toCoverageLines(string fileContent) { 246 return fileContent 247 .splitLines 248 .filter!(a => a.indexOf('|') != -1 && a.indexOf('|') < 10) 249 .map!(a => a.strip) 250 .map!(a => LineCoverage(a)); 251 } 252 253 /// It should convert a .lst file to covered line structs 254 unittest { 255 auto lines = 256 " | 257 |import std.stdio; 258 75| this(File f) { 259 | } 260 core/cloud/source/cloud/system.d is 88% covered 261 ".toCoverageLines.array; 262 263 lines.length.should.equal(4); 264 265 lines[0].code.should.equal(""); 266 lines[0].hits.should.equal(0); 267 lines[0].hasCode.should.equal(false); 268 269 lines[1].code.should.equal("import std.stdio;"); 270 lines[1].hits.should.equal(0); 271 lines[1].hasCode.should.equal(false); 272 273 lines[2].code.should.equal(" this(File f) {"); 274 lines[2].hits.should.equal(75); 275 lines[2].hasCode.should.equal(true); 276 277 lines[3].code.should.equal(" }"); 278 lines[3].hits.should.equal(0); 279 lines[3].hasCode.should.equal(false); 280 } 281 282 /// Structure that represents one .lst file 283 struct CoveredFile { 284 /// The covered file path 285 string path; 286 287 /// Is true if the file is from the tested library and 288 /// false if is an external file 289 bool isInCurrentProject; 290 291 /// Is true if the file is set to be ignored 292 /// from the final report 293 bool isIgnored; 294 295 /// The module name 296 string moduleName; 297 298 /// The covered percent 299 double coveragePercent; 300 301 /// The file lines with coverage data 302 LineCoverage[] lines; 303 } 304 305 /// Check if a file should be ignored from the report 306 bool isIgnored(const string content) { 307 auto firstLine = content.splitter('\n'); 308 309 if(firstLine.empty) { 310 return false; 311 } 312 313 auto smallCase = firstLine.front.strip.toLower; 314 auto pieces = smallCase.replace("\t", " ").splitter(' ').filter!(a => a != "").array; 315 316 if(pieces[0].indexOf("//") == -1 && pieces[0].indexOf("/*") == -1 && pieces[0].indexOf("/+") == -1) { 317 return false; 318 } 319 320 if(pieces.length == 2) { 321 return pieces[0].indexOf("ignore") != -1 && pieces[1] == "coverage"; 322 } 323 324 if(pieces.length < 3) { 325 return false; 326 } 327 328 return pieces[1] == "ignore" && pieces[2] == "coverage"; 329 } 330 331 /// It should return true for ignored coverage files 332 unittest { 333 "// IGNORE COVERAGE".isIgnored.should.equal(true); 334 "// \t IGNORE \t COVERAGE".isIgnored.should.equal(true); 335 "// ignore coverage".isIgnored.should.equal(true); 336 "//IGNORE COVERAGE".isIgnored.should.equal(true); 337 "/////IGNORE COVERAGE".isIgnored.should.equal(true); 338 "// IGNORE COVERAGE ".isIgnored.should.equal(true); 339 "/* IGNORE COVERAGE */".isIgnored.should.equal(true); 340 "/***** IGNORE COVERAGE ".isIgnored.should.equal(true); 341 "/***** IGNORE COVERAGE ****/".isIgnored.should.equal(true); 342 "/+ IGNORE COVERAGE +/".isIgnored.should.equal(true); 343 "/+++++ IGNORE COVERAGE ".isIgnored.should.equal(true); 344 "/+++++ IGNORE COVERAGE +++++/".isIgnored.should.equal(true); 345 } 346 347 348 /// It should return false for when the ignore coverage file is missing 349 unittest { 350 "".isIgnored.should.equal(false); 351 "//\nIGNORE COVERAGE".isIgnored.should.equal(false); 352 "//\nIGNORE COVERAGE".isIgnored.should.equal(false); 353 "/////\nIGNORE COVERAGE".isIgnored.should.equal(false); 354 "//\n IGNORE COVERAGE ".isIgnored.should.equal(false); 355 "/*\n IGNORE COVERAGE */".isIgnored.should.equal(false); 356 "/***** \n IGNORE COVERAGE ".isIgnored.should.equal(false); 357 "/***** \n IGNORE COVERAGE ****/".isIgnored.should.equal(false); 358 "/+ \n IGNORE COVERAGE +/".isIgnored.should.equal(false); 359 "/+++++ \n IGNORE COVERAGE ".isIgnored.should.equal(false); 360 "/+++++ \n IGNORE COVERAGE +++++/".isIgnored.should.equal(false); 361 "// IGNORE\nCOVERAGE".isIgnored.should.equal(false); 362 "//IGNORE\nCOVERAGE".isIgnored.should.equal(false); 363 } 364 365 366 /// Check if a file is in the current path 367 bool isPackagePath(string fullPath, string packagePath) { 368 if(fullPath.indexOf("/.trial/") != -1) { 369 return false; 370 } 371 372 if(fullPath.indexOf("trial_") != -1) { 373 return false; 374 } 375 376 if(fullPath.indexOf("submodules") != -1) { 377 return false; 378 } 379 380 if(fullPath.indexOf(packagePath) == 0) { 381 return true; 382 } 383 384 if(fullPath.replace("\\", "/").indexOf(packagePath) == 0) { 385 return true; 386 } 387 388 return false; 389 } 390 391 /// Check project paths 392 unittest { 393 "../../something.d".isPackagePath("/Users/trial/").should.equal(false); 394 "/Users/trial/trial_.d".isPackagePath("/Users/trial/").should.equal(false); 395 "/Users/trial/runner.d".isPackagePath("/Users/trial/").should.equal(true); 396 "/Users/trial/.trial/runner.d".isPackagePath("/Users/trial/").should.equal(false); 397 "C:\\Users\\trial\\runner.d".isPackagePath("C:/Users/trial/").should.equal(true); 398 } 399 400 /// Converts a .lst file content to a CoveredFile structure 401 CoveredFile toCoverageFile(string content, string packagePath) { 402 auto fileName = content.getFileName; 403 auto fullPath = buildNormalizedPath(getcwd, fileName); 404 405 return CoveredFile( 406 fileName, 407 fullPath.isPackagePath(packagePath), 408 content.isIgnored(), 409 getModuleName(fullPath), 410 getCoveragePercent(content), 411 content.toCoverageLines.array); 412 } 413 414 /// should convert a .lst file to CoveredFile structure 415 unittest { 416 auto result = ` |/++ 417 | The main runner logic. You can find here some LifeCycle logic and test runner 418 | initalization 419 |+/ 420 |module trial.runner; 421 | 422 | /// Send the begin run event to all listeners 423 | void begin(ulong testCount) { 424 23| lifecycleListeners.each!(a => a.begin(testCount)); 425 | } 426 lifecycle/trial/runner.d is 74% covered 427 `.toCoverageFile(buildPath(getcwd, "lifecycle/trial")); 428 429 result.path.should.equal("lifecycle/trial/runner.d"); 430 result.isInCurrentProject.should.equal(true); 431 result.moduleName.should.equal("trial.runner"); 432 result.coveragePercent.should.equal(74); 433 result.lines.length.should.equal(10); 434 result.lines[0].code.should.equal("/++"); 435 result.lines[9].code.should.equal(" }"); 436 } 437 438 /// should mark the `trial_.d` file as external file 439 unittest { 440 auto result = `trial_package.d is 74% covered 441 `.toCoverageFile(buildPath(getcwd, "lifecycle/trial")); 442 443 result.isInCurrentProject.should.equal(false); 444 } 445 446 /// Generate the html for a line coverage 447 string toLineCoverage(T)(LineCoverage line, T index) { 448 return import("templates/coverageColumn.html") 449 .replaceVariable("hasCode", line.hasCode ? "has-code" : "") 450 .replaceVariable("hit", line.hits > 0 ? "hit" : "") 451 .replaceVariable("line", index.to!string) 452 .replaceVariable("hitCount", line.hits.to!string); 453 } 454 455 /// Render line coverage column 456 unittest { 457 LineCoverage(" |").toLineCoverage(1).should.contain([`<span class="line-number">1</span>`, `<span class="hit-count">0</span>`]); 458 LineCoverage(" |").toLineCoverage(1).should.not.contain(["has-code", `hit"`]); 459 460 LineCoverage(" 1|code").toLineCoverage(2).should.contain([ `<span class="line-number">2</span>`, `<span class="hit-count">1</span>`, "has-code", `hit"` ]); 461 } 462 463 /// Get the line coverage column for the html report 464 string toHtmlCoverage(LineCoverage[] lines) { 465 return lines.enumerate(1).map!(a => a[1].toLineCoverage(a[0])).array.join("").replace("\n", ""); 466 } 467 468 /// Cont how many lines were hit 469 auto hitLines(LineCoverage[] lines) { 470 return lines.filter!(a => a.hits > 0).array.length; 471 } 472 473 /// Cont how many lines were hit 474 auto codeLines(LineCoverage[] lines) { 475 return lines.filter!(a => a.hasCode).array.length; 476 } 477 478 /// Replace an `{variable}` inside a string 479 string replaceVariable(const string page, const string key, const string value) pure { 480 return page.replace("{"~key~"}", value); 481 } 482 483 /// It should replace a variable inside a page 484 unittest { 485 `-{key}-`.replaceVariable("key", "value").should.equal("-value-"); 486 } 487 488 /// wraps some string in a html page 489 string wrapToHtml(string content, string title) { 490 return import("templates/page.html").replaceVariable("content", content).replaceVariable("title", title); 491 } 492 493 ///should replace the variables inside the page.html 494 unittest { 495 auto page = wrapToHtml("some content", "some title"); 496 497 page.should.contain(`<title>some title</title>`); 498 page.should.contain("<body>\n some content\n</body>"); 499 } 500 501 /// Create an html progress bar 502 string htmlProgress(string percent) { 503 return import("templates/progress.html").replaceVariable("percent", percent); 504 } 505 506 ///should replace the variables inside the page.html 507 unittest { 508 htmlProgress("33").should.contain(`33%`); 509 htmlProgress("33").should.contain(`33% Covered`); 510 } 511 512 /// Generate the coverage page header 513 string coverageHeader(CoveredFile coveredFile) { 514 return import("templates/coverageHeader.html") 515 .replaceVariable("title", coveredFile.moduleName) 516 .replaceVariable("hitLines", coveredFile.lines.hitLines.to!string) 517 .replaceVariable("totalLines", coveredFile.lines.codeLines.to!string) 518 .replaceVariable("coveragePercent", coveredFile.coveragePercent.to!string) 519 .replaceVariable("pathPieces", pathSplitter(coveredFile.path).array.join(`</li><li>`)); 520 } 521 522 /// Check variables for the coverage header 523 unittest { 524 CoveredFile coveredFile; 525 coveredFile.moduleName = "module.name"; 526 coveredFile.coveragePercent = 30; 527 coveredFile.path = "a/b"; 528 coveredFile.lines = [ LineCoverage(" 0| not code"), LineCoverage(" 1| some code") ]; 529 530 auto header = coverageHeader(coveredFile); 531 532 header.should.contain(`<h1>module.name`); 533 header.should.contain(`1/2`); 534 header.should.contain(`30%`); 535 header.should.contain(`<li>a</li><li>b</li>`); 536 } 537 538 /// Convert a `CoveredFile` struct to html 539 string toHtml(CoveredFile coveredFile) { 540 return wrapToHtml( 541 coverageHeader(coveredFile) ~ 542 import("templates/coverageBody.html") 543 .replaceVariable("lines", coveredFile.lines.toHtmlCoverage) 544 .replaceVariable("code", coveredFile.lines.map!(a => a.code.replace("<", "<").replace(">", ">")).array.join("\n")), 545 546 coveredFile.moduleName ~ " coverage" 547 ); 548 } 549 550 /// Check variables for the coverage html 551 unittest { 552 CoveredFile coveredFile; 553 coveredFile.moduleName = "module.name"; 554 coveredFile.coveragePercent = 30; 555 coveredFile.path = "a/b"; 556 coveredFile.lines = [ LineCoverage(" 0| <not code>"), LineCoverage(" 1| some code") ]; 557 558 auto html = toHtml(coveredFile); 559 560 html.should.contain(`<h1>module.name`); 561 html.should.contain(`<not code>`); 562 html.should.contain(`<title>module.name coverage</title>`); 563 html.should.contain(`hit"`); 564 } 565 566 string indexTable(string content) { 567 return import("templates/indexTable.html").replaceVariable("content", content); 568 } 569 570 string ignoredTable(string content) { 571 return import("templates/ignoredTable.html").replaceVariable("content", content); 572 } 573 574 /// Check if the table body is inserted 575 unittest { 576 indexTable("some content").should.contain(`<tbody>some content</tbody>`); 577 } 578 579 /// Calculate the coverage percent from the current project 580 double coveragePercent(CoveredFile[] coveredFiles) { 581 if(coveredFiles.length == 0) { 582 return 100; 583 } 584 585 double total = 0; 586 double covered = 0; 587 588 foreach(file; coveredFiles.filter!"a.isInCurrentProject".filter!"!a.isIgnored") { 589 total += file.lines.map!(a => a.hasCode ? 1 : 0).sum; 590 covered += file.lines.filter!(a => a.hasCode).map!(a => a.hits > 0 ? 1 : 0).sum; 591 } 592 593 if(total == 0) { 594 return 100; 595 } 596 597 return round((covered / total) * 10000) / 100; 598 } 599 600 /// No files are always 100% covered 601 unittest { 602 [].coveragePercent.should.equal(100); 603 } 604 605 /// check a 50% covered file 606 unittest { 607 auto coveredFile = CoveredFile("", true, false, "", 50, [ LineCoverage(" 75| this(File f)"), LineCoverage(" 0| this(File f)") ]); 608 [coveredFile].coveragePercent.should.equal(50); 609 } 610 611 /// check a 50% external covered file 612 unittest { 613 auto coveredFile = CoveredFile("", false, false, "", 0, [ LineCoverage(" 0| this(File f)"), LineCoverage(" 0| this(File f)") ]); 614 [coveredFile].coveragePercent.should.equal(100); 615 } 616 617 618 string toHtmlIndex(CoveredFile[] coveredFiles, string name) { 619 sort!("toUpper(a.path) < toUpper(b.path)", SwapStrategy.stable)(coveredFiles); 620 string content; 621 622 string table; 623 size_t totalHitLines; 624 size_t totalLines; 625 size_t ignoredLines; 626 int count; 627 628 foreach(file; coveredFiles.filter!"a.isInCurrentProject".filter!"!a.isIgnored") { 629 auto currentHitLines = file.lines.hitLines; 630 auto currentTotalLines = file.lines.codeLines; 631 632 table ~= `<tr> 633 <td><a href="` ~ file.path.toCoverageHtmlFileName ~ `">` ~ file.path ~ `</a></td> 634 <td>` ~ file.moduleName ~ `</td> 635 <td>` ~ file.lines.hitLines.to!string ~ `/` ~ currentTotalLines.to!string ~ `</td> 636 <td>` ~ file.coveragePercent.to!string.htmlProgress ~ `</td> 637 </tr>`; 638 639 totalHitLines += currentHitLines; 640 totalLines += currentTotalLines; 641 count++; 642 } 643 644 table ~= `<tr> 645 <th colspan="2">Total</td> 646 <th>` ~ totalHitLines.to!string ~ `/` ~ totalLines.to!string ~ `</td> 647 <th>` ~ coveredFiles.coveragePercent.to!string.htmlProgress ~ `</td> 648 </tr>`; 649 650 content ~= indexHeader(name) ~ table.indexTable; 651 652 653 /// Ignored files 654 table = ""; 655 foreach(file; coveredFiles.filter!"a.isInCurrentProject".filter!"a.isIgnored") { 656 auto currentTotalLines = file.lines.codeLines; 657 658 table ~= `<tr> 659 <td><a href="` ~ file.path.toCoverageHtmlFileName ~ `">` ~ file.path ~ `</a></td> 660 <td>` ~ file.moduleName ~ `</td> 661 <td>` ~ currentTotalLines.to!string ~ `/` ~ totalLines.to!string ~ `</td> 662 </tr>`; 663 664 ignoredLines += currentTotalLines; 665 count++; 666 } 667 668 table ~= `<tr> 669 <th colspan="2">Total</td> 670 <th>` ~ ignoredLines.to!string ~ `/` ~ totalLines.to!string ~ `</td> 671 </tr>`; 672 673 content ~= `<h1>Ignored</h1>` ~ table.ignoredTable; 674 675 /// external files 676 table = ""; 677 foreach(file; coveredFiles.filter!"!a.isInCurrentProject") { 678 table ~= `<tr> 679 <td><a href="` ~ file.path.toCoverageHtmlFileName ~ `">` ~ file.path ~ `</a></td> 680 <td>` ~ file.moduleName ~ `</td> 681 <td>` ~ file.lines.hitLines.to!string ~ `/` ~ file.lines.codeLines.to!string ~ `</td> 682 <td>` ~ file.coveragePercent.to!string.htmlProgress ~ `</td> 683 </tr>`; 684 } 685 686 content ~= `<h1>Dependencies</h1>` ~ table.indexTable; 687 688 content = `<div class="container">` ~ content ~ `</div>`; 689 690 return wrapToHtml(content, "Code Coverage report"); 691 } 692 693 string indexHeader(string name) { 694 return `<h1>` ~ name ~ ` <img src="coverage-shield.svg"></h1>`; 695 } 696 697 698 /// Create line coverage shield as svg 699 string coverageShield(string percent) { 700 return import("templates/coverage.svg").replace("?%", percent ~ "%"); 701 } 702 703 /// The line coverage shield should contain the percent 704 unittest { 705 coverageShield("30").should.contain("30%"); 706 }