ExtendedLocalCoverage coverage report

Total Coverage
98.88%
176 of 178 lines
Lines Covered
176
Lines Uncovered
2
Tracked Files
4

📋 Files Overview

FileTotal LinesCoveredMissedCoverageMissing Lines
src/ExtendedLocalCoverage.jl55550100.0%
src/html_report.jl112110298.21%303–304
ext/JuliaSyntaxHighlightingExt.jl550100.0%
ext/StyledStringsExt.jl660100.0%

📁 File Coverage Details

src/ExtendedLocalCoverage.jl
100.0%📊 55/55 lines❌ 0 missed
1
module ExtendedLocalCoverage
2
3
using LocalCoverage:
4
LocalCoverage,
5
write_lcov_to_xml,
6
pkgdir,
7
eval_coverage_metrics,
8
PackageCoverage,
9
FileCoverageSummary,
10
format_gaps
11
using HypertextTemplates: HypertextTemplates, @component, @deftag, @render, @text, SafeString
12
using HypertextTemplates.Elements:
13
Elements,
14
@a,
15
@body,
16
@div,
17
@footer,
18
@h1,
19
@h2,
20
@head,
21
@header,
22
@html,
23
@meta,
24
@span,
25
@strong,
26
@style,
27
@table,
28
@tbody,
29
@td,
30
@th,
31
@thead,
32
@title,
33
@tr
34
using Revise: Revise, parse_pkg_files
35
using TOML: TOML, tryparsefile
36
using CoverageTools: CoverageTools, LCOV
37
import Pkg
38
39
export generate_package_coverage, generate_html_report
40
41
const StyledStringsLoaded = Ref(false)
42
const JuliaSyntaxHighlightingLoaded = Ref(false)
43
44
# Native Julia HTML report generation (without Python dependencies)
45
include("html_report.jl")
46
47
function extract_package_info(pkg_dir)
48
project_toml = TOML.tryparsefile(joinpath(pkg_dir, "Project.toml"))
49
pkg_name = project_toml["name"]
50
pkg_uuid = project_toml["uuid"] |> Base.UUID
51
pkg_extensions = get(Dict{String,Any}, project_toml, "extensions") |> keys
52
pkg_id = Base.PkgId(pkg_uuid, pkg_name)
53
return (; pkg_name, pkg_uuid, pkg_id, pkg_extensions)
54
end
55
56
function extract_included_files(pkg_id::Base.PkgId)
57
# We always import the package in Main to make sure the Revise can find the source files
58
Base.eval(Main, :(import $(Symbol(pkg_id.name))))
59
pkgfiles = parse_pkg_files(pkg_id)
60
return unique(pkgfiles.info.files)
61
end
62
63
"""
64
generate_package_coverage(pkg = nothing; kwargs...)
65
66
Generate a summary of coverage results for package `pkg`.
67
68
If no `pkg` is supplied, the method operates in the currently active package.
69
This acts similary to (and based on) the `generate_coverage` function from [LocalCoverage.jl](https://github.com/JuliaCI/LocalCoverage.jl), but providing two main differences:
70
- It automatically extracts the list of files included by the package using `Revise.parse_pkg_files`.
71
- It allows to generate an HTML report (does so by default) using the `pycobertura` Python package which is installed by default via CondaPkg.
72
- In contrast, the HTML report from `LocalCoverage.jl` relies on lcov being already available on your system and does not work on Windows machines.
73
74
# Keyword arguments (and their defaults)
75
76
- `use_existing_lcov = false` if true, the coverage is assumed to be already computed and available in `coverage/lcov.info` within the package directory. If false, the coverage is generated from scratch calling `LocalCoverage.generate_coverage`.
77
78
- `run_test = true` this is forwarded to `LocalCoverage.generate_coverage` and determines whether tests are executed. When `false`, test execution step is skipped allowing an easier use in combination with other test packages.
79
80
- `test_args = [""]` this is forwarded to `LocalCoverage.generate_coverage` and is there passed on to `Pkg.test`.
81
82
- `exclude = []` is used to specify string or regexes that are used to filter out some of the files in the list of package includes. The exclusion is done by removing from the list of files all files for which `occursin(needle, filename)` returns `true`, where `needle` is any element of `exclude`.
83
84
- `html_name = "index.html"` is the name of the HTML file to be generated. If nothing is provided, no HTML report is generated. The report is always generated in the `coverage` subdirectory of the target package directory.
85
86
- `cobertura_name = "cobertura-coverage.xml"` is the name of the cobertura XML file to be generated. If nothing is provided both to this kwarg and to `html_name`, no cobertura XML file is generated. The file is always generated in the `coverage` subdirectory of the target package directory.
87
88
- `print_to_stdout = true` determines whether the coverage summary is printed to the standard output.
89
90
- `extensions = true` when `true`, also tries to add to the coverage files in the `ext` directory that match an extension name specified in the `Project.toml` file.
91
92
- `lines_function` use to customize how the HTML report is generated. Is directly forwarded to `generate_html_report` and can only be provided if the `html_name` kwarg is also provided.
93
94
- `html_function` use to customize how the HTML report is generated. Is directly forwarded to `generate_html_report` and can only be provided if the `html_name` kwarg is also provided.
95
96
# Return values
97
98
The function returns a named tuple with the following fields:
99
100
- `cov` the coverage summary as returned by `LocalCoverage.generate_coverage`.
101
- `cobertura_file` the full path to the cobertura XML file, if any was generated.
102
- `html_file` the full path to the HTML file, if any was generated.
103
"""
104
function generate_package_coverage(
105
pkg = nothing;
106
use_existing_lcov = false,
107
run_test = true,
108
test_args = [""],
109
exclude = [],
110
html_name = "index.html",
111
cobertura_name = "cobertura-coverage.xml",
112
print_to_stdout = true,
113
extensions = true,
114
lines_function = nothing,
115
html_function = nothing,
116
)
117
pkg_dir = pkgdir(pkg)
118
(; pkg_name, pkg_id, pkg_extensions) = extract_package_info(pkg_dir)
119
coverage_dir = joinpath(pkg_dir, "coverage")
120
lcov_file = joinpath(coverage_dir, "lcov.info")
121
# We do some input checks
122
isnothing(html_name) && (!isnothing(html_function) || !isnothing(lines_function)) && throw(ArgumentError("Cannot provide `html_function` or `lines_function` when `html_name` is `nothing`."))
123
# Generate the coverage
124
cov =
125
if use_existing_lcov
126
coverage = LCOV.readfile(lcov_file)
127
eval_coverage_metrics(coverage, pkg_dir)
128
else
129
file_list = map(extract_included_files(pkg_id)) do relative_path
130
abspath(pkg_dir, relative_path)
131
end
132
extensions && maybe_add_extensions!(file_list, pkg_extensions, pkg_dir)
133
filter!(file_list) do filename
134
for needle in exclude
135
occursin(needle, filename) && return false
136
end
137
return true
138
end
139
try
140
LocalCoverage.generate_coverage(
141
pkg;
142
run_test,
143
test_args,
144
folder_list = [],
145
file_list,
146
)
147
catch e
148
# We used to do this for pretty tables error. Now still kept for a while to test
149
rethrow()
150
end
151
end
152
if print_to_stdout
153
show(IOContext(stdout, :print_gaps => true), cov)
154
end
155
cobertura_file = if isnothing(cobertura_name) && isnothing(html_name)
156
nothing
157
else
158
joinpath(coverage_dir, @something(cobertura_name, "cobertura-coverage.xml"))
159
end
160
html_file = isnothing(html_name) ? nothing : joinpath(coverage_dir, html_name)
161
if !isnothing(cobertura_file)
162
write_lcov_to_xml(cobertura_file, lcov_file)
163
end
164
# Create the cobertura html file with source code
165
if !isnothing(html_file)
166
generate_html_report(
167
lcov_file,
168
html_file;
169
title = pkg_name * " coverage report",
170
pkg_dir,
171
lines_function,
172
html_function,
173
)
174
end
175
return (; cov, cobertura_file, html_file)
176
end
177
178
function maybe_add_extensions!(files_list, pkg_extensions, pkg_dir)
179
(isnothing(pkg_extensions) || isempty(pkg_extensions)) && return files_list
180
for (path, dir, files) in walkdir(joinpath(pkg_dir, "ext"))
181
for file in files
182
endswith(file, ".jl") || continue
183
noext_name = chopsuffix(file, ".jl")
184
if noext_name in pkg_extensions || basename(path) in pkg_extensions
185
fullpath = joinpath(path, file)
186
push!(files_list, fullpath)
187
end
188
end
189
end
190
unique!(files_list)
191
return nothing
192
end
193
194
end
src/html_report.jl
98.21%📊 110/112 lines❌ 2 missed
1
# The CSS and html structure within this file was mostly generated by Claude and further refined.
2
3
#=
4
modern_css_styles() -> String
5
6
Return modern CSS styling for the coverage report.
7
Fully static, no runtime JavaScript required.
8
=#
9
function modern_css_styles()
10
return read(joinpath(@__DIR__, "style.css"), String)
11
end
12
13
"""
14
highlight_with_show(line::Union{AnnotatedString, SubString{<:AnnotatedString}}) -> String
15
16
Take as input an annotated string containing synthax highlighting and generate an HTML representation of it using the `show` method with `MIME"text/html"()`, relying on `StyledStrings.jl` implementation to create HTML with synthax highlighting.
17
"""
18
function highlight_with_show end
19
20
function calculate_file_stats(file::FileCoverageSummary)
21
total_lines = file.lines_tracked
22
covered_lines = file.lines_hit
23
missed_lines = total_lines - covered_lines
24
coverage_pct = total_lines == 0 ? 100.0 : (covered_lines / total_lines) * 100.0
25
26
return (;
27
total = total_lines,
28
covered = covered_lines,
29
missed = missed_lines,
30
coverage = coverage_pct,
31
missing_str = format_gaps(file),
32
)
33
end
34
35
#=
36
coverage_badge_class(coverage::Float64) -> String
37
38
Return CSS class for coverage badge based on coverage percentage.
39
=#
40
function coverage_badge_class(coverage::Float64)
41
return coverage >= 80 ? "coverage-excellent" :
42
coverage >= 60 ? "coverage-good" :
43
"coverage-poor"
44
end
45
46
@component function file_summary_table_component(; data::PackageCoverage)
47
@div {class = "summary-table-section"} begin
48
@h2 "📋 Files Overview"
49
@table {class = "file-summary-table"} begin
50
@thead begin
51
@tr begin
52
@th "File"
53
@th {class = "stats-cell"} "Total Lines"
54
@th {class = "stats-cell"} "Covered"
55
@th {class = "stats-cell"} "Missed"
56
@th {class = "stats-cell"} "Coverage"
57
@th "Missing Lines"
58
end
59
end
60
@tbody begin
61
for (idx, file) in enumerate(data.files)
62
fname = file.filename
63
stats = calculate_file_stats(file)
64
badge_class = coverage_badge_class(stats.coverage)
65
file_anchor = "file-$idx"
66
67
@tr begin
68
@td begin
69
@a {class = "file-link", href = "#$file_anchor"} $(fname)
70
end
71
@td {class = "stats-cell"} $(stats.total)
72
@td {class = "stats-cell"} $(stats.covered)
73
@td {class = "stats-cell"} $(stats.missed)
74
@td {class = "stats-cell"} begin
75
@span {class = "coverage-badge $badge_class"} "$(round(stats.coverage, digits=2))%"
76
end
77
@td {class = "missing-cell"} $(
78
isempty(stats.missing_str) ? "" : stats.missing_str
79
)
80
end
81
end
82
end
83
end
84
end
85
end
86
@deftag macro file_summary_table_component end
87
88
# Component that renders the summary metrics section
89
@component function summary_section_component(; data::PackageCoverage)
90
lines_covered = data.lines_hit
91
lines_valid = data.lines_tracked
92
coverage_pct = (lines_covered / lines_valid) * 100
93
94
@div {class = "summary"} begin
95
@div {class = "metric"} begin
96
@div {class = "metric-label"} "Total Coverage"
97
@div {class = "metric-value"} "$(round(coverage_pct, digits=2))%"
98
@div {class = "metric-subtext"} "$(lines_covered) of $(lines_valid) lines"
99
end
100
@div {class = "metric"} begin
101
@div {class = "metric-label"} "Lines Covered"
102
@div {class = "metric-value"} "$(lines_covered)"
103
@div {class = "metric-subtext"} ""
104
end
105
@div {class = "metric"} begin
106
@div {class = "metric-label"} "Lines Uncovered"
107
@div {class = "metric-value"} "$(lines_valid - lines_covered)"
108
@div {class = "metric-subtext"} ""
109
end
110
@div {class = "metric"} begin
111
@div {class = "metric-label"} "Tracked Files"
112
@div {class = "metric-value"} "$(length(data.files))"
113
@div {class = "metric-subtext"} ""
114
end
115
end
116
end
117
@deftag macro summary_section_component end
118
119
# Component that renders a single line of source code with coverage highlighting
120
@component function code_line_component(;
121
line_num::Int,
122
content::AbstractString,
123
hits::Union{Int,Nothing},
124
html_function
125
)
126
line_class = if isnothing(hits)
127
"code-line line-neutral"
128
elseif hits > 0
129
"code-line line-covered"
130
else
131
"code-line line-uncovered"
132
end
133
134
# Apply syntax highlighting
135
highlighted_content = html_function(content)
136
137
@div {class = line_class} begin
138
@div {class = "line-number"} $line_num
139
@div {class = "line-content"} @text highlighted_content
140
end
141
end
142
@deftag macro code_line_component end
143
144
# Component that renders a single file card with coverage information and source code
145
@component function file_card_component(;
146
file::FileCoverageSummary,
147
pkg_dir::String,
148
file_index::Int,
149
lines_hits::Vector{Union{Int,Nothing}},
150
lines_function,
151
html_function,
152
)
153
stats = calculate_file_stats(file)
154
source_lines = extract_file_lines(lines_function, file.filename, pkg_dir)
155
156
badge_class = coverage_badge_class(stats.coverage)
157
file_anchor = "file-$file_index"
158
159
@div {class = "file-card", id = file_anchor} begin
160
@div {class = "file-header"} begin
161
@div {class = "file-name"} $(file.filename)
162
@div {class = "file-stats"} begin
163
@span {class = "stat"} begin
164
@span {class = "coverage-badge $badge_class"} "$(round(stats.coverage, digits=2))%"
165
end
166
@span {class = "stat"} begin
167
@span "📊 $(stats.covered)/$(stats.total) lines"
168
end
169
@span {class = "stat"} begin
170
@span "$(stats.missed) missed"
171
end
172
end
173
end
174
175
# Source code section
176
@div {class = "source-code"} begin
177
for (i, line) in enumerate(source_lines)
178
@code_line_component {
179
line_num = i,
180
content = line,
181
hits = i > length(lines_hits) ? nothing : lines_hits[i],
182
html_function
183
}
184
end
185
end
186
187
# Missing lines section
188
if !isempty(stats.missing_str)
189
@div {class = "missing-lines"} begin
190
@strong "Uncovered Lines: "
191
@text stats.missing_str
192
end
193
end
194
end
195
end
196
@deftag macro file_card_component end
197
198
"""
199
generate_html_report(lcov_file, html_file; title = nothing, pkg_dir = nothing)
200
201
Generate a static HTML report from a LCOV file using a native Julia solution.
202
203
The `lcov_file` and `html_file` arguments are the full paths to the LCOV file used as input and of the HTML file to be generated, respectively.
204
205
# Keyword arguments
206
207
- `title = "Package Coverage Report"` is the title used at the top of the HTML report.
208
- `pkg_dir` is the directory of the package being covered. It is used to generate the source code links in the HTML report.
209
- `lines_function` is a function that takes an `IO` stream representing the contents of a file and returns a vector of `AbstractString` objects representing each line of the source file. When not provided, it defaults to [`ExtendedLocalCoverage.default_lines_function`](@ref).
210
- `html_function` is a function that takes a single line of source code as an `AbstractString` and returns an HTML-safe representation of that line, potentially with syntax highlighting. When not provided, it defaults to [`ExtendedLocalCoverage.default_html_function`](@ref).
211
"""
212
function generate_html_report(
213
lcov_file::String,
214
output_file::String;
215
title::String = "Coverage Report",
216
pkg_dir::String,
217
lines_function = nothing,
218
html_function = nothing,
219
)
220
# Eventually set defaults for lines and html functions depending on loaded extensions
221
lines_function = @something lines_function default_lines_function()
222
html_function = @something html_function default_html_function(lines_function)
223
# Parse the LCOV file
224
raw_coverage = LCOV.readfile(lcov_file)
225
# Make the package directory absolute
226
pkg_dir = abspath(pkg_dir)
227
# We make the filepaths absolute
228
foreach(raw_coverage) do fc
229
fc.filename = abspath(pkg_dir, fc.filename)
230
end
231
data = eval_coverage_metrics(raw_coverage, pkg_dir)
232
233
# Write to file
234
open(output_file, "w") do io
235
# We disable debug mode which is automatically enabled in HypertextTemplates.jl when Revise is loaded. This is a hack as mentioned in https://github.com/MichaelHatherly/HypertextTemplates.jl/issues/36#issuecomment-3004032438
236
ctx = IOContext(io, HypertextTemplates._include_data_htloc() => false)
237
@render ctx begin
238
@html {lang = "en"} begin
239
@head begin
240
@meta {charset = "UTF-8"}
241
@meta {
242
name = "viewport",
243
content = "width=device-width, initial-scale=1.0",
244
}
245
@title $title
246
@style @text modern_css_styles()
247
end
248
@body begin
249
@div {class = "container"} begin
250
# Header
251
@header begin
252
@h1 $title
253
end
254
255
# Summary metrics
256
@summary_section_component {data = data}
257
258
# File summary table
259
@file_summary_table_component {data = data}
260
261
# File coverage details
262
@div {class = "files-section"} begin
263
@h2 "📁 File Coverage Details"
264
for (idx, file) in enumerate(data.files)
265
@file_card_component {
266
file = file,
267
pkg_dir = pkg_dir,
268
file_index = idx,
269
lines_hits = raw_coverage[idx].coverage,
270
lines_function,
271
html_function
272
}
273
end
274
end
275
276
# Footer
277
@footer begin
278
"Generated by ExtendedLocalCoverage.jl using HypertextTemplates.jl"
279
end
280
end
281
end
282
end
283
end
284
end
285
end
286
287
"""
288
highlighted_lines(io::IO)
289
290
Extract lines from the given `IO` stream and return them with syntax highlighting using `JuliaSyntaxHighlighting.jl`.
291
292
These lines usually processed with [`ExtendedLocalCoverage.highlight_with_show`](@ref) to get synthax highlighting of julia code in the static HTML report.
293
"""
294
function highlighted_lines end
295
296
"""
297
plain_lines(io::IO)
298
299
Extract lines from the given `IO` stream and return them as plain text without any syntax highlighting.
300
301
This function simply does `collect(eachline(io))` and is the default fallback used as `lines_function` when `JuliaSyntaxHighlighting.jl` is not loaded.
302
"""
303
function plain_lines(io::IO)
304
collect(eachline(io))
305
end
306
function extract_file_lines(lines_function, filepath::String, pkg_dir::String)
307
fullpath = joinpath(pkg_dir, filepath)
308
isfile(fullpath) || throw(ArgumentError("Source file not found: $fullpath"))
309
return open(fullpath, "r") do io
310
lines_function(io)
311
end
312
end
313
314
"""
315
default_html_function(lines_function)
316
317
Extract the default function to be passed as `html_function` argument to `generate_html_report`.
318
319
It takes the `lines_function` as an argument and returns `highlight_with_show` if the `lines_function` is `ExtendedLocalCoverage.highlighted_lines` and simply return the `String` constructor otherwise.
320
"""
321
function default_html_function(lines_function)
322
return StyledStringsLoaded[] && lines_function === highlighted_lines ? highlight_with_show : String
323
end
324
325
"""
326
default_lines_function()
327
Extract the default function to be passed as `lines_function` argument to `generate_html_report`.
328
329
If the `JuliaSyntaxHighlighting` package is loaded, this returns the `ExtendedLocalCoverage.highlighted_lines` function, otherwise it returns the `ExtendedLocalCoverage.plain_lines` function.
330
"""
331
function default_lines_function()
332
return JuliaSyntaxHighlightingLoaded[] ? highlighted_lines : plain_lines
333
end
Uncovered Lines: 303–304
ext/JuliaSyntaxHighlightingExt.jl
100.0%📊 5/5 lines❌ 0 missed
1
module JuliaSyntaxHighlightingExt
2
using JuliaSyntaxHighlighting: JuliaSyntaxHighlighting, highlight
3
using ExtendedLocalCoverage: ExtendedLocalCoverage
4
5
6
function ExtendedLocalCoverage.highlighted_lines(io::IO)
7
highlighted = highlight(io)
8
map(eachsplit(highlighted, '\n')) do line
9
endswith(line, '\r') ? line[1:end-1] : line # Deal with Windows line endings
10
end
11
end
12
13
function __init__()
14
# We set this flag to true to indicate that tye JuliaSyntaxHighlighting extension has been loaded
15
ExtendedLocalCoverage.JuliaSyntaxHighlightingLoaded[] = true
16
end
17
end
ext/StyledStringsExt.jl
100.0%📊 6/6 lines❌ 0 missed
1
module StyledStringsExt
2
using StyledStrings: StyledStrings, AnnotatedString
3
using ExtendedLocalCoverage: ExtendedLocalCoverage, StyledStringsLoaded, SafeString
4
5
const VALID_TYPE = Union{AnnotatedString, SubString{<:AnnotatedString}}
6
7
function ExtendedLocalCoverage.highlight_with_show(line::VALID_TYPE)
8
io = IOBuffer()
9
show(io, MIME"text/html"(), line)
10
return String(take!(io)) |> SafeString
11
end
12
13
function __init__()
14
# We set this flag to true to indicate that the StyledStrings extension has been loaded
15
StyledStringsLoaded[] = true
16
end
17
18
end