gdtest_analysis/
coverage.rs1use std::collections::HashSet;
11use std::path::Path;
12
13use anyhow::{Context, Result};
14use tree_sitter::Parser;
15
16pub 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
27pub 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
61fn leading_whitespace(line: &str) -> &str {
64 let trimmed_len = line.trim_start().len();
65 &line[..line.len() - trimmed_len]
66}
67
68fn 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; let func_end_row = node.end_position().row as u32;
89
90 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 && child.kind() == "body"
100 {
101 let bsr = child.start_position().row as u32;
102 if bsr > func_start_row {
103 body_start_row = bsr;
104 }
105 break;
106 }
107 }
108
109 for row0 in body_start_row..=func_end_row {
110 lines.insert(row0 + 1); }
112 }
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#[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 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 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 assert!(
192 !instrumented.contains(&3),
193 "class-level var should not be instrumented"
194 );
195 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}