nurl-cov/gcovtext.nu — the annotated source listing, in gcov's own format.
One listing per source file: every line of the original, prefixed by how many times it ran. A line with no code gets -, a line with code that never ran gets #####, and that column is the whole point — it is where you look to find what the tests never touched.
function classify called 2 returned 100% blocks executed 70% 2: 1:@ classify i n → s { 2: 2: ? < n 0 { ^ negative } {} branch 0 taken 2 branch 1 taken 0 #####: 5: ^ large
The layout is deliberately byte-compatible with llvm-cov gcov -b -c. That is not nostalgia. It means this reader can be checked against an independent implementation of the same files, which is the only way to know that numbers nobody can compute by hand are right — and the package's test suite does exactly that, over the compiler's own corpus.
This renders ONE object. Merged, multi-binary reporting is report.nu's job; it cannot answer per-block questions like "blocks executed" and does not pretend to.
@ gcovtext_render GcovObj o i src → StringRender one source file's annotated listing. A file whose text cannot be read still gets a listing: the counts are the part that matters, and a missing source is not a reason to withhold them.
@ gcovtext_pct i part i whole → igcov's percentage: truncating, except that anything above zero but below one percent reports as 1 rather than rounding away to nothing.
nurl-cov/model.nu — one coverage picture from many test binaries.
A suite is not one program. Each test is built and run on its own, so each leaves its own pair of coverage graphs, and each sees only the part of the package it exercises. The number a person wants is the union: a line covered by the parser test and the writer test is covered, and one covered by neither is the finding.
Merging is addition, on three axes:
traffic of every test,
notes fix, which every binary built from the same source agrees on,
A file no test ever compiled in is simply absent, and absent is not the same as uncovered: this model reports what the tests built. runner.nu is what makes sure they build everything the package ships.
: CovFn: CovFn {
String name
i line
i called // entries, summed over every object
}
: i CBR_W 3Branch table stride.
: i CBR_LINE 0: i CBR_IDX 1 // ordinal within the line, as the notes numbered it: i CBR_COUNT 2: CovFile: CovFile {
String path
( Vec i ) exists // indexed by line number: 1 when the line has code
( Vec i ) count // indexed by line number: executions
( Vec i ) branches // stride CBR_W
( Vec CovFn ) funcs
}
: Cov: Cov {
( Vec CovFile ) files
i objects // how many coverage objects were folded in
}
@ cov_objects Cov c → iHow many coverage objects were folded in.
: CovStat: CovStat {
i lines_found
i lines_hit
i branches_found
i branches_hit
i funcs_found
i funcs_hit
}
@ cov_new → Cov@ cov_free sink Cov c → vThe files (and everything they hold) are dropped with their Vec. Let go of c now rather than at the end of its owner's scope.
@ cov_file_count Cov c → i@ cov_file_path Cov c i idx → s@ cov_file_idx inout Cov c s path → iThe row for path, created when this is the first object to mention it.
@ cov_add_object inout Cov c GcovObj o → v@ cov_keep_only inout Cov c ( Vec String ) prefixes → v@ cov_sort inout Cov c → vOrder the report the way a person reads it: by path.
@ cov_file_stat Cov c i idx → CovStat@ cov_total Cov c → CovStat@ cov_max_line Cov c i idx → i@ cov_line_exists Cov c i idx i line → b@ cov_line_count Cov c i idx i line → i@ cov_branch_rows Cov c i idx → i@ cov_branch_field Cov c i idx i row i field → i@ cov_fn_rows Cov c i idx → i@ cov_fn_name Cov c i idx i row → s@ cov_fn_line Cov c i idx i row → i@ cov_fn_called Cov c i idx i row → i@ cov_pct_tenths i hit i found → iPercentages in tenths, so a report prints one decimal without floating point. An empty denominator is 100%: there is nothing left uncovered.
nurl-cov/runner.nu — build the suite instrumented, run it, collect the graphs.
A NURL package's tests are programs: one .nu per file under tests/, each importing the package's own modules and exiting non-zero on failure. Measuring them means building each one again with the compiler's coverage instrumentation, running it, and reading what it leaves behind.
Two flags make the difference between a report and a flattering one:
-O0 the optimiser is free to fold, duplicate and reorder blocks, and a line's count stops being a line's count. --no-dce dead-code elimination removes functions nothing calls — which is EXACTLY the code a coverage report exists to find. Without it the report cannot see what it is for: on a small sample here that one flag moved the number from 88.9% to an honest 72.7%.
The build driver is resolved the way nurlpkg test resolves it, so a checkout and an installed toolchain both work without configuration.
: RunOne: RunOne {
String name
b built
b ran
i code
String detail // compiler or runtime output when something went wrong
}
: RunResult: RunResult {
( Vec RunOne ) tests
i built
i failed // tests that exited non-zero
i broken // tests that would not build or would not run
}
@ runresult_free sink RunResult r → vA plain value: the tests (names and details) are dropped with their Vec. Let go of r now rather than at the end of its owner's scope.
@ runner_driver → StringThe build driver, in the order nurlpkg test looks for one: an explicit override, then a checkout you are standing in, then the installed tool.
@ runner_find_tests s dir → ( Vec String )Every .nu directly under dir, in name order so a run is repeatable.
@ runner_one String driver String testpath String stem s workdir → RunOneBuild and run one test. The binary and its coverage graphs go to workdir; the compiler writes <stem>.gcno there, and the program writes <stem>.gcda beside it on exit — both paths are absolute in the generated module, so it does not matter where the program is started.
@ runner_run_all String driver ( Vec String ) tests s testdir s workdir → RunResult@ runner_clean s dir → vStart from a clean slate.
A .gcda ACCUMULATES: running an instrumented program again adds to the counters already there. That is the right behaviour for a program you are exercising by hand, and the wrong one for a suite you are measuring — the second nurl-cov run would report the first run's traffic too, and a .gcno left over from a test that has since been deleted would keep contributing coverage for code nothing tests any more. Both are silent, and both flatter.
@ runner_objects s dir → ( Vec String )Every coverage object in dir: a .gcno the compiler wrote, paired with the .gcda its program left — or not, when the program never ran.
@ runner_data_path s notes → Stringnurl-cov/lcov.nu — the tracefile everything else already reads.
LCOV's .info format is the lingua franca of coverage: genhtml renders it, Codecov and Coveralls ingest it, and editors draw the gutter from it. Emitting it is what lets a NURL package sit in a CI pipeline that was never taught anything about NURL.
SF:<path> one section per source file FN:<line>,<name> a function and where it starts FNDA:<count>,<name> how often it was called FNF/FNH functions found / hit BRDA:<line>,0,<n>,<count> a branch outcome ("-" when never reached) BRF/BRH branches found / hit DA:<line>,<count> a line and its count LF/LH lines found / hit end_of_record
The block field of BRDA is always 0: NURL has no notion a consumer could use it for, and lcov itself only requires the triple to be stable.
@ lcov_render Cov c → Stringnurl-cov/report.nu — the table a person reads, and the lines they act on.
The summary answers "how much of this package do the tests touch"; the uncovered listing answers the question that actually changes what someone does next: WHICH lines. A percentage alone has never told anybody where to write a test, so --uncovered prints the line ranges, collapsed, in the order they appear in the file.
Colour is used only to rank: red under the floor, yellow near it, green above. It is switched off when stdout is not a terminal, and by $NO_COLOR, so redirecting the report into a file gives plain text.
: i REP_NAME_W 44: RepStyle: RepStyle {
b colour
i warn_tenths // below this, a file is flagged
i floor_tenths // below this, a file is a failure
}
@ report_style b colour i floor_tenths → RepStyle@ report_colour_wanted → bColour is a courtesy to a human at a terminal, never something a pipe has to strip back out.
@ report_display s path s root → StringCoverage graphs name their sources absolutely, because the compiler wrote them that way. A report read next to the code wants the path the reader would type, so the working directory is taken off the front.
@ report_render Cov c RepStyle st s root → String@ report_uncovered Cov c s root → StringThe lines nothing ran, collapsed into ranges. This is the part of a coverage report that is actionable: it names the work.
nurl-cov/gcov.nu — read the compiler's coverage graphs, in pure NURL.
nurlc --coverage=PREFIX asks LLVM's GCOV pass to write two files:
PREFIX.gcno the NOTES: one control-flow graph per function, and the source lines each basic block came from. Written once, at compile time. PREFIX.gcda the DATA: one counter per instrumented arc, written when the instrumented program exits. Re-running the program accumulates into the same file.
Neither file holds a per-line count. The notes hold a spanning tree over each function's arcs, and only the arcs OUTSIDE that tree are counted — that is the whole point of the encoding, and it is why a coverage reader has real work to do. This module does that work:
Turning that into per-line numbers is model.nu's job; rendering it the way gcov does is gcovtext.nu's.
Format: the version word is "408*" — GCC 4.8's layout, which is what LLVM's GCOV pass emits and what this reader implements. A file that announces a different version is rejected rather than guessed at.
Memory model: gcov_read returns a GcovObj handle — every copy is the same object and the last owner releases it (gcov_free is an optional early release). Every table inside it is flat — parallel ( Vec i ) columns indexed by function/block/arc number — so nothing owns anything twice.
: i GCOV_TAG_FUNCTION 0x01000000Record tags, as they appear in both files.
: i GCOV_TAG_BLOCKS 0x01410000: i GCOV_TAG_ARCS 0x01430000: i GCOV_TAG_LINES 0x01450000: i GCOV_TAG_COUNTER 0x01a10000: i GCOV_TAG_PROGRAM 0xa3000000: i GCOV_ARC_ON_TREE 1Arc flags.
: i GCOV_ARC_FAKE 2: i GFN_W 13Function table stride. One row per function.
: i GFN_IDENT 0: i GFN_LCS 11 // line-number checksum, as the notes recorded it: i GFN_CCS 12 // control-flow checksum: i GFN_SRC 1 // index into files``: i GFN_LINE 2: i GFN_NBLOCK 3: i GFN_BLK_OFF 4 // first row in blk_count``: i GFN_ARC_OFF 5 // first row in arcs (already multiplied by GARC_W): i GFN_ARC_N 6: i GFN_BL_OFF 7 // first row in blines (already multiplied by GBL_W): i GFN_BL_N 8: i GFN_CTR_OFF 9 // first slot in counters``: i GFN_CTR_N 10: i GARC_W 5Arc table stride.
: i GARC_SRC 0: i GARC_DST 1: i GARC_FLAGS 2: i GARC_COUNT 3: i GARC_VALID 4: i GBL_W 3Block-line table stride: which source line a block covers.
: i GBL_BLOCK 0: i GBL_SRC 1: i GBL_LINE 2: i GCOV_MAX_LINE 16777216A line number no source file reaches. The per-line tables are indexed BY line number, so an untrusted number decides how much memory this reader asks for: one flipped byte in a notes file named line 1970155382, and a dense table that long is tens of gigabytes and a hang. The largest file in this project is 33795 lines. Sixteen million is a ceiling no real source reaches and every corrupt one blows straight through.
: GcovObjImpl: GcovObjImpl {
String notes // path of the .gcno that was read
String data // path of the .gcda, or empty when none was found
i stamp
i runs // how many times the instrumented program has exited
b has_data // false → the program never ran; every counter is 0
( Vec String ) files // source files this object mentions
( Vec i ) fns // stride GFN_W
( Vec String ) fn_names
( Vec i ) blk_count // solved execution count, per block, per function
( Vec i ) arcs // stride GARC_W
( Vec i ) blines // stride GBL_W
( Vec i ) counters // raw arc counters from the .gcda
// Edge index: for every block, the arcs entering and leaving it, in
// the order the notes stored them. Without it, finding a block's
// arcs means scanning its function's whole arc table, and a large
// module turns into an O(n^2) walk.
( Vec i ) pred_head // per block slot: first arc slot + 1
( Vec i ) pred_next // per arc slot: next arc slot + 1
( Vec i ) pred_tail
( Vec i ) succ_head
( Vec i ) succ_next
( Vec i ) succ_tail
}
: GcovObj { ( Arc GcovObjImpl ) a }A GcovObj is a handle on its state in an Arc (stdlib/std/arc.nu): every copy is the same state, and the last owner releases it.
@ GcovObj_share GcovObj h → GcovObj@ GcovObj_drop sink GcovObj h → v: | GcovErr: | GcovErr {
GcovNoNotes // the .gcno could not be read
GcovBadMagic // not a coverage file at all
GcovBadVersion // a GCOV layout this reader does not implement
GcovTruncated // a record runs past the end of the file
GcovStampMismatch // .gcda belongs to a different build than the .gcno
GcovChecksumMismatch // a function's own checksums disagree across the pair
GcovBadLine // a line number no source file could have
GcovBadBlock // a record names a block the function does not have
}
@ gcov_err_name GcovErr e → s@ gcov_fn_count GcovObj o__h → i@ gcov_fn_name GcovObj o__h i fi → s@ gcov_fn_src GcovObj o__h i fi → i@ gcov_fn_line GcovObj o__h i fi → i@ gcov_fn_nblocks GcovObj o__h i fi → i@ gcov_fn_blk_off GcovObj o__h i fi → iWhere this function's blocks start in the object-wide block table. An index over all blocks needs one flat numbering, and this is it.
@ gcov_total_blocks GcovObj o__h → i@ gcov_total_arcs GcovObj o__h → i@ gcov_block_count GcovObj o__h i fi i blk → i@ gcov_file_count GcovObj o__h → i@ gcov_runs GcovObj o__h → i@ gcov_notes_path GcovObj o__h → s@ gcov_data_path GcovObj o__h → s@ gcov_file_path GcovObj o__h i idx → s@ gcov_fn_bl_first GcovObj o__h i fi → iThe block-line rows of one function, as a half-open range over blines.
@ gcov_fn_bl_end GcovObj o__h i fi → i@ gcov_bl_block GcovObj o__h i row → i@ gcov_bl_src GcovObj o__h i row → i@ gcov_bl_line GcovObj o__h i row → i@ gcov_fn_arc_first GcovObj o__h i fi → i@ gcov_fn_arc_end GcovObj o__h i fi → i@ gcov_arc_src GcovObj o__h i ai → i@ gcov_arc_dst GcovObj o__h i ai → i@ gcov_arc_flags GcovObj o__h i ai → i@ gcov_arc_count GcovObj o__h i ai → i@ gcov_arc_is_branch GcovObj o__h i ai → bAn arc the instrumenter treated as a real branch. Fake arcs (the ones LLVM adds for abnormal exits) are not branches a test can take.
@ gcov_edge_first GcovObj o__h i fi i blk i side → iThe first arc entering (side 0) or leaving (side 1) a block, as a chain cursor: non-zero is an arc slot plus one, zero is the end.
@ gcov_edge_next GcovObj o__h i cursor i side → i@ gcov_edge_arc i cursor → iThe arc a cursor points at, as an index into the arc table.
: i GST_W 6Stack frame layout for the walk below. An explicit stack, not recursion: a deeply nested function would otherwise decide how much stack a coverage report needs.
The frame carries a CURSOR into the block's arc chain rather than a count of how many arcs it has consumed. Counting means re-walking the chain to find the next one, which is quadratic in a block's degree — invisible on ordinary code, and a hang on a graph where one block has thousands of arcs pointing at it.
: i GST_BLK 0: i GST_PRED 1 // arc index we arrived by, -1 at a root: i GST_INDST 2 // did we arrive along that arc's direction?: i GST_SIDE 3 // -1 not started, 0 walking the arcs in, 1 the arcs out: i GST_CUR 4 // chain cursor, 0 when this side is exhausted: i GST_EXCESS 5@ gcov_free sink GcovObj o → vLet go of o now rather than at the end of its owner's scope.
@ gcov_read s notes_path s data_path → !GcovObj GcovErrRead one coverage object: the notes, and the data if it is there.
A missing .gcda is NOT an error — it is the answer "this program was built but never run", and a coverage report has to be able to say that. Every counter then reads zero, which is exactly what it means.
nurl-cov — test-coverage mapper for NURL.
nurl-cov run build tests/ instrumented, run, report nurl-cov report <dir> report on coverage graphs already there nurl-cov gcov <dir|file.gcno> gcov-style annotated source
Options that shape the output apply to run and report alike: --uncovered list the line ranges nothing executed --lcov FILE write an LCOV tracefile --html FILE write one self-contained HTML report --json FILE write machine-readable JSON --fail-under PCT exit non-zero below this line coverage --include PREFIX keep only files under PREFIX (repeatable) --all keep every file, the stdlib included
nurl-cov run --fail-under 80 --lcov coverage.info
Exit codes: 0 all good · 1 below the floor, or a test failed · 2 usage or a broken build. A gate that cannot tell "untested" from "failed to build" is not a gate, so those are different codes and different words.
: s NURLCOV_VERSION ( pkg_version )@ main → inurl-cov/lines.nu — from basic blocks to "this line ran N times".
A line is not a block, and the answer is not "add up the blocks that touch it". A condition and the two arms it guards can all carry the same source line; adding them reports the line running twice as often as the function was entered. The rule that IS correct — and the one gcov itself implements — measures flow instead of blocks:
the count of a line is the traffic ENTERING that line's blocks from outside them, plus the traffic that goes round in circles INSIDE them.
The first part is a sum over incoming arcs whose source is not itself on the line (the entry block has no predecessors, so its outgoing arcs stand in). The second part is what makes a one-line loop report the number of iterations rather than the single entry into it; finding it is cycle cancelling over the line's own sub-graph, repeatedly draining the cheapest arc of some cycle until no cycle is left.
Both halves are needed. Without the first, a loop header reports its back-edge only; without the second, it reports the one entry.
The same table drives the annotated listing and the merged report, so there is exactly one implementation of the rule in this package.
: i LOC_W 3Occurrence table stride: one row each time a block names a line.
: i LOC_LINE 0: i LOC_FN 1: i LOC_BLK 2: i LBR_W 3Branch table stride.
: i LBR_LINE 0: i LBR_COUNT 1 // this arc's traffic: i LBR_TOTAL 2 // traffic over all arcs leaving the same block: i LFN_W 2Function-start table stride.
: i LFN_LINE 0: i LFN_FN 1: LineTabImpl: LineTabImpl {
i src // which of the object's source files this describes
i maxline
( Vec i ) exists // per line: 1 when some block names it
( Vec i ) count // per line: executions
( Vec i ) br // stride LBR_W, in gcov's print order
( Vec i ) fnrow // stride LFN_W: functions starting on a line
( Vec i ) occ // stride LOC_W
( Vec i ) occ_next // next occurrence on the same line, + 1
( Vec i ) head // per line: first occurrence + 1
( Vec i ) tail // per line: last occurrence + 1
}
: LineTab { ( Arc LineTabImpl ) a }A LineTab is a handle on its state in an Arc (stdlib/std/arc.nu): every copy is the same state, and the last owner releases it.
@ LineTab_share LineTab h → LineTab@ LineTab_drop sink LineTab h → v@ linetab_maxline LineTab h → iThe table's columns, read in place: a column is never handed out as a Vec, which out of a shared table would be a copy of it per call.
@ linetab_lines LineTab h → iPer-line columns, indexed by line number; 0 past the end.
@ linetab_exists_at LineTab h i line → i@ linetab_count_at LineTab h i line → i@ linetab_br_rows LineTab h → iBranch rows (stride LBR_W) and function-start rows (stride LFN_W).
@ linetab_br_at LineTab h i row i field → i@ linetab_fn_rows LineTab h → i@ linetab_fn_at LineTab h i row i field → i@ linetab_free sink LineTab t → vLet go of t now rather than at the end of its owner's scope.
@ lines_build GcovObj o i src → LineTabnurl-cov/jsonout.nu — the machine-readable shape of the same numbers.
For a CI step that wants to compare two runs, post a comment, or gate on something more specific than one percentage. The line map is an object keyed by line number, so a consumer can ask about a line directly instead of scanning an array.
{ "objects": 7, "totals": { "lines_found": …, "lines_hit": …, … }, "files": [ { "path": "src/x.nu", "lines_found": …, "lines_hit": …, "lines": { "12": 4, "13": 0 }, "branches": [ { "line": 12, "index": 0, "count": 4 } ], "functions": [ { "name": "f", "line": 3, "called": 4 } ] } ] }
@ json_render Cov c → Stringnurl-cov/html.nu — one self-contained page you can send to someone.
A coverage report is read by opening it, scrolling to the red, and writing a test. That wants the source next to the counts, not a percentage in a table — so this emits the summary AND every file's text, line by line, with its count in the gutter and uncovered lines marked.
One file, no assets, no JavaScript beyond a details/summary toggle the browser implements itself. It opens from a file:// URL, survives being mailed around, and can be published as a CI artifact without a server.
@ html_render Cov c i floor_tenths s title s root → String