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("<", "&lt;").replace(">", "&gt;")).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(`&lt;not code&gt;`);
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 }