From 79772267b5a03b7e8f67d596cd8e8904064c3a16 Mon Sep 17 00:00:00 2001 From: Eljees <3.14hell@gmail.com> Date: Fri, 7 Aug 2026 02:29:34 +0300 Subject: [PATCH] fix(parser): accept FN records with a negative line number FN:-1, is emitted for synthetic functions that have no source line (JaCoCo output for Scala's .curried/.tupled). The FN branch peeks at the first byte and rejects anything that is not a digit, so in the default strict mode manage_parsing_error! returns Err and the whole coverage file is dropped, not just the one record. Function.start is a u32, so a negative line cannot be represented. Accept an optional leading '-', treat such a function as synthetic and record it at line 0, which keeps the function name and its FNDA counter working. A missing line number (FN:,name) stays an error. Addresses the parsing half of #1514. --- src/parser.rs | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/parser.rs b/src/parser.rs index ecc1a6ef0..04e3c7ef4 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -286,6 +286,14 @@ pub fn parse_lcov( } // FN:int,string + // A negative line number marks a synthetic function + // that has no location in the source file: keep the + // record and report it at line 0 instead of discarding + // the whole coverage file. + let synthetic = iter.peek() == Some(&&b'-'); + if synthetic { + iter.next(); + } if let Some(c) = iter.peek() { if !c.is_ascii_digit() { manage_parsing_error!( @@ -298,6 +306,7 @@ pub fn parse_lcov( let start = iter .take_while(|&&c| c.is_ascii_digit()) .fold(0, |r, &x| r * 10 + u32::from(x - b'0')); + let start = if synthetic { 0 } else { start }; if iter.peek().is_none() { manage_parsing_error!( ignore_parsing_error, @@ -1384,6 +1393,38 @@ mod tests { assert!(func.executed); } + #[allow(non_snake_case)] + #[test] + fn test_lcov_parser_FN_record_with_negative_line_number() { + // Some producers emit synthetic functions without a source line, e.g. + // JaCoCo for Scala's `.curried` / `.tupled`. + let buf = "TN:\nSF:foo.scala\nFN:-1,curried\nFNDA:0,curried\nDA:1,0\nend_of_record\n" + .as_bytes() + .to_vec(); + let results = parse_lcov(buf, false, false).unwrap(); + assert_eq!(results.len(), 1); + + let (ref source_name, ref result) = results[0]; + assert_eq!(source_name, "foo.scala"); + assert_eq!(result.lines, [(1, 0)].iter().cloned().collect()); + let func = result.functions.get("curried").unwrap(); + assert_eq!(func.start, 0); + assert!(!func.executed); + } + + #[allow(non_snake_case)] + #[test] + fn test_lcov_parser_FN_record_without_line_number() { + // Control: a missing line number is still an invalid record. + let buf = "SF:foo.scala\nFN:,curried\nend_of_record\n" + .as_bytes() + .to_vec(); + let result = parse_lcov(buf, false, false); + assert!(result.is_err()); + let error = result.unwrap_err(); + assert_eq!(error.to_string(), "Invalid record: 'FN at line 2'"); + } + #[allow(non_snake_case)] #[test] fn test_lcov_parser_invalid_DA_record() {