Skip to main content

gpui_rhai/
script_lint.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3
4use rhai::{AST, ASTNode, Engine, Expr, FnAccess, OptimizationLevel, Stmt};
5use serde::Deserialize;
6use thiserror::Error;
7
8use crate::ModuleId;
9
10#[derive(Clone, Debug, Eq, PartialEq)]
11/// One type-agnostic Rhai function-name or arity diagnostic.
12pub struct KnownCallDiagnostic {
13    /// Diagnostic source name supplied by the Host.
14    pub source: String,
15    /// One-based source line, when Rhai retained it in the AST.
16    pub line: Option<usize>,
17    /// One-based source column, when Rhai retained it in the AST.
18    pub column: Option<usize>,
19    /// Qualified call namespace, or `None` for direct and method calls.
20    pub namespace: Option<String>,
21    /// Called function or method name.
22    pub function: String,
23    /// Number of explicit arguments at the call site.
24    pub arity: usize,
25    /// Known valid explicit arities; empty means the name is unknown.
26    pub expected_arities: Vec<usize>,
27}
28
29impl fmt::Display for KnownCallDiagnostic {
30    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31        write!(formatter, "{}", self.source)?;
32        if let Some(line) = self.line {
33            write!(formatter, ":{line}")?;
34            if let Some(column) = self.column {
35                write!(formatter, ":{column}")?;
36            }
37        }
38        let qualified = self.namespace.as_ref().map_or_else(
39            || self.function.clone(),
40            |namespace| format!("{namespace}::{}", self.function),
41        );
42        if self.expected_arities.is_empty() {
43            write!(
44                formatter,
45                ": unknown function call `{qualified}/{}`",
46                self.arity
47            )
48        } else {
49            let expected = self
50                .expected_arities
51                .iter()
52                .map(usize::to_string)
53                .collect::<Vec<_>>()
54                .join(", ");
55            write!(
56                formatter,
57                ": call `{qualified}/{}` has the wrong arity; expected {expected}",
58                self.arity
59            )
60        }
61    }
62}
63
64#[derive(Debug, Error)]
65/// Failure to construct or parse inputs for known-call validation.
66pub enum KnownCallLintError {
67    #[error("failed to read registered Rhai function metadata: {0}")]
68    Metadata(#[from] serde_json::Error),
69    #[error("failed to parse `{source_name}` for known-call validation: {message}")]
70    Parse {
71        source_name: String,
72        message: String,
73    },
74}
75
76#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
77#[serde(rename_all = "camelCase")]
78enum MetadataFunctionType {
79    Native,
80    Script,
81}
82
83#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
84#[serde(rename_all = "camelCase")]
85enum MetadataNamespace {
86    Internal,
87    Global,
88}
89
90#[derive(Debug, Deserialize)]
91#[serde(rename_all = "camelCase")]
92struct MetadataFunction {
93    name: String,
94    namespace: MetadataNamespace,
95    #[serde(rename = "type")]
96    function_type: MetadataFunctionType,
97    num_params: usize,
98}
99
100#[derive(Debug, Default, Deserialize)]
101struct MetadataModule {
102    #[serde(default)]
103    modules: BTreeMap<String, Self>,
104    #[serde(default)]
105    functions: Vec<MetadataFunction>,
106}
107
108#[derive(Clone, Debug, Default)]
109struct FunctionSet {
110    direct: BTreeMap<String, BTreeSet<usize>>,
111    method: BTreeMap<String, BTreeSet<usize>>,
112}
113
114impl FunctionSet {
115    fn insert_direct(&mut self, name: impl Into<String>, arity: usize) {
116        self.direct.entry(name.into()).or_default().insert(arity);
117    }
118
119    fn insert_method(&mut self, name: impl Into<String>, arity: usize) {
120        self.method.entry(name.into()).or_default().insert(arity);
121    }
122}
123
124#[derive(Clone, Debug, Default)]
125struct KnownCallCatalog {
126    global: FunctionSet,
127    modules: BTreeMap<String, FunctionSet>,
128}
129
130impl KnownCallCatalog {
131    fn from_engine(engine: &Engine) -> Result<Self, KnownCallLintError> {
132        let metadata: MetadataModule =
133            serde_json::from_str(&engine.gen_fn_metadata_to_json(true)?)?;
134        let mut catalog = Self::default();
135        catalog.ingest_metadata_module("", &metadata);
136        catalog.insert_language_intrinsics();
137        Ok(catalog)
138    }
139
140    fn insert_language_intrinsics(&mut self) {
141        // Rhai 1.26 implements these in evaluator control flow and emits them
142        // in builtin-functions.d.rhai, so they are intentionally absent from
143        // Engine function metadata. Keep this list characterized with the
144        // pinned Rhai version.
145        for (name, arities) in [
146            ("print", &[1][..]),
147            ("debug", &[1][..]),
148            ("type_of", &[1][..]),
149            ("Fn", &[1][..]),
150            ("call", &[1][..]),
151            ("curry", &[2][..]),
152            ("is_def_fn", &[2, 3][..]),
153            ("is_def_var", &[1][..]),
154            ("is_shared", &[1][..]),
155            ("eval", &[1][..]),
156        ] {
157            for arity in arities {
158                self.global.insert_direct(name, *arity);
159            }
160        }
161        for (name, arity) in [
162            ("print", 0),
163            ("debug", 0),
164            ("type_of", 0),
165            ("call", 0),
166            ("curry", 1),
167            ("is_shared", 0),
168        ] {
169            self.global.insert_method(name, arity);
170        }
171    }
172
173    fn ingest_metadata_module(&mut self, path: &str, module: &MetadataModule) {
174        for function in &module.functions {
175            if path.is_empty() {
176                if function.namespace == MetadataNamespace::Global {
177                    self.global
178                        .insert_direct(function.name.clone(), function.num_params);
179                }
180                match function.function_type {
181                    MetadataFunctionType::Native if function.num_params > 0 => self
182                        .global
183                        .insert_method(function.name.clone(), function.num_params - 1),
184                    MetadataFunctionType::Script => self
185                        .global
186                        .insert_method(function.name.clone(), function.num_params),
187                    MetadataFunctionType::Native => {}
188                }
189            } else {
190                self.modules
191                    .entry(path.to_owned())
192                    .or_default()
193                    .insert_direct(function.name.clone(), function.num_params);
194            }
195        }
196        for (name, child) in &module.modules {
197            let child_path = if path.is_empty() {
198                name.clone()
199            } else {
200                format!("{path}::{name}")
201            };
202            self.ingest_metadata_module(&child_path, child);
203        }
204    }
205}
206
207struct ParsedSource {
208    name: String,
209    module: Option<String>,
210    ast: AST,
211}
212
213pub(crate) fn lint_known_calls(
214    engine: &mut Engine,
215    entry_name: &str,
216    entry_source: &str,
217    modules: &BTreeMap<ModuleId, String>,
218) -> Result<Vec<KnownCallDiagnostic>, KnownCallLintError> {
219    let catalog = KnownCallCatalog::from_engine(engine)?;
220    let previous_optimization = engine.optimization_level();
221    engine.set_optimization_level(OptimizationLevel::None);
222    let parsed = parse_sources(engine, entry_name, entry_source, modules);
223    engine.set_optimization_level(previous_optimization);
224    let parsed = parsed?;
225
226    let mut module_functions = BTreeMap::<String, FunctionSet>::new();
227    for source in &parsed {
228        if let Some(module) = &source.module {
229            let functions = module_functions.entry(module.clone()).or_default();
230            for function in source.ast.iter_functions() {
231                if function.access == FnAccess::Public {
232                    functions.insert_direct(function.name, function.params.len());
233                }
234            }
235        }
236    }
237
238    let mut diagnostics = Vec::new();
239    for source in &parsed {
240        lint_source(source, &catalog, &module_functions, &mut diagnostics);
241    }
242    diagnostics.sort_by(|left, right| {
243        (
244            &left.source,
245            left.line,
246            left.column,
247            &left.namespace,
248            &left.function,
249            left.arity,
250        )
251            .cmp(&(
252                &right.source,
253                right.line,
254                right.column,
255                &right.namespace,
256                &right.function,
257                right.arity,
258            ))
259    });
260    diagnostics.dedup();
261    Ok(diagnostics)
262}
263
264fn parse_sources(
265    engine: &Engine,
266    entry_name: &str,
267    entry_source: &str,
268    modules: &BTreeMap<ModuleId, String>,
269) -> Result<Vec<ParsedSource>, KnownCallLintError> {
270    let mut parsed = Vec::with_capacity(modules.len() + 1);
271    parsed.push(parse_source(engine, entry_name, None, entry_source)?);
272    for (module, source) in modules {
273        parsed.push(parse_source(
274            engine,
275            module.as_str(),
276            Some(module.as_str()),
277            source,
278        )?);
279    }
280    Ok(parsed)
281}
282
283fn parse_source(
284    engine: &Engine,
285    name: &str,
286    module: Option<&str>,
287    source: &str,
288) -> Result<ParsedSource, KnownCallLintError> {
289    let mut ast = engine
290        .compile(source)
291        .map_err(|error| KnownCallLintError::Parse {
292            source_name: name.to_owned(),
293            message: error.to_string(),
294        })?;
295    ast.set_source(name);
296    Ok(ParsedSource {
297        name: name.to_owned(),
298        module: module.map(ToOwned::to_owned),
299        ast,
300    })
301}
302
303fn lint_source(
304    source: &ParsedSource,
305    catalog: &KnownCallCatalog,
306    module_functions: &BTreeMap<String, FunctionSet>,
307    diagnostics: &mut Vec<KnownCallDiagnostic>,
308) {
309    let mut local = FunctionSet::default();
310    for function in source.ast.iter_functions() {
311        local.insert_direct(function.name, function.params.len());
312        local.insert_method(function.name, function.params.len());
313    }
314    let imports = collect_imports(&source.ast);
315    source.ast.walk(&mut |path| {
316        match path.last() {
317            Some(
318                ASTNode::Expr(Expr::FnCall(call, position))
319                | ASTNode::Stmt(Stmt::FnCall(call, position)),
320            ) if !call.is_operator_call() => {
321                let namespace = (!call.namespace.is_empty()).then(|| call.namespace.to_string());
322                if namespace.is_none()
323                    && language_variadic_accepts(&call.name, false, call.args.len())
324                {
325                    return true;
326                }
327                let expected = direct_arities(
328                    catalog,
329                    module_functions,
330                    &local,
331                    &imports,
332                    namespace.as_deref(),
333                    &call.name,
334                );
335                push_if_invalid(
336                    diagnostics,
337                    source,
338                    *position,
339                    namespace,
340                    &call.name,
341                    call.args.len(),
342                    expected,
343                );
344            }
345            Some(ASTNode::Expr(Expr::MethodCall(call, position))) => {
346                if language_variadic_accepts(&call.name, true, call.args.len()) {
347                    return true;
348                }
349                let expected = merged_arities(
350                    catalog.global.method.get(call.name.as_str()),
351                    local.method.get(call.name.as_str()),
352                );
353                push_if_invalid(
354                    diagnostics,
355                    source,
356                    *position,
357                    None,
358                    &call.name,
359                    call.args.len(),
360                    expected,
361                );
362            }
363            _ => {}
364        }
365        true
366    });
367}
368
369fn language_variadic_accepts(function: &str, method: bool, arity: usize) -> bool {
370    match (function, method) {
371        ("call", true) => true,
372        ("curry", false) => arity >= 2,
373        ("call", false) | ("curry", true) => arity >= 1,
374        _ => false,
375    }
376}
377
378fn collect_imports(ast: &AST) -> BTreeMap<String, String> {
379    let mut imports = BTreeMap::new();
380    ast.walk(&mut |path| {
381        if let Some(ASTNode::Stmt(Stmt::Import(import, ..))) = path.last()
382            && let (Expr::StringConstant(module, ..), alias) = &**import
383            && !alias.is_empty()
384        {
385            imports.insert(alias.as_str().to_owned(), module.to_string());
386        }
387        true
388    });
389    imports
390}
391
392fn direct_arities(
393    catalog: &KnownCallCatalog,
394    module_functions: &BTreeMap<String, FunctionSet>,
395    local: &FunctionSet,
396    imports: &BTreeMap<String, String>,
397    namespace: Option<&str>,
398    function: &str,
399) -> BTreeSet<usize> {
400    let Some(namespace) = namespace else {
401        return merged_arities(
402            catalog.global.direct.get(function),
403            local.direct.get(function),
404        );
405    };
406    if namespace == "global" {
407        return catalog
408            .global
409            .direct
410            .get(function)
411            .cloned()
412            .unwrap_or_default();
413    }
414    let mut segments = namespace.split("::");
415    let root = segments.next().unwrap_or_default();
416    if let Some(module) = imports.get(root) {
417        let suffix = segments.collect::<Vec<_>>().join("::");
418        let module = if suffix.is_empty() {
419            module.clone()
420        } else {
421            format!("{module}::{suffix}")
422        };
423        return module_functions
424            .get(&module)
425            .and_then(|functions| functions.direct.get(function))
426            .cloned()
427            .unwrap_or_default();
428    }
429    catalog
430        .modules
431        .get(namespace)
432        .and_then(|functions| functions.direct.get(function))
433        .cloned()
434        .unwrap_or_default()
435}
436
437fn merged_arities(
438    first: Option<&BTreeSet<usize>>,
439    second: Option<&BTreeSet<usize>>,
440) -> BTreeSet<usize> {
441    first.into_iter().chain(second).flatten().copied().collect()
442}
443
444fn push_if_invalid(
445    diagnostics: &mut Vec<KnownCallDiagnostic>,
446    source: &ParsedSource,
447    position: rhai::Position,
448    namespace: Option<String>,
449    function: &str,
450    arity: usize,
451    expected: BTreeSet<usize>,
452) {
453    if expected.contains(&arity) {
454        return;
455    }
456    diagnostics.push(KnownCallDiagnostic {
457        source: source.name.clone(),
458        line: position.line(),
459        column: position.position(),
460        namespace,
461        function: function.to_owned(),
462        arity,
463        expected_arities: expected.into_iter().collect(),
464    });
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use crate::RuntimeEngine;
471
472    #[test]
473    fn known_call_lint_accepts_local_module_core_and_standard_calls() {
474        let mut runtime = RuntimeEngine::new();
475        let modules = BTreeMap::from([(
476            ModuleId::parse("components/probe").unwrap(),
477            "fn Probe(value) { text(value) }".to_owned(),
478        )]);
479        let diagnostics = runtime
480            .lint_known_calls(
481                "ui/main.rhai",
482                r#"
483                    import "components/probe" as probe;
484                    fn helper(value) { value.to_upper() }
485                    fn view(ctx) { probe::Probe(helper("ready")) }
486                "#,
487                &modules,
488            )
489            .unwrap();
490        assert!(diagnostics.is_empty(), "{diagnostics:#?}");
491    }
492
493    #[test]
494    fn known_call_lint_checks_unexecuted_branches_and_arities() {
495        let mut runtime = RuntimeEngine::new();
496        let diagnostics = runtime
497            .lint_known_calls(
498                "ui/main.rhai",
499                r#"
500                    fn view(ctx) {
501                        if false {
502                            texxt("unreachable");
503                            text();
504                        }
505                        text("ok")
506                    }
507                "#,
508                &BTreeMap::new(),
509            )
510            .unwrap();
511        assert_eq!(diagnostics.len(), 2);
512        assert_eq!(diagnostics[0].function, "texxt");
513        assert!(diagnostics[0].expected_arities.is_empty());
514        assert_eq!(diagnostics[1].function, "text");
515        assert_eq!(diagnostics[1].expected_arities, vec![1]);
516    }
517
518    #[test]
519    fn known_call_lint_normalizes_native_method_receiver_arity() {
520        let mut runtime = RuntimeEngine::new();
521        let diagnostics = runtime
522            .lint_known_calls(
523                "ui/main.rhai",
524                r#"fn view(ctx) { text("probe").with_key() }"#,
525                &BTreeMap::new(),
526            )
527            .unwrap();
528        assert_eq!(diagnostics.len(), 1);
529        assert_eq!(diagnostics[0].function, "with_key");
530        assert_eq!(diagnostics[0].expected_arities, vec![1]);
531    }
532
533    #[test]
534    fn known_call_lint_checks_qualified_installed_modules() {
535        let mut runtime = RuntimeEngine::new();
536        let modules = BTreeMap::from([(
537            ModuleId::parse("components/probe").unwrap(),
538            "fn Probe(value) { text(value) }".to_owned(),
539        )]);
540        let diagnostics = runtime
541            .lint_known_calls(
542                "ui/main.rhai",
543                r#"
544                    import "components/probe" as probe;
545                    fn view(ctx) { probe::Probe() }
546                "#,
547                &modules,
548            )
549            .unwrap();
550        assert_eq!(diagnostics.len(), 1);
551        assert_eq!(diagnostics[0].namespace.as_deref(), Some("probe"));
552        assert_eq!(diagnostics[0].expected_arities, vec![1]);
553    }
554
555    #[test]
556    fn known_call_lint_characterizes_rhai_language_intrinsics() {
557        let mut runtime = RuntimeEngine::new();
558        let diagnostics = runtime
559            .lint_known_calls(
560                "ui/main.rhai",
561                r#"
562                    fn callback(first, second) { first + second }
563                    fn view(ctx) {
564                        let callback = Fn("callback");
565                        callback.call(1, 2);
566                        callback.curry(1).call(2);
567                        text(type_of(is_def_fn("callback", 2)))
568                    }
569                "#,
570                &BTreeMap::new(),
571            )
572            .unwrap();
573        assert!(diagnostics.is_empty(), "{diagnostics:#?}");
574    }
575
576    #[test]
577    fn known_call_lint_restores_engine_optimization_after_parse_failure() {
578        let mut runtime = RuntimeEngine::new();
579        let previous = runtime.engine().optimization_level();
580        assert!(
581            runtime
582                .lint_known_calls("ui/main.rhai", "fn view(", &BTreeMap::new())
583                .is_err()
584        );
585        assert_eq!(runtime.engine().optimization_level(), previous);
586    }
587}