Quill: Template Coverage¶
Quill measures which parts of a .quill template your tests actually exercised: which
statements and interpolations ran, and which arms of each branch were taken. This is
branch-aware coverage for templates, the analogue of go tool cover for Go, aggregated
across many renders and exported as a text summary, LCOV, or a highlighted HTML report.
Coverage answers questions a plain render count cannot: whether the @else arm of an @if
was ever taken, or whether a @for loop was ever entered with a non-empty collection. Quill
records exactly that, per template node and aggregated across every render in a test run.
Coverage is opt-in and zero-overhead when disabled: an Environment with no collector pays no per-node cost on the render hot path. The instrumentation records reachability only; it never changes rendered output. A template renders byte-identically with or without coverage enabled. This is the binding invariant the conformance suite enforces.
The companion documents are the Language Reference, the value and semantics rules in Types, and the Architecture document.
1. What coverage measures¶
Coverage is defined over the AST the parser builds (package ast): a uniform Node with a
Kind discriminator, a 1-based Line, a 1-based Col, and a Src naming the template.
Every coverable thing in a template is a region anchored at a node's position.
A region is identified by a stable key:
template-name : line : col : kind
The template-name is the Source name (a path or logical id). line:col is the node's
1-based start position. kind distinguishes the branch role at that position (see below), so
two branch arms that begin at the same line:col (which cannot happen in practice but is
guarded against) never collide. Line coverage is derived: a line is covered when any
region on it is covered.
1.1 Coverable units (statement and output coverage)¶
A unit is a region that is "covered" the moment control reaches it and it is about to do its work. Units answer "did this run at all?". Each is recorded at the node's position when the interpreter dispatches it:
| Construct | Node kind(s) | Covered when |
|---|---|---|
Interpolation {{ expr }} |
KindPrint |
the print is evaluated and emitted |
| Literal text span | KindText, KindVerbatim |
the span is emitted |
@set / @set = capture |
KindSet, KindCapture |
the assignment executes |
@do, @with, @apply |
KindDo, KindWith, KindApply |
the statement executes |
@log <expr> |
KindLog |
the expression evaluates and is logged |
@escape, @sandbox, @cache |
KindEscape, KindSandbox, KindCache |
the region body is entered |
@tab(n) region |
KindTabBlock |
the indented body is entered |
@guard selected body |
KindGuard |
the taken body is entered (see 1.2) |
@include / @embed |
KindInclude, KindEmbed |
the include is resolved and rendered |
@block render site |
KindBlock |
the block's resolved body is rendered |
@macro body |
KindMacro |
the macro is invoked at least once |
Declaration-only heads that emit nothing and take no branch (@extends, @import,
@from, @use, @types, @line, @deprecated) are not counted as units. They have
no runtime reachability to measure, so counting them would only dilute the percentage. @do
is counted because it evaluates an expression for effect, and @log for the same reason: it
evaluates and logs its expression even though it emits no rendered output. A comment
{# ... #} is NOT a coverable unit at all: the lexer consumes it and produces no node, so
there is nothing for the Collector to seed or hit.
1.2 Branch points (branch coverage)¶
A branch region has two or more arms; coverage records which arms were taken across all
renders. An arm is "covered" when control flowed through it at least once. A branch is fully
covered only when every arm has been taken. These are the branch points, keyed by the
kind tag shown:
@if / @elseif / @else (if-then / if-else per clause).
Each KindClause contributes one arm. An if or elseif clause has a then arm (the
condition was truthy and its body ran) and an implicit else arm (the condition was
evaluated false). The final @else clause is a single arm (its body ran). A chain of N
clauses yields N condition-true arms plus one "fell through all" arm. Concretely, for each
condition-bearing clause we record two branch outcomes at the clause position: taken and
not-taken; the terminal @else records one taken outcome at its position.
@for body-executed vs zero-iteration (for-body / for-empty).
Two arms at the KindFor position: looped (the body ran for at least one item across
some render) and empty (the collection drained to zero pairs, so the @else body ran or
nothing ran). A loop that is sometimes non-empty and sometimes empty across renders covers
both arms. A @for ... @else makes the empty arm's body a unit as well.
Postfix if / unless (ternary-then / ternary-else).
The parser desugars {{ x if c }} and {{ x unless c }} into a KindTernary at parse time
(see the Language Reference), so postfix conditionals are measured exactly like an inline
ternary: two arms, then (condition truthy) and else (condition falsy). No separate model
is needed: the desugaring means one implementation covers both surface forms.
Ternary c ? a : b, elvis a ?: b, coalesce a ?? b (ternary-*, elvis-*, coalesce-*).
Each is a two-arm branch at its node position. For the ternary the arms are the then
(Child(1)) and else (Child(2)) sides. For elvis the arms are left-kept (the left
was truthy and returned) and right-used (the fallback was taken). For coalesce the arms
are left-kept (left was non-null) and right-used (left was null, fallback taken).
Short-circuit logical and / or (logical-short / logical-full), optional.
and / or short-circuit (see evalLogical). The two arms are short-circuited (the
right operand was never evaluated) and evaluated-both (the right operand ran). xor
always evaluates both and is not a branch. This is recorded when logical-branch coverage is
enabled; it is off by default because short-circuit arms are noisy for many
templates.
@guard kind("name") present vs absent (guard-present / guard-absent).
Two arms: the guarded body ran (the callable was registered) or the @else body ran (it was
not). Each taken body is also a unit.
Null-safe access a?.b / a?[k] (safe-hit / safe-null), optional.
Two arms: the receiver was non-null and the member was read, or the receiver was null and the
access short-circuited to null. Off by default (same noise rationale as logical).
Every branch point is also a unit: reaching the construct counts the unit, and the arm
outcome is recorded on top. So a never-reached @if shows as an uncovered unit and two
uncovered arms; a reached @if whose @else never fired shows a covered unit with one
covered and one uncovered arm.
1.3 What "covered" means, precisely¶
- A unit at
T:L:Cis covered iff the interpreter dispatched that node at least once across all aggregated renders. - A branch arm at
T:L:C#armis covered iff control flowed through that specific arm at least once. - A line L in template T is covered iff at least one unit on line L is covered. Its hit count is the sum of the unit hit counts on that line.
- A template percentage is
covered-units / total-units; a separate branch percentage iscovered-arms / total-arms. The report shows both.
Counts are monotonic hit counters (how many times a region fired), not just booleans, so the
LCOV DA/BRDA records carry real execution counts and the HTML report can show hot lines.
2. How it works (instrumentation model)¶
2.1 Node positions carry line and column¶
ast.Node carries Line, Col, and Src. The lexer already tracks a 1-based column on
every token; the parser threads the head token's Col into each node it builds, so every
region has an exact line:col anchor. This is a parser-only change: positions are metadata,
never consulted during evaluation, so it cannot affect rendered bytes.
2.2 A Collector, threaded through the interpreter¶
Coverage lives in a new package cover. Its central type is a Collector:
type Collector struct { /* unexported: per-template region tables, hit counters */ }
func NewCollector() *Collector
func (c *Collector) Report() *Report
The internal interpreter (internal/interp) gains one nullable coverage field. When it
is nil, coverage is off and every hook is a single nil-check that the compiler and branch
predictor make free: this is the zero-overhead-when-disabled guarantee. When it is set, the
interpreter calls into it at each coverable point:
execItemrecords the unit for the dispatched node before doing its work.execIfrecords the taken clause's then arm and each evaluated-false clause's not-taken arm.execForrecords for-body when it enters the loop and for-empty when the pair set is empty.evalTernary/evalElvis/evalCoalesce/evalLogicalrecord their arm as they pick a side.execGuard,execBlockSite,callMacro,execInclude/execEmbedrecord their unit / present-absent arm at the point the body or target is chosen.
Two-phase design keeps the model complete even for never-rendered code. Before a render, the
Collector is seeded by a static walk of each template's AST (the same walk shape as
collectUsed in internal/interp/template.go): every coverable node is registered as a region with a
zero hit count. Rendering then only increments. This is why a template line that no test
ever reaches still appears in the report as 0 rather than being silently absent: the
denominator is the whole template, not just what ran. Seeding is idempotent and keyed by
region id, so re-seeding the same template across renders is a no-op.
Seeding boundary (template granularity). Seeding is gated on a template being entered
by a render, not on it being merely referenced. The engine seeds the render root and its
inheritance chain, an @include/@embed target when its statement actually executes, and a
macro home when one of its macros is invoked. A template that is only referenced but never
entered (imported for macros that are never called, or an @include whose statement never
runs because it sits in a never-taken @if arm) is never seeded, so it is absent from
the report rather than shown at 0%.
The unit of seeding depends on why a template was entered, because different entries make different regions reachable:
- Full entry: render root, inheritance target, or an executed
@include/@embed. The template's top-level body is rendered, soCollector.SeedTemplateseeds the whole module: an untaken branch or an unreached statement anywhere in it still reports0. - Macro-home entry: the template is reached only because one of its macros is invoked
via
@import/@from. An import never renders the home's top-level markup, so that markup is unreachable in this context.Collector.SeedMacrotherefore seeds only the invoked macro's subtree, and the top-level statements/text are not seeded: they are absent rather than reported as an uncovered0%gap. Seeding a macro home's whole body would charge the denominator for code the import can never reach and distort the percentage for the common partial-that-also-exports-macros pattern.
The two seeds are independent and idempotent, so a partial that is both imported for a macro
and rendered (as a root or executed @include) gets its whole body seeded by the full
entry, which supersedes the narrower macro-home seed. Within whatever was seeded, an untaken
branch or unreached statement still reports 0; only regions unreachable through the actual
entry fall out. This keeps the denominator to code the render pipeline could reach. A caller
that wants an unexercised partial to count as 0% must seed it explicitly by walking the
reference graph and calling Collector.SeedTemplate on each target. The semantics are pinned
by TestCoverageUnreachedIncludeIsAbsent and TestCoverageMacroHomeTopLevelNotSeeded.
2.3 Aggregation across renders¶
A single Collector is shared across every Render call made through the Environment it is
attached to. Each render unions its hits into the Collector's per-template tables, keyed by
region id. Rendering template page.quill a hundred times with different data accumulates into
one region table for page.quill; the report is the union. Includes, parents, traits, and macro
homes are seeded and recorded under their own template names, so a shared partial's coverage
aggregates across every template that includes it. The Collector is safe for sequential
renders; concurrent renders should each use their own Collector and be merged with
Report.Merge (see 3.4).
3. Go API¶
3.1 Enabling coverage on an Environment¶
Coverage is a construction option, mirroring the existing WithAutoescapeHTML /
WithStrictVariables options:
coll := cover.NewCollector()
env := quill.New(
loader.NewFilesystemLoader("templates"),
quill.WithCoverage(coll),
)
ctx := context.Background()
_, _ = env.Render(ctx, "page.quill", vars) // records into coll
_, _ = env.Render(ctx, "page.quill", other) // unions more hits
report := coll.Report()
WithCoverage(nil) is the same as not passing it: coverage stays off. Without the option the
Environment builds an interpreter whose cov field is nil and no region is ever seeded.
3.2 The Report and writing formats¶
coll.Report() returns an immutable snapshot:
type Report struct { /* per-template regions with hit counts */ }
func (r *Report) Templates() []TemplateCoverage // sorted by name
func (r *Report) Totals() Summary // units%, branches%, lines%
func (r *Report) WriteText(w io.Writer) error // human summary
func (r *Report) WriteLCOV(w io.Writer) error // .info for Codecov/CI
func (r *Report) WriteHTML(w io.Writer) error // highlighted source
type TemplateCoverage struct {
Name string
Units Counts // covered / total
Branches Counts
Lines Counts
Regions []Region // for a per-region breakdown
}
Each writer takes an io.Writer, so a caller streams to a file, a buffer, or stdout. All
three are produced from the same Report, so they never disagree.
3.3 go-test integration pattern¶
The intended usage is: build one Environment with a Collector for a test package, render all your fixtures through it, then assert a threshold and dump a report artifact.
func TestTemplateCoverage(t *testing.T) {
coll := cover.NewCollector()
env := quill.New(loader.NewFilesystemLoader("testdata/templates"),
quill.WithCoverage(coll))
for _, tc := range fixtures { // each renders one template + data
if _, err := env.Render(context.Background(), tc.Template, tc.Vars); err != nil {
t.Fatalf("%s: %v", tc.Template, err)
}
}
report := coll.Report()
// Write an LCOV artifact for CI to ingest.
f, _ := os.Create("coverage.info")
defer f.Close()
_ = report.WriteLCOV(f)
// Fail below a threshold.
if got := report.Totals().Units.Percent(); got < 90.0 {
report.WriteText(os.Stdout)
t.Fatalf("template unit coverage %.1f%% < 90%%", got)
}
}
Counts.Percent() returns 100 * covered / total (and 100 for an empty template, so an
all-text partial never drags the number down). A Report.FailUnder(threshold float64) error
convenience wraps the compare-and-report so a test is one line.
3.4 Merging (parallel tests)¶
For t.Parallel() fixtures, give each goroutine its own Collector and merge the reports:
merged := cover.MergeReports(r1, r2, r3) // unions hit counts by region id
Merging is by region id, so the same template measured in two goroutines combines correctly.
4. Report formats¶
4.1 Text summary¶
A per-template table plus a total line, and (with -v) a per-region breakdown grouped by
function-like scope (the module body, each @block, each @macro). Percentages for units
and branches are shown separately:
Template Units Branches Lines
------------------------ -------------- -------------- --------------
page.quill 42/47 89.4% 11/16 68.8% 20/22 90.9%
partials/nav.quill 8/8 100.0% 2/2 100.0% 5/5 100.0%
------------------------ -------------- -------------- --------------
TOTAL 50/55 90.9% 13/18 72.2% 25/27 92.6%
The verbose region breakdown lists every uncovered region with its line:col, kind, and (for
branches) which arm is missing, so a developer can jump straight to the gap:
page.quill
block "body"
14:3 if-else else arm never taken
22:9 for-empty loop never ran empty
macro "row"
31:5 Print never reached
4.2 LCOV export¶
WriteLCOV emits a standard .info stream that Codecov, genhtml, and CI coverage gates
ingest directly. One SF section per template, DA line-hit records derived from unit hit
counts, and BRDA branch records for every arm:
TN:
SF:page.quill
DA:14,3
DA:15,3
DA:22,0
BRDA:14,0,0,3 # line 14, block 0, branch 0 (then) taken 3x
BRDA:14,0,1,- # line 14, block 0, branch 1 (else) never taken
BRDA:22,1,0,0 # line 22, for block, body arm, 0 hits
BRDA:22,1,1,5 # line 22, for block, empty arm, 5 hits
BRF:4
BRH:3
LF:22
LH:20
end_of_record
BRDA fields are line, block, branch, taken where taken is - for an arm the tool
proved reachable but never taken (distinct from a 0 hit count on a measured arm). The
block id groups the arms of one branch point (one @if chain, one @for, one ternary);
branch numbers the arms within it. LF/LH are line found/hit, BRF/BRH are branch
found/hit: the numbers a CI gate thresholds on.
4.3 HTML report¶
WriteHTML renders a self-contained HTML page (stdlib html/template, no external assets)
showing each template's source with per-line highlighting:
- Each source line is shown with its line number and hit count.
- Covered lines are green, uncovered lines (units that exist but never ran) are red, lines with no coverable unit are neutral.
- Branch markers sit in the gutter next to a branch point: a filled marker when every arm was taken, a half-filled marker when only some arms were (partial branch), an empty marker when the branch was never reached. Hovering a marker names the missing arm(s), e.g. "else arm never taken".
- A per-template header shows the unit / branch / line percentages, and an index page links every template sorted by coverage ascending, so the least-covered template is first.
The HTML is emitted through html/template, so template source shown in the report is
auto-escaped and cannot break the report page.
5. CLI: quill cover¶
quill cover renders one or more template+data cases and writes a coverage report. It is the
command-line front door to the same Collector/Report API.
Single template, text report to stdout:
quill cover -root templates -data data.json page.quill
Choose a format and an output file:
quill cover -root templates -data data.json -format lcov -o coverage.info page.quill
quill cover -root templates -data data.json -format html -o cover.html page.quill
Aggregate many cases from a JSON case file (each case is a template plus its variables), so a single report unions coverage across a whole fixture set:
quill cover -root templates -cases cases.json -format html -o cover.html
The cases.json shape is a list of { "template": name, "data": object }:
[
{ "template": "page.quill", "data": { "user": { "admin": true } } },
{ "template": "page.quill", "data": { "user": { "admin": false } } },
{ "template": "partials/nav.quill","data": { "items": [] } }
]
Rendering both admin states above covers both arms of an @if user.admin; the empty items
list covers the for-empty arm of the nav partial. This is how you drive branch coverage
from the CLI: enumerate the data cases that exercise each arm.
Flags:
-root string template root the loader resolves names under (default ".")
-data string JSON data file for a single-template run ("-" reads stdin)
-cases string JSON file of {template,data} cases; unions coverage across all
-format string report format: text (default), lcov, or html
-o string output file (default stdout)
-fail-under N exit non-zero if unit coverage percent is below N
-threshold N alias for -fail-under
-autoescape off (default) or html; matches the render option so instrumentation
runs under the same strategy the template ships with
-strict strict-undefined handling (default true)
A single template is named as a positional argument alongside -data; -cases
replaces the positional name and data with a JSON list. Supplying both, or
neither, is an error.
-fail-under makes quill cover a CI gate: it renders the cases, writes the report, and
exits 1 when unit coverage is below the threshold, printing the uncovered-region breakdown to
stderr so the failing CI log shows exactly what to cover.
6. Guarantees and non-goals¶
Zero overhead when disabled. No Collector means the interpreter's cov field is nil and
every hook is one nil comparison. No region tables are allocated, no positions are consulted.
An Environment built without WithCoverage renders exactly as it does today.
Output invariance. Instrumentation only reads node positions and increments counters; it never touches the value pipeline or the output sink. The pre-existing conformance suite passes byte-identically with coverage on. This is asserted directly: a conformance variant renders every fixture twice, once with a Collector attached, and diffs the output.
Reachability, not correctness. Coverage says an arm ran, not that it produced the right bytes; that is what golden fixtures are for. High coverage with weak assertions is still weak; coverage tells you where assertions are absent, not where they are wrong.
Not a profiler. Hit counts indicate hot regions but are not timed. Use them to find dead template branches, not to optimize render latency.