Skip to main content

lanekeep_js/
host.rs

1//! The `ctx` object rule code receives.
2//!
3//! This is the trust boundary made concrete. Everything a rule can do to the outside world
4//! it does through a function installed here; anything not installed does not exist. Adding
5//! to this surface widens what a rule may reach and bumps the host API version that feeds
6//! the cache key.
7//!
8//! # What is here so far
9//!
10//! Reporting, tree navigation, binding resolution, facts, and tracked file reads.
11//!
12//! # Two contexts, not one
13//!
14//! [`HostContext`] serves the per-file pass and [`ReduceContext`] serves the reduce phase,
15//! and neither is a subset of the other by accident:
16//!
17//! - `emitFact` exists only in the per-file context. A reduce phase that could emit facts
18//!   could feed itself, and there is no second pass for the result to reach.
19//! - `facts` and `files` exist only in the reduce context. If `check` could read the corpus,
20//!   a file's result would depend on files other than itself, and caching that result
21//!   against its own content would be unsound. This is not a stylistic split — it is what
22//!   makes per-file cache entries mean anything.
23
24use std::cell::{Cell, RefCell};
25use std::collections::BTreeMap;
26use std::fmt::Write as _;
27use std::rc::Rc;
28use std::sync::Arc;
29
30use rquickjs::function::Opt;
31use rquickjs::object::Accessor;
32use rquickjs::{Ctx, Function, Object, Value};
33
34use lanekeep_lang::binding::{Binding, BindingResolver, ImportedName};
35
36use lanekeep_core::fix::Fix;
37use lanekeep_query::CompiledQuery;
38
39use crate::files::FileAccess;
40use crate::nodes::{Handle, NodeArena};
41
42/// The version of the `ctx` surface this build exposes.
43///
44/// **Bump this whenever `ctx` gains, loses or changes a function.** It is a cache-key input,
45/// and it has to be: a result computed by a build where `ctx.readFile` did not exist is not
46/// a valid result for a build where it does — the rule could not have called something that
47/// was not there, so its cached verdict was reached without evidence it would have used.
48///
49/// Nothing detects this automatically. A function added without bumping it serves stale
50/// results silently, which is the failure mode the whole cache design is arranged against.
51///
52/// History:
53/// - `1` — reporting, navigation, binding resolution, `emitFact`, `readFile`, `fileExists`.
54pub const HOST_API_VERSION: u32 = 1;
55
56/// A fact a rule emitted, before the engine attaches the file and rule it came from.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct EmittedFact {
59    /// The `kind` field, lifted out so the reduce phase can filter without parsing.
60    pub kind: String,
61    /// The whole fact, as `JSON.stringify` rendered it. Always a JSON object.
62    pub data: String,
63}
64
65/// A violation a rule asked for.
66///
67/// Deliberately not a `lanekeep_core::Violation`. A rule supplies a position and optionally
68/// a message; the rule's identity, severity and card come from the engine, which is what
69/// stops a rule from reporting under someone else's name.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct Report {
72    /// The node reported at, when the rule passed one.
73    pub node: Option<Handle>,
74    /// One-based line.
75    pub line: u32,
76    /// One-based column.
77    pub column: u32,
78    /// A message overriding the rule card's, when the rule supplied one.
79    pub message: Option<String>,
80    /// A replacement the rule offered.
81    pub fix: Option<Fix>,
82}
83
84/// Host state for one file, shared with the functions installed on `ctx`.
85///
86/// `Debug` is hand-written: requiring it on `BindingResolver` would burden every language
87/// implementation for the sake of one derive here.
88#[derive(Clone)]
89pub struct HostContext {
90    arena: Rc<RefCell<NodeArena>>,
91    reports: Rc<RefCell<Vec<Report>>>,
92    facts: Rc<RefCell<Vec<EmittedFact>>>,
93    file_path: Rc<str>,
94    resolver: Option<Arc<dyn BindingResolver>>,
95    files: Option<Rc<FileAccess>>,
96    /// The grammar `querySubtree` and `closestAncestor` compile against.
97    language: Option<Arc<dyn lanekeep_lang::Language>>,
98    /// The date a rule sees as `ctx.today`, if the host supplied one.
99    today: Option<Rc<str>>,
100    /// Whether anything actually read `ctx.today` while checking this file.
101    ///
102    /// Tracked rather than assumed, because reading the date makes a file's result depend on
103    /// what day it is. Assuming every file might read it would date every cache entry and
104    /// invalidate the whole corpus daily; assuming none does would serve yesterday's answer.
105    date_read: Rc<Cell<bool>>,
106    /// Queries compiled so far this file, by source.
107    ///
108    /// A rule that calls `querySubtree` inside a handler calls it once per match, with the
109    /// same query string every time. Compiling per call would make the second-cheapest
110    /// operation in the host the most expensive one. Failures are cached too, so a bad
111    /// query is reported once rather than recompiled on every match.
112    queries: QueryCache,
113}
114
115/// Queries compiled so far for one file, by source.
116///
117/// A named type because it appears in three signatures, and because the shape — a shared,
118/// mutable map whose values are *either* a compiled query or the reason it did not compile —
119/// says more with a name than inline.
120type QueryCache = Rc<RefCell<BTreeMap<String, Result<Rc<CompiledQuery>, String>>>>;
121
122impl std::fmt::Debug for HostContext {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        f.debug_struct("HostContext")
125            .field("file_path", &self.file_path)
126            .field("interned_nodes", &self.arena.borrow().len())
127            .field("reports", &self.reports.borrow().len())
128            .field("facts", &self.facts.borrow().len())
129            .field("has_resolver", &self.resolver.is_some())
130            .field("has_file_access", &self.files.is_some())
131            .field("has_language", &self.language.is_some())
132            .field("has_today", &self.today.is_some())
133            .field("date_read", &self.date_read.get())
134            .field("compiled_queries", &self.queries.borrow().len())
135            .finish()
136    }
137}
138
139impl HostContext {
140    /// Build a context over a parsed file.
141    #[must_use]
142    pub fn new(tree: tree_sitter::Tree, source: String, file_path: &str) -> Self {
143        Self {
144            arena: Rc::new(RefCell::new(NodeArena::new(tree, source))),
145            reports: Rc::new(RefCell::new(Vec::new())),
146            facts: Rc::new(RefCell::new(Vec::new())),
147            file_path: Rc::from(file_path),
148            resolver: None,
149            files: None,
150            language: None,
151            today: None,
152            date_read: Rc::new(Cell::new(false)),
153            queries: Rc::new(RefCell::new(BTreeMap::new())),
154        }
155    }
156
157    /// Attach whatever resolver a language provides, if any.
158    #[must_use]
159    pub fn with_resolver_from(self, language: &dyn lanekeep_lang::Language) -> Self {
160        match language.resolver() {
161            Some(resolver) => self.with_resolver(resolver),
162            None => self,
163        }
164    }
165
166    /// Supply the date rules see as `ctx.today`.
167    ///
168    /// Fixed for the run by the caller, not read here: two files checked a millisecond apart
169    /// must not disagree about what day it is. Without one, `ctx.today` is absent rather than
170    /// empty — a rule comparing against `undefined` would silently take a branch nobody
171    /// intended, where a missing property is a `TypeError` naming what it reached for.
172    #[must_use]
173    pub fn with_today(mut self, today: &str) -> Self {
174        self.today = Some(Rc::from(today));
175        self
176    }
177
178    /// Whether anything read `ctx.today` while checking this file.
179    ///
180    /// The caller needs this to decide whether the result may be cached across days.
181    #[must_use]
182    pub fn date_was_read(&self) -> bool {
183        self.date_read.get()
184    }
185
186    /// Attach the grammar `querySubtree` and `closestAncestor` compile queries against.
187    ///
188    /// Without one they are absent rather than present-and-failing. A rule reaching for them
189    /// then gets a `TypeError` naming the function, which is the truthful answer — a stub
190    /// returning nothing would look like a query that matched nothing.
191    #[must_use]
192    pub fn with_language(mut self, language: Arc<dyn lanekeep_lang::Language>) -> Self {
193        self.language = Some(language);
194        self
195    }
196
197    /// Attach a binding resolver, enabling the import-resolution functions.
198    ///
199    /// Without one, those functions return `false` or `undefined` rather than being
200    /// absent. A rule written against a language that has no resolver then behaves as
201    /// though nothing resolves, which is a truthful answer — where a missing function
202    /// would be a `TypeError` blamed on the rule.
203    #[must_use]
204    pub fn with_resolver(mut self, resolver: Arc<dyn BindingResolver>) -> Self {
205        self.resolver = Some(resolver);
206        self
207    }
208
209    /// Allow tracked reads of other files in the project.
210    ///
211    /// Without one, `readFile` and `fileExists` are absent rather than present-and-failing.
212    /// A rule reaching for them then gets a `TypeError` naming the function, which is the
213    /// truthful answer — where a stub returning `undefined` would look like an empty project
214    /// and produce a rule that silently checks nothing.
215    #[must_use]
216    pub fn with_file_access(mut self, files: Rc<FileAccess>) -> Self {
217        self.files = Some(files);
218        self
219    }
220
221    /// The arena, for interning query captures before invoking a handler.
222    #[must_use]
223    pub fn arena(&self) -> &Rc<RefCell<NodeArena>> {
224        &self.arena
225    }
226
227    /// Take everything reported so far, leaving the context empty.
228    #[must_use]
229    pub fn take_reports(&self) -> Vec<Report> {
230        std::mem::take(&mut self.reports.borrow_mut())
231    }
232
233    /// Take everything emitted so far, in emission order, leaving the context empty.
234    #[must_use]
235    pub fn take_facts(&self) -> Vec<EmittedFact> {
236        std::mem::take(&mut self.facts.borrow_mut())
237    }
238
239    /// Build the `ctx` object.
240    ///
241    /// # Errors
242    ///
243    /// Returns an engine error if a property cannot be defined, which would mean a broken
244    /// build rather than anything about a rule.
245    pub fn build<'js>(&self, ctx: &Ctx<'js>) -> rquickjs::Result<Object<'js>> {
246        let object = Object::new(ctx.clone())?;
247
248        object.set("filePath", &*self.file_path)?;
249        object.set("root", NodeArena::ROOT)?;
250        {
251            let arena = self.arena.borrow();
252            object.set("fileText", arena.source())?;
253        }
254
255        self.install_navigation(ctx, &object)?;
256        self.install_bindings(ctx, &object)?;
257        self.install_reporting(ctx, &object)?;
258        self.install_facts(ctx, &object)?;
259        self.install_reads(ctx, &object)?;
260        self.install_queries(ctx, &object)?;
261
262        // `ctx.today` is a property backed by a getter, so reading it can be *observed*. A
263        // plain value would be indistinguishable from an unread one, and the cache would
264        // have to guess which files depend on the date.
265        if let Some(today) = self.today.clone() {
266            let date_read = Rc::clone(&self.date_read);
267            object.prop(
268                "today",
269                Accessor::from(move || {
270                    date_read.set(true);
271                    today.to_string()
272                }),
273            )?;
274        }
275
276        Ok(object)
277    }
278
279    /// Reading the tree.
280    fn install_navigation<'js>(
281        &self,
282        ctx: &Ctx<'js>,
283        object: &Object<'js>,
284    ) -> rquickjs::Result<()> {
285        // --- tree navigation -----------------------------------------------------------
286        //
287        // Every one of these takes a handle and returns plain data. A handle that does not
288        // resolve yields `undefined` or an empty array rather than throwing: rule code is
289        // arbitrary and may pass any number, and a thrown error there would be reported as
290        // a rule bug when the real cause is a typo in a handle variable.
291
292        let arena = Rc::clone(&self.arena);
293        object.set(
294            "kind",
295            Function::new(ctx.clone(), move |handle: Handle| {
296                arena.borrow().kind(handle).map(ToOwned::to_owned)
297            })?,
298        )?;
299
300        let arena = Rc::clone(&self.arena);
301        object.set(
302            "text",
303            Function::new(ctx.clone(), move |handle: Handle| {
304                arena.borrow().text(handle).map(ToOwned::to_owned)
305            })?,
306        )?;
307
308        let arena = Rc::clone(&self.arena);
309        object.set(
310            "isNamed",
311            Function::new(ctx.clone(), move |handle: Handle| {
312                arena.borrow().is_named(handle)
313            })?,
314        )?;
315
316        // Position is exposed as two primitives rather than as a `{line, column}` object.
317        //
318        // Building the object in Rust needs the `Ctx`, and a host function can neither
319        // capture one — cloning a `Ctx` into a `'static` closure keeps the context alive
320        // past `JS_FreeRuntime` and aborts the process on an unfreed-objects assertion —
321        // nor take one as a parameter, because the returned object's lifetime cannot be
322        // named inside a closure.
323        //
324        // A rule that wants the pair writes `{ line: ctx.line(n), column: ctx.column(n) }`,
325        // which is a fair trade for two fewer ways to get the boundary wrong.
326        let arena = Rc::clone(&self.arena);
327        object.set(
328            "line",
329            Function::new(ctx.clone(), move |handle: Handle| {
330                arena.borrow().position(handle).map(|(line, _)| line)
331            })?,
332        )?;
333
334        let arena = Rc::clone(&self.arena);
335        object.set(
336            "column",
337            Function::new(ctx.clone(), move |handle: Handle| {
338                arena.borrow().position(handle).map(|(_, column)| column)
339            })?,
340        )?;
341
342        let arena = Rc::clone(&self.arena);
343        object.set(
344            "parent",
345            Function::new(ctx.clone(), move |handle: Handle| {
346                arena.borrow_mut().parent(handle)
347            })?,
348        )?;
349
350        let arena = Rc::clone(&self.arena);
351        object.set(
352            "children",
353            Function::new(ctx.clone(), move |handle: Handle| {
354                arena.borrow_mut().children(handle)
355            })?,
356        )?;
357
358        let arena = Rc::clone(&self.arena);
359        object.set(
360            "namedChildren",
361            Function::new(ctx.clone(), move |handle: Handle| {
362                arena.borrow_mut().named_children(handle)
363            })?,
364        )?;
365
366        let arena = Rc::clone(&self.arena);
367        object.set(
368            "ancestors",
369            Function::new(ctx.clone(), move |handle: Handle| {
370                arena.borrow_mut().ancestors(handle)
371            })?,
372        )?;
373
374        Ok(())
375    }
376
377    /// Resolving what an identifier refers to.
378    fn install_bindings<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
379        // --- binding resolution ----------------------------------------------------------
380        //
381        // The light semantic layer of §6.4. A rule matching `makeStyles(...)` on identifier
382        // text alone is wrong twice: it misses `import { makeStyles as ms }`, and it fires
383        // on a local `const makeStyles` that has nothing to do with the import.
384
385        let arena = Rc::clone(&self.arena);
386        let resolver = self.resolver.clone();
387        object.set(
388            "resolvesToImport",
389            Function::new(
390                ctx.clone(),
391                move |handle: Handle, module: String, name: Opt<String>| {
392                    let Some(resolver) = resolver.as_deref() else {
393                        return false;
394                    };
395                    match arena.borrow().resolve_binding(handle, resolver) {
396                        Some(Binding::Import {
397                            module: from,
398                            name: imported,
399                        }) => {
400                            from == module
401                                && name.0.is_none_or(|wanted| match &imported {
402                                    ImportedName::Named(actual) => *actual == wanted,
403                                    ImportedName::Default => wanted == "default",
404                                    ImportedName::Namespace => wanted == "*",
405                                })
406                        }
407                        _ => false,
408                    }
409                },
410            )?,
411        )?;
412
413        let arena = Rc::clone(&self.arena);
414        let resolver = self.resolver.clone();
415        object.set(
416            "isImportedFrom",
417            Function::new(ctx.clone(), move |handle: Handle, pattern: String| {
418                let Some(resolver) = resolver.as_deref() else {
419                    return false;
420                };
421                match arena.borrow().resolve_binding(handle, resolver) {
422                    Some(Binding::Import { module, .. }) => glob_matches(&pattern, &module),
423                    _ => false,
424                }
425            })?,
426        )?;
427
428        let arena = Rc::clone(&self.arena);
429        let resolver = self.resolver.clone();
430        object.set(
431            "bindingKind",
432            Function::new(ctx.clone(), move |handle: Handle| {
433                let resolver = resolver.as_deref()?;
434                arena
435                    .borrow()
436                    .resolve_binding(handle, resolver)
437                    .map(|binding| binding.kind_str().to_owned())
438            })?,
439        )?;
440
441        let arena = Rc::clone(&self.arena);
442        let resolver = self.resolver.clone();
443        object.set(
444            "isShadowed",
445            Function::new(ctx.clone(), move |handle: Handle| {
446                resolver
447                    .as_deref()
448                    .is_some_and(|resolver| arena.borrow().is_shadowed(handle, resolver))
449            })?,
450        )?;
451
452        Ok(())
453    }
454
455    /// Recording violations.
456    fn install_reporting<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
457        // --- reporting -------------------------------------------------------------------
458
459        // --- positions -------------------------------------------------------------------
460
461        let arena = Rc::clone(&self.arena);
462        let file_path = Rc::clone(&self.file_path);
463        object.set(
464            "loc",
465            // `{ file, line, column }`, which is exactly the shape a fact carries and the
466            // shape a reduce phase reports at. A rule that emits `at: ctx.loc(m.node)` and
467            // later calls `ctx.report(f.at)` needs no glue between the two.
468            Function::new(
469                ctx.clone(),
470                move |ctx: Ctx<'js>, handle: Handle| -> rquickjs::Result<Value<'js>> {
471                    let Some((line, column)) = arena.borrow().position(handle) else {
472                        // The same posture as reporting at an unresolvable handle: nothing,
473                        // rather than a made-up position a reader would go and look at.
474                        return Ok(Value::new_undefined(ctx.clone()));
475                    };
476
477                    let object = Object::new(ctx.clone())?;
478                    object.set("file", &*file_path)?;
479                    object.set("line", line)?;
480                    object.set("column", column)?;
481                    Ok(object.into_value())
482                },
483            )?,
484        )?;
485
486        let arena = Rc::clone(&self.arena);
487        let reports = Rc::clone(&self.reports);
488        object.set(
489            "report",
490            // The second argument is either a message or an options object. A union rather
491            // than two functions, because `ctx.report(node, 'why')` is the overwhelmingly
492            // common call and should stay the short one — and because a rule that wants a
493            // fix usually wants a specific message too.
494            Function::new(
495                ctx.clone(),
496                move |ctx: Ctx<'js>,
497                      handle: Handle,
498                      options: Opt<Value<'js>>|
499                      -> rquickjs::Result<()> {
500                    // A report at an unresolvable handle is dropped rather than recorded at
501                    // a made-up position. Reporting at 1:1 would point a reader at an
502                    // unrelated line, which is worse than the rule appearing not to fire.
503                    let Some((line, column)) = arena.borrow().position(handle) else {
504                        return Ok(());
505                    };
506
507                    let (message, fix) = match options.0 {
508                        None => (None, None),
509                        Some(value) if value.is_string() => (value.get::<String>().ok(), None),
510                        Some(value) => {
511                            let Some(object) = value.as_object() else {
512                                return Err(throw(
513                                    &ctx,
514                                    "ctx.report expects a message string or an options \
515                                     object — { message?, fix? }",
516                                ));
517                            };
518                            let message = object.get::<_, String>("message").ok();
519                            let fix = read_fix(&ctx, object, &arena)?;
520                            (message, fix)
521                        }
522                    };
523
524                    reports.borrow_mut().push(Report {
525                        node: Some(handle),
526                        line,
527                        column,
528                        message,
529                        fix,
530                    });
531                    Ok(())
532                },
533            )?,
534        )?;
535
536        Ok(())
537    }
538
539    /// Emitting facts for the reduce phase.
540    fn install_facts<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
541        // --- facts -----------------------------------------------------------------------
542
543        let facts = Rc::clone(&self.facts);
544        object.set(
545            "emitFact",
546            Function::new(
547                ctx.clone(),
548                move |ctx: Ctx<'js>, fact: Value<'js>| -> rquickjs::Result<()> {
549                    let Some(fact_object) = fact.as_object() else {
550                        return Err(throw(&ctx, "ctx.emitFact expects an object"));
551                    };
552
553                    // `kind` is what `ctx.facts('export')` filters on. A fact without one
554                    // could never be retrieved, so emitting it is always a mistake — and a
555                    // silent one, since the rule would look like it was working right up
556                    // until `reduce` found nothing.
557                    let kind = match fact_object.get::<_, String>("kind") {
558                        Ok(kind) if !kind.is_empty() => kind,
559                        _ => {
560                            return Err(throw(
561                                &ctx,
562                                "ctx.emitFact requires a non-empty string `kind` — it is what \
563                                 ctx.facts(kind) selects on, so a fact without one can never \
564                                 be read back",
565                            ));
566                        }
567                    };
568
569                    // `JSON.stringify` is the definition of serializable here rather than a
570                    // check alongside it, so what a rule can emit is exactly what it could
571                    // write to a file — and exactly what a cache entry can hold. A cycle
572                    // throws from inside stringify and surfaces as the rule's error.
573                    let Some(json) = ctx.json_stringify(fact)? else {
574                        return Err(throw(
575                            &ctx,
576                            "ctx.emitFact could not serialize this fact — facts are cached, \
577                             so they have to survive JSON",
578                        ));
579                    };
580
581                    facts.borrow_mut().push(EmittedFact {
582                        kind,
583                        data: json.to_string()?,
584                    });
585                    Ok(())
586                },
587            )?,
588        )?;
589
590        Ok(())
591    }
592
593    /// Running queries from inside a handler.
594    fn install_queries<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
595        // --- scoped queries --------------------------------------------------------------
596
597        let Some(language) = self.language.clone() else {
598            return Ok(());
599        };
600
601        let arena = Rc::clone(&self.arena);
602        let queries = Rc::clone(&self.queries);
603        let grammar = Arc::clone(&language);
604        object.set(
605            "querySubtree",
606            Function::new(
607                ctx.clone(),
608                move |ctx: Ctx<'js>,
609                      handle: Handle,
610                      source: String|
611                      -> rquickjs::Result<Value<'js>> {
612                    let compiled = compile(&queries, grammar.as_ref(), &source)
613                        .map_err(|problem| throw(&ctx, &problem))?;
614
615                    let matches = arena.borrow().query_subtree(handle, &compiled);
616                    let interned = intern_matches(&arena, matches);
617                    captures_to_js(&ctx, interned)
618                },
619            )?,
620        )?;
621
622        let arena = Rc::clone(&self.arena);
623        let queries = Rc::clone(&self.queries);
624        object.set(
625            "closestAncestor",
626            Function::new(
627                ctx.clone(),
628                move |ctx: Ctx<'js>,
629                      handle: Handle,
630                      source: String|
631                      -> rquickjs::Result<Value<'js>> {
632                    let compiled = compile(&queries, language.as_ref(), &source)
633                        .map_err(|problem| throw(&ctx, &problem))?;
634
635                    let found = arena.borrow().closest_ancestor_paths(handle, &compiled);
636                    let Some(captures) = found else {
637                        // Nothing matched. `undefined` rather than an empty object, so
638                        // `if (!ctx.closestAncestor(...))` reads correctly — an empty object
639                        // is truthy in JavaScript and would silently take the wrong branch.
640                        return Ok(Value::new_undefined(ctx.clone()));
641                    };
642
643                    let interned = intern_matches(&arena, vec![captures]);
644                    let one = interned.into_iter().next().unwrap_or_default();
645                    let object = Object::new(ctx.clone())?;
646                    for (name, handle) in one {
647                        object.set(name, handle)?;
648                    }
649                    Ok(object.into_value())
650                },
651            )?,
652        )?;
653
654        Ok(())
655    }
656
657    /// Reading other files in the project.
658    fn install_reads<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
659        // --- tracked reads -----------------------------------------------------------------
660
661        let Some(files) = self.files.clone() else {
662            return Ok(());
663        };
664
665        let reader = Rc::clone(&files);
666        object.set(
667            "readFile",
668            Function::new(
669                ctx.clone(),
670                move |ctx: Ctx<'js>, path: String| -> rquickjs::Result<Option<String>> {
671                    // A refusal throws; an absence returns `undefined`. The distinction is
672                    // the point: "not there" is an ordinary answer a rule should handle,
673                    // and "you tried to leave the project" is a bug in the rule.
674                    reader.read(&path).map_err(|e| throw(&ctx, &e.to_string()))
675                },
676            )?,
677        )?;
678
679        let reader = Rc::clone(&files);
680        object.set(
681            "fileExists",
682            Function::new(
683                ctx.clone(),
684                move |ctx: Ctx<'js>, path: String| -> rquickjs::Result<bool> {
685                    reader
686                        .exists(&path)
687                        .map_err(|e| throw(&ctx, &e.to_string()))
688                },
689            )?,
690        )?;
691
692        Ok(())
693    }
694}
695
696/// A violation a reduce phase asked for.
697///
698/// Carries a file of its own: a cross-file rule reports at the site the fact came from,
699/// which is by definition not "the file being checked" — there isn't one.
700#[derive(Debug, Clone, PartialEq, Eq)]
701pub struct ReduceReport {
702    /// Path of the file to report against, as the rule gave it.
703    pub file: String,
704    /// One-based line.
705    pub line: u32,
706    /// One-based column.
707    pub column: u32,
708    /// A message overriding the rule card's, when the rule supplied one.
709    pub message: Option<String>,
710}
711
712/// One fact as the reduce phase will see it: payload plus the file it came from.
713#[derive(Debug, Clone, PartialEq, Eq)]
714pub struct ReduceFact {
715    /// The `kind`, for filtering.
716    pub kind: String,
717    /// The payload as JSON, with `file` merged in.
718    pub json: String,
719}
720
721/// Host state for the reduce phase of one rule.
722///
723/// Built per rule rather than per run, because a rule sees only its own facts — letting one
724/// rule read another's would turn a private payload shape into a contract between rules.
725#[derive(Debug, Clone)]
726pub struct ReduceContext {
727    files: Rc<[String]>,
728    facts: Rc<[ReduceFact]>,
729    reports: Rc<RefCell<Vec<ReduceReport>>>,
730}
731
732impl ReduceContext {
733    /// Build a context over one rule's facts and the discovered file list.
734    #[must_use]
735    pub fn new(files: Vec<String>, facts: Vec<ReduceFact>) -> Self {
736        Self {
737            files: files.into(),
738            facts: facts.into(),
739            reports: Rc::new(RefCell::new(Vec::new())),
740        }
741    }
742
743    /// Take everything reported so far, leaving the context empty.
744    #[must_use]
745    pub fn take_reports(&self) -> Vec<ReduceReport> {
746        std::mem::take(&mut self.reports.borrow_mut())
747    }
748
749    /// Build the `ctx` object the reduce phase receives.
750    ///
751    /// # Errors
752    ///
753    /// Returns an engine error if a property cannot be defined, which would mean a broken
754    /// build rather than anything about a rule.
755    pub fn build<'js>(&self, ctx: &Ctx<'js>) -> rquickjs::Result<Object<'js>> {
756        let object = Object::new(ctx.clone())?;
757
758        object.set("files", &*self.files)?;
759
760        // No `filePath`, no `root`, no `fileText`, no navigation. There is no file and no
761        // tree here — that is invariant 1, and a context that offered them would be lying
762        // about what the phase can see.
763
764        let facts = Rc::clone(&self.facts);
765        object.set(
766            "facts",
767            Function::new(
768                ctx.clone(),
769                move |ctx: Ctx<'js>, kind: Opt<String>| -> rquickjs::Result<Value<'js>> {
770                    // One array, one parse. Handing back N separately-parsed objects would
771                    // cost N crossings for a phase whose whole job is bulk work.
772                    let wanted = kind.0;
773                    let mut json = String::from("[");
774                    for fact in facts
775                        .iter()
776                        .filter(|f| wanted.as_ref().is_none_or(|k| *k == f.kind))
777                    {
778                        if json.len() > 1 {
779                            json.push(',');
780                        }
781                        json.push_str(&fact.json);
782                    }
783                    json.push(']');
784
785                    ctx.json_parse(json)
786                },
787            )?,
788        )?;
789
790        let reports = Rc::clone(&self.reports);
791        object.set(
792            "report",
793            Function::new(
794                ctx.clone(),
795                move |ctx: Ctx<'js>,
796                      at: Value<'js>,
797                      message: Opt<Value<'js>>|
798                      -> rquickjs::Result<()> {
799                    let Some(at) = at.as_object() else {
800                        return Err(throw(
801                            &ctx,
802                            "ctx.report in a reduce phase expects { file, line, column } — \
803                             there is no parse tree here, so there are no nodes to report at",
804                        ));
805                    };
806
807                    let (Ok(file), Ok(line), Ok(column)) = (
808                        at.get::<_, String>("file"),
809                        at.get::<_, u32>("line"),
810                        at.get::<_, u32>("column"),
811                    ) else {
812                        return Err(throw(
813                            &ctx,
814                            "ctx.report in a reduce phase needs `file`, `line` and `column` — \
815                             emit them on the fact during the per-file pass, where the node \
816                             positions are still available",
817                        ));
818                    };
819
820                    // A bare string or `{ message }`, because the per-file `ctx.report` takes
821                    // options as its second argument and nobody remembers that this one is
822                    // different. Accepting both costs nothing and removes a papercut whose
823                    // only symptom was a type-conversion error naming neither argument.
824                    let message = match message.0 {
825                        None => None,
826                        Some(value) if value.is_undefined() || value.is_null() => None,
827                        Some(value) => {
828                            if let Some(text) = value.as_string() {
829                                Some(text.to_string()?)
830                            } else if let Some(options) = value.as_object() {
831                                match options.get::<_, Value<'js>>("message") {
832                                    Ok(found) if found.is_string() => found
833                                        .as_string()
834                                        .map(rquickjs::String::to_string)
835                                        .transpose()?,
836                                    _ => {
837                                        return Err(throw(
838                                            &ctx,
839                                            "ctx.report in a reduce phase takes a message: \
840                                             either a string, or { message }",
841                                        ));
842                                    }
843                                }
844                            } else {
845                                return Err(throw(
846                                    &ctx,
847                                    "ctx.report in a reduce phase takes a message: either a \
848                                     string, or { message }",
849                                ));
850                            }
851                        }
852                    };
853
854                    reports.borrow_mut().push(ReduceReport {
855                        file,
856                        line,
857                        column,
858                        message,
859                    });
860                    Ok(())
861                },
862            )?,
863        )?;
864
865        Ok(object)
866    }
867}
868
869/// Read a `fix` off a report's options object.
870///
871/// `{ node, text, safe? }`. The range comes from a node handle rather than from raw offsets:
872/// a rule already has the node it matched, and offsets it computed itself are offsets it can
873/// get wrong — the one mistake that would let a fix corrupt a file.
874fn read_fix<'js>(
875    ctx: &Ctx<'js>,
876    options: &Object<'js>,
877    arena: &Rc<RefCell<NodeArena>>,
878) -> rquickjs::Result<Option<Fix>> {
879    let Ok(value) = options.get::<_, Value<'js>>("fix") else {
880        return Ok(None);
881    };
882    if value.is_undefined() || value.is_null() {
883        return Ok(None);
884    }
885
886    let Some(fix) = value.as_object() else {
887        return Err(throw(
888            &ctx.clone(),
889            "ctx.report's `fix` expects { node, text, safe? }",
890        ));
891    };
892
893    let (Ok(handle), Ok(replacement)) =
894        (fix.get::<_, Handle>("node"), fix.get::<_, String>("text"))
895    else {
896        return Err(throw(
897            &ctx.clone(),
898            "ctx.report's `fix` needs a `node` to replace and the `text` to put there",
899        ));
900    };
901
902    let Some((start, end)) = arena.borrow().byte_range(handle) else {
903        // The same posture as reporting at an unresolvable handle: drop the fix rather than
904        // guess at a range. A fix at the wrong offsets would rewrite the wrong code.
905        return Ok(None);
906    };
907
908    Ok(Some(Fix {
909        start,
910        end,
911        replacement,
912        // Absent means suggestion. The cautious mistake costs a manual edit; the other one
913        // rewrites code silently.
914        safe: fix.get::<_, bool>("safe").unwrap_or(false),
915    }))
916}
917
918/// Compile a query, or return the failure this file already saw for it.
919fn compile(
920    cache: &QueryCache,
921    language: &dyn lanekeep_lang::Language,
922    source: &str,
923) -> Result<Rc<CompiledQuery>, String> {
924    if let Some(found) = cache.borrow().get(source) {
925        return found.clone();
926    }
927
928    let compiled = CompiledQuery::compile(language, source)
929        .map(Rc::new)
930        .map_err(|e| e.to_string());
931    cache
932        .borrow_mut()
933        .insert(source.to_owned(), compiled.clone());
934    compiled
935}
936
937/// Turn capture paths into handles, once the tree borrow has ended.
938fn intern_matches(
939    arena: &Rc<RefCell<NodeArena>>,
940    matches: Vec<Vec<(String, Vec<u32>)>>,
941) -> Vec<Vec<(String, Handle)>> {
942    let mut arena = arena.borrow_mut();
943    matches
944        .into_iter()
945        .map(|captures| {
946            captures
947                .into_iter()
948                .filter_map(|(name, path)| arena.intern_path(path).map(|handle| (name, handle)))
949                .collect()
950        })
951        .collect()
952}
953
954/// Render matches as an array of `{ captureName: handle }`.
955fn captures_to_js<'js>(
956    ctx: &Ctx<'js>,
957    matches: Vec<Vec<(String, Handle)>>,
958) -> rquickjs::Result<Value<'js>> {
959    let array = rquickjs::Array::new(ctx.clone())?;
960    for (index, captures) in matches.into_iter().enumerate() {
961        let object = Object::new(ctx.clone())?;
962        for (name, handle) in captures {
963            object.set(name, handle)?;
964        }
965        array.set(index, object)?;
966    }
967    Ok(array.into_value())
968}
969
970/// Throw a `TypeError` carrying a message meant for a rule author.
971///
972/// A real `Error` object, not a thrown string: the sandbox reports a thrown string by
973/// telling the author to throw an `Error` instead, which is sound advice about their code
974/// and nonsense when the host is the one that threw. It would also lose the message.
975fn throw(ctx: &Ctx<'_>, message: &str) -> rquickjs::Error {
976    rquickjs::Exception::throw_type(ctx, message)
977}
978
979/// Merge a `file` into a fact's serialized payload.
980///
981/// Textual because the input is `JSON.stringify` output — always an object, always with the
982/// braces at the ends — so this is one allocation rather than a parse, an insert and a
983/// re-serialize, per fact, for a phase whose input is the whole corpus.
984///
985/// `file` goes **last** on purpose. A rule is free to put its own `file` in a fact, and JSON
986/// parsing takes the last of duplicate keys — so the host's value wins, and a rule cannot
987/// misattribute a violation by shadowing it.
988#[must_use]
989pub fn merge_file(data: &str, file: &str) -> String {
990    let inner = data
991        .trim()
992        .strip_prefix('{')
993        .and_then(|rest| rest.strip_suffix('}'))
994        .unwrap_or_default()
995        .trim();
996
997    let mut out = String::with_capacity(data.len() + file.len() + 12);
998    out.push('{');
999    if !inner.is_empty() {
1000        out.push_str(inner);
1001        out.push(',');
1002    }
1003    out.push_str("\"file\":");
1004    escape_json_string(file, &mut out);
1005    out.push('}');
1006    out
1007}
1008
1009/// Append `text` as a quoted JSON string.
1010fn escape_json_string(text: &str, out: &mut String) {
1011    out.push('"');
1012    for ch in text.chars() {
1013        match ch {
1014            '"' => out.push_str("\\\""),
1015            '\\' => out.push_str("\\\\"),
1016            '\n' => out.push_str("\\n"),
1017            '\r' => out.push_str("\\r"),
1018            '\t' => out.push_str("\\t"),
1019            c if (c as u32) < 0x20 => {
1020                // Writing rather than formatting into a temporary: this runs once per
1021                // character of every fact's file path.
1022                let _ = write!(out, "\\u{:04x}", c as u32);
1023            }
1024            c => out.push(c),
1025        }
1026    }
1027    out.push('"');
1028}
1029
1030/// Match a module specifier against a pattern where `*` stands for any run of characters.
1031///
1032/// Written out rather than pulled in, because the whole need is `@scope/*` and `*/themed`.
1033/// A glob crate would bring a dependency and a dialect — character classes, `**`, escapes —
1034/// for a surface this small.
1035fn glob_matches(pattern: &str, text: &str) -> bool {
1036    let mut parts = pattern.split('*');
1037    let Some(first) = parts.next() else {
1038        return true;
1039    };
1040    if !text.starts_with(first) {
1041        return false;
1042    }
1043
1044    let mut rest = &text[first.len()..];
1045    let segments: Vec<&str> = parts.collect();
1046
1047    // No `*` at all: the pattern has to account for the whole specifier.
1048    if segments.is_empty() {
1049        return rest.is_empty();
1050    }
1051
1052    for (index, segment) in segments.iter().enumerate() {
1053        if segment.is_empty() {
1054            continue;
1055        }
1056        // The final segment has to sit at the end, or `@scope/*` would match
1057        // `@scope/pkg/nested` on a pattern the author meant to be exact after the star.
1058        if index == segments.len() - 1 {
1059            return rest.ends_with(segment);
1060        }
1061        match rest.find(segment) {
1062            Some(at) => rest = &rest[at + segment.len()..],
1063            None => return false,
1064        }
1065    }
1066
1067    // The pattern ended with `*`, so whatever is left is matched.
1068    true
1069}
1070
1071#[cfg(test)]
1072mod tests {
1073    use lanekeep_lang::Language;
1074    use lanekeep_lang_js::TypeScript;
1075
1076    use super::*;
1077    use crate::{Limits, Sandbox};
1078
1079    fn parse(source: &str) -> tree_sitter::Tree {
1080        let mut parser = tree_sitter::Parser::new();
1081        parser
1082            .set_language(&TypeScript.grammar())
1083            .expect("grammar loads");
1084        parser.parse(source, None).expect("parses")
1085    }
1086
1087    fn host(source: &str) -> HostContext {
1088        HostContext::new(parse(source), source.to_owned(), "src/example.ts")
1089            .with_resolver(Arc::new(lanekeep_lang_js::binding::JsBindingResolver))
1090    }
1091
1092    /// A host with no resolver, for the degraded path.
1093    fn host_without_resolver(source: &str) -> HostContext {
1094        HostContext::new(parse(source), source.to_owned(), "src/example.ts")
1095    }
1096
1097    /// The handle of the last identifier reading `name`, the way a query capture arrives.
1098    fn handle_of(host: &HostContext, name: &str) -> Handle {
1099        let mut arena = host.arena().borrow_mut();
1100        let source = arena.source().to_owned();
1101
1102        let path = {
1103            let mut best: Option<tree_sitter::Node<'_>> = None;
1104            let mut stack = vec![arena.tree().root_node()];
1105            while let Some(node) = stack.pop() {
1106                if node.kind() == "identifier"
1107                    && source.get(node.byte_range()) == Some(name)
1108                    && best.is_none_or(|b| node.start_byte() > b.start_byte())
1109                {
1110                    best = Some(node);
1111                }
1112                let mut cursor = node.walk();
1113                stack.extend(node.children(&mut cursor));
1114            }
1115            arena
1116                .path_of(best.unwrap_or_else(|| panic!("no identifier `{name}`")))
1117                .expect("has a path")
1118        };
1119
1120        arena.intern_path(path).expect("interns")
1121    }
1122
1123    /// Evaluate rule-shaped code with `ctx` in scope.
1124    fn run<T>(host: &HostContext, code: &str) -> T
1125    where
1126        T: for<'js> rquickjs::FromJs<'js> + Default,
1127    {
1128        let sandbox = Sandbox::with_limits(Limits::default()).expect("sandbox builds");
1129        sandbox.eval_with_host(host, code).expect("evaluates")
1130    }
1131
1132    #[test]
1133    fn exposes_the_file_path_and_text() {
1134        let host = host("const x = 1;");
1135        assert_eq!(run::<String>(&host, "ctx.filePath"), "src/example.ts");
1136        assert_eq!(run::<String>(&host, "ctx.fileText"), "const x = 1;");
1137    }
1138
1139    #[test]
1140    fn navigates_from_the_root() {
1141        let host = host("const x = 1;\nconst y = 2;");
1142        assert_eq!(run::<String>(&host, "ctx.kind(ctx.root)"), "program");
1143        assert_eq!(run::<u32>(&host, "ctx.namedChildren(ctx.root).length"), 2);
1144        assert_eq!(
1145            run::<String>(&host, "ctx.kind(ctx.namedChildren(ctx.root)[0])"),
1146            "lexical_declaration"
1147        );
1148    }
1149
1150    #[test]
1151    fn reads_text_and_position() {
1152        let host = host("const x = 1;\nconst y = 2;");
1153        assert_eq!(
1154            run::<String>(&host, "ctx.text(ctx.namedChildren(ctx.root)[1])"),
1155            "const y = 2;"
1156        );
1157        assert_eq!(
1158            run::<u32>(&host, "ctx.line(ctx.namedChildren(ctx.root)[1])"),
1159            2
1160        );
1161        assert_eq!(
1162            run::<u32>(&host, "ctx.column(ctx.namedChildren(ctx.root)[1])"),
1163            1
1164        );
1165    }
1166
1167    #[test]
1168    fn walks_up_and_back_down() {
1169        let host = host("const x = 1;");
1170        assert!(run::<bool>(
1171            &host,
1172            "const d = ctx.namedChildren(ctx.root)[0];
1173             const inner = ctx.namedChildren(d)[0];
1174             ctx.parent(inner) === d && ctx.parent(d) === ctx.root"
1175        ));
1176    }
1177
1178    #[test]
1179    fn handles_compare_equal_for_the_same_node() {
1180        // Rules use `===` on handles. If the same node interned twice produced two
1181        // numbers, a rule asking "is this capture the same node as that one" would
1182        // silently always say no.
1183        let host = host("const x = 1;");
1184        assert!(run::<bool>(
1185            &host,
1186            "ctx.namedChildren(ctx.root)[0] === ctx.namedChildren(ctx.root)[0]"
1187        ));
1188    }
1189
1190    #[test]
1191    fn ancestors_end_at_the_root() {
1192        let host = host("function f() { return 1; }");
1193        assert!(run::<bool>(
1194            &host,
1195            "const fn = ctx.namedChildren(ctx.root)[0];
1196             const body = ctx.namedChildren(fn).at(-1);
1197             const stmt = ctx.namedChildren(body)[0];
1198             const a = ctx.ancestors(stmt);
1199             a[0] === body && a.at(-1) === ctx.root"
1200        ));
1201    }
1202
1203    #[test]
1204    fn named_children_omits_anonymous_tokens() {
1205        let host = host("const x = 1;");
1206        assert!(run::<bool>(
1207            &host,
1208            "const d = ctx.namedChildren(ctx.root)[0];
1209             ctx.children(d).length > ctx.namedChildren(d).length"
1210        ));
1211    }
1212
1213    #[test]
1214    fn an_unresolvable_handle_returns_nothing_rather_than_throwing() {
1215        // Rule code is arbitrary and will pass stale or invented numbers. Throwing here
1216        // would be reported as a rule bug when the cause is a mistyped variable.
1217        let host = host("const x = 1;");
1218        assert!(run::<bool>(
1219            &host,
1220            "ctx.kind(9999) === undefined &&
1221             ctx.text(9999) === undefined &&
1222             ctx.line(9999) === undefined &&
1223             ctx.column(9999) === undefined &&
1224             ctx.parent(9999) === undefined &&
1225             ctx.children(9999).length === 0 &&
1226             ctx.ancestors(9999).length === 0"
1227        ));
1228    }
1229
1230    // --- reporting ---------------------------------------------------------------------
1231
1232    #[test]
1233    fn records_a_report_at_the_node_position() {
1234        let host = host("const x = 1;\nconst y = 2;");
1235        let _: () = run(&host, "ctx.report(ctx.namedChildren(ctx.root)[1])");
1236
1237        let reports = host.take_reports();
1238        assert_eq!(reports.len(), 1);
1239        assert_eq!(reports[0].line, 2);
1240        assert_eq!(reports[0].column, 1);
1241        assert_eq!(reports[0].message, None);
1242    }
1243
1244    #[test]
1245    fn records_an_overriding_message() {
1246        let host = host("const x = 1;");
1247        let _: () = run(&host, "ctx.report(ctx.root, 'something specific')");
1248
1249        let reports = host.take_reports();
1250        assert_eq!(reports[0].message.as_deref(), Some("something specific"));
1251    }
1252
1253    #[test]
1254    fn records_every_report_in_order() {
1255        let host = host("const a = 1;\nconst b = 2;\nconst c = 3;");
1256        let _: () = run(
1257            &host,
1258            "for (const d of ctx.namedChildren(ctx.root)) { ctx.report(d, ctx.text(d)); }",
1259        );
1260
1261        let reports = host.take_reports();
1262        let lines: Vec<u32> = reports.iter().map(|r| r.line).collect();
1263        assert_eq!(lines, [1, 2, 3]);
1264        assert_eq!(reports[2].message.as_deref(), Some("const c = 3;"));
1265    }
1266
1267    #[test]
1268    fn a_report_at_an_unresolvable_handle_is_dropped() {
1269        // Recording it at 1:1 would point a reader at an unrelated line, which is worse
1270        // than the rule appearing not to have fired.
1271        let host = host("const x = 1;");
1272        let _: () = run(&host, "ctx.report(9999)");
1273        assert!(host.take_reports().is_empty());
1274    }
1275
1276    #[test]
1277    fn taking_reports_empties_the_context() {
1278        let host = host("const x = 1;");
1279        let _: () = run(&host, "ctx.report(ctx.root)");
1280
1281        assert_eq!(host.take_reports().len(), 1);
1282        assert!(
1283            host.take_reports().is_empty(),
1284            "reports must not be reported twice"
1285        );
1286    }
1287
1288    #[test]
1289    fn a_rule_that_throws_still_leaves_earlier_reports() {
1290        // A handler may report several times and then hit a bug. What it already found is
1291        // still true, and discarding it would make the failure harder to diagnose rather
1292        // than easier.
1293        let host = host("const x = 1;");
1294        let sandbox = Sandbox::with_limits(Limits::default()).expect("sandbox builds");
1295        let result: Result<(), _> =
1296            sandbox.eval_with_host(&host, "ctx.report(ctx.root); throw new Error('later')");
1297
1298        assert!(result.is_err());
1299        assert_eq!(host.take_reports().len(), 1);
1300    }
1301
1302    #[test]
1303    fn navigation_is_bounded_by_the_rule_timeout() {
1304        // The host API is reachable from arbitrary rule code, so a loop calling into it
1305        // must still be interruptible. Nothing here may take a lock the interrupt handler
1306        // needs, or the run would hang instead of failing.
1307        let host = host("const x = 1;");
1308        let sandbox = Sandbox::with_limits(
1309            Limits::default().with_rule_timeout(std::time::Duration::from_millis(120)),
1310        )
1311        .expect("sandbox builds");
1312
1313        let result: Result<(), _> = sandbox.eval_with_host(
1314            &host,
1315            "for (;;) { ctx.kind(ctx.root); ctx.children(ctx.root); }",
1316        );
1317        assert!(
1318            matches!(result, Err(crate::SandboxError::RuleTimeout { .. })),
1319            "expected a timeout, got {result:?}"
1320        );
1321    }
1322
1323    #[test]
1324    fn the_sandbox_still_withholds_everything_it_did_before() {
1325        // Installing `ctx` must not have widened the surface as a side effect.
1326        let host = host("const x = 1;");
1327        assert!(run::<bool>(
1328            &host,
1329            "typeof Date === 'undefined' &&
1330             typeof performance === 'undefined' &&
1331             typeof Math.random === 'undefined' &&
1332             typeof fetch === 'undefined' &&
1333             typeof process === 'undefined'"
1334        ));
1335    }
1336
1337    // --- binding resolution -------------------------------------------------------------
1338
1339    #[test]
1340    fn resolves_an_import_through_its_alias() {
1341        // The case §6.4 exists for. A rule looking for `makeStyles` has to find `ms`.
1342        let host = host("import { makeStyles as ms } from '@rneui/themed';\nms();");
1343        let handle = handle_of(&host, "ms");
1344
1345        assert!(run::<bool>(
1346            &host,
1347            &format!("ctx.resolvesToImport({handle}, '@rneui/themed', 'makeStyles')")
1348        ));
1349        assert!(!run::<bool>(
1350            &host,
1351            &format!("ctx.resolvesToImport({handle}, 'somewhere-else', 'makeStyles')")
1352        ));
1353        assert!(!run::<bool>(
1354            &host,
1355            &format!("ctx.resolvesToImport({handle}, '@rneui/themed', 'notThatOne')")
1356        ));
1357    }
1358
1359    #[test]
1360    fn a_local_declaration_does_not_resolve_to_the_import_it_shadows() {
1361        // The false positive this prevents: a rule keyed on the name firing on a local
1362        // that has nothing to do with the import.
1363        let host = host(
1364            "import { makeStyles } from '@rneui/themed';\n\
1365             function f() { const makeStyles = () => {}; return makeStyles(); }",
1366        );
1367        let handle = handle_of(&host, "makeStyles");
1368
1369        assert!(!run::<bool>(
1370            &host,
1371            &format!("ctx.resolvesToImport({handle}, '@rneui/themed', 'makeStyles')")
1372        ));
1373        assert_eq!(
1374            run::<String>(&host, &format!("ctx.bindingKind({handle})")),
1375            "const"
1376        );
1377        assert!(run::<bool>(&host, &format!("ctx.isShadowed({handle})")));
1378    }
1379
1380    #[test]
1381    fn omitting_the_name_matches_any_export_of_the_module() {
1382        let host = host("import { a } from 'm';\na();");
1383        let handle = handle_of(&host, "a");
1384        assert!(run::<bool>(
1385            &host,
1386            &format!("ctx.resolvesToImport({handle}, 'm')")
1387        ));
1388    }
1389
1390    #[test]
1391    fn matches_a_module_by_glob() {
1392        let host = host("import { a } from '@scope/pkg';\na();");
1393        let handle = handle_of(&host, "a");
1394
1395        assert!(run::<bool>(
1396            &host,
1397            &format!("ctx.isImportedFrom({handle}, '@scope/*')")
1398        ));
1399        assert!(run::<bool>(
1400            &host,
1401            &format!("ctx.isImportedFrom({handle}, '*/pkg')")
1402        ));
1403        assert!(run::<bool>(
1404            &host,
1405            &format!("ctx.isImportedFrom({handle}, '@scope/pkg')")
1406        ));
1407        assert!(!run::<bool>(
1408            &host,
1409            &format!("ctx.isImportedFrom({handle}, '@other/*')")
1410        ));
1411    }
1412
1413    #[test]
1414    fn reports_binding_kinds() {
1415        for (source, name, expected) in [
1416            ("import { a } from 'm';\na();", "a", "import"),
1417            ("const b = 1;\nb;", "b", "const"),
1418            ("let c = 1;\nc;", "c", "let"),
1419            ("function d() {}\nd();", "d", "function"),
1420            ("class E {}\nnew E();", "E", "class"),
1421            ("function f(p) { return p; }", "p", "param"),
1422        ] {
1423            let host = host(source);
1424            let handle = handle_of(&host, name);
1425            assert_eq!(
1426                run::<String>(&host, &format!("ctx.bindingKind({handle})")),
1427                expected,
1428                "for {name} in {source}"
1429            );
1430        }
1431    }
1432
1433    #[test]
1434    fn an_undeclared_name_has_no_binding_kind() {
1435        let host = host("globalThing();");
1436        let handle = handle_of(&host, "globalThing");
1437        assert!(run::<bool>(
1438            &host,
1439            &format!("ctx.bindingKind({handle}) === undefined")
1440        ));
1441    }
1442
1443    #[test]
1444    fn without_a_resolver_nothing_resolves_rather_than_throwing() {
1445        // A language with no resolver should make rules see "nothing resolves", not a
1446        // TypeError blamed on the rule for calling a function that is missing.
1447        let host = host_without_resolver("import { a } from 'm';\na();");
1448        assert!(run::<bool>(
1449            &host,
1450            "ctx.resolvesToImport(0, 'm', 'a') === false &&
1451             ctx.isImportedFrom(0, '*') === false &&
1452             ctx.isShadowed(0) === false &&
1453             ctx.bindingKind(0) === undefined"
1454        ));
1455    }
1456
1457    #[test]
1458    fn glob_matching_handles_the_shapes_that_appear_in_rules() {
1459        assert!(glob_matches("m", "m"));
1460        assert!(!glob_matches("m", "mm"));
1461        assert!(glob_matches("*", "anything"));
1462        assert!(glob_matches("@scope/*", "@scope/pkg"));
1463        assert!(!glob_matches("@scope/*", "@other/pkg"));
1464        assert!(glob_matches("*/themed", "@rneui/themed"));
1465        assert!(!glob_matches("*/themed", "@rneui/other"));
1466        assert!(glob_matches("@a/*/c", "@a/b/c"));
1467        assert!(!glob_matches("@a/*/c", "@a/b/d"));
1468        assert!(glob_matches("", ""));
1469        assert!(!glob_matches("", "x"));
1470    }
1471
1472    #[test]
1473    fn navigation_stays_lazy() {
1474        // The arena must not have materialized the tree just because `ctx` exists.
1475        let host = host("const a = 1; const b = 2; function c() { return [1,2,3] }");
1476        assert!(
1477            host.arena().borrow().is_empty(),
1478            "nothing should be interned yet"
1479        );
1480
1481        let _: () = run(&host, "ctx.kind(ctx.root)");
1482        assert!(
1483            host.arena().borrow().is_empty(),
1484            "reading the root's kind should not intern anything new"
1485        );
1486    }
1487
1488    // --- facts -------------------------------------------------------------------------
1489
1490    /// Run source with a per-file `ctx` and return the facts it emitted.
1491    fn emitted(source: &str) -> Vec<EmittedFact> {
1492        let host = host("const a = 1;");
1493        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1494        sandbox
1495            .eval_with_host::<()>(&host, source)
1496            .expect("evaluates");
1497        host.take_facts()
1498    }
1499
1500    #[test]
1501    fn a_fact_is_captured_with_its_kind_and_payload() {
1502        let facts = emitted("ctx.emitFact({ kind: 'export', symbol: 'parse' })");
1503        assert_eq!(facts.len(), 1);
1504        assert_eq!(facts[0].kind, "export");
1505        assert!(
1506            facts[0].data.contains(r#""symbol":"parse""#),
1507            "{:?}",
1508            facts[0]
1509        );
1510    }
1511
1512    #[test]
1513    fn facts_are_kept_in_emission_order() {
1514        // Within a file, the order a rule emitted in is the only order it can have meant.
1515        let facts = emitted(
1516            "ctx.emitFact({ kind: 'a', n: 1 }); \
1517             ctx.emitFact({ kind: 'b', n: 2 }); \
1518             ctx.emitFact({ kind: 'a', n: 3 });",
1519        );
1520        assert_eq!(
1521            facts.iter().map(|f| f.kind.as_str()).collect::<Vec<_>>(),
1522            vec!["a", "b", "a"]
1523        );
1524    }
1525
1526    #[test]
1527    fn a_fact_without_a_kind_is_rejected() {
1528        // Not dropped. A fact with no kind can never be selected by `ctx.facts(kind)`, so
1529        // accepting it would leave the rule looking correct until reduce found nothing.
1530        let host = host("const a = 1;");
1531        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1532        let error = sandbox
1533            .eval_with_host::<()>(&host, "ctx.emitFact({ symbol: 'parse' })")
1534            .expect_err("is rejected");
1535        assert!(error.to_string().contains("kind"), "{error}");
1536        assert!(host.take_facts().is_empty());
1537    }
1538
1539    #[test]
1540    fn a_fact_with_an_empty_kind_is_rejected() {
1541        let host = host("const a = 1;");
1542        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1543        assert!(
1544            sandbox
1545                .eval_with_host::<()>(&host, "ctx.emitFact({ kind: '' })")
1546                .is_err()
1547        );
1548    }
1549
1550    #[test]
1551    fn a_fact_that_is_not_an_object_is_rejected() {
1552        let host = host("const a = 1;");
1553        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1554        for bad in ["'export'", "42", "null", "undefined"] {
1555            assert!(
1556                sandbox
1557                    .eval_with_host::<()>(&host, &format!("ctx.emitFact({bad})"))
1558                    .is_err(),
1559                "`{bad}` should not be emittable"
1560            );
1561        }
1562    }
1563
1564    #[test]
1565    fn a_cyclic_fact_is_rejected_rather_than_hanging() {
1566        let host = host("const a = 1;");
1567        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1568        let error = sandbox
1569            .eval_with_host::<()>(
1570                &host,
1571                "const f = { kind: 'x' }; f.self = f; ctx.emitFact(f)",
1572            )
1573            .expect_err("is rejected");
1574        // JSON.stringify's own error. Letting it through unchanged is right: it says
1575        // "circular structure", which is more specific than anything this layer knows.
1576        assert!(!error.to_string().is_empty());
1577    }
1578
1579    #[test]
1580    fn the_reduce_surface_is_absent_from_the_per_file_context() {
1581        // The invariant that makes per-file cache entries mean anything: a file's result
1582        // must not depend on any other file.
1583        let host = host("const a = 1;");
1584        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1585        for absent in ["ctx.facts", "ctx.files"] {
1586            let present: bool = sandbox
1587                .eval_with_host(&host, &format!("{absent} !== undefined"))
1588                .expect("evaluates");
1589            assert!(
1590                !present,
1591                "`{absent}` must not exist during the per-file pass"
1592            );
1593        }
1594    }
1595
1596    // --- the reduce context ------------------------------------------------------------
1597
1598    fn reduce_fact(kind: &str, json: &str) -> ReduceFact {
1599        ReduceFact {
1600            kind: kind.to_owned(),
1601            json: json.to_owned(),
1602        }
1603    }
1604
1605    /// The reduce phase's budget in these tests. Generous: nothing here is timing-sensitive.
1606    fn budget() -> std::time::Duration {
1607        std::time::Duration::from_secs(5)
1608    }
1609
1610    /// The per-file `ctx.report` takes `(node, { message })` and this one takes a location
1611    /// and a message, so both spellings of the second argument have to work. Rejecting the
1612    /// object form produced a type-conversion error naming neither argument, which is a
1613    /// remarkably unhelpful thing to be told.
1614    #[test]
1615    fn a_reduce_report_takes_a_string_or_an_options_object() {
1616        for expression in [
1617            r"ctx.report({ file: 'a.ts', line: 1, column: 2 }, 'plain string')",
1618            r"ctx.report({ file: 'a.ts', line: 1, column: 2 }, { message: 'plain string' })",
1619        ] {
1620            let context = ReduceContext::new(vec!["a.ts".to_owned()], vec![]);
1621            let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1622            sandbox
1623                .eval_with_reduce_host::<()>(&context, expression, budget())
1624                .unwrap_or_else(|e| panic!("{expression} should be accepted: {e}"));
1625
1626            let reports = context.take_reports();
1627            assert_eq!(reports.len(), 1, "{expression}");
1628            assert_eq!(
1629                reports[0].message.as_deref(),
1630                Some("plain string"),
1631                "{expression}"
1632            );
1633        }
1634    }
1635
1636    /// Anything else is refused rather than quietly dropped, so a rule reporting with the
1637    /// wrong shape hears about it.
1638    #[test]
1639    fn a_reduce_report_refuses_a_message_that_is_neither() {
1640        let context = ReduceContext::new(vec!["a.ts".to_owned()], vec![]);
1641        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1642        let error = sandbox
1643            .eval_with_reduce_host::<()>(
1644                &context,
1645                r"ctx.report({ file: 'a.ts', line: 1, column: 2 }, { detail: 'wrong key' })",
1646                budget(),
1647            )
1648            .expect_err("should refuse");
1649        assert!(
1650            error.to_string().contains("message"),
1651            "the error should say what it wanted: {error}"
1652        );
1653    }
1654
1655    #[test]
1656    fn facts_come_back_as_objects() {
1657        let context = ReduceContext::new(
1658            vec!["a.ts".to_owned()],
1659            vec![reduce_fact(
1660                "export",
1661                r#"{"kind":"export","symbol":"parse","file":"a.ts"}"#,
1662            )],
1663        );
1664        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1665        let symbol: String = sandbox
1666            .eval_with_reduce_host(&context, "ctx.facts('export')[0].symbol", budget())
1667            .expect("evaluates");
1668        assert_eq!(symbol, "parse");
1669    }
1670
1671    #[test]
1672    fn facts_filter_by_kind_and_default_to_everything() {
1673        let context = ReduceContext::new(
1674            vec![],
1675            vec![
1676                reduce_fact("export", r#"{"kind":"export","file":"a.ts"}"#),
1677                reduce_fact("import", r#"{"kind":"import","file":"b.ts"}"#),
1678                reduce_fact("export", r#"{"kind":"export","file":"c.ts"}"#),
1679            ],
1680        );
1681        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1682        let counts: Vec<i32> = sandbox
1683            .eval_with_reduce_host(
1684                &context,
1685                "[ctx.facts('export').length, ctx.facts('import').length, ctx.facts().length]",
1686                budget(),
1687            )
1688            .expect("evaluates");
1689        assert_eq!(counts, vec![2, 1, 3]);
1690    }
1691
1692    #[test]
1693    fn an_unknown_kind_yields_an_empty_array_rather_than_undefined() {
1694        // So `for (const f of ctx.facts('nope'))` is a no-op instead of a TypeError.
1695        let context = ReduceContext::new(vec![], vec![]);
1696        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1697        let length: i32 = sandbox
1698            .eval_with_reduce_host(&context, "ctx.facts('nope').length", budget())
1699            .expect("evaluates");
1700        assert_eq!(length, 0);
1701    }
1702
1703    #[test]
1704    fn the_file_list_is_visible() {
1705        let context = ReduceContext::new(vec!["a.ts".to_owned(), "b.ts".to_owned()], vec![]);
1706        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1707        let files: Vec<String> = sandbox
1708            .eval_with_reduce_host(&context, "ctx.files", budget())
1709            .expect("evaluates");
1710        assert_eq!(files, vec!["a.ts".to_owned(), "b.ts".to_owned()]);
1711    }
1712
1713    #[test]
1714    fn reporting_names_a_file_of_its_own() {
1715        let context = ReduceContext::new(vec![], vec![]);
1716        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1717        sandbox
1718            .eval_with_reduce_host::<()>(
1719                &context,
1720                "ctx.report({ file: 'b.ts', line: 4, column: 2 }, 'unused export')",
1721                budget(),
1722            )
1723            .expect("evaluates");
1724        assert_eq!(
1725            context.take_reports(),
1726            vec![ReduceReport {
1727                file: "b.ts".to_owned(),
1728                line: 4,
1729                column: 2,
1730                message: Some("unused export".to_owned()),
1731            }]
1732        );
1733    }
1734
1735    #[test]
1736    fn the_message_is_optional() {
1737        let context = ReduceContext::new(vec![], vec![]);
1738        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1739        sandbox
1740            .eval_with_reduce_host::<()>(
1741                &context,
1742                "ctx.report({ file: 'b.ts', line: 1, column: 1 })",
1743                budget(),
1744            )
1745            .expect("evaluates");
1746        let reports = context.take_reports();
1747        assert_eq!(reports.len(), 1);
1748        assert_eq!(reports[0].message, None);
1749    }
1750
1751    #[test]
1752    fn reporting_without_a_position_is_rejected() {
1753        // A cross-file violation with no site is unactionable, and defaulting to 1:1 would
1754        // point a reader at an unrelated line.
1755        let context = ReduceContext::new(vec![], vec![]);
1756        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1757        for bad in [
1758            "ctx.report({ file: 'b.ts' })",
1759            "ctx.report({ line: 1, column: 1 })",
1760            "ctx.report(3)",
1761            "ctx.report('b.ts')",
1762        ] {
1763            let error = sandbox
1764                .eval_with_reduce_host::<()>(&context, bad, budget())
1765                .expect_err("is rejected");
1766            assert!(!error.to_string().is_empty(), "`{bad}` should be rejected");
1767        }
1768        assert!(context.take_reports().is_empty());
1769    }
1770
1771    #[test]
1772    fn the_per_file_surface_is_absent_from_the_reduce_context() {
1773        // Invariant 1: the reduce phase never touches parse trees. A context that offered
1774        // navigation would be lying about what the phase can see.
1775        let context = ReduceContext::new(vec![], vec![]);
1776        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1777        for absent in [
1778            "ctx.emitFact",
1779            "ctx.text",
1780            "ctx.kind",
1781            "ctx.parent",
1782            "ctx.namedChildren",
1783            "ctx.filePath",
1784            "ctx.fileText",
1785            "ctx.root",
1786        ] {
1787            let present: bool = sandbox
1788                .eval_with_reduce_host(&context, &format!("{absent} !== undefined"), budget())
1789                .expect("evaluates");
1790            assert!(
1791                !present,
1792                "`{absent}` must not exist during the reduce phase"
1793            );
1794        }
1795    }
1796
1797    // --- merging the file into a payload -------------------------------------------------
1798
1799    #[test]
1800    fn merge_file_adds_the_field() {
1801        assert_eq!(
1802            merge_file(r#"{"kind":"export"}"#, "src/a.ts"),
1803            r#"{"kind":"export","file":"src/a.ts"}"#
1804        );
1805    }
1806
1807    #[test]
1808    fn merge_file_handles_an_empty_payload() {
1809        assert_eq!(merge_file("{}", "a.ts"), r#"{"file":"a.ts"}"#);
1810    }
1811
1812    #[test]
1813    fn merge_file_overrides_a_file_the_rule_supplied() {
1814        // Last duplicate key wins in JSON, so the host's value is the one that survives —
1815        // a rule cannot misattribute a violation to a file it did not come from.
1816        let merged = merge_file(r#"{"kind":"export","file":"lies.ts"}"#, "truth.ts");
1817        assert!(merged.ends_with(r#""file":"truth.ts"}"#), "{merged}");
1818
1819        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1820        let file: String = sandbox
1821            .eval(&format!("JSON.parse({merged:?}).file"))
1822            .expect("parses");
1823        assert_eq!(file, "truth.ts");
1824    }
1825
1826    #[test]
1827    fn merge_file_escapes_the_path() {
1828        // A path is untrusted input as far as this function is concerned: it comes from the
1829        // filesystem, and a quote in it would otherwise produce a payload that no longer
1830        // parses — or worse, one that parses into something else.
1831        let awkward = "a\"b\\c\nd.ts";
1832        let merged = merge_file("{}", awkward);
1833        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1834        let file: String = sandbox
1835            .eval(&format!("JSON.parse({merged:?}).file"))
1836            .expect("parses");
1837        assert_eq!(file, awkward);
1838    }
1839
1840    // --- scoped queries ------------------------------------------------------------------
1841
1842    /// A host with a language attached, so the query functions exist.
1843    fn host_with_language(source: &str) -> HostContext {
1844        HostContext::new(parse(source), source.to_owned(), "src/example.ts")
1845            .with_resolver(Arc::new(lanekeep_lang_js::binding::JsBindingResolver))
1846            .with_language(Arc::new(TypeScript))
1847    }
1848
1849    #[test]
1850    fn a_subtree_query_finds_only_what_is_inside() {
1851        // The point of scoping: a rule that matched a function and wants to look inside it
1852        // should not have to filter the whole file's matches by position.
1853        let source = "function a() { const x = 1; }\nfunction b() { const y = 2; const z = 3; }\n";
1854        let host = host_with_language(source);
1855        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1856
1857        let names: Vec<String> = sandbox
1858            .eval_with_host(
1859                &host,
1860                "const fns = ctx.querySubtree(ctx.root, '(function_declaration) @fn');\n\
1861                 const inner = ctx.querySubtree(fns[1].fn, '(variable_declarator name: (identifier) @name)');\n\
1862                 inner.map((m) => ctx.text(m.name))",
1863            )
1864            .expect("evaluates");
1865
1866        assert_eq!(names, vec!["y".to_owned(), "z".to_owned()]);
1867    }
1868
1869    #[test]
1870    fn a_subtree_query_with_no_matches_is_an_empty_array() {
1871        // So `for (const m of ctx.querySubtree(...))` is a no-op rather than a TypeError.
1872        let host = host_with_language("const a = 1;\n");
1873        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1874        let length: i32 = sandbox
1875            .eval_with_host(
1876                &host,
1877                "ctx.querySubtree(ctx.root, '(debugger_statement) @d').length",
1878            )
1879            .expect("evaluates");
1880        assert_eq!(length, 0);
1881    }
1882
1883    #[test]
1884    fn an_invalid_query_is_reported_to_the_rule() {
1885        let host = host_with_language("const a = 1;\n");
1886        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1887        let error = sandbox
1888            .eval_with_host::<()>(&host, "ctx.querySubtree(ctx.root, '(((')")
1889            .expect_err("is rejected");
1890        assert!(!error.to_string().is_empty());
1891    }
1892
1893    #[test]
1894    fn closest_ancestor_finds_the_nearest_one() {
1895        // Nearest, not outermost — the whole reason a rule walks upward.
1896        let source = "function outer() { function inner() { const x = 1; } }\n";
1897        let host = host_with_language(source);
1898        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1899
1900        let name: String = sandbox
1901            .eval_with_host(
1902                &host,
1903                "const decls = ctx.querySubtree(ctx.root, '(variable_declarator name: (identifier) @n)');\n\
1904                 const found = ctx.closestAncestor(decls[0].n, '(function_declaration name: (identifier) @name) @fn');\n\
1905                 ctx.text(found.name)",
1906            )
1907            .expect("evaluates");
1908
1909        assert_eq!(name, "inner");
1910    }
1911
1912    #[test]
1913    fn closest_ancestor_returns_undefined_when_nothing_matches() {
1914        // `undefined` rather than an empty object: an empty object is truthy, so
1915        // `if (!ctx.closestAncestor(...))` would silently take the wrong branch.
1916        let host = host_with_language("const a = 1;\n");
1917        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1918        let absent: bool = sandbox
1919            .eval_with_host(
1920                &host,
1921                "const d = ctx.querySubtree(ctx.root, '(variable_declarator name: (identifier) @n)');\n\
1922                 ctx.closestAncestor(d[0].n, '(class_declaration) @c') === undefined",
1923            )
1924            .expect("evaluates");
1925        assert!(absent);
1926    }
1927
1928    #[test]
1929    fn closest_ancestor_does_not_match_the_node_itself_from_inside() {
1930        // A query matching something *within* an ancestor must not make that ancestor the
1931        // answer — otherwise the outermost node matches every time.
1932        let source = "function outer() { function inner() { const x = 1; } }\n";
1933        let host = host_with_language(source);
1934        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1935
1936        let name: String = sandbox
1937            .eval_with_host(
1938                &host,
1939                "const d = ctx.querySubtree(ctx.root, '(variable_declarator name: (identifier) @n)');\n\
1940                 const found = ctx.closestAncestor(d[0].n, '(statement_block) @block');\n\
1941                 ctx.kind(found.block)",
1942            )
1943            .expect("evaluates");
1944        assert_eq!(name, "statement_block");
1945    }
1946
1947    #[test]
1948    fn the_query_functions_are_absent_without_a_language() {
1949        // A stub returning nothing would look like a query that matched nothing.
1950        let host = host("const a = 1;");
1951        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1952        for absent in ["ctx.querySubtree", "ctx.closestAncestor"] {
1953            let present: bool = sandbox
1954                .eval_with_host(&host, &format!("{absent} !== undefined"))
1955                .expect("evaluates");
1956            assert!(!present, "`{absent}` should not exist without a language");
1957        }
1958    }
1959
1960    // --- ctx.loc and ctx.today -----------------------------------------------------------
1961
1962    #[test]
1963    fn loc_gives_the_shape_a_fact_and_a_reduce_report_both_use() {
1964        // A rule that emits `at: ctx.loc(node)` and later calls `ctx.report(f.at)` needs no
1965        // glue between the two, which is the whole reason this returns an object.
1966        let source = "const alpha = 1;\nconst beta = 2;\n";
1967        let host = host(source);
1968        let handle = handle_of(&host, "beta");
1969        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1970
1971        let rendered: String = sandbox
1972            .eval_with_host(
1973                &host,
1974                &format!("const l = ctx.loc({handle}); `${{l.file}}:${{l.line}}:${{l.column}}`"),
1975            )
1976            .expect("evaluates");
1977        assert_eq!(rendered, "src/example.ts:2:7");
1978    }
1979
1980    #[test]
1981    fn loc_at_an_unresolvable_handle_is_undefined() {
1982        // The same posture as reporting at one: nothing, rather than a made-up position a
1983        // reader would go and look at.
1984        let host = host("const a = 1;");
1985        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1986        let absent: bool = sandbox
1987            .eval_with_host(&host, "ctx.loc(9999) === undefined")
1988            .expect("evaluates");
1989        assert!(absent);
1990    }
1991
1992    #[test]
1993    fn today_is_what_the_host_supplied() {
1994        let host = host("const a = 1;").with_today("2026-08-01");
1995        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1996        let today: String = sandbox
1997            .eval_with_host(&host, "ctx.today")
1998            .expect("evaluates");
1999        assert_eq!(today, "2026-08-01");
2000    }
2001
2002    #[test]
2003    fn today_is_absent_when_the_host_supplied_none() {
2004        // Absent rather than empty: a rule comparing against `undefined` would silently take
2005        // a branch nobody intended, where a missing property is a `TypeError` naming what it
2006        // reached for.
2007        let host = host("const a = 1;");
2008        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
2009        let absent: bool = sandbox
2010            .eval_with_host(&host, "ctx.today === undefined")
2011            .expect("evaluates");
2012        assert!(absent);
2013    }
2014
2015    #[test]
2016    fn reading_today_is_observed_and_not_reading_it_is_not() {
2017        // The property the cache depends on. A plain value would be indistinguishable from
2018        // an unread one, and every file would have to be treated as date-dependent.
2019        let unread = host("const a = 1;").with_today("2026-08-01");
2020        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
2021        sandbox
2022            .eval_with_host::<i32>(&unread, "1 + 1")
2023            .expect("evaluates");
2024        assert!(!unread.date_was_read(), "nothing read the date");
2025
2026        let read = host("const a = 1;").with_today("2026-08-01");
2027        sandbox
2028            .eval_with_host::<String>(&read, "ctx.today")
2029            .expect("evaluates");
2030        assert!(read.date_was_read(), "the read was not observed");
2031    }
2032
2033    #[test]
2034    fn today_does_not_bring_a_clock_with_it() {
2035        // The invariant it sits next to: a rule gets a date, not the ability to observe time.
2036        let host = host("const a = 1;").with_today("2026-08-01");
2037        let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
2038        for absent in ["Date", "performance"] {
2039            let present: bool = sandbox
2040                .eval_with_host(&host, &format!("typeof {absent} !== 'undefined'"))
2041                .expect("evaluates");
2042            assert!(!present, "`{absent}` must not exist");
2043        }
2044    }
2045}