Skip to main content

gdtest_analysis/
coverage.rs

1//! GDScript coverage instrumentation.
2//!
3//! Inserts `Gdt._cov_hit("res://path", line)` before each executable line
4//! inside function bodies. Uses gdsyntax to identify function body line
5//! ranges; blank lines and comment lines are skipped via text heuristics.
6//!
7//! The caller is responsible for backing up original files before writing the
8//! instrumented bytes and restoring them after the test run.
9
10use std::collections::HashSet;
11use std::path::Path;
12
13use anyhow::{Context, Result};
14use gdsyntax::ast::{AstNode, Func};
15
16// ── Public API ────────────────────────────────────────────────────────────────
17
18/// Instrument a GDScript source file for coverage tracking.
19/// Returns `(instrumented_bytes, instrumented_line_numbers)`.
20/// `instrumented_line_numbers` is the set of 1-based line numbers that received
21/// a hit-tracking call — this is the "lines found" (LF) count for LCOV.
22pub fn instrument_file(path: &Path, res_path: &str) -> Result<(Vec<u8>, HashSet<u32>)> {
23    let source = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
24    Ok(instrument_source(&source, res_path))
25}
26
27/// Instrument raw GDScript bytes. Returns `(instrumented_bytes, instrumented_lines)`.
28pub fn instrument_source(source: &[u8], res_path: &str) -> (Vec<u8>, HashSet<u32>) {
29    let body_lines = find_function_body_lines(source);
30    if body_lines.is_empty() {
31        return (source.to_vec(), HashSet::new());
32    }
33
34    let source_str = match std::str::from_utf8(source) {
35        Ok(s) => s,
36        Err(_) => return (source.to_vec(), HashSet::new()),
37    };
38
39    let escaped = res_path.replace('\\', "\\\\").replace('"', "\\\"");
40    let mut result = String::with_capacity(source.len() + body_lines.len() * 60);
41    let mut instrumented: HashSet<u32> = HashSet::new();
42
43    for (i, line) in source_str.lines().enumerate() {
44        let line_num = (i + 1) as u32;
45        let trimmed = line.trim();
46
47        if body_lines.contains(&line_num) && !trimmed.is_empty() && !trimmed.starts_with('#') {
48            let ws = leading_whitespace(line);
49            result.push_str(ws);
50            result.push_str(&format!(r#"Gdt._cov_hit("{escaped}", {line_num})"#));
51            result.push('\n');
52            instrumented.insert(line_num);
53        }
54        result.push_str(line);
55        result.push('\n');
56    }
57
58    (result.into_bytes(), instrumented)
59}
60
61// ── Internals ─────────────────────────────────────────────────────────────────
62
63fn leading_whitespace(line: &str) -> &str {
64    let trimmed_len = line.trim_start().len();
65    &line[..line.len() - trimmed_len]
66}
67
68/// Returns all 1-based line numbers that lie inside some function body.
69///
70/// A body runs from the line after the signature to the body's last line of
71/// code. A body on the signature's line, as in `func f(): pass`, has no lines.
72fn find_function_body_lines(source: &[u8]) -> HashSet<u32> {
73    // Offsets must index into `source`, so invalid UTF-8 is not instrumented.
74    let Ok(text) = std::str::from_utf8(source) else {
75        return HashSet::new();
76    };
77    let tree = gdsyntax::parse(text);
78    let line_of = |offset: gdsyntax::rowan::TextSize| tree.position(offset.into()).line;
79    let mut lines = HashSet::new();
80    // Inner-class methods are FUNC nodes too; lambdas are not.
81    for func in tree.root().descendants().filter_map(Func::cast) {
82        let (Some(signature), Some(body)) = (
83            func.signature_range(),
84            func.body().and_then(|body| body.code_range()),
85        ) else {
86            continue;
87        };
88        let start = line_of(body.start()).max(line_of(signature.end()) + 1);
89        // The last token can span lines, as a multi-line string does.
90        for line in start..=line_of(body.end()) {
91            lines.insert(line as u32);
92        }
93    }
94    lines
95}
96
97// ── Tests ─────────────────────────────────────────────────────────────────────
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn instruments_function_body() {
105        let src = br#"extends RefCounted
106
107func take_damage(amount: int) -> void:
108    health -= amount
109    if health <= 0:
110        die()
111
112func heal(amount: int) -> void:
113    pass
114"#;
115        let (out, instrumented) = instrument_source(src, "res://src/player.gd");
116        let s = std::str::from_utf8(&out).unwrap();
117        assert!(
118            s.contains(r#"Gdt._cov_hit("res://src/player.gd","#),
119            "missing hit call:\n{s}"
120        );
121        // Lines 4, 5, 6 (take_damage body) and 9 (heal body) should be instrumented.
122        assert!(
123            instrumented.contains(&4),
124            "line 4 not instrumented: {instrumented:?}"
125        );
126        assert!(
127            instrumented.contains(&9),
128            "line 9 not instrumented: {instrumented:?}"
129        );
130        // `extends` (line 1) and `func` headers (lines 3, 8) must NOT be instrumented.
131        assert!(
132            !instrumented.contains(&1),
133            "extends should not be instrumented"
134        );
135        assert!(
136            !instrumented.contains(&3),
137            "func header should not be instrumented"
138        );
139    }
140
141    #[test]
142    fn skips_comments_and_blanks() {
143        let src = br#"func foo() -> void:
144    # a comment
145    var x = 1
146"#;
147        let (_, instrumented) = instrument_source(src, "res://test.gd");
148        assert!(
149            !instrumented.contains(&2),
150            "comment line must not be instrumented"
151        );
152        assert!(instrumented.contains(&3), "var line should be instrumented");
153    }
154
155    #[test]
156    fn no_instrumentation_outside_functions() {
157        let src = br#"extends Node
158
159var speed := 100.0
160
161func _ready() -> void:
162    speed = 200.0
163"#;
164        let (_, instrumented) = instrument_source(src, "res://player.gd");
165        // Class-level var — outside any function, must NOT be instrumented.
166        assert!(
167            !instrumented.contains(&3),
168            "class-level var should not be instrumented"
169        );
170        // Assignment inside _ready — should be instrumented.
171        assert!(
172            instrumented.contains(&6),
173            "assignment inside _ready should be instrumented"
174        );
175    }
176
177    #[test]
178    fn skips_multi_line_signatures() {
179        let src = b"func f(a: int,\n\t\tb: int) -> int:\n\treturn a + b\n";
180        let (_, instrumented) = instrument_source(src, "res://test.gd");
181        let mut lines: Vec<u32> = instrumented.into_iter().collect();
182        lines.sort();
183        assert_eq!(lines, vec![3]);
184    }
185
186    #[test]
187    fn instruments_init_and_skips_bodiless_functions() {
188        let src =
189            b"@abstract\nclass A:\n\t@abstract func f() -> int\n\nfunc _init() -> void:\n\tprint(1)\n";
190        let (_, instrumented) = instrument_source(src, "res://test.gd");
191        let mut lines: Vec<u32> = instrumented.into_iter().collect();
192        lines.sort();
193        assert_eq!(lines, vec![6]);
194    }
195
196    #[test]
197    fn body_ends_after_a_trailing_multi_line_string() {
198        let src = b"func f() -> void:\n\tvar s = \"\"\"\n\ttext\n\t\"\"\"\n";
199        let (_, instrumented) = instrument_source(src, "res://test.gd");
200        assert!(instrumented.contains(&4), "{instrumented:?}");
201    }
202
203    #[test]
204    fn hit_marker_has_correct_indentation() {
205        let src = b"func foo() -> void:\n\tvar x = 1\n";
206        let (out, _) = instrument_source(src, "res://test.gd");
207        let s = std::str::from_utf8(&out).unwrap();
208        let hit_line = s.lines().find(|l| l.contains("_cov_hit")).unwrap();
209        assert!(
210            hit_line.starts_with('\t'),
211            "hit marker should preserve tab indentation: {hit_line:?}"
212        );
213    }
214}