Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ TOML = "1"
julia = "1.10"

[extras]
Logging = "56ddb016-857b-54e1-b83d-db4d58db5568"
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"

[targets]
test = ["Test"]
test = ["Logging", "Test"]
46 changes: 40 additions & 6 deletions src/CoverageTools.jl
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,29 @@ function merge_coverage_counts(as::Vector{FileCoverage}...)
return source_files
end

# Julia writes a nine-byte count field (or a dash), then a space and the source.
# Inspect only the ASCII prefix: the source portion need not be valid UTF-8.
function parse_cov_count(line::AbstractString)
bytes = codeunits(line)
n = length(bytes)
i = 1
while i <= n && bytes[i] == UInt8(' ')
i += 1
end
i <= 9 && n >= 10 || return missing
if bytes[i] == UInt8('-')
return i == 9 && bytes[10] == UInt8(' ') ? nothing : missing
end
j = i
while j <= n && UInt8('0') <= bytes[j] <= UInt8('9')
j += 1
end
# The count ends in column nine unless it is too wide for that field.
(j <= n && bytes[j] == UInt8(' ') && (i == 1 ? j >= 10 : j == 10)) || return missing
count = tryparse(Int, SubString(line, i, j - 1))
return count === nothing ? missing : count
end

"""
process_cov(filename, folder) -> Vector{CovCount}

Expand All @@ -183,11 +206,21 @@ function process_cov(filename, folder)
for file in files
@info "CoverageTools.process_cov: processing $file"
coverage = CovCount[]
for line in eachline(file)
# Columns 1:9 contain the coverage count
cov_segment = line[1:9]
# If coverage is NA, there will be a dash
push!(coverage, cov_segment[9] == '-' ? nothing : parse(Int, cov_segment))
malformed = 0
first_malformed = 0
for (lineno, line) in enumerate(eachline(file))
count = parse_cov_count(line)
if count === missing
# Keep the line's position so later counts still align.
first_malformed == 0 && (first_malformed = lineno)
malformed += 1
push!(coverage, nothing)
else
push!(coverage, count)
end
end
if malformed > 0
@warn "Ignoring $malformed malformed coverage line(s) in $file, starting at line $first_malformed"
end
full_coverage = merge_coverage_counts(full_coverage, coverage)
end
Expand Down Expand Up @@ -405,7 +438,8 @@ function amend_coverage_from_src!(fc::FileCoverage)

# also check for line markers
if excluded || occursin("COV_EXCL_LINE", line)
coverage[l] = nothing
# A truncated .cov file may end before this source line.
l <= length(coverage) && (coverage[l] = nothing)
end
end
end
Expand Down
90 changes: 90 additions & 0 deletions test/runtests.jl
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using CoverageTools
using Test
import Logging

if Base.VERSION < v"1.1"
isnothing(x) = false
Expand Down Expand Up @@ -404,4 +405,93 @@ end
end
end

# Regressions for short and empty .cov lines (#70 and #92).
@testset "malformed coverage files" begin
@testset "parse_cov_count" begin
parse_cov_count = CoverageTools.parse_cov_count

# Lines as written by Julia itself
@test parse_cov_count(" - # just a comment") === nothing
@test parse_cov_count(" - ") === nothing
@test parse_cov_count(" 0 f(x) = x") === 0
@test parse_cov_count(" 1 f(x) = x") === 1
@test parse_cov_count(" 42 f(x) = x") === 42
@test parse_cov_count("999999999 f(x) = x") === 999999999
# Source code containing spaces, digits, and dashes
@test parse_cov_count(" 3 y = 1 - 2") === 3
# Julia pads the count to nine characters but uses more room for
# counts that do not fit; these must not be cut off
@test parse_cov_count("1234567890 f(x) = x") === 1234567890
@test parse_cov_count("$(typemax(Int)) f(x) = x") === typemax(Int)

# The empty line of issue #92 and the short lines of issue #70
@test parse_cov_count("") === missing
@test parse_cov_count(" ") === missing
@test parse_cov_count(" ") === missing
# A count cut off by a truncated line is unusable since we cannot
# tell how many of its digits are missing
@test parse_cov_count(" 1") === missing
@test parse_cov_count(" 12") === missing
# Other ways in which a corrupted line can look
@test parse_cov_count(" 1x f(x) = x") === missing
@test parse_cov_count("f(x) = x") === missing
# A count too large to be represented as an `Int`
@test parse_cov_count("$(widen(typemax(Int)) + 1) f(x) = x") === missing

# The source portion need not be valid UTF-8.
@test parse_cov_count(String(vcat(codeunits(" 7 s = \"ä"),
UInt8[0xc3]))) === 7
# Invalid UTF-8 within the count itself is malformed, but must not
# throw a `StringIndexError` either
@test parse_cov_count(String(UInt8[0x80, 0x31, 0x20, 0x78])) === missing
@test parse_cov_count(" -") === missing
@test parse_cov_count(" -x") === missing
@test parse_cov_count("- x") === missing
@test parse_cov_count(" 1 x") === missing
@test parse_cov_count(" 1 x") === missing
end

@testset "process_cov" begin
mktempdir() do dir
srcname = joinpath(dir, "malformed.jl")
write(srcname, "f(x) = x + 1\ng(x) = x - 1\nf(1)\ng(1)\n")

# An empty line in the middle of the file, cf. issue #92
covname = srcname * ".1234.cov"
write(covname, " 1 f(x) = x + 1\n\n 1 f(1)\n - g(1)\n")
coverage = @test_logs (:warn, r"1 malformed coverage line.*line 2") match_mode=:any process_cov(srcname, dir)
# The empty line must not shift the counts of the following lines
@test coverage == CoverageTools.CovCount[1, nothing, 1, nothing]

# A file truncated in the middle of a line, cf. issue #70
write(covname, " 1 f(x) = x + 1\n 2 g(x) = x - 1\n ")
coverage = @test_logs (:warn, r"1 malformed coverage line.*line 3") match_mode=:any process_cov(srcname, dir)
@test coverage == CoverageTools.CovCount[1, 2, nothing]

# A well-formed file is merged with a malformed one, filling in
# the counts that got lost
write(joinpath(dir, "malformed.jl.5678.cov"),
" 4 f(x) = x + 1\n 8 g(x) = x - 1\n 1 f(1)\n 1 g(1)\n")
coverage = @test_logs (:warn,) match_mode=:any process_cov(srcname, dir)
@test coverage == CoverageTools.CovCount[5, 10, 1, 1]

# A well-formed file alone must not warn
rm(covname)
coverage = @test_logs min_level=Logging.Warn process_cov(srcname, dir)
@test coverage == CoverageTools.CovCount[4, 8, 1, 1]
end
end

@testset "process_file with fewer coverage lines than source lines" begin
mktempdir() do dir
srcname = joinpath(dir, "truncated.jl")
write(srcname, "f(x) = x + 1\nf(1)\nf(2) # COV_EXCL_LINE\n")
# The coverage file stops before the excluded line
write(srcname * ".1234.cov", " 1 f(x) = x + 1\n 1 f(1)\n")
fc = process_file(srcname, dir)
@test fc.coverage == CoverageTools.CovCount[1, 1]
end
end
end

end # withenv
Loading