Skip to main content

gdtest_analysis/
rewrite.rs

1//! GDScript assert rewriter: preprocessing for pytest-style rich diffs.
2//!
3//! Rewrites `assert(expr)` statements 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 asserts are rewritten; multi-line ones are left unchanged
14//! so line counts are never disturbed.
15//!
16//! In gdsyntax's tree an assert is an `ASSERT_STMT`: the `assert` keyword and
17//! an `ARG_LIST` holding the condition and an optional message. The replaced
18//! range runs from the keyword to the closing parenthesis.
19
20use std::path::Path;
21
22use anyhow::{Context, Result};
23use gdsyntax::SyntaxKind;
24use gdsyntax::ast::{AssertStmt, AstNode, BinaryOp as AstBinaryOp, Expr};
25
26// ── Public API ────────────────────────────────────────────────────────────────
27
28pub struct RewriteOutput {
29    /// Modified source bytes. Identical to input when nothing was rewritten.
30    pub source: Vec<u8>,
31    /// Whether any assert calls were actually rewritten.
32    pub changed: bool,
33}
34
35pub fn rewrite_file(path: &Path, original_res_path: &str) -> Result<RewriteOutput> {
36    let source = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
37    Ok(rewrite_source(&source, original_res_path))
38}
39
40pub fn rewrite_source(source: &[u8], original_res_path: &str) -> RewriteOutput {
41    let Some(asserts) = find_asserts(source) else {
42        return RewriteOutput {
43            source: source.to_vec(),
44            changed: false,
45        };
46    };
47    if asserts.is_empty() {
48        return RewriteOutput {
49            source: source.to_vec(),
50            changed: false,
51        };
52    }
53
54    // Collect replacements in reverse byte order so earlier positions stay valid.
55    let mut replacements: Vec<(usize, usize, String)> = Vec::new();
56    for a in &asserts {
57        if let Some(repl) = build_replacement(a, source, original_res_path) {
58            replacements.push((a.start_byte, a.end_byte, repl));
59        }
60    }
61    if replacements.is_empty() {
62        return RewriteOutput {
63            source: source.to_vec(),
64            changed: false,
65        };
66    }
67
68    replacements.sort_by_key(|r| std::cmp::Reverse(r.0));
69    let mut result = source.to_vec();
70    for (start, end, replacement) in &replacements {
71        result.splice(start..end, replacement.bytes());
72    }
73    RewriteOutput {
74        source: result,
75        changed: true,
76    }
77}
78
79// ── Internal types ────────────────────────────────────────────────────────────
80
81struct AssertInfo {
82    /// Byte range of the statement, from `assert` to `)`.
83    start_byte: usize,
84    end_byte: usize,
85    /// 1-based original line number.
86    line: u32,
87    /// Full text of the condition expression.
88    condition_text: String,
89    /// Parsed binary comparison, if the condition is one.
90    binary: Option<BinaryInfo>,
91}
92
93struct BinaryInfo {
94    op: BinaryOp,
95    left_text: String,
96    right_text: String,
97}
98
99#[derive(Clone, Copy)]
100enum BinaryOp {
101    Eq,
102    Ne,
103    Lt,
104    Lte,
105    Gt,
106    Gte,
107}
108
109impl BinaryOp {
110    fn gdt_suffix(self) -> &'static str {
111        match self {
112            BinaryOp::Eq => "eq",
113            BinaryOp::Ne => "ne",
114            BinaryOp::Lt => "lt",
115            BinaryOp::Lte => "lte",
116            BinaryOp::Gt => "gt",
117            BinaryOp::Gte => "gte",
118        }
119    }
120    fn from_ast(op: AstBinaryOp) -> Option<Self> {
121        match op {
122            AstBinaryOp::Equal => Some(BinaryOp::Eq),
123            AstBinaryOp::NotEqual => Some(BinaryOp::Ne),
124            AstBinaryOp::Less => Some(BinaryOp::Lt),
125            AstBinaryOp::LessEqual => Some(BinaryOp::Lte),
126            AstBinaryOp::Greater => Some(BinaryOp::Gt),
127            AstBinaryOp::GreaterEqual => Some(BinaryOp::Gte),
128            _ => None,
129        }
130    }
131}
132
133// ── Tree traversal ────────────────────────────────────────────────────────────
134
135fn find_asserts(source: &[u8]) -> Option<Vec<AssertInfo>> {
136    // Byte offsets index into `source`, so it must be the parsed text as is.
137    let text = std::str::from_utf8(source).ok()?;
138    let tree = gdsyntax::parse(text);
139    Some(
140        tree.root()
141            .descendants()
142            .filter_map(AssertStmt::cast)
143            .filter_map(|stmt| parse_assert(&tree, &stmt))
144            .collect(),
145    )
146}
147
148/// Returns Some if this is a complete, single-line `assert(...)`.
149fn parse_assert(tree: &gdsyntax::SyntaxTree, stmt: &AssertStmt) -> Option<AssertInfo> {
150    let args = stmt.arguments()?;
151    // An unclosed argument list is a syntax error; leave it alone.
152    if args.syntax().last_token()?.kind() != SyntaxKind::PARENTHESIS_CLOSE {
153        return None;
154    }
155    // From `assert` (after any annotation) to `)`; a comment after it is not code.
156    let start_byte: usize = stmt.assert_token()?.text_range().start().into();
157    let end_byte: usize = args.code_range()?.end().into();
158
159    // Only rewrite single-line asserts.
160    let line = tree.position(start_byte).line;
161    if tree.position(end_byte).line != line {
162        return None;
163    }
164
165    // The message is not needed: the rewritten call reports the condition.
166    let condition = stmt.condition()?;
167    let binary = match &condition {
168        Expr::Binary(binary) => (|| {
169            Some(BinaryInfo {
170                op: BinaryOp::from_ast(binary.op()?)?,
171                left_text: node_text(tree, binary.lhs()?.syntax()),
172                right_text: node_text(tree, binary.rhs()?.syntax()),
173            })
174        })(),
175        _ => None,
176    };
177
178    Some(AssertInfo {
179        start_byte,
180        end_byte,
181        line: line as u32,
182        condition_text: node_text(tree, condition.syntax()),
183        binary,
184    })
185}
186
187/// The source text of a node's code, without surrounding whitespace or comments.
188fn node_text(tree: &gdsyntax::SyntaxTree, node: &gdsyntax::SyntaxNode) -> String {
189    gdsyntax::ast::code_range(node)
190        .map(|range| tree.source()[range].to_string())
191        .unwrap_or_default()
192}
193
194// ── Code generation ───────────────────────────────────────────────────────────
195
196fn build_replacement(info: &AssertInfo, source: &[u8], original_res_path: &str) -> Option<String> {
197    let indent = leading_whitespace(info.start_byte, source).to_string();
198    let file = escape_gdstring(original_res_path);
199    let line = info.line;
200
201    let repl = if let Some(bin) = &info.binary {
202        let expr = escape_gdstring(&info.condition_text);
203        format!(
204            r#"{indent}Gdt._rewrite_{suffix}({lhs}, {rhs}, "{expr}", "{file}", {line})"#,
205            suffix = bin.op.gdt_suffix(),
206            lhs = bin.left_text,
207            rhs = bin.right_text,
208        )
209    } else {
210        let expr = escape_gdstring(&info.condition_text);
211        format!(
212            r#"{indent}Gdt._rewrite_true({cond}, "{expr}", "{file}", {line})"#,
213            cond = info.condition_text,
214        )
215    };
216
217    Some(repl)
218}
219
220fn leading_whitespace(pos: usize, source: &[u8]) -> &str {
221    let line_start = source[..pos]
222        .iter()
223        .rposition(|&b| b == b'\n')
224        .map(|p| p + 1)
225        .unwrap_or(0);
226    let prefix = &source[line_start..pos];
227    let ws_end = prefix
228        .iter()
229        .take_while(|&&b| b == b' ' || b == b'\t')
230        .count();
231    std::str::from_utf8(&prefix[..ws_end]).unwrap_or("")
232}
233
234fn escape_gdstring(s: &str) -> String {
235    s.replace('\\', "\\\\").replace('"', "\\\"")
236}
237
238// ── Tests ─────────────────────────────────────────────────────────────────────
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn rewrites_eq_comparison() {
246        let src = b"extends RefCounted\n\nfunc test_foo():\n\tassert(player.hp == 100)\n";
247        let out = rewrite_source(src, "res://test_player.gd");
248        assert!(out.changed, "expected rewrite");
249        let s = std::str::from_utf8(&out.source).unwrap();
250        assert!(s.contains("Gdt._rewrite_eq"), "missing _rewrite_eq:\n{s}");
251        assert!(s.contains("player.hp"), "missing left operand:\n{s}");
252        assert!(s.contains("100"), "missing right operand:\n{s}");
253        assert!(s.contains("test_player.gd"), "missing original file:\n{s}");
254        assert!(
255            s.contains("\"player.hp == 100\""),
256            "missing expr text:\n{s}"
257        );
258    }
259
260    #[test]
261    fn rewrites_ne() {
262        let src = b"func test_foo():\n\tassert(a != b)\n";
263        let out = rewrite_source(src, "res://test.gd");
264        assert!(out.changed);
265        let s = std::str::from_utf8(&out.source).unwrap();
266        assert!(s.contains("_rewrite_ne"), "{s}");
267    }
268
269    #[test]
270    fn rewrites_lt_lte_gt_gte() {
271        for (op, suffix) in [("<", "lt"), ("<=", "lte"), (">", "gt"), (">=", "gte")] {
272            let src = format!("func test_foo():\n\tassert(a {op} b)\n");
273            let out = rewrite_source(src.as_bytes(), "res://test.gd");
274            assert!(out.changed, "op={op}");
275            let s = std::str::from_utf8(&out.source).unwrap();
276            assert!(
277                s.contains(&format!("_rewrite_{suffix}")),
278                "op={op} missing suffix:\n{s}"
279            );
280        }
281    }
282
283    #[test]
284    fn rewrites_bool_assert() {
285        let src = b"func test_foo():\n\tassert(is_alive)\n";
286        let out = rewrite_source(src, "res://test.gd");
287        assert!(out.changed);
288        let s = std::str::from_utf8(&out.source).unwrap();
289        assert!(s.contains("Gdt._rewrite_true"), "{s}");
290        assert!(s.contains("is_alive"), "{s}");
291    }
292
293    #[test]
294    fn no_rewrite_multiline() {
295        let src = b"func test_foo():\n\tassert(\n\t\tx == y\n\t)\n";
296        let out = rewrite_source(src, "res://test.gd");
297        assert!(!out.changed, "multi-line assert must not be rewritten");
298    }
299
300    #[test]
301    fn preserves_indentation() {
302        let src = b"func test_foo():\n\tassert(x == y)\n";
303        let out = rewrite_source(src, "res://test.gd");
304        let s = std::str::from_utf8(&out.source).unwrap();
305        let line = s.lines().find(|l| l.contains("_rewrite_")).unwrap();
306        assert!(line.starts_with('\t'), "indentation lost: {line:?}");
307    }
308
309    #[test]
310    fn no_assert_unchanged() {
311        let src = b"func test_foo():\n\tvar x = 1\n";
312        let out = rewrite_source(src, "res://test.gd");
313        assert!(!out.changed);
314        assert_eq!(out.source, src);
315    }
316
317    #[test]
318    fn multiple_asserts_in_one_function() {
319        let src = b"func test_foo():\n\tassert(a == b)\n\tassert(c != d)\n\tassert(flag)\n";
320        let out = rewrite_source(src, "res://test.gd");
321        assert!(out.changed);
322        let s = std::str::from_utf8(&out.source).unwrap();
323        assert!(s.contains("_rewrite_eq"), "{s}");
324        assert!(s.contains("_rewrite_ne"), "{s}");
325        assert!(s.contains("_rewrite_true"), "{s}");
326    }
327
328    #[test]
329    fn line_number_embedded() {
330        // assert is on line 3 (1-based)
331        let src = b"extends RefCounted\n\nfunc test_foo():\n\tassert(x == y)\n";
332        let out = rewrite_source(src, "res://test.gd");
333        let s = std::str::from_utf8(&out.source).unwrap();
334        // Line 4 (1-based: extends=1, blank=2, func=3, assert=4)
335        assert!(
336            s.contains(", 4)"),
337            "line number not embedded correctly:\n{s}"
338        );
339    }
340
341    #[test]
342    fn keeps_message_out_and_comment_after() {
343        let src = b"func test_foo():\n\tassert(a == b, \"msg\")  # note\n";
344        let out = rewrite_source(src, "res://test.gd");
345        let s = std::str::from_utf8(&out.source).unwrap();
346        assert!(
347            s.contains("\tGdt._rewrite_eq(a, b, \"a == b\", \"res://test.gd\", 2)  # note\n"),
348            "{s}"
349        );
350    }
351
352    #[test]
353    fn rewrites_annotated_asserts() {
354        let src = b"func test_foo():\n\t@warning_ignore(\"assert_always_true\")\n\tassert(true)\n";
355        let out = rewrite_source(src, "res://test.gd");
356        assert!(out.changed);
357    }
358
359    #[test]
360    fn rewrites_asserts_in_lambdas() {
361        let src = b"func test_foo():\n\tvar f := func(): assert(x)\n";
362        let out = rewrite_source(src, "res://test.gd");
363        assert!(out.changed);
364    }
365
366    #[test]
367    fn does_not_rewrite_non_assert_calls() {
368        let src = b"func test_foo():\n\tmy_assert(x == y)\n\tassert_eq(a, b)\n";
369        let out = rewrite_source(src, "res://test.gd");
370        // my_assert and assert_eq are not the built-in assert keyword
371        assert!(
372            !out.changed,
373            "should not rewrite non-assert calls:\n{}",
374            std::str::from_utf8(&out.source).unwrap()
375        );
376    }
377}