gdtest_analysis/
coverage.rs1use std::collections::HashSet;
11use std::path::Path;
12
13use anyhow::{Context, Result};
14use gdsyntax::ast::{AstNode, Func};
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> {
73 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 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 for line in start..=line_of(body.end()) {
91 lines.insert(line as u32);
92 }
93 }
94 lines
95}
96
97#[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 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 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 assert!(
167 !instrumented.contains(&3),
168 "class-level var should not be instrumented"
169 );
170 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}