1use std::path::Path;
21
22use anyhow::{Context, Result};
23use gdsyntax::SyntaxKind;
24use gdsyntax::ast::{AssertStmt, AstNode, BinaryOp as AstBinaryOp, Expr};
25
26pub struct RewriteOutput {
29 pub source: Vec<u8>,
31 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 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
79struct AssertInfo {
82 start_byte: usize,
84 end_byte: usize,
85 line: u32,
87 condition_text: String,
89 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
133fn find_asserts(source: &[u8]) -> Option<Vec<AssertInfo>> {
136 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
148fn parse_assert(tree: &gdsyntax::SyntaxTree, stmt: &AssertStmt) -> Option<AssertInfo> {
150 let args = stmt.arguments()?;
151 if args.syntax().last_token()?.kind() != SyntaxKind::PARENTHESIS_CLOSE {
153 return None;
154 }
155 let start_byte: usize = stmt.assert_token()?.text_range().start().into();
157 let end_byte: usize = args.code_range()?.end().into();
158
159 let line = tree.position(start_byte).line;
161 if tree.position(end_byte).line != line {
162 return None;
163 }
164
165 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
187fn 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
194fn 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#[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 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 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 assert!(
372 !out.changed,
373 "should not rewrite non-assert calls:\n{}",
374 std::str::from_utf8(&out.source).unwrap()
375 );
376 }
377}