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)]
11pub struct KnownCallDiagnostic {
13 pub source: String,
15 pub line: Option<usize>,
17 pub column: Option<usize>,
19 pub namespace: Option<String>,
21 pub function: String,
23 pub arity: usize,
25 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)]
65pub 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 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}