source/rooibos/Coverage.bs

namespace rooibos.Coverage

    function saveResults() as dynamic
        cc = m.global._rbs_ccn
        if cc = invalid then
            ? "There was no rooibos code coverage component - not generating coverage report"
            return invalid
        end if
        ' Observe before requesting the save so the one-time publish can't be missed
        port = createObject("roMessagePort")
        cc.observeFieldScoped("coverageResults", port)
        cc.save = true
        coverageResults = cc.coverageResults
        if coverageResults = invalid then
            msg = wait(0, port)
            coverageResults = msg.getData()
        end if
        cc.unobserveFieldScoped("coverageResults")

        return coverageResults
    end function

    function reportCodeCoverage(coverageResults as dynamic) as void

        if m.global._rbs_ccn = invalid then
            ? "There was no rooibos code coverage component - not generating coverage report"
            return
        end if
        t = createObject("roTimespan")
        ? ""
        ? "...Generating code coverage report"
        ? ""

        hitFiles = []
        missFiles = []
        allLinesCount = 0
        allLinesHit = 0
        for each file in coverageResults.files
            filename = "pkg:/" + file.sourceFile
            expectedCount = file.lineTotalFound
            allLinesCount += expectedCount
            allLinesHit += file.lineTotalHit
            if file.lineTotalHit > 0 then
                resolvedPercent = (file.lineTotalHit / expectedCount) * 100
                hitFiles.push({ percent: resolvedPercent, text: filename + ": " + str(resolvedPercent).trim() + "% (" + stri(file.lineTotalHit).trim() + "/" + stri(expectedCount).trim() + ")" })
            else
                missFiles.push(filename + ": MISS!")
            end if
        end for
        if allLinesHit = 0 then
            allLinesPercent = 0
        else
            allLinesPercent = (allLinesHit / allLinesCount) * 100
        end if
        ? ""
        ? ""
        ? "+++++++++++++++++++++++++++++++++++++++++++"
        ? "Code Coverage Report"
        ? "+++++++++++++++++++++++++++++++++++++++++++"
        ? ""
        ? "Total Coverage: " ; str(allLinesPercent).trim() ; "% (" ; stri(allLinesHit).trim() ; "/" + stri(allLinesCount).trim() ; ")"
        ? "Files: " ; hitFiles.count(); "/" ; coverageResults.files.count()
        ? ""
        ? "HIT FILES"
        ? "---------"
        hitFiles.SortBy("percent")
        for i = 0 to hitFiles.count() - 1
            ? hitFiles[i].text
        end for
        ? ""
        ? "MISSED FILES"
        ? "------------"
        for i = 0 to missFiles.count() - 1
            ? missFiles[i]
        end for
        ? ""
        ? "+++++++++++++++++++++++++++++++++++++++++++"
        ? "Code Coverage Report Complete"; t.totalMilliseconds(); "ms"
        ? "+++++++++++++++++++++++++++++++++++++++++++"
    end function

    ' Prints the condensed hit-counts stream consumed by the rooibos CLI whenever code
    ' coverage is recorded. One JSON line per file that recorded any hits,
    ' sparse (zero counts omitted), with every count keyed by its INDEX into the
    ' corresponding array of components/rooibos/CodeCoverage.json - the CLI re-reads that
    ' file from the built package and joins the two. The static model never changes order
    ' at runtime (hits are incremented in place), so index alignment is guaranteed.
    ' This stream is a fraction of the lcov payload, so it doesn't flood the console.
    function printCoverageCounts(coverageResults as dynamic)
        ?
        ? "+-=-coverage-counts:start"
        ? `{"v":1}`
        fileIndex = 0
        for each file in coverageResults.files
            lineHits = {}
            i = 0
            for each line in file.lines
                if line.totalHit > 0 then
                    lineHits[stri(i).trim()] = line.totalHit
                end if
                i++
            end for

            functionHits = {}
            i = 0
            for each func in file.functions
                if func.totalHit > 0 then
                    functionHits[stri(i).trim()] = func.totalHit
                end if
                i++
            end for

            branchHits = {}
            i = 0
            for each block in file.blocks
                armHits = []
                anyHit = false
                for each branch in block.branches
                    armHits.push(branch.totalHit)
                    if branch.totalHit > 0 then
                        anyHit = true
                    end if
                end for
                if anyHit then
                    branchHits[stri(i).trim()] = armHits
                end if
                i++
            end for

            if lineHits.count() > 0 or functionHits.count() > 0 or branchHits.count() > 0 then
                ? FormatJson({ "i": fileIndex, "l": lineHits, "f": functionHits, "b": branchHits })
                ' Brief pause so console scrapers can drain the buffer (same reason as the
                ' lcov printer, but the payload here is far smaller so the pause is too).
                sleep(5)
            end if
            fileIndex++
        end for
        ? "+-=-coverage-counts:end"
    end function

    function createLCovOutput(coverageResults as dynamic, logToConsole = true as boolean) as string
        ? "Generating lcov.info file..."
        fileResults = []

        for each file in coverageResults.files
            buffer = `TN:\n`
            buffer += `SF:${file.sourceFile}\n`
            ' buffer += `VER:\n`

            ' Add all the found functions for the file. Strictly 2-arg FN rows: the 3-arg
            ' FN:start,end,name form is lcov v2+ and older/strict parsers (Coveralls,
            ' lcov-parse) reject or mis-read it. The end line still ships to host tooling
            ' via components/rooibos/CodeCoverage.json.
            for each func in file.functions
                buffer += `FN:${func.startLine},${func.name}\n`
            end for

            ' Write function related data. Emit FNDA for every function (including unhit ones with 0)
            ' so consumers like genhtml / istanbul-reports can show missed functions.
            for each func in file.functions
                buffer += `FNDA:${func.totalHit},${func.name}\n`
            end for

            buffer += `FNF:${file.functionTotalFound}\n`
            buffer += `FNH:${file.functionTotalHit}\n`

            ' Write branch related data. Emit BRDA for every branch including unhit ones so missed
            ' branches render in the HTML report. For single-arm ifs (isIfArm=true with only one
            ' tracked arm), synthesize the implicit-else branch from the if-line's evaluation count
            ' minus the tracked arm hits. This lets the HTML viewer flag never-taken falsy paths
            ' even though we don't directly instrument the implicit fall-through.
            ' One line-hit lookup pass per file - rescanning file.lines for every
            ' single-arm if is O(blocks x lines) in interpreted code on the device.
            hitsByLine = {}
            for each line in file.lines
                hitsByLine[stri(line.lineNumber).trim()] = line.totalHit
            end for
            brf = 0
            brh = 0
            for each block in file.blocks
                for each branch in block.branches
                    brf++
                    if branch.totalHit > 0 then
                        brh++
                    end if
                    buffer += `BRDA:${branch.line},${block.id},${branch.id},${branch.totalHit}\n`
                end for
                if block.isIfArm = true and block.branches.count() = 1 then
                    ifLine = block.branches[0].line
                    evaluations = hitsByLine[stri(ifLine).trim()] ?? 0
                    skipHits = evaluations - block.branches[0].totalHit
                    if skipHits < 0 then
                        skipHits = 0
                    end if
                    brf++
                    if skipHits > 0 then
                        brh++
                    end if
                    buffer += `BRDA:${ifLine},${block.id},1,${skipHits}\n`
                end if
            end for

            buffer += `BRF:${brf}\n`
            buffer += `BRH:${brh}\n`

            ' Write the per line related data. This output is deliberately plain lcov -
            ' rich detail (statement spans, branch arm columns) lives in the static
            ' CodeCoverage.json model and flows through the condensed-counts channel that
            ' is always printed alongside this report.
            for each line in file.lines
                buffer += `DA:${line.lineNumber},${line.totalHit}\n`
            end for

            buffer += `LF:${file.lineTotalFound}\n`
            buffer += `LH:${file.lineTotalHit}\n`
            buffer += `end_of_record`

            if logToConsole then
                ? buffer
                ' When logging to the console it is very possible to flood the buffer and cause the application to exit.
                ' Sleep for a short amount of time so as to give console scrapers time to empty the buffer
                sleep(30)
            else
                fileResults.push(buffer)
            end if
        end for

        if not logToConsole then
            return fileResults.join(chr(10))
        else
            return ""
        end if
    end function

    function printLCovInfo(coverageResults as dynamic)

        ?
        ? "+++++++++++++++++++++++++++++++++++++++++++"
        ? "LCOV.INFO FILE"
        ? "+++++++++++++++++++++++++++++++++++++++++++"
        ?
        ? "+-=-coverage:start"
        rooibos.coverage.createLCovOutput(coverageResults)
        ? "+-=-coverage:end"
    end function

end namespace