import File from "File" import { fulfill } from "Wish" import Dictionary from "Dictionary" import List from "List" import String from "String" import FilePath from "FilePath" import Math from "Math" import { fromMaybe, Just, Nothing } from "Maybe" import Integer from "Integer" import {} from "Float" import { always, when } from "Function" import Process from "Process" import IO from "IO" import Terminal from "Terminal" import __CoverageTracking__ from "__CoverageTracking__" COVERAGE_TEXT :: String COVERAGE_TEXT = "COVERAGE_TEXT" NL :: String NL = String.singleton('\n') IS_COLOR_ENABLED :: Boolean IS_COLOR_ENABLED = do { noColor = Process.getEnv("NO_COLOR") return noColor == Just("") || noColor == Nothing } CWD :: String CWD = Process.getCurrentWorkingDirectory() alias HitCounter = { increment :: {} -> {}, read :: {} -> Integer } type CoverableLine = CoverableLine(Integer) derive Comparable CoverableLine type CoverableFunction = CoverableFunction(Integer, String) derive Comparable CoverableFunction type CoverableBranch = CoverableBranch(Integer, Integer, Integer) derive Comparable CoverableBranch alias CoverageData = { coverableLines :: Dictionary CoverableLine HitCounter, coverableFunctions :: Dictionary CoverableFunction HitCounter, coverableBranches :: Dictionary CoverableBranch HitCounter, } // ---------- LCOV ------------ // TN: usually empty // SF: // FN:, // FNDA:, // FNF: // FNH: // BRDA:,,, // BRF: // BRH: // DA:, // LH: // LF: // end_of_record type LcovLine = TN(String) | SF(String) | FN(Integer, String) | FNDA(Integer, String) | FNF(Integer) | FNH(Integer) | DA(Integer, Integer) | LF(Integer) | LH(Integer) | BRF(Integer) | BRH(Integer) | BRDA(Integer, Integer, Integer, Integer) alias LcovSection = { tn :: LcovLine, sf :: LcovLine, fns :: List LcovLine, fndas :: List LcovLine, fnf :: LcovLine, fnh :: LcovLine, das :: List LcovLine, lf :: LcovLine, lh :: LcovLine, brf :: LcovLine, brh :: LcovLine, brdas :: List LcovLine, } alias LcovInfo = List LcovSection emptySection :: LcovSection emptySection = { tn: TN(""), sf: SF(""), fns: [], fndas: [], fnf: FNF(0), fnh: FNH(0), das: [], lf: LF(0), lh: LH(0), brf: BRF(0), brh: BRH(0), brdas: [], } stringifyLine :: LcovLine -> String stringifyLine = (line) => where(line){ TN(arg) => "TN:" ++ arg ++ "\n" SF(arg) => "SF:" ++ arg ++ "\n" FN(l, name) => "FN:" ++ show(l) ++ "," ++ name ++ "\n" FNDA(count, name) => "FNDA:" ++ show(count) ++ "," ++ name ++ "\n" FNF(count) => "FNF:" ++ show(count) ++ "\n" FNH(count) => "FNH:" ++ show(count) ++ "\n" DA(l, count) => "DA:" ++ show(l) ++ "," ++ show(count) ++ "\n" LF(count) => "LF:" ++ show(count) ++ "\n" LH(count) => "LH:" ++ show(count) ++ "\n" BRF(count) => "BRF:" ++ show(count) ++ "\n" BRH(count) => "BRH:" ++ show(count) ++ "\n" BRDA(l, blockNumber, branchNumber, count) => `BRDA:${show(l)},${show(blockNumber)},${show(branchNumber)},${show(count)}\n` } stringifySection :: LcovSection -> String stringifySection = (section) => stringifyLine(section.tn) ++ stringifyLine(section.sf) ++ List.reduceLeft((fns, line) => fns ++ stringifyLine(line), "", section.fns) ++ List.reduceLeft((fndas, line) => fndas ++ stringifyLine(line), "", section.fndas) ++ stringifyLine(section.fnf) ++ stringifyLine(section.fnh) ++ List.reduceLeft((das, line) => das ++ stringifyLine(line), "", section.das) ++ stringifyLine(section.lf) ++ stringifyLine(section.lh) ++ stringifyLine(section.brf) ++ stringifyLine(section.brh) ++ List.reduceLeft((brdas, line) => brdas ++ stringifyLine(line), "", section.brdas) ++ "end_of_record\n" stringify :: LcovInfo -> String export stringify = (lcov) => List.reduceLeft((r, section) => r ++ stringifySection(section), "", lcov) processModuleResult :: String -> CoverageData -> LcovSection processModuleResult = (modulePath, coverageData) => { lineResults = Dictionary.toList(coverageData.coverableLines) functionResults = Dictionary.toList(coverageData.coverableFunctions) branchResults = Dictionary.toList(coverageData.coverableBranches) return { ...emptySection, sf: SF(modulePath), lf: LF(List.length(lineResults)), lh: LH(List.length(List.filter(where { #[_, hitCounter] => hitCounter.read() > 0 }, lineResults))), das: map( where { #[CoverableLine(line), hitCounter] => DA(line, hitCounter.read()) }, lineResults ), fnf: FNF(List.length(functionResults)), fnh: FNH(List.length(List.filter(where { #[_, hitCounter] => hitCounter.read() > 0 }, functionResults))), fns: map( where { #[CoverableFunction(line, name), _] => FN(line, name) }, functionResults ), fndas: map( where { #[CoverableFunction(_, name), hitCounter] => FNDA(hitCounter.read(), name) }, functionResults ), brdas: map( where { #[CoverableBranch(line, blockIndex, branchIndex), hitCounter] => BRDA(line, blockIndex, branchIndex, hitCounter.read()) }, branchResults ), brf: BRF(List.length(branchResults)), brh: BRH(List.length(List.filter(where { #[_, hitCounter] => hitCounter.read() > 0 }, branchResults))), } } dropTestMainData :: List #[String, a] -> List #[String, a] dropTestMainData = List.filter( where { #[modulePath, _] => String.takeLast(16, modulePath) != "__TestMain__.mad" } ) keepTwoDecimals :: String -> String keepTwoDecimals = (input) => pipe( String.split("."), where { [before, after, ..._] => if (String.length(after) == 1) { before ++ "." ++ after ++ "0" } else { before ++ "." ++ String.take(2, after) } [before] => before ++ ".00" [] => "" } )(input) type ModuleResult = ModuleResult(String, Float, Float, Float, Float) buildModuleResult :: String -> CoverageData -> ModuleResult buildModuleResult = (modulePath, coverageData) => { lineResults = Dictionary.toList(coverageData.coverableLines) functionResults = Dictionary.toList(coverageData.coverableFunctions) branchResults = Dictionary.toList(coverageData.coverableBranches) linesFound = List.length(lineResults) linesHit = List.length(List.filter(where { #[_, hitCounter] => hitCounter.read() > 0 }, lineResults)) functionsFound = List.length(functionResults) functionsHit = List.length(List.filter(where { #[_, hitCounter] => hitCounter.read() > 0 }, functionResults)) branchesFound = List.length(branchResults) branchesHit = List.length(List.filter(where { #[_, hitCounter] => hitCounter.read() > 0 }, branchResults)) lineCov = linesFound == 0 ? 100 : linesHit / linesFound * 100 functionCov = functionsFound == 0 ? 100 : functionsHit / functionsFound * 100 branchCov = branchesFound == 0 ? 100 : branchesHit / branchesFound * 100 total = (lineCov + functionCov + branchCov) / 3 return ModuleResult(modulePath, lineCov, functionCov, branchCov, total) } buildAllResults :: Dictionary String CoverageData -> List ModuleResult buildAllResults = (coverageData) => { moduleResults = pipe( Dictionary.toList, dropTestMainData, map( where { #[modulePath, data] => buildModuleResult(modulePath, data) } ) )(coverageData) moduleCount = Integer.toFloat(List.length(moduleResults)) totalRow = pipe( List.reduce( (totalResult, moduleResult) => where(#[totalResult, moduleResult]) { #[ModuleResult(n, l, f, b, t), ModuleResult(m, ml, mf, mb, mt)] => ModuleResult(n, l + ml, f + mf, b + mb, t + mt) }, ModuleResult("Total", 0, 0, 0, 0) ), where { ModuleResult(n, l, f, b, t) => ModuleResult(n, l / moduleCount, f / moduleCount, b / moduleCount, t / moduleCount) } )(moduleResults) return [...moduleResults, totalRow] } printRows :: List (List String) -> {} printRows = (rows) => { finalRows = pipe( List.mapWithIndex( (row, index) => where(row) { [module, line, fn, branch, total] => if (index == 0) { `${module} ${line} ${fn} ${branch} ${total}` } else if (index < List.length(rows) - 1) { `${renderModulePath(module)} ${renderPercentage(line)} ${renderPercentage(fn)} ${renderPercentage(branch)} ${renderPercentage(total)}` } else { `${module} ${renderPercentage(line)} ${renderPercentage(fn)} ${renderPercentage(branch)} ${renderPercentage(total)}` } _ => "" } ), where { [header, ...moduleRows] => [ header, pipe(String.length, String.repeat('-'), when(always(IS_COLOR_ENABLED), IO.grey))(header), ...moduleRows ] _ => [""] }, String.join(NL) )(rows) IO.putLine(`${NL}Coverage`) IO.putLine(IS_COLOR_ENABLED ? IO.grey(`--------${NL}`) : `--------${NL}`) IO.putLine(finalRows) IO.putLine("") } formatRows :: List (List String) -> List (List String) formatRows = (rows) => if (List.isEmpty(rows)) { [] } else { do { columnCount = pipe(List.nth(0), fromMaybe([]), List.length)(rows) colMaxWidths = pipe( map( (columnIndex) => List.reduce( (maxWidth, colContent) => pipe( List.nth(columnIndex), fromMaybe(""), String.length, Math.max(maxWidth) )(colContent), 0, rows ) ) )(List.range(0, columnCount)) return map( List.mapWithIndex((colContent, colIndex) => pipe( List.nth($, colMaxWidths), fromMaybe(0), Math.substract($, String.length(colContent)), List.repeat(' '), String.fromList, (spaces) => colIndex == 0 ? colContent ++ spaces : spaces ++ colContent )(colIndex)) )(rows) } } renderPercentage :: String -> String renderPercentage = (input) => if (IS_COLOR_ENABLED) { where(scan(String.dropLast(1, String.trim(input)))) { Just(num) => if (num > 90_f) { Terminal.text.brightGreen(input) } else if (num > 75_f) { Terminal.text.brightYellow(input) } else { Terminal.text.brightRed(input) } Nothing => IO.red(input) } } else { input } renderModulePath :: String -> String renderModulePath = (modulePath) => pipe( FilePath.splitPath, (parts) => { dirParts = List.init(parts) path = if (List.isEmpty(dirParts)) { "" } else { pipe(FilePath.joinPath, mappend($, "/"), when(always(IS_COLOR_ENABLED), IO.grey))(dirParts) } fileName = pipe( List.last, fromMaybe("") )(parts) return path ++ fileName } )(modulePath) processSuitePath :: String -> String processSuitePath = (suitePath) => { cwdParts = FilePath.splitPath(CWD) partIndex = 0 return pipe( FilePath.splitPath, List.dropWhile((part) => { justPart = pipe( FilePath.dropTrailingPathSeparator, Just )(part) justCwdPart = pipe( List.nth(partIndex), map(FilePath.dropTrailingPathSeparator) )(cwdParts) drop = justPart == justCwdPart partIndex := partIndex + 1 return drop }), FilePath.joinPath, (p) => where(String.firstChar(p)) { Just('/') => String.drop(1, p) _ => p } )(suitePath) } displayCoverageResults :: Dictionary String CoverageData -> {} displayCoverageResults = (coverageData) => pipe( buildAllResults, map( where { ModuleResult(astPath, line, fn, branch, total) => [ processSuitePath(astPath), `${keepTwoDecimals(show(line))}%`, `${keepTwoDecimals(show(fn))}%`, `${keepTwoDecimals(show(branch))}%`, `${keepTwoDecimals(show(total))}%`, ] } ), (rs) => [["Module", "Lines", "Functions", "Branches", "Total"], ...rs], formatRows, printRows )(coverageData) generateLcov :: Dictionary String CoverageData -> String generateLcov = (coverageData) => pipe( Dictionary.toList, dropTestMainData, map( where { #[modulePath, data] => processModuleResult(modulePath, data) } ), stringify )(coverageData) // ---------- Coverage Reporter ------------ emptyModuleData :: CoverageData emptyModuleData = { coverableLines: {{}}, coverableFunctions: {{}}, coverableBranches: {{}} } ensureModule :: String -> Dictionary String CoverageData -> CoverageData ensureModule = (path, acc) => fromMaybe(emptyModuleData, Dictionary.get(path, acc)) collectCoverageData :: {} -> Dictionary String CoverageData collectCoverageData = () => do { withLines = List.reduce( (acc, tracker) => where(tracker) { #[path, line, hc] => do { existing = ensureModule(path, acc) return Dictionary.insert( path, { ...existing, coverableLines: Dictionary.insert(CoverableLine(line), hc, existing.coverableLines) }, acc ) } }, {{}}, __CoverageTracking__.Tracking.getLineTrackers() ) withFunctions = List.reduce( (acc, tracker) => where(tracker) { #[path, line, name, hc] => do { existing = ensureModule(path, acc) return Dictionary.insert( path, { ...existing, coverableFunctions: Dictionary.insert(CoverableFunction(line, name), hc, existing.coverableFunctions) }, acc ) } }, withLines, __CoverageTracking__.Tracking.getFunctionTrackers() ) return List.reduce( (acc, tracker) => where(tracker) { #[path, line, blockIndex, branchIndex, hc] => do { existing = ensureModule(path, acc) return Dictionary.insert( path, { ...existing, coverableBranches: Dictionary.insert(CoverableBranch(line, blockIndex, branchIndex), hc, existing.coverableBranches) }, acc ) } }, withFunctions, __CoverageTracking__.Tracking.getBranchTrackers() ) } generateReport :: {} -> {} export generateReport = () => { coverageData = collectCoverageData() lcovStr = generateLcov(coverageData) fulfill(() => {}, () => {})(File.write(".coverage/lcov.info", lcovStr)) if (Process.getEnv(COVERAGE_TEXT) == Just("ON")) { displayCoverageResults(coverageData) } }