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 tree-sitter 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 tree_sitter::Parser;
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.
69fn find_function_body_lines(source: &[u8]) -> HashSet<u32> {
70    let mut parser = Parser::new();
71    if parser
72        .set_language(&tree_sitter_gdscript::LANGUAGE.into())
73        .is_err()
74    {
75        return HashSet::new();
76    }
77    let Some(tree) = parser.parse(source, None) else {
78        return HashSet::new();
79    };
80    let mut lines = HashSet::new();
81    collect_body_lines(tree.root_node(), &mut lines);
82    lines
83}
84
85fn collect_body_lines(node: tree_sitter::Node, lines: &mut HashSet<u32>) {
86    if node.kind() == "function_definition" {
87        let func_start_row = node.start_position().row as u32; // 0-based
88        let func_end_row = node.end_position().row as u32;
89
90        // Find the explicit 'body' child to get the precise body start row.
91        // Look for explicit 'body' child to get the precise body start.
92        // In tree-sitter-gdscript the body node begins on the same row as the
93        // `func` keyword, so only use it when strictly after the header row
94        // (multi-line signatures). For single-line headers fall back to
95        // func_start_row + 1 so the header line is never instrumented.
96        let mut body_start_row = func_start_row + 1;
97        for i in 0..node.child_count() {
98            if let Some(child) = node.child(i as u32) {
99                if child.kind() == "body" {
100                    let bsr = child.start_position().row as u32;
101                    if bsr > func_start_row {
102                        body_start_row = bsr;
103                    }
104                    break;
105                }
106            }
107        }
108
109        for row0 in body_start_row..=func_end_row {
110            lines.insert(row0 + 1); // 0-based → 1-based
111        }
112        // Still descend so inner-class methods are also collected.
113    }
114
115    for i in 0..node.child_count() {
116        if let Some(child) = node.child(i as u32) {
117            collect_body_lines(child, lines);
118        }
119    }
120}
121
122// ── Tests ─────────────────────────────────────────────────────────────────────
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn instruments_function_body() {
130        let src = br#"extends RefCounted
131
132func take_damage(amount: int) -> void:
133    health -= amount
134    if health <= 0:
135        die()
136
137func heal(amount: int) -> void:
138    pass
139"#;
140        let (out, instrumented) = instrument_source(src, "res://src/player.gd");
141        let s = std::str::from_utf8(&out).unwrap();
142        assert!(
143            s.contains(r#"Gdt._cov_hit("res://src/player.gd","#),
144            "missing hit call:\n{s}"
145        );
146        // Lines 4, 5, 6 (take_damage body) and 9 (heal body) should be instrumented.
147        assert!(
148            instrumented.contains(&4),
149            "line 4 not instrumented: {instrumented:?}"
150        );
151        assert!(
152            instrumented.contains(&9),
153            "line 9 not instrumented: {instrumented:?}"
154        );
155        // `extends` (line 1) and `func` headers (lines 3, 8) must NOT be instrumented.
156        assert!(
157            !instrumented.contains(&1),
158            "extends should not be instrumented"
159        );
160        assert!(
161            !instrumented.contains(&3),
162            "func header should not be instrumented"
163        );
164    }
165
166    #[test]
167    fn skips_comments_and_blanks() {
168        let src = br#"func foo() -> void:
169    # a comment
170    var x = 1
171"#;
172        let (_, instrumented) = instrument_source(src, "res://test.gd");
173        assert!(
174            !instrumented.contains(&2),
175            "comment line must not be instrumented"
176        );
177        assert!(instrumented.contains(&3), "var line should be instrumented");
178    }
179
180    #[test]
181    fn no_instrumentation_outside_functions() {
182        let src = br#"extends Node
183
184var speed := 100.0
185
186func _ready() -> void:
187    speed = 200.0
188"#;
189        let (_, instrumented) = instrument_source(src, "res://player.gd");
190        // Class-level var — outside any function, must NOT be instrumented.
191        assert!(
192            !instrumented.contains(&3),
193            "class-level var should not be instrumented"
194        );
195        // Assignment inside _ready — should be instrumented.
196        assert!(
197            instrumented.contains(&6),
198            "assignment inside _ready should be instrumented"
199        );
200    }
201
202    #[test]
203    fn hit_marker_has_correct_indentation() {
204        let src = b"func foo() -> void:\n\tvar x = 1\n";
205        let (out, _) = instrument_source(src, "res://test.gd");
206        let s = std::str::from_utf8(&out).unwrap();
207        let hit_line = s.lines().find(|l| l.contains("_cov_hit")).unwrap();
208        assert!(
209            hit_line.starts_with('\t'),
210            "hit marker should preserve tab indentation: {hit_line:?}"
211        );
212    }
213}