Skip to content

Commit f867eac

Browse files
Detect syntax version from project files to support parsing newer Julia syntax (#80)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9943dd0 commit f867eac

File tree

8 files changed

+373
-13
lines changed

8 files changed

+373
-13
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,7 @@ jobs:
1515
fail-fast: false
1616
matrix:
1717
version:
18-
- '1.0'
19-
- '1.6'
18+
- 'min'
2019
- '1'
2120
- 'nightly'
2221
os:

Project.toml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
name = "CoverageTools"
22
uuid = "c36e975a-824b-4404-a568-ef97ca766997"
3-
authors = ["Iain Dunning <iaindunning@gmail.com>", "contributors"]
43
version = "1.3.2"
4+
authors = ["Iain Dunning <iaindunning@gmail.com>", "contributors"]
5+
6+
[deps]
7+
JuliaSyntax = "70703baa-626e-46a2-a12c-08ffd08c73b4"
8+
TOML = "fa267f1f-6049-4f14-aa54-33bafae1ed76"
59

610
[compat]
7-
julia = "1"
11+
JuliaSyntax = "1"
12+
TOML = "1"
13+
julia = "1.10"
814

915
[extras]
1016
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"

src/CoverageTools.jl

Lines changed: 196 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
module CoverageTools
22

3+
import JuliaSyntax
4+
import TOML
5+
36
export process_folder, process_file
47
export clean_folder, clean_file
58
export process_cov, amend_coverage_from_src!
@@ -12,6 +15,58 @@ export FileCoverage
1215
# line (e.g. a comment), but 0 means it could have run but didn't.
1316
const CovCount = Union{Nothing,Int}
1417

18+
"""
19+
has_embedded_errors(expr)
20+
21+
Recursively check if an expression contains any `:error` nodes.
22+
"""
23+
function has_embedded_errors(expr)
24+
expr isa Expr || return false
25+
expr.head === :error && return true
26+
return any(has_embedded_errors, expr.args)
27+
end
28+
29+
"""
30+
find_error_line(expr)
31+
32+
Find the line number of the first error in an expression by locating
33+
the LineNumberNode or Expr(:line) that precedes the first :error node.
34+
Returns nothing if no error is found.
35+
"""
36+
function find_error_line(expr, last_line=nothing)
37+
if expr isa LineNumberNode
38+
return expr.line, false
39+
end
40+
41+
if expr isa Expr
42+
# Handle Expr(:line, ...) nodes emitted by JuliaSyntax
43+
if expr.head === :line && length(expr.args) >= 1
44+
line_num = expr.args[1]
45+
if line_num isa Integer
46+
return Int(line_num), false
47+
end
48+
end
49+
50+
if expr.head === :error
51+
# Found an error, return the last seen line number
52+
return last_line, true
53+
end
54+
55+
current_line = last_line
56+
for arg in expr.args
57+
line_result, found_error = find_error_line(arg, current_line)
58+
if found_error
59+
return line_result, true
60+
end
61+
if line_result !== nothing && !found_error
62+
current_line = line_result
63+
end
64+
end
65+
end
66+
67+
return nothing, false
68+
end
69+
1570
"""
1671
FileCoverage
1772
@@ -139,6 +194,87 @@ function process_cov(filename, folder)
139194
return full_coverage
140195
end
141196

197+
"""
198+
detect_syntax_version(filename::AbstractString) -> VersionNumber
199+
200+
Detect the appropriate Julia syntax version for parsing a source file by looking
201+
for the nearest project file (Project.toml or JuliaProject.toml) and reading its
202+
syntax version configuration, or by looking for the VERSION file in Julia's own
203+
source tree (for base/ files).
204+
205+
Defaults to v"1.14" if no specific version is found, as JuliaSyntax generally
206+
maintains backwards compatibility with older syntax.
207+
"""
208+
function detect_syntax_version(filename::AbstractString)
209+
dir = dirname(abspath(filename))
210+
# Walk up the directory tree looking for project file or VERSION file
211+
while true
212+
# Check for project file first (for packages and stdlib)
213+
# Use Base.locate_project_file to handle both Project.toml and JuliaProject.toml
214+
project_file = Base.locate_project_file(dir)
215+
216+
if project_file !== nothing && project_file !== true && isfile(project_file)
217+
# Use Base.project_file_load_spec if available (Julia 1.14+)
218+
# This properly handles syntax.julia_version entries
219+
if isdefined(Base, :project_file_load_spec)
220+
spec = Base.project_file_load_spec(project_file, "")
221+
return spec.julia_syntax_version
222+
else
223+
# Fallback for older Julia versions - only check syntax.julia_version
224+
project = TOML.tryparsefile(project_file)
225+
if !(project isa Base.TOML.ParserError)
226+
syntax_table = get(project, "syntax", nothing)
227+
if syntax_table !== nothing
228+
jv = get(syntax_table, "julia_version", nothing)
229+
if jv !== nothing
230+
try
231+
return VersionNumber(jv)
232+
catch e
233+
e isa ArgumentError || rethrow()
234+
end
235+
end
236+
end
237+
end
238+
end
239+
end
240+
241+
# Check for VERSION file (for Julia's own base/ source without project file)
242+
version_file = joinpath(dir, "VERSION")
243+
if isfile(version_file)
244+
version_str = nothing
245+
try
246+
version_str = strip(read(version_file, String))
247+
catch e
248+
e isa SystemError || rethrow()
249+
# If we can't read VERSION, continue searching
250+
end
251+
if version_str !== nothing
252+
# Parse version string like "1.14.0-DEV"
253+
m = match(r"^(\d+)\.(\d+)", version_str)
254+
if m !== nothing
255+
try
256+
major = parse(Int, m.captures[1])
257+
minor = parse(Int, m.captures[2])
258+
return VersionNumber(major, minor)
259+
catch e
260+
e isa ArgumentError || rethrow()
261+
# If we can't parse VERSION, continue searching
262+
end
263+
end
264+
end
265+
end
266+
267+
parent = dirname(dir)
268+
if parent == dir # reached root
269+
break
270+
end
271+
dir = parent
272+
end
273+
# Default to v"1.14" - JuliaSyntax maintains backwards compatibility
274+
# so using a recent version generally works for older code
275+
return v"1.14"
276+
end
277+
142278
"""
143279
amend_coverage_from_src!(coverage::Vector{CovCount}, srcname)
144280
amend_coverage_from_src!(fc::FileCoverage)
@@ -168,6 +304,12 @@ function amend_coverage_from_src!(fc::FileCoverage)
168304
push!(linepos, position(io))
169305
end
170306
pos = 1
307+
# Detect the appropriate syntax version for this package
308+
syntax_version = detect_syntax_version(fc.filename)
309+
# When parsing, use the detected syntax version to ensure we can parse
310+
# all syntax features available in that version, even when running under
311+
# a different Julia version (e.g., parsing Julia 1.14 code with Julia 1.11).
312+
# JuliaSyntax provides version-aware parsing for any Julia version.
171313
while pos <= length(content)
172314
# We now want to convert the one-based offset pos into a line
173315
# number, by looking it up in linepos. But linepos[i] contains the
@@ -177,13 +319,63 @@ function amend_coverage_from_src!(fc::FileCoverage)
177319
# that later on to shift other one-based line numbers, we must
178320
# subtract 1 from the offset to make it zero-based.
179321
lineoffset = searchsortedlast(linepos, pos - 1) - 1
322+
# 1-based line number for error reporting (lineoffset is 0-based)
323+
current_line = lineoffset + 1
180324

181325
# now we can parse the next chunk of the input
182-
ast, pos = Meta.parse(content, pos; raise=false)
326+
local ast, newpos
327+
try
328+
ast, newpos = JuliaSyntax.parsestmt(Expr, content, pos;
329+
version=syntax_version,
330+
ignore_errors=true,
331+
ignore_warnings=true)
332+
catch e
333+
if isa(e, JuliaSyntax.ParseError)
334+
throw(Base.Meta.ParseError("parsing error in $(fc.filename):$current_line: $e", e))
335+
end
336+
rethrow()
337+
end
338+
339+
# If position didn't advance, we have a malformed token/byte - throw error
340+
if newpos <= pos
341+
throw(Base.Meta.ParseError("parsing error in $(fc.filename):$current_line: parser did not advance", nothing))
342+
end
343+
pos = newpos
344+
183345
isa(ast, Expr) || continue
184-
if ast.head (:error, :incomplete)
185-
line = searchsortedlast(linepos, pos - 1)
186-
throw(Base.Meta.ParseError("parsing error in $(fc.filename):$line: $(ast.args[1])"))
346+
# Compute line number based on parse position (compatible with Meta.parse behavior)
347+
error_line_from_pos = searchsortedlast(linepos, pos - 1)
348+
349+
# For files with only actual parse errors (not end-of-file), we should throw
350+
# But we need to distinguish real errors from benign cases
351+
if ast.head === :error
352+
errmsg = isempty(ast.args) ? "" : string(ast.args[1])
353+
# Only treat as EOF if we're actually at end of content AND it's an empty error or premature EOF
354+
if pos >= length(content) && (isempty(errmsg) || occursin("premature end of input", errmsg))
355+
break # Done parsing, no more content
356+
end
357+
# Real parse error - throw it
358+
throw(Base.Meta.ParseError("parsing error in $(fc.filename):$current_line: $errmsg", nothing))
359+
end
360+
# Check if the AST contains any embedded :error nodes (from ignore_errors=true).
361+
# When we can't locate an explicit line node, fall back to the parse position
362+
# to preserve Meta.parse-style error line reporting across statements.
363+
if has_embedded_errors(ast)
364+
# Try to find the actual line where the error occurred
365+
error_internal_line, found = find_error_line(ast)
366+
if found && error_internal_line !== nothing
367+
# error_internal_line is relative to the parsed content (1-based)
368+
# We need to add lineoffset to get the actual file line
369+
error_line = lineoffset + error_internal_line
370+
throw(Base.Meta.ParseError("parsing error in $(fc.filename):$error_line", nothing))
371+
else
372+
# Fallback to the line where we started parsing this statement
373+
throw(Base.Meta.ParseError("parsing error in $(fc.filename):$error_line_from_pos", nothing))
374+
end
375+
end
376+
# Incomplete expressions indicate truncated/malformed code - treat as parse error
377+
if ast.head === :incomplete
378+
throw(Base.Meta.ParseError("parsing error in $(fc.filename):$current_line: incomplete expression", nothing))
187379
end
188380
flines = function_body_lines(ast, coverage, lineoffset)
189381
if !isempty(flines)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Test file with error after first statement
2+
x = 1
3+
4+
for i [1,2,3]
5+
println(i)
6+
end
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Test file with unexpected EOF
2+
function foo()
3+
x = 1
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Test file with error in middle
2+
function works()
3+
return 1
4+
end
5+
6+
function broken()
7+
for x [1,2,3]
8+
println(x)
9+
end
10+
end
11+
12+
function also_works()
13+
return 2
14+
end
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Test file with error at beginning
2+
function [invalid syntax
3+
return 1
4+
end

0 commit comments

Comments
 (0)