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