Skip to main content

rhai_analyzer/
lib.rs

1#![doc = include_str!("../README.md")]
2
3//! Static analysis utilities for Rhai scripts.
4//!
5//! Traverses a compiled [`rhai::AST`] to extract variable access paths,
6//! local variable definitions, and string literal comparisons. The output is
7//! entirely domain-agnostic — callers decide what to do with the results.
8
9use std::collections::{HashMap, HashSet};
10
11use rhai::{AST, ASTNode, Expr, Stmt};
12
13/// The result of a static analysis pass over a Rhai [`AST`].
14#[derive(Debug, Default)]
15pub struct ScriptAnalysisResult {
16    /// All unique, fully-qualified variable paths accessed in the script
17    /// (e.g. `"tx.value"`, `"log.name"`).
18    pub accessed_variables: HashSet<String>,
19
20    /// Local variables defined within the script via `let` or loop iteration
21    /// variables.
22    pub local_variables: HashSet<String>,
23
24    /// Maps a fully-qualified variable path to the set of string literals it
25    /// is compared against (via `==` or `!=`) anywhere in the script.
26    ///
27    /// Example: `log.name == "Transfer"` produces
28    /// `"log.name" => {"Transfer"}`.
29    pub string_comparisons: HashMap<String, HashSet<String>>,
30}
31
32/// Extension trait that adds static analysis capabilities directly to
33/// [`rhai::AST`].
34///
35/// Import this trait to call the analysis methods on any compiled Rhai AST:
36///
37/// ```rust
38/// use rhai::Engine;
39/// use rhai_analyzer::RhaiAnalyzerExt;
40///
41/// let engine = Engine::new();
42/// let ast = engine.compile(r#"if user.balance > 100 { send_alert(user.email); }"#).unwrap();
43///
44/// let vars = ast.accessed_variables();
45/// assert!(vars.contains("user.balance"));
46/// assert!(vars.contains("user.email"));
47/// ```
48pub trait RhaiAnalyzerExt {
49    /// Returns the full [`ScriptAnalysisResult`] for the script.
50    fn analyze(&self) -> ScriptAnalysisResult;
51
52    /// Returns all unique, fully-qualified variable paths accessed by the
53    /// script (e.g. `"tx.value"`, `"log.name"`).
54    fn accessed_variables(&self) -> HashSet<String> {
55        self.analyze().accessed_variables
56    }
57
58    /// Returns the set of string literals that `variable_path` is compared
59    /// against (via `==` or `!=`) anywhere in the script.
60    fn string_comparisons_for(&self, variable_path: &str) -> HashSet<String> {
61        self.analyze()
62            .string_comparisons
63            .remove(variable_path)
64            .unwrap_or_default()
65    }
66}
67
68impl RhaiAnalyzerExt for AST {
69    fn analyze(&self) -> ScriptAnalysisResult {
70        analyze_ast(self)
71    }
72}
73
74/// Traverses a compiled [`AST`] and returns a [`ScriptAnalysisResult`].
75///
76/// This is the primary entry point for the analyzer.
77pub fn analyze_ast(ast: &AST) -> ScriptAnalysisResult {
78    let mut result = ScriptAnalysisResult::default();
79
80    ast.walk(&mut |nodes: &[ASTNode]| {
81        let parent = nodes.len().checked_sub(2).map(|i| &nodes[i]);
82
83        match nodes.last().unwrap() {
84            // ---------------------------------------------------------------
85            // Statement nodes
86            // ---------------------------------------------------------------
87            ASTNode::Stmt(stmt) => match *stmt {
88                // Track locally-defined variables.
89                Stmt::Var(var_def, _, _) => {
90                    result.local_variables.insert(var_def.0.name.to_string());
91                }
92                Stmt::For(for_loop, _) => {
93                    result.local_variables.insert(for_loop.0.name.to_string());
94                    if let Some(counter) = &for_loop.1 {
95                        result.local_variables.insert(counter.name.to_string());
96                    }
97                }
98                // Rhai compiles a bare `a == "b"` as Stmt::FnCall (not
99                // Stmt::Expr), so the FnCall is never surfaced as
100                // ASTNode::Expr. Check for comparisons here.
101                Stmt::FnCall(fn_call, _)
102                    if fn_call.namespace.is_empty() && fn_call.args.len() == 2 =>
103                {
104                    match fn_call.name.as_str() {
105                        "==" | "!=" => {
106                            record_string_comparison(
107                                &fn_call.args[0],
108                                &fn_call.args[1],
109                                &mut result,
110                            );
111                            record_string_comparison(
112                                &fn_call.args[1],
113                                &fn_call.args[0],
114                                &mut result,
115                            );
116                        }
117                        _ => {}
118                    }
119                }
120                _ => {}
121            },
122
123            // ---------------------------------------------------------------
124            // Expression nodes
125            // ---------------------------------------------------------------
126            ASTNode::Expr(expr) => {
127                // String comparison detection (single node; walker recurses
128                // for us into FnCall args, And, Or, etc.).
129                if let Expr::FnCall(fn_call, _) = *expr
130                    && fn_call.namespace.is_empty()
131                    && fn_call.args.len() == 2
132                {
133                    match fn_call.name.as_str() {
134                        "==" | "!=" => {
135                            record_string_comparison(
136                                &fn_call.args[0],
137                                &fn_call.args[1],
138                                &mut result,
139                            );
140                            record_string_comparison(
141                                &fn_call.args[1],
142                                &fn_call.args[0],
143                                &mut result,
144                            );
145                        }
146                        _ => {}
147                    }
148                }
149
150                // Variable path tracking.
151                // Skip Property nodes — they are only the rhs component of a
152                // Dot expression and are covered when the parent Dot node is
153                // visited.  Inserting them standalone would wrongly emit bare
154                // field names from chains like `arr[0].name`.
155                if !matches!(*expr, Expr::Property(..))
156                    && let Some(path) = get_full_variable_path(expr)
157                {
158                    if !parent_subsumes(parent) {
159                        result.accessed_variables.insert(path);
160                    }
161                    // Index expressions also expose their index operand as
162                    // a separate access.
163                    if let Expr::Index(bin, _, _) = *expr
164                        && let Some(idx_path) = get_full_variable_path(&bin.rhs)
165                    {
166                        result.accessed_variables.insert(idx_path);
167                    }
168                }
169            }
170
171            _ => {}
172        }
173
174        true
175    });
176
177    result
178}
179
180// ---------------------------------------------------------------------------
181// Parent-subsumption check
182// ---------------------------------------------------------------------------
183
184/// Returns `true` when the current expression's variable path is already
185/// covered by its parent, so we should not insert it separately.
186///
187/// * A [`Expr::Dot`] parent that forms a complete path will have inserted the
188///   longer dotted path (e.g. `"tx.value"`) itself, so we skip the child
189///   `Variable("tx")`.
190/// * An [`Expr::Index`] parent handles both the lhs path and the rhs index
191///   path explicitly, so we skip both children.
192fn parent_subsumes(parent: Option<&ASTNode>) -> bool {
193    match parent {
194        Some(ASTNode::Expr(parent_expr)) => match *parent_expr {
195            Expr::Dot(..) => is_variable_path(parent_expr),
196            Expr::Index(..) => true,
197            _ => false,
198        },
199        _ => false,
200    }
201}
202
203/// Returns `true` if `expr` has the shape of a variable path (chain of
204/// [`Expr::Dot`] / [`Expr::Property`] / [`Expr::Variable`] / [`Expr::Index`]
205/// lhs), without allocating any strings.
206fn is_variable_path(expr: &Expr) -> bool {
207    match expr {
208        Expr::Dot(bin, _, _) => is_variable_path(&bin.lhs) && is_variable_path(&bin.rhs),
209        Expr::Property(..) | Expr::Variable(..) => true,
210        Expr::Index(bin, _, _) => is_variable_path(&bin.lhs),
211        _ => false,
212    }
213}
214
215// ---------------------------------------------------------------------------
216// Path reconstruction
217// ---------------------------------------------------------------------------
218
219/// Attempts to reconstruct a full dotted variable path (e.g. `"tx.value"`)
220/// from an expression.
221fn get_full_variable_path(expr: &Expr) -> Option<String> {
222    fn collect_path(expr: &Expr, parts: &mut Vec<String>) -> bool {
223        match expr {
224            Expr::Dot(binary_expr, _, _) => {
225                collect_path(&binary_expr.lhs, parts) && collect_path(&binary_expr.rhs, parts)
226            }
227            Expr::Property(prop_info, _) => {
228                parts.push(prop_info.2.to_string());
229                true
230            }
231            Expr::Variable(var_info, _, _) => {
232                parts.push(var_info.1.to_string());
233                true
234            }
235            Expr::Index(binary_expr, _, _) => collect_path(&binary_expr.lhs, parts),
236            _ => false,
237        }
238    }
239
240    let mut path_parts = Vec::new();
241    if collect_path(expr, &mut path_parts) && !path_parts.is_empty() {
242        Some(path_parts.join("."))
243    } else {
244        None
245    }
246}
247
248// ---------------------------------------------------------------------------
249// String comparison tracking
250// ---------------------------------------------------------------------------
251
252/// If `lhs` is a variable path and `rhs` is a string literal, records the
253/// comparison in `result.string_comparisons`.
254fn record_string_comparison(lhs: &Expr, rhs: &Expr, result: &mut ScriptAnalysisResult) {
255    if let Some(var_path) = get_full_variable_path(lhs)
256        && let Expr::StringConstant(string_val, _) = rhs
257    {
258        result
259            .string_comparisons
260            .entry(var_path)
261            .or_default()
262            .insert(string_val.to_string());
263    }
264}
265
266// ---------------------------------------------------------------------------
267// Tests
268// ---------------------------------------------------------------------------
269
270#[cfg(test)]
271mod tests {
272    use rhai::{Engine, ParseError};
273
274    use super::*;
275
276    fn compile(script: &str) -> Result<rhai::AST, ParseError> {
277        Engine::new().compile(script)
278    }
279
280    fn analyze_script(script: &str) -> Result<ScriptAnalysisResult, ParseError> {
281        Ok(analyze_ast(&compile(script)?))
282    }
283
284    // -----------------------------------------------------------------------
285    // RhaiAnalyzerExt trait
286    // -----------------------------------------------------------------------
287
288    #[test]
289    fn test_trait_accessed_variables() {
290        let ast = compile("if user.balance > 100 { send_alert(user.email); }").unwrap();
291        let vars = ast.accessed_variables();
292        assert!(vars.contains("user.balance"));
293        assert!(vars.contains("user.email"));
294    }
295
296    #[test]
297    fn test_trait_string_comparisons_for() {
298        let ast = compile(r#"log.name == "Transfer" || log.name == "Approval""#).unwrap();
299        let vals = ast.string_comparisons_for("log.name");
300        assert_eq!(
301            vals,
302            HashSet::from(["Transfer".to_string(), "Approval".to_string()])
303        );
304    }
305
306    #[test]
307    fn test_trait_string_comparisons_for_missing() {
308        let ast = compile("tx.value > 100").unwrap();
309        assert!(ast.string_comparisons_for("tx.value").is_empty());
310    }
311
312    #[test]
313    fn test_trait_analyze_returns_full_result() {
314        let ast = compile(r#"let x = 1; log.name == "Transfer""#).unwrap();
315        let result = ast.analyze();
316        assert!(result.local_variables.contains("x"));
317        assert!(result.string_comparisons.contains_key("log.name"));
318    }
319
320    #[test]
321    fn test_simple_binary_op() {
322        let result = analyze_script("tx.value > 100").unwrap();
323        assert_eq!(
324            result.accessed_variables,
325            HashSet::from(["tx.value".to_string()])
326        );
327    }
328
329    #[test]
330    fn test_logical_operators() {
331        let script = r#"tx.from == owner && log.name != "Transfer" || block.number > 1000"#;
332        let result = analyze_script(script).unwrap();
333        assert_eq!(
334            result.accessed_variables,
335            HashSet::from([
336                "tx.from".to_string(),
337                "owner".to_string(),
338                "log.name".to_string(),
339                "block.number".to_string(),
340            ])
341        );
342    }
343
344    #[test]
345    fn test_multiple_variables_and_coalesce() {
346        let result = analyze_script("tx.from ?? fallback_addr.address").unwrap();
347        assert_eq!(
348            result.accessed_variables,
349            HashSet::from(["tx.from".to_string(), "fallback_addr.address".to_string()])
350        );
351    }
352
353    #[test]
354    fn test_deeply_nested_variable() {
355        let script = r#"log.params.level_one.level_two.user == "admin""#;
356        let result = analyze_script(script).unwrap();
357        assert_eq!(
358            result.accessed_variables,
359            HashSet::from(["log.params.level_one.level_two.user".to_string()])
360        );
361    }
362
363    #[test]
364    fn test_variables_in_function_calls() {
365        let result = analyze_script("my_func(tx.value, log.params.user, 42)").unwrap();
366        assert_eq!(
367            result.accessed_variables,
368            HashSet::from(["tx.value".to_string(), "log.params.user".to_string()])
369        );
370    }
371
372    #[test]
373    fn test_variables_in_let_and_if() {
374        let script = r#"
375            let threshold = config.min_value;
376            if tx.value > threshold && tx.to != blacklist.address {
377                true
378            } else {
379                false
380            }
381        "#;
382        let result = analyze_script(script).unwrap();
383        assert_eq!(
384            result.accessed_variables,
385            HashSet::from([
386                "config.min_value".to_string(),
387                "tx.value".to_string(),
388                "threshold".to_string(),
389                "tx.to".to_string(),
390                "blacklist.address".to_string()
391            ])
392        );
393    }
394
395    #[test]
396    fn test_variables_in_loops() {
397        let script = r#"
398            for item in tx.items {
399                if item.cost > max_cost {
400                    return false;
401                }
402            }
403            while x < limit {
404                x = x + 1;
405            }
406        "#;
407        let result = analyze_script(script).unwrap();
408        assert_eq!(
409            result.accessed_variables,
410            HashSet::from([
411                "tx.items".to_string(),
412                "item.cost".to_string(),
413                "max_cost".to_string(),
414                "x".to_string(),
415                "limit".to_string(),
416            ])
417        );
418    }
419
420    #[test]
421    fn test_variables_in_strings_or_comments_are_ignored() {
422        let script = r#"
423            // This is a comment about tx.value
424            let x = "this string mentions log.name";
425            tx.from == "0x123"
426        "#;
427        let result = analyze_script(script).unwrap();
428        assert_eq!(
429            result.accessed_variables,
430            HashSet::from(["tx.from".to_string()])
431        );
432    }
433
434    #[test]
435    fn test_indexing_expression() {
436        let script = r#"tx.logs[0].name == "Transfer" && some_array[tx.index] > 100"#;
437        let result = analyze_script(script).unwrap();
438        assert_eq!(
439            result.accessed_variables,
440            HashSet::from([
441                "tx.logs".to_string(),
442                "some_array".to_string(),
443                "tx.index".to_string(),
444            ])
445        );
446    }
447
448    #[test]
449    fn test_method_calls() {
450        let script = r#"my_array.contains(tx.value) && other_var.to_string() == "hello""#;
451        let result = analyze_script(script).unwrap();
452        assert_eq!(
453            result.accessed_variables,
454            HashSet::from([
455                "my_array".to_string(),
456                "tx.value".to_string(),
457                "other_var".to_string(),
458            ])
459        );
460    }
461
462    #[test]
463    fn test_switch_statement() {
464        let script = r#"
465            switch tx.action {
466                "transfer" => do_transfer(log.params.amount),
467                "approve" if log.approved => do_approve(),
468                _ => do_nothing(contract.address)
469            }
470        "#;
471        let result = analyze_script(script).unwrap();
472        assert_eq!(
473            result.accessed_variables,
474            HashSet::from([
475                "tx.action".to_string(),
476                "log.params.amount".to_string(),
477                "log.approved".to_string(),
478                "contract.address".to_string(),
479            ])
480        );
481    }
482
483    #[test]
484    fn test_no_variables() {
485        let result = analyze_script("1 + 1 == 2").unwrap();
486        assert!(result.accessed_variables.is_empty());
487    }
488
489    #[test]
490    fn test_array_and_map_literals() {
491        let script = r#"
492            let my_array = [tx.value, log.topic];
493            let my_map = #{ a: some.value, b: 42 };
494            my_array[0] > my_map.a
495        "#;
496        let result = analyze_script(script).unwrap();
497        assert_eq!(
498            result.accessed_variables,
499            HashSet::from([
500                "tx.value".to_string(),
501                "log.topic".to_string(),
502                "some.value".to_string(),
503                "my_array".to_string(),
504                "my_map.a".to_string(),
505            ])
506        );
507    }
508
509    #[test]
510    fn test_string_comparison_simple() {
511        let result = analyze_script(r#"log.name == "Transfer""#).unwrap();
512        assert_eq!(
513            result.accessed_variables,
514            HashSet::from(["log.name".to_string()])
515        );
516        let names = result.string_comparisons.get("log.name").unwrap();
517        assert_eq!(names, &HashSet::from(["Transfer".to_string()]));
518    }
519
520    #[test]
521    fn test_string_comparison_reversed() {
522        let result = analyze_script(r#""Approval" == log.name"#).unwrap();
523        let names = result.string_comparisons.get("log.name").unwrap();
524        assert_eq!(names, &HashSet::from(["Approval".to_string()]));
525    }
526
527    #[test]
528    fn test_string_comparison_in_logical_or() {
529        let result = analyze_script(r#"tx.value > 100 || log.name == "Deposit""#).unwrap();
530        let names = result.string_comparisons.get("log.name").unwrap();
531        assert_eq!(names, &HashSet::from(["Deposit".to_string()]));
532    }
533
534    #[test]
535    fn test_string_comparison_multiple_values() {
536        let result = analyze_script(r#"log.name == "Transfer" || log.name == "Approval""#).unwrap();
537        let names = result.string_comparisons.get("log.name").unwrap();
538        assert_eq!(
539            names,
540            &HashSet::from(["Transfer".to_string(), "Approval".to_string()])
541        );
542    }
543
544    #[test]
545    fn test_string_comparison_inequality() {
546        let result = analyze_script(r#"log.name != "Transfer""#).unwrap();
547        let names = result.string_comparisons.get("log.name").unwrap();
548        assert_eq!(names, &HashSet::from(["Transfer".to_string()]));
549    }
550
551    #[test]
552    fn test_string_comparison_different_path() {
553        let result = analyze_script(r#"tx.status == "success" && tx.type != "mint""#).unwrap();
554        let statuses = result.string_comparisons.get("tx.status").unwrap();
555        assert_eq!(statuses, &HashSet::from(["success".to_string()]));
556        let types = result.string_comparisons.get("tx.type").unwrap();
557        assert_eq!(types, &HashSet::from(["mint".to_string()]));
558    }
559}