11module CoverageTools
22
3+ import JuliaSyntax
4+ import TOML
5+
36export process_folder, process_file
47export clean_folder, clean_file
58export 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.
1316const 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"""
1671FileCoverage
1772
@@ -139,6 +194,87 @@ function process_cov(filename, folder)
139194 return full_coverage
140195end
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)
0 commit comments