diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb65e21..eabd9d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,8 +15,7 @@ jobs: fail-fast: false matrix: version: - - '1.0' - - '1.6' + - 'min' - '1' - 'nightly' os: diff --git a/Project.toml b/Project.toml index 938fbfe..4c4494f 100644 --- a/Project.toml +++ b/Project.toml @@ -1,10 +1,16 @@ name = "CoverageTools" uuid = "c36e975a-824b-4404-a568-ef97ca766997" -authors = ["Iain Dunning ", "contributors"] version = "1.3.2" +authors = ["Iain Dunning ", "contributors"] + +[deps] +JuliaSyntax = "70703baa-626e-46a2-a12c-08ffd08c73b4" +TOML = "fa267f1f-6049-4f14-aa54-33bafae1ed76" [compat] -julia = "1" +JuliaSyntax = "1" +TOML = "1" +julia = "1.10" [extras] Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" diff --git a/src/CoverageTools.jl b/src/CoverageTools.jl index 2071220..bf1923b 100644 --- a/src/CoverageTools.jl +++ b/src/CoverageTools.jl @@ -1,5 +1,8 @@ module CoverageTools +import JuliaSyntax +import TOML + export process_folder, process_file export clean_folder, clean_file export process_cov, amend_coverage_from_src! @@ -12,6 +15,58 @@ export FileCoverage # line (e.g. a comment), but 0 means it could have run but didn't. const CovCount = Union{Nothing,Int} +""" + has_embedded_errors(expr) + +Recursively check if an expression contains any `:error` nodes. +""" +function has_embedded_errors(expr) + expr isa Expr || return false + expr.head === :error && return true + return any(has_embedded_errors, expr.args) +end + +""" + find_error_line(expr) + +Find the line number of the first error in an expression by locating +the LineNumberNode or Expr(:line) that precedes the first :error node. +Returns nothing if no error is found. +""" +function find_error_line(expr, last_line=nothing) + if expr isa LineNumberNode + return expr.line, false + end + + if expr isa Expr + # Handle Expr(:line, ...) nodes emitted by JuliaSyntax + if expr.head === :line && length(expr.args) >= 1 + line_num = expr.args[1] + if line_num isa Integer + return Int(line_num), false + end + end + + if expr.head === :error + # Found an error, return the last seen line number + return last_line, true + end + + current_line = last_line + for arg in expr.args + line_result, found_error = find_error_line(arg, current_line) + if found_error + return line_result, true + end + if line_result !== nothing && !found_error + current_line = line_result + end + end + end + + return nothing, false +end + """ FileCoverage @@ -139,6 +194,87 @@ function process_cov(filename, folder) return full_coverage end +""" + detect_syntax_version(filename::AbstractString) -> VersionNumber + +Detect the appropriate Julia syntax version for parsing a source file by looking +for the nearest project file (Project.toml or JuliaProject.toml) and reading its +syntax version configuration, or by looking for the VERSION file in Julia's own +source tree (for base/ files). + +Defaults to v"1.14" if no specific version is found, as JuliaSyntax generally +maintains backwards compatibility with older syntax. +""" +function detect_syntax_version(filename::AbstractString) + dir = dirname(abspath(filename)) + # Walk up the directory tree looking for project file or VERSION file + while true + # Check for project file first (for packages and stdlib) + # Use Base.locate_project_file to handle both Project.toml and JuliaProject.toml + project_file = Base.locate_project_file(dir) + + if project_file !== nothing && project_file !== true && isfile(project_file) + # Use Base.project_file_load_spec if available (Julia 1.14+) + # This properly handles syntax.julia_version entries + if isdefined(Base, :project_file_load_spec) + spec = Base.project_file_load_spec(project_file, "") + return spec.julia_syntax_version + else + # Fallback for older Julia versions - only check syntax.julia_version + project = TOML.tryparsefile(project_file) + if !(project isa Base.TOML.ParserError) + syntax_table = get(project, "syntax", nothing) + if syntax_table !== nothing + jv = get(syntax_table, "julia_version", nothing) + if jv !== nothing + try + return VersionNumber(jv) + catch e + e isa ArgumentError || rethrow() + end + end + end + end + end + end + + # Check for VERSION file (for Julia's own base/ source without project file) + version_file = joinpath(dir, "VERSION") + if isfile(version_file) + version_str = nothing + try + version_str = strip(read(version_file, String)) + catch e + e isa SystemError || rethrow() + # If we can't read VERSION, continue searching + end + if version_str !== nothing + # Parse version string like "1.14.0-DEV" + m = match(r"^(\d+)\.(\d+)", version_str) + if m !== nothing + try + major = parse(Int, m.captures[1]) + minor = parse(Int, m.captures[2]) + return VersionNumber(major, minor) + catch e + e isa ArgumentError || rethrow() + # If we can't parse VERSION, continue searching + end + end + end + end + + parent = dirname(dir) + if parent == dir # reached root + break + end + dir = parent + end + # Default to v"1.14" - JuliaSyntax maintains backwards compatibility + # so using a recent version generally works for older code + return v"1.14" +end + """ amend_coverage_from_src!(coverage::Vector{CovCount}, srcname) amend_coverage_from_src!(fc::FileCoverage) @@ -168,6 +304,12 @@ function amend_coverage_from_src!(fc::FileCoverage) push!(linepos, position(io)) end pos = 1 + # Detect the appropriate syntax version for this package + syntax_version = detect_syntax_version(fc.filename) + # When parsing, use the detected syntax version to ensure we can parse + # all syntax features available in that version, even when running under + # a different Julia version (e.g., parsing Julia 1.14 code with Julia 1.11). + # JuliaSyntax provides version-aware parsing for any Julia version. while pos <= length(content) # We now want to convert the one-based offset pos into a line # number, by looking it up in linepos. But linepos[i] contains the @@ -177,13 +319,63 @@ function amend_coverage_from_src!(fc::FileCoverage) # that later on to shift other one-based line numbers, we must # subtract 1 from the offset to make it zero-based. lineoffset = searchsortedlast(linepos, pos - 1) - 1 + # 1-based line number for error reporting (lineoffset is 0-based) + current_line = lineoffset + 1 # now we can parse the next chunk of the input - ast, pos = Meta.parse(content, pos; raise=false) + local ast, newpos + try + ast, newpos = JuliaSyntax.parsestmt(Expr, content, pos; + version=syntax_version, + ignore_errors=true, + ignore_warnings=true) + catch e + if isa(e, JuliaSyntax.ParseError) + throw(Base.Meta.ParseError("parsing error in $(fc.filename):$current_line: $e", e)) + end + rethrow() + end + + # If position didn't advance, we have a malformed token/byte - throw error + if newpos <= pos + throw(Base.Meta.ParseError("parsing error in $(fc.filename):$current_line: parser did not advance", nothing)) + end + pos = newpos + isa(ast, Expr) || continue - if ast.head ∈ (:error, :incomplete) - line = searchsortedlast(linepos, pos - 1) - throw(Base.Meta.ParseError("parsing error in $(fc.filename):$line: $(ast.args[1])")) + # Compute line number based on parse position (compatible with Meta.parse behavior) + error_line_from_pos = searchsortedlast(linepos, pos - 1) + + # For files with only actual parse errors (not end-of-file), we should throw + # But we need to distinguish real errors from benign cases + if ast.head === :error + errmsg = isempty(ast.args) ? "" : string(ast.args[1]) + # Only treat as EOF if we're actually at end of content AND it's an empty error or premature EOF + if pos >= length(content) && (isempty(errmsg) || occursin("premature end of input", errmsg)) + break # Done parsing, no more content + end + # Real parse error - throw it + throw(Base.Meta.ParseError("parsing error in $(fc.filename):$current_line: $errmsg", nothing)) + end + # Check if the AST contains any embedded :error nodes (from ignore_errors=true). + # When we can't locate an explicit line node, fall back to the parse position + # to preserve Meta.parse-style error line reporting across statements. + if has_embedded_errors(ast) + # Try to find the actual line where the error occurred + error_internal_line, found = find_error_line(ast) + if found && error_internal_line !== nothing + # error_internal_line is relative to the parsed content (1-based) + # We need to add lineoffset to get the actual file line + error_line = lineoffset + error_internal_line + throw(Base.Meta.ParseError("parsing error in $(fc.filename):$error_line", nothing)) + else + # Fallback to the line where we started parsing this statement + throw(Base.Meta.ParseError("parsing error in $(fc.filename):$error_line_from_pos", nothing)) + end + end + # Incomplete expressions indicate truncated/malformed code - treat as parse error + if ast.head === :incomplete + throw(Base.Meta.ParseError("parsing error in $(fc.filename):$current_line: incomplete expression", nothing)) end flines = function_body_lines(ast, coverage, lineoffset) if !isempty(flines) diff --git a/test/BustedPackage/src/error_after_first.jl b/test/BustedPackage/src/error_after_first.jl new file mode 100644 index 0000000..8addcad --- /dev/null +++ b/test/BustedPackage/src/error_after_first.jl @@ -0,0 +1,6 @@ +# Test file with error after first statement +x = 1 + +for i [1,2,3] + println(i) +end diff --git a/test/BustedPackage/src/error_eof.jl b/test/BustedPackage/src/error_eof.jl new file mode 100644 index 0000000..3e78ab7 --- /dev/null +++ b/test/BustedPackage/src/error_eof.jl @@ -0,0 +1,3 @@ +# Test file with unexpected EOF +function foo() + x = 1 diff --git a/test/BustedPackage/src/error_middle.jl b/test/BustedPackage/src/error_middle.jl new file mode 100644 index 0000000..d5413ae --- /dev/null +++ b/test/BustedPackage/src/error_middle.jl @@ -0,0 +1,14 @@ +# Test file with error in middle +function works() + return 1 +end + +function broken() + for x [1,2,3] + println(x) + end +end + +function also_works() + return 2 +end diff --git a/test/BustedPackage/src/error_start.jl b/test/BustedPackage/src/error_start.jl new file mode 100644 index 0000000..16eeeea --- /dev/null +++ b/test/BustedPackage/src/error_start.jl @@ -0,0 +1,4 @@ +# Test file with error at beginning +function [invalid syntax + return 1 +end diff --git a/test/runtests.jl b/test/runtests.jl index 2bfefe3..706455e 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -6,6 +6,51 @@ if Base.VERSION < v"1.1" isnothing(x::Nothing) = true end +# Test internal functions for better coverage +@testset "find_error_line with Expr(:line)" begin + # Test handling of Expr(:line) nodes + ast = Expr(:block, + Expr(:line, 5), + :(x = 1), + Expr(:line, 10), + Expr(:error) + ) + line, found = CoverageTools.find_error_line(ast) + @test found + @test line == 10 + + # Test with LineNumberNode + ast2 = Expr(:block, + LineNumberNode(7), + :(y = 2), + LineNumberNode(12), + Expr(:error) + ) + line2, found2 = CoverageTools.find_error_line(ast2) + @test found2 + @test line2 == 12 + + # Test with no error + ast3 = Expr(:block, :(z = 3)) + line3, found3 = CoverageTools.find_error_line(ast3) + @test !found3 + @test line3 === nothing +end + +@testset "has_embedded_errors with empty error" begin + # Test that empty :error nodes are detected + ast_empty_error = Expr(:block, Expr(:error)) + @test CoverageTools.has_embedded_errors(ast_empty_error) + + # Test nested empty error + ast_nested = Expr(:block, :(x = 1), Expr(:function, :(f()), Expr(:error))) + @test CoverageTools.has_embedded_errors(ast_nested) + + # Test no error + ast_no_error = Expr(:block, :(x = 1), :(y = 2)) + @test !CoverageTools.has_embedded_errors(ast_no_error) +end + withenv("DISABLE_AMEND_COVERAGE_FROM_SRC" => nothing) do @testset "iscovfile" begin @@ -198,15 +243,106 @@ end # testset elseif VERSION.major == 1 && VERSION.minor == 5 msg = "parsing error in $bustedfile:7: space before \"[\" not allowed in \"i [\" at none:4" elseif VERSION.major == 1 && VERSION.minor > 9 - msg = if Sys.iswindows() - """parsing error in $bustedfile:8: Base.Meta.ParseError(\"ParseError:\\n# Error @ none:3:10\\n s = 0\\r\\n for i [1,2,3] # this line has a parsing error\\r\\n# └─┘ ── invalid iteration spec: expected one of `=` `in` or `∈`\", Base.JuliaSyntax.ParseError(Base.JuliaSyntax.SourceFile(\"function parseerr()\\r\\n s = 0\\r\\n for i [1,2,3] # this line has a parsing error\\r\\n \", 46, \"none\", 1, [1, 22, 33, 86, 94]), Base.JuliaSyntax.Diagnostic[Base.JuliaSyntax.Diagnostic(88, 90, :error, \"invalid iteration spec: expected one of `=` `in` or `∈`\"), Base.JuliaSyntax.Diagnostic(93, 92, :error, \"invalid iteration spec: expected one of `=` `in` or `∈`\"), Base.JuliaSyntax.Diagnostic(95, 94, :error, \"invalid iteration spec: expected one of `=` `in` or `∈`\"), Base.JuliaSyntax.Diagnostic(95, 95, :error, \"unexpected `]`\"), Base.JuliaSyntax.Diagnostic(95, 94, :error, \"Expected `end`\"), Base.JuliaSyntax.Diagnostic(95, 94, :error, \"Expected `end`\"), Base.JuliaSyntax.Diagnostic(95, 95, :error, \"extra tokens after end of expression\")], :none))""" - else - """parsing error in $bustedfile:8: Base.Meta.ParseError(\"ParseError:\\n# Error @ none:3:10\\n s = 0\\n for i [1,2,3] # this line has a parsing error\\n# └─┘ ── invalid iteration spec: expected one of `=` `in` or `∈`\", Base.JuliaSyntax.ParseError(Base.JuliaSyntax.SourceFile(\"function parseerr()\\n s = 0\\n for i [1,2,3] # this line has a parsing error\\n \", 42, \"none\", 1, [1, 21, 31, 83, 91]), Base.JuliaSyntax.Diagnostic[Base.JuliaSyntax.Diagnostic(82, 84, :error, \"invalid iteration spec: expected one of `=` `in` or `∈`\"), Base.JuliaSyntax.Diagnostic(87, 86, :error, \"invalid iteration spec: expected one of `=` `in` or `∈`\"), Base.JuliaSyntax.Diagnostic(89, 88, :error, \"invalid iteration spec: expected one of `=` `in` or `∈`\"), Base.JuliaSyntax.Diagnostic(89, 89, :error, \"unexpected `]`\"), Base.JuliaSyntax.Diagnostic(89, 88, :error, \"Expected `end`\"), Base.JuliaSyntax.Diagnostic(89, 88, :error, \"Expected `end`\"), Base.JuliaSyntax.Diagnostic(89, 89, :error, \"extra tokens after end of expression\")], :none))""" - end + # With JuliaSyntax, the error is detected on the line where the statement starts + msg = "parsing error in $bustedfile:7" else msg = "parsing error in $bustedfile:7: invalid iteration specification" end @test_throws Base.Meta.ParseError(msg) process_file(bustedfile, srcdir) + + # Test error at beginning of file + error_start = joinpath(srcdir, "error_start.jl") + @test_throws Base.Meta.ParseError process_file(error_start, srcdir) + + # Test error in middle of file - should report correct line + error_middle = joinpath(srcdir, "error_middle.jl") + err = try + process_file(error_middle, srcdir) + nothing + catch e + e + end + @test err isa Base.Meta.ParseError + @test occursin(":7", err.msg) # Error on line 7 + + # Test error after first statement - should report correct line + error_after_first = joinpath(srcdir, "error_after_first.jl") + err_after = try + process_file(error_after_first, srcdir) + nothing + catch e + e + end + @test err_after isa Base.Meta.ParseError + @test occursin(":4", err_after.msg) # Error on line 4 + + # Test unexpected EOF should raise a parse error (not be silently ignored) + error_eof = joinpath(srcdir, "error_eof.jl") + @test_throws Base.Meta.ParseError process_file(error_eof, srcdir) + + clean_folder(srcdir) +end # testset + +@testset "Syntax version detection" begin + # Test default version + mktempdir() do dir + testfile = joinpath(dir, "test.jl") + write(testfile, "x = 1") + version = CoverageTools.detect_syntax_version(testfile) + @test version == v"1.14" + end + + # Test explicit syntax.julia_version in Project.toml + if isdefined(Base, :project_file_load_spec) + # Julia 1.14+ uses Base.project_file_load_spec + mktempdir() do dir + project = joinpath(dir, "Project.toml") + write(project, """ + name = "TestPkg" + [syntax] + julia_version = "1.11" + """) + testfile = joinpath(dir, "test.jl") + write(testfile, "x = 1") + version = CoverageTools.detect_syntax_version(testfile) + # Julia 1.14+ clamps syntax versions to minimum v"1.13" (NON_VERSIONED_SYNTAX) + # since syntax versioning was first introduced in 1.14. We specified 1.11, + # so it should read from Project.toml and clamp to 1.13 (not default to 1.14) + @test version == v"1.13" + end + else + # Test TOML fallback path for Julia < 1.14 + mktempdir() do dir + project = joinpath(dir, "Project.toml") + write(project, """ + name = "TestPkg" + [syntax] + julia_version = "1.12" + """) + testfile = joinpath(dir, "test.jl") + write(testfile, "x = 1") + version = CoverageTools.detect_syntax_version(testfile) + @test version == v"1.12" + end + end + + # Test VERSION file parsing (for Julia's own base/) + mktempdir() do dir + write(joinpath(dir, "VERSION"), "1.12.0-DEV") + testfile = joinpath(dir, "test.jl") + write(testfile, "x = 1") + version = CoverageTools.detect_syntax_version(testfile) + @test version == v"1.12" + end + + # Test fallback when VERSION file can't be parsed + mktempdir() do dir + write(joinpath(dir, "VERSION"), "invalid") + testfile = joinpath(dir, "test.jl") + write(testfile, "x = 1") + version = CoverageTools.detect_syntax_version(testfile) + @test version == v"1.14" # Falls back to default + end end # testset @testset "malloc.jl" begin