Skip to main content

gdtest_analysis/
rewrite.rs

1//! GDScript assert rewriter — tree-sitter preprocessing for pytest-style rich diffs.
2//!
3//! Rewrites `assert(expr)` calls into `Gdt._rewrite_*(...)` calls that carry
4//! operand values AND the source-text of the condition to the failure formatter,
5//! giving messages like:
6//!
7//! ```text
8//! FAIL  assert(player.hp == 100)  (test_player.gd:42)
9//!   left:  87
10//!   right: 100
11//! ```
12//!
13//! Only single-line assert calls are rewritten; multi-line ones are left unchanged
14//! so line counts are never disturbed.
15//!
16//! # Tree-sitter structure (verified via dump_assert_tree test)
17//!
18//! ```text
19//! expression_statement
20//!   call
21//!     identifier "assert"          ← function name
22//!     arguments
23//!       (
24//!       binary_operator            ← condition (may be any expression)
25//!         <left>
26//!         ==                       ← unnamed operator token
27//!         <right>
28//!       [, string]                 ← optional message
29//!       )
30//! ```
31
32use std::path::Path;
33
34use anyhow::{Context, Result};
35use tree_sitter::Parser;
36
37// ── Public API ────────────────────────────────────────────────────────────────
38
39pub struct RewriteOutput {
40    /// Modified source bytes. Identical to input when nothing was rewritten.
41    pub source: Vec<u8>,
42    /// Whether any assert calls were actually rewritten.
43    pub changed: bool,
44}
45
46pub fn rewrite_file(path: &Path, original_res_path: &str) -> Result<RewriteOutput> {
47    let source = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
48    Ok(rewrite_source(&source, original_res_path))
49}
50
51pub fn rewrite_source(source: &[u8], original_res_path: &str) -> RewriteOutput {
52    let Some(asserts) = find_asserts(source) else {
53        return RewriteOutput {
54            source: source.to_vec(),
55            changed: false,
56        };
57    };
58    if asserts.is_empty() {
59        return RewriteOutput {
60            source: source.to_vec(),
61            changed: false,
62        };
63    }
64
65    // Collect replacements in reverse byte order so earlier positions stay valid.
66    let mut replacements: Vec<(usize, usize, String)> = Vec::new();
67    for a in &asserts {
68        if let Some(repl) = build_replacement(a, source, original_res_path) {
69            replacements.push((a.start_byte, a.end_byte, repl));
70        }
71    }
72    if replacements.is_empty() {
73        return RewriteOutput {
74            source: source.to_vec(),
75            changed: false,
76        };
77    }
78
79    replacements.sort_by(|a, b| b.0.cmp(&a.0));
80    let mut result = source.to_vec();
81    for (start, end, replacement) in &replacements {
82        result.splice(start..end, replacement.bytes());
83    }
84    RewriteOutput {
85        source: result,
86        changed: true,
87    }
88}
89
90// ── Internal types ────────────────────────────────────────────────────────────
91
92struct AssertInfo {
93    /// Byte range of the `call` node ("assert(...)").
94    start_byte: usize,
95    end_byte: usize,
96    /// 1-based original line number.
97    line: u32,
98    /// Full text of the condition expression.
99    condition_text: String,
100    /// Parsed binary comparison, if the condition is one.
101    binary: Option<BinaryInfo>,
102}
103
104struct BinaryInfo {
105    op: BinaryOp,
106    left_text: String,
107    right_text: String,
108}
109
110#[derive(Clone, Copy)]
111enum BinaryOp {
112    Eq,
113    Ne,
114    Lt,
115    Lte,
116    Gt,
117    Gte,
118}
119
120impl BinaryOp {
121    fn gdt_suffix(self) -> &'static str {
122        match self {
123            BinaryOp::Eq => "eq",
124            BinaryOp::Ne => "ne",
125            BinaryOp::Lt => "lt",
126            BinaryOp::Lte => "lte",
127            BinaryOp::Gt => "gt",
128            BinaryOp::Gte => "gte",
129        }
130    }
131    fn from_text(op: &str) -> Option<Self> {
132        match op {
133            "==" => Some(BinaryOp::Eq),
134            "!=" => Some(BinaryOp::Ne),
135            "<" => Some(BinaryOp::Lt),
136            "<=" => Some(BinaryOp::Lte),
137            ">" => Some(BinaryOp::Gt),
138            ">=" => Some(BinaryOp::Gte),
139            _ => None,
140        }
141    }
142}
143
144// ── Tree-sitter traversal ─────────────────────────────────────────────────────
145
146fn find_asserts(source: &[u8]) -> Option<Vec<AssertInfo>> {
147    let mut parser = Parser::new();
148    parser
149        .set_language(&tree_sitter_gdscript::LANGUAGE.into())
150        .ok()?;
151    let tree = parser.parse(source, None)?;
152    let mut out = Vec::new();
153    collect_assert_calls(tree.root_node(), source, &mut out);
154    Some(out)
155}
156
157fn collect_assert_calls(node: tree_sitter::Node, source: &[u8], out: &mut Vec<AssertInfo>) {
158    if node.kind() == "call" {
159        if let Some(info) = parse_assert_call(node, source) {
160            out.push(info);
161            return; // don't descend further into this call
162        }
163    }
164    for i in 0..node.child_count() {
165        if let Some(child) = node.child(i as u32) {
166            collect_assert_calls(child, source, out);
167        }
168    }
169}
170
171/// Returns Some if this `call` node is a single-line `assert(...)` call.
172fn parse_assert_call(node: tree_sitter::Node, source: &[u8]) -> Option<AssertInfo> {
173    // First child of `call` is the function expression (identifier "assert").
174    let func_node = node.child(0)?;
175    if func_node.kind() != "identifier" {
176        return None;
177    }
178    if func_node.utf8_text(source).ok()? != "assert" {
179        return None;
180    }
181
182    // Only rewrite single-line calls.
183    if node.start_position().row != node.end_position().row {
184        return None;
185    }
186
187    // Second child is `arguments`.
188    let args_node = node.child(1)?;
189    if args_node.kind() != "arguments" {
190        return None;
191    }
192
193    // Walk arguments children: "(" [condition] ["," message] ")"
194    // Named children are the actual arguments; unnamed are punctuation.
195    let mut condition_node: Option<tree_sitter::Node> = None;
196    for i in 0..args_node.child_count() {
197        let child = args_node.child(i as u32)?;
198        if child.kind() == "(" || child.kind() == ")" || child.kind() == "," {
199            continue;
200        }
201        if condition_node.is_none() {
202            condition_node = Some(child);
203            break; // stop at first arg (condition); ignore message
204        }
205    }
206
207    let condition_node = condition_node?;
208    let condition_text = condition_node.utf8_text(source).ok()?.to_string();
209    let binary = try_parse_binary(condition_node, source);
210
211    Some(AssertInfo {
212        start_byte: node.start_byte(),
213        end_byte: node.end_byte(),
214        line: node.start_position().row as u32 + 1,
215        condition_text,
216        binary,
217    })
218}
219
220/// If node is a `binary_operator` with a comparison op, extract both sides.
221fn try_parse_binary(node: tree_sitter::Node, source: &[u8]) -> Option<BinaryInfo> {
222    if node.kind() != "binary_operator" {
223        return None;
224    }
225
226    // Children: <left> <op_token> <right>
227    // Named children are left and right; unnamed is the operator token.
228    let mut left: Option<tree_sitter::Node> = None;
229    let mut right: Option<tree_sitter::Node> = None;
230    let mut op: Option<BinaryOp> = None;
231
232    for i in 0..node.child_count() {
233        let child = node.child(i as u32)?;
234        if child.is_named() {
235            if left.is_none() {
236                left = Some(child);
237            } else {
238                right = Some(child);
239            }
240        } else if let Ok(txt) = child.utf8_text(source) {
241            if let Some(parsed) = BinaryOp::from_text(txt) {
242                op = Some(parsed);
243            }
244        }
245    }
246
247    Some(BinaryInfo {
248        op: op?,
249        left_text: left?.utf8_text(source).ok()?.to_string(),
250        right_text: right?.utf8_text(source).ok()?.to_string(),
251    })
252}
253
254// ── Code generation ───────────────────────────────────────────────────────────
255
256fn build_replacement(info: &AssertInfo, source: &[u8], original_res_path: &str) -> Option<String> {
257    let indent = leading_whitespace(info.start_byte, source).to_string();
258    let file = escape_gdstring(original_res_path);
259    let line = info.line;
260
261    let repl = if let Some(bin) = &info.binary {
262        let expr = escape_gdstring(&info.condition_text);
263        format!(
264            r#"{indent}Gdt._rewrite_{suffix}({lhs}, {rhs}, "{expr}", "{file}", {line})"#,
265            suffix = bin.op.gdt_suffix(),
266            lhs = bin.left_text,
267            rhs = bin.right_text,
268        )
269    } else {
270        let expr = escape_gdstring(&info.condition_text);
271        format!(
272            r#"{indent}Gdt._rewrite_true({cond}, "{expr}", "{file}", {line})"#,
273            cond = info.condition_text,
274        )
275    };
276
277    Some(repl)
278}
279
280fn leading_whitespace(pos: usize, source: &[u8]) -> &str {
281    let line_start = source[..pos]
282        .iter()
283        .rposition(|&b| b == b'\n')
284        .map(|p| p + 1)
285        .unwrap_or(0);
286    let prefix = &source[line_start..pos];
287    let ws_end = prefix
288        .iter()
289        .take_while(|&&b| b == b' ' || b == b'\t')
290        .count();
291    std::str::from_utf8(&prefix[..ws_end]).unwrap_or("")
292}
293
294fn escape_gdstring(s: &str) -> String {
295    s.replace('\\', "\\\\").replace('"', "\\\"")
296}
297
298// ── Tests ─────────────────────────────────────────────────────────────────────
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn dump_assert_tree() {
306        let src = b"extends RefCounted\n\nfunc test_foo():\n\tassert(x == y)\n\tassert(player.hp == 100, \"hp check\")\n\tassert(flag)\n";
307        let mut parser = Parser::new();
308        parser
309            .set_language(&tree_sitter_gdscript::LANGUAGE.into())
310            .unwrap();
311        let tree = parser.parse(src, None).unwrap();
312        fn dump(node: tree_sitter::Node, src: &[u8], indent: usize) {
313            let preview: String = node
314                .utf8_text(src)
315                .unwrap_or("?")
316                .chars()
317                .take(40)
318                .collect::<String>()
319                .replace('\n', "↵");
320            println!(
321                "{}{} [{}:{}-{}:{}] {:?}",
322                "  ".repeat(indent),
323                node.kind(),
324                node.start_position().row + 1,
325                node.start_position().column,
326                node.end_position().row + 1,
327                node.end_position().column,
328                preview
329            );
330            for i in 0..node.child_count() {
331                if let Some(c) = node.child(i as u32) {
332                    dump(c, src, indent + 1);
333                }
334            }
335        }
336        dump(tree.root_node(), src, 0);
337    }
338
339    #[test]
340    fn rewrites_eq_comparison() {
341        let src = b"extends RefCounted\n\nfunc test_foo():\n\tassert(player.hp == 100)\n";
342        let out = rewrite_source(src, "res://test_player.gd");
343        assert!(out.changed, "expected rewrite");
344        let s = std::str::from_utf8(&out.source).unwrap();
345        assert!(s.contains("Gdt._rewrite_eq"), "missing _rewrite_eq:\n{s}");
346        assert!(s.contains("player.hp"), "missing left operand:\n{s}");
347        assert!(s.contains("100"), "missing right operand:\n{s}");
348        assert!(s.contains("test_player.gd"), "missing original file:\n{s}");
349        assert!(
350            s.contains("\"player.hp == 100\""),
351            "missing expr text:\n{s}"
352        );
353    }
354
355    #[test]
356    fn rewrites_ne() {
357        let src = b"func test_foo():\n\tassert(a != b)\n";
358        let out = rewrite_source(src, "res://test.gd");
359        assert!(out.changed);
360        let s = std::str::from_utf8(&out.source).unwrap();
361        assert!(s.contains("_rewrite_ne"), "{s}");
362    }
363
364    #[test]
365    fn rewrites_lt_lte_gt_gte() {
366        for (op, suffix) in [("<", "lt"), ("<=", "lte"), (">", "gt"), (">=", "gte")] {
367            let src = format!("func test_foo():\n\tassert(a {op} b)\n");
368            let out = rewrite_source(src.as_bytes(), "res://test.gd");
369            assert!(out.changed, "op={op}");
370            let s = std::str::from_utf8(&out.source).unwrap();
371            assert!(
372                s.contains(&format!("_rewrite_{suffix}")),
373                "op={op} missing suffix:\n{s}"
374            );
375        }
376    }
377
378    #[test]
379    fn rewrites_bool_assert() {
380        let src = b"func test_foo():\n\tassert(is_alive)\n";
381        let out = rewrite_source(src, "res://test.gd");
382        assert!(out.changed);
383        let s = std::str::from_utf8(&out.source).unwrap();
384        assert!(s.contains("Gdt._rewrite_true"), "{s}");
385        assert!(s.contains("is_alive"), "{s}");
386    }
387
388    #[test]
389    fn no_rewrite_multiline() {
390        let src = b"func test_foo():\n\tassert(\n\t\tx == y\n\t)\n";
391        let out = rewrite_source(src, "res://test.gd");
392        assert!(!out.changed, "multi-line assert must not be rewritten");
393    }
394
395    #[test]
396    fn preserves_indentation() {
397        let src = b"func test_foo():\n\tassert(x == y)\n";
398        let out = rewrite_source(src, "res://test.gd");
399        let s = std::str::from_utf8(&out.source).unwrap();
400        let line = s.lines().find(|l| l.contains("_rewrite_")).unwrap();
401        assert!(line.starts_with('\t'), "indentation lost: {line:?}");
402    }
403
404    #[test]
405    fn no_assert_unchanged() {
406        let src = b"func test_foo():\n\tvar x = 1\n";
407        let out = rewrite_source(src, "res://test.gd");
408        assert!(!out.changed);
409        assert_eq!(out.source, src);
410    }
411
412    #[test]
413    fn multiple_asserts_in_one_function() {
414        let src = b"func test_foo():\n\tassert(a == b)\n\tassert(c != d)\n\tassert(flag)\n";
415        let out = rewrite_source(src, "res://test.gd");
416        assert!(out.changed);
417        let s = std::str::from_utf8(&out.source).unwrap();
418        assert!(s.contains("_rewrite_eq"), "{s}");
419        assert!(s.contains("_rewrite_ne"), "{s}");
420        assert!(s.contains("_rewrite_true"), "{s}");
421    }
422
423    #[test]
424    fn line_number_embedded() {
425        // assert is on line 3 (1-based)
426        let src = b"extends RefCounted\n\nfunc test_foo():\n\tassert(x == y)\n";
427        let out = rewrite_source(src, "res://test.gd");
428        let s = std::str::from_utf8(&out.source).unwrap();
429        // Line 4 (1-based: extends=1, blank=2, func=3, assert=4)
430        assert!(
431            s.contains(", 4)"),
432            "line number not embedded correctly:\n{s}"
433        );
434    }
435
436    #[test]
437    fn does_not_rewrite_non_assert_calls() {
438        let src = b"func test_foo():\n\tmy_assert(x == y)\n\tassert_eq(a, b)\n";
439        let out = rewrite_source(src, "res://test.gd");
440        // my_assert and assert_eq are not the built-in assert keyword
441        assert!(
442            !out.changed,
443            "should not rewrite non-assert calls:\n{}",
444            std::str::from_utf8(&out.source).unwrap()
445        );
446    }
447}