use std::cell::{Cell, RefCell};
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::rc::Rc;
use std::sync::Arc;
use rquickjs::function::Opt;
use rquickjs::object::Accessor;
use rquickjs::{Ctx, Function, Object, Value};
use lanekeep_lang::binding::BindingResolver;
use lanekeep_core::files::FileAccess;
use lanekeep_core::fix::Fix;
use lanekeep_nodes::{Handle, NodeArena};
use lanekeep_query::CompiledQuery;
pub const HOST_API_VERSION: u32 = 2;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EmittedFact {
pub kind: String,
pub data: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Report {
pub node: Option<Handle>,
pub line: u32,
pub column: u32,
pub message: Option<String>,
pub fix: Option<Fix>,
}
#[derive(Clone)]
pub struct HostContext {
arena: Rc<RefCell<NodeArena>>,
reports: Rc<RefCell<Vec<Report>>>,
facts: Rc<RefCell<Vec<EmittedFact>>>,
file_path: Rc<str>,
resolver: Option<Arc<dyn BindingResolver>>,
files: Option<Arc<FileAccess>>,
language: Option<Arc<dyn lanekeep_lang::Language>>,
today: Option<Rc<str>>,
date_read: Rc<Cell<bool>>,
queries: QueryCache,
}
type QueryCache = Rc<RefCell<BTreeMap<String, Result<Rc<CompiledQuery>, String>>>>;
impl std::fmt::Debug for HostContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HostContext")
.field("file_path", &self.file_path)
.field("interned_nodes", &self.arena.borrow().len())
.field("reports", &self.reports.borrow().len())
.field("facts", &self.facts.borrow().len())
.field("has_resolver", &self.resolver.is_some())
.field("has_file_access", &self.files.is_some())
.field("has_language", &self.language.is_some())
.field("has_today", &self.today.is_some())
.field("date_read", &self.date_read.get())
.field("compiled_queries", &self.queries.borrow().len())
.finish()
}
}
impl HostContext {
#[must_use]
pub fn new(tree: tree_sitter::Tree, source: String, file_path: &str) -> Self {
Self {
arena: Rc::new(RefCell::new(NodeArena::new(tree, source))),
reports: Rc::new(RefCell::new(Vec::new())),
facts: Rc::new(RefCell::new(Vec::new())),
file_path: Rc::from(file_path),
resolver: None,
files: None,
language: None,
today: None,
date_read: Rc::new(Cell::new(false)),
queries: Rc::new(RefCell::new(BTreeMap::new())),
}
}
#[must_use]
pub fn with_resolver_from(self, language: &dyn lanekeep_lang::Language) -> Self {
match language.resolver() {
Some(resolver) => self.with_resolver(resolver),
None => self,
}
}
#[must_use]
pub fn with_today(mut self, today: &str) -> Self {
self.today = Some(Rc::from(today));
self
}
#[must_use]
pub fn date_was_read(&self) -> bool {
self.date_read.get()
}
#[must_use]
pub fn with_language(mut self, language: Arc<dyn lanekeep_lang::Language>) -> Self {
self.language = Some(language);
self
}
#[must_use]
pub fn with_resolver(mut self, resolver: Arc<dyn BindingResolver>) -> Self {
self.resolver = Some(resolver);
self
}
#[must_use]
pub fn with_file_access(mut self, files: Arc<FileAccess>) -> Self {
self.files = Some(files);
self
}
#[must_use]
pub fn arena(&self) -> &Rc<RefCell<NodeArena>> {
&self.arena
}
#[must_use]
pub fn take_reports(&self) -> Vec<Report> {
std::mem::take(&mut self.reports.borrow_mut())
}
#[must_use]
pub fn take_facts(&self) -> Vec<EmittedFact> {
std::mem::take(&mut self.facts.borrow_mut())
}
pub fn build<'js>(&self, ctx: &Ctx<'js>) -> rquickjs::Result<Object<'js>> {
let object = Object::new(ctx.clone())?;
object.set("filePath", &*self.file_path)?;
object.set("root", NodeArena::ROOT)?;
{
let arena = self.arena.borrow();
object.set("fileText", arena.source())?;
}
self.install_navigation(ctx, &object)?;
self.install_bindings(ctx, &object)?;
self.install_reporting(ctx, &object)?;
self.install_facts(ctx, &object)?;
self.install_reads(ctx, &object)?;
self.install_queries(ctx, &object)?;
if let Some(today) = self.today.clone() {
let date_read = Rc::clone(&self.date_read);
object.prop(
"today",
Accessor::from(move || {
date_read.set(true);
today.to_string()
}),
)?;
}
Ok(object)
}
fn install_navigation<'js>(
&self,
ctx: &Ctx<'js>,
object: &Object<'js>,
) -> rquickjs::Result<()> {
let arena = Rc::clone(&self.arena);
object.set(
"kind",
Function::new(ctx.clone(), move |handle: Handle| {
arena.borrow().kind(handle).map(ToOwned::to_owned)
})?,
)?;
let arena = Rc::clone(&self.arena);
object.set(
"text",
Function::new(ctx.clone(), move |handle: Handle| {
arena.borrow().text(handle).map(ToOwned::to_owned)
})?,
)?;
let arena = Rc::clone(&self.arena);
object.set(
"isNamed",
Function::new(ctx.clone(), move |handle: Handle| {
arena.borrow().is_named(handle)
})?,
)?;
let arena = Rc::clone(&self.arena);
object.set(
"line",
Function::new(ctx.clone(), move |handle: Handle| {
arena.borrow().position(handle).map(|(line, _)| line)
})?,
)?;
let arena = Rc::clone(&self.arena);
object.set(
"column",
Function::new(ctx.clone(), move |handle: Handle| {
arena.borrow().position(handle).map(|(_, column)| column)
})?,
)?;
let arena = Rc::clone(&self.arena);
object.set(
"parent",
Function::new(ctx.clone(), move |handle: Handle| {
arena.borrow_mut().parent(handle)
})?,
)?;
let arena = Rc::clone(&self.arena);
object.set(
"children",
Function::new(ctx.clone(), move |handle: Handle| {
arena.borrow_mut().children(handle)
})?,
)?;
let arena = Rc::clone(&self.arena);
object.set(
"namedChildren",
Function::new(ctx.clone(), move |handle: Handle| {
arena.borrow_mut().named_children(handle)
})?,
)?;
let arena = Rc::clone(&self.arena);
object.set(
"ancestors",
Function::new(ctx.clone(), move |handle: Handle| {
arena.borrow_mut().ancestors(handle)
})?,
)?;
let arena = Rc::clone(&self.arena);
object.set(
"structureFingerprint",
Function::new(
ctx.clone(),
move |ctx: Ctx<'js>, handle: Handle| -> rquickjs::Result<Value<'js>> {
let Some(fingerprint) = arena.borrow().structure_fingerprint(handle) else {
return Ok(Value::new_undefined(ctx.clone()));
};
let object = Object::new(ctx.clone())?;
object.set("hash", fingerprint.hash)?;
object.set("nodes", fingerprint.nodes)?;
Ok(object.into_value())
},
)?,
)?;
Ok(())
}
fn install_bindings<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
let arena = Rc::clone(&self.arena);
let resolver = self.resolver.clone();
object.set(
"resolvesToImport",
Function::new(
ctx.clone(),
move |handle: Handle, module: String, name: Opt<String>| {
let Some(resolver) = resolver.as_deref() else {
return false;
};
arena
.borrow()
.resolve_binding(handle, resolver)
.is_some_and(|binding| binding.is_import_of(&module, name.0.as_deref()))
},
)?,
)?;
let arena = Rc::clone(&self.arena);
let resolver = self.resolver.clone();
object.set(
"isImportedFrom",
Function::new(ctx.clone(), move |handle: Handle, pattern: String| {
let Some(resolver) = resolver.as_deref() else {
return false;
};
arena
.borrow()
.resolve_binding(handle, resolver)
.is_some_and(|binding| binding.is_imported_from(&pattern))
})?,
)?;
let arena = Rc::clone(&self.arena);
let resolver = self.resolver.clone();
object.set(
"bindingKind",
Function::new(ctx.clone(), move |handle: Handle| {
let resolver = resolver.as_deref()?;
arena
.borrow()
.resolve_binding(handle, resolver)
.map(|binding| binding.kind_str().to_owned())
})?,
)?;
let arena = Rc::clone(&self.arena);
let resolver = self.resolver.clone();
object.set(
"isShadowed",
Function::new(ctx.clone(), move |handle: Handle| {
resolver
.as_deref()
.is_some_and(|resolver| arena.borrow().is_shadowed(handle, resolver))
})?,
)?;
Ok(())
}
fn install_reporting<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
let arena = Rc::clone(&self.arena);
let file_path = Rc::clone(&self.file_path);
object.set(
"loc",
Function::new(
ctx.clone(),
move |ctx: Ctx<'js>, handle: Handle| -> rquickjs::Result<Value<'js>> {
let Some((line, column)) = arena.borrow().position(handle) else {
return Ok(Value::new_undefined(ctx.clone()));
};
let object = Object::new(ctx.clone())?;
object.set("file", &*file_path)?;
object.set("line", line)?;
object.set("column", column)?;
Ok(object.into_value())
},
)?,
)?;
let arena = Rc::clone(&self.arena);
let reports = Rc::clone(&self.reports);
object.set(
"report",
Function::new(
ctx.clone(),
move |ctx: Ctx<'js>,
handle: Handle,
options: Opt<Value<'js>>|
-> rquickjs::Result<()> {
let Some((line, column)) = arena.borrow().position(handle) else {
return Ok(());
};
let (message, fix) = match options.0 {
None => (None, None),
Some(value) if value.is_string() => (value.get::<String>().ok(), None),
Some(value) => {
let Some(object) = value.as_object() else {
return Err(throw(
&ctx,
"ctx.report expects a message string or an options \
object — { message?, fix? }",
));
};
let message = object.get::<_, String>("message").ok();
let fix = read_fix(&ctx, object, &arena)?;
(message, fix)
}
};
reports.borrow_mut().push(Report {
node: Some(handle),
line,
column,
message,
fix,
});
Ok(())
},
)?,
)?;
Ok(())
}
fn install_facts<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
let facts = Rc::clone(&self.facts);
object.set(
"emitFact",
Function::new(
ctx.clone(),
move |ctx: Ctx<'js>, fact: Value<'js>| -> rquickjs::Result<()> {
let Some(fact_object) = fact.as_object() else {
return Err(throw(&ctx, "ctx.emitFact expects an object"));
};
let kind = match fact_object.get::<_, String>("kind") {
Ok(kind) if !kind.is_empty() => kind,
_ => {
return Err(throw(
&ctx,
"ctx.emitFact requires a non-empty string `kind` — it is what \
ctx.facts(kind) selects on, so a fact without one can never \
be read back",
));
}
};
let Some(json) = ctx.json_stringify(fact)? else {
return Err(throw(
&ctx,
"ctx.emitFact could not serialize this fact — facts are cached, \
so they have to survive JSON",
));
};
facts.borrow_mut().push(EmittedFact {
kind,
data: json.to_string()?,
});
Ok(())
},
)?,
)?;
Ok(())
}
fn install_queries<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
let Some(language) = self.language.clone() else {
return Ok(());
};
let arena = Rc::clone(&self.arena);
let queries = Rc::clone(&self.queries);
let grammar = Arc::clone(&language);
object.set(
"querySubtree",
Function::new(
ctx.clone(),
move |ctx: Ctx<'js>,
handle: Handle,
source: String|
-> rquickjs::Result<Value<'js>> {
let compiled = compile(&queries, grammar.as_ref(), &source)
.map_err(|problem| throw(&ctx, &problem))?;
let matches = arena.borrow().query_subtree(handle, &compiled);
let interned = intern_matches(&arena, matches);
captures_to_js(&ctx, interned)
},
)?,
)?;
let arena = Rc::clone(&self.arena);
let queries = Rc::clone(&self.queries);
object.set(
"closestAncestor",
Function::new(
ctx.clone(),
move |ctx: Ctx<'js>,
handle: Handle,
source: String|
-> rquickjs::Result<Value<'js>> {
let compiled = compile(&queries, language.as_ref(), &source)
.map_err(|problem| throw(&ctx, &problem))?;
let found = arena.borrow().closest_ancestor_paths(handle, &compiled);
let Some(captures) = found else {
return Ok(Value::new_undefined(ctx.clone()));
};
let interned = intern_matches(&arena, vec![captures]);
let one = interned.into_iter().next().unwrap_or_default();
let object = Object::new(ctx.clone())?;
for (name, handle) in one {
object.set(name, handle)?;
}
Ok(object.into_value())
},
)?,
)?;
Ok(())
}
fn install_reads<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
let Some(files) = self.files.clone() else {
return Ok(());
};
let reader = Arc::clone(&files);
object.set(
"readFile",
Function::new(
ctx.clone(),
move |ctx: Ctx<'js>, path: String| -> rquickjs::Result<Option<String>> {
reader.read(&path).map_err(|e| throw(&ctx, &e.to_string()))
},
)?,
)?;
let reader = Arc::clone(&files);
object.set(
"fileExists",
Function::new(
ctx.clone(),
move |ctx: Ctx<'js>, path: String| -> rquickjs::Result<bool> {
reader
.exists(&path)
.map_err(|e| throw(&ctx, &e.to_string()))
},
)?,
)?;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReduceReport {
pub file: String,
pub line: u32,
pub column: u32,
pub message: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReduceFact {
pub kind: String,
pub json: String,
}
#[derive(Debug, Clone)]
pub struct ReduceContext {
files: Rc<[String]>,
facts: Rc<[ReduceFact]>,
reports: Rc<RefCell<Vec<ReduceReport>>>,
}
impl ReduceContext {
#[must_use]
pub fn new(files: Vec<String>, facts: Vec<ReduceFact>) -> Self {
Self {
files: files.into(),
facts: facts.into(),
reports: Rc::new(RefCell::new(Vec::new())),
}
}
#[must_use]
pub fn take_reports(&self) -> Vec<ReduceReport> {
std::mem::take(&mut self.reports.borrow_mut())
}
pub fn build<'js>(&self, ctx: &Ctx<'js>) -> rquickjs::Result<Object<'js>> {
let object = Object::new(ctx.clone())?;
object.set("files", &*self.files)?;
let facts = Rc::clone(&self.facts);
object.set(
"facts",
Function::new(
ctx.clone(),
move |ctx: Ctx<'js>, kind: Opt<String>| -> rquickjs::Result<Value<'js>> {
let wanted = kind.0;
let mut json = String::from("[");
for fact in facts
.iter()
.filter(|f| wanted.as_ref().is_none_or(|k| *k == f.kind))
{
if json.len() > 1 {
json.push(',');
}
json.push_str(&fact.json);
}
json.push(']');
ctx.json_parse(json)
},
)?,
)?;
let reports = Rc::clone(&self.reports);
object.set(
"report",
Function::new(
ctx.clone(),
move |ctx: Ctx<'js>,
at: Value<'js>,
message: Opt<Value<'js>>|
-> rquickjs::Result<()> {
let Some(at) = at.as_object() else {
return Err(throw(
&ctx,
"ctx.report in a reduce phase expects { file, line, column } — \
there is no parse tree here, so there are no nodes to report at",
));
};
let (Ok(file), Ok(line), Ok(column)) = (
at.get::<_, String>("file"),
at.get::<_, u32>("line"),
at.get::<_, u32>("column"),
) else {
return Err(throw(
&ctx,
"ctx.report in a reduce phase needs `file`, `line` and `column` — \
emit them on the fact during the per-file pass, where the node \
positions are still available",
));
};
let message = match message.0 {
None => None,
Some(value) if value.is_undefined() || value.is_null() => None,
Some(value) => {
if let Some(text) = value.as_string() {
Some(text.to_string()?)
} else if let Some(options) = value.as_object() {
match options.get::<_, Value<'js>>("message") {
Ok(found) if found.is_string() => found
.as_string()
.map(rquickjs::String::to_string)
.transpose()?,
_ => {
return Err(throw(
&ctx,
"ctx.report in a reduce phase takes a message: \
either a string, or { message }",
));
}
}
} else {
return Err(throw(
&ctx,
"ctx.report in a reduce phase takes a message: either a \
string, or { message }",
));
}
}
};
reports.borrow_mut().push(ReduceReport {
file,
line,
column,
message,
});
Ok(())
},
)?,
)?;
Ok(object)
}
}
fn read_fix<'js>(
ctx: &Ctx<'js>,
options: &Object<'js>,
arena: &Rc<RefCell<NodeArena>>,
) -> rquickjs::Result<Option<Fix>> {
let Ok(value) = options.get::<_, Value<'js>>("fix") else {
return Ok(None);
};
if value.is_undefined() || value.is_null() {
return Ok(None);
}
let Some(fix) = value.as_object() else {
return Err(throw(
&ctx.clone(),
"ctx.report's `fix` expects { node, text, safe? }",
));
};
let (Ok(handle), Ok(replacement)) =
(fix.get::<_, Handle>("node"), fix.get::<_, String>("text"))
else {
return Err(throw(
&ctx.clone(),
"ctx.report's `fix` needs a `node` to replace and the `text` to put there",
));
};
let Some((start, end)) = arena.borrow().byte_range(handle) else {
return Ok(None);
};
Ok(Some(Fix {
start,
end,
replacement,
safe: fix.get::<_, bool>("safe").unwrap_or(false),
}))
}
fn compile(
cache: &QueryCache,
language: &dyn lanekeep_lang::Language,
source: &str,
) -> Result<Rc<CompiledQuery>, String> {
if let Some(found) = cache.borrow().get(source) {
return found.clone();
}
let compiled = CompiledQuery::compile(language, source)
.map(Rc::new)
.map_err(|e| e.to_string());
cache
.borrow_mut()
.insert(source.to_owned(), compiled.clone());
compiled
}
fn intern_matches(
arena: &Rc<RefCell<NodeArena>>,
matches: Vec<Vec<(String, Vec<u32>)>>,
) -> Vec<Vec<(String, Handle)>> {
let mut arena = arena.borrow_mut();
matches
.into_iter()
.map(|captures| {
captures
.into_iter()
.filter_map(|(name, path)| arena.intern_path(path).map(|handle| (name, handle)))
.collect()
})
.collect()
}
fn captures_to_js<'js>(
ctx: &Ctx<'js>,
matches: Vec<Vec<(String, Handle)>>,
) -> rquickjs::Result<Value<'js>> {
let array = rquickjs::Array::new(ctx.clone())?;
for (index, captures) in matches.into_iter().enumerate() {
let object = Object::new(ctx.clone())?;
for (name, handle) in captures {
object.set(name, handle)?;
}
array.set(index, object)?;
}
Ok(array.into_value())
}
fn throw(ctx: &Ctx<'_>, message: &str) -> rquickjs::Error {
rquickjs::Exception::throw_type(ctx, message)
}
#[must_use]
pub fn merge_file(data: &str, file: &str) -> String {
let inner = data
.trim()
.strip_prefix('{')
.and_then(|rest| rest.strip_suffix('}'))
.unwrap_or_default()
.trim();
let mut out = String::with_capacity(data.len() + file.len() + 12);
out.push('{');
if !inner.is_empty() {
out.push_str(inner);
out.push(',');
}
out.push_str("\"file\":");
escape_json_string(file, &mut out);
out.push('}');
out
}
fn escape_json_string(text: &str, out: &mut String) {
out.push('"');
for ch in text.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => {
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
}
out.push('"');
}
#[cfg(test)]
mod tests {
use lanekeep_lang::Language;
use lanekeep_lang_js::TypeScript;
use super::*;
use crate::{Limits, Sandbox};
fn parse(source: &str) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&TypeScript.grammar())
.expect("grammar loads");
parser.parse(source, None).expect("parses")
}
fn host(source: &str) -> HostContext {
HostContext::new(parse(source), source.to_owned(), "src/example.ts")
.with_resolver(Arc::new(lanekeep_lang_js::binding::JsBindingResolver))
}
fn host_without_resolver(source: &str) -> HostContext {
HostContext::new(parse(source), source.to_owned(), "src/example.ts")
}
fn handle_of(host: &HostContext, name: &str) -> Handle {
let mut arena = host.arena().borrow_mut();
let source = arena.source().to_owned();
let path = {
let mut best: Option<tree_sitter::Node<'_>> = None;
let mut stack = vec![arena.tree().root_node()];
while let Some(node) = stack.pop() {
if node.kind() == "identifier"
&& source.get(node.byte_range()) == Some(name)
&& best.is_none_or(|b| node.start_byte() > b.start_byte())
{
best = Some(node);
}
let mut cursor = node.walk();
stack.extend(node.children(&mut cursor));
}
arena
.path_of(best.unwrap_or_else(|| panic!("no identifier `{name}`")))
.expect("has a path")
};
arena.intern_path(path).expect("interns")
}
fn run<T>(host: &HostContext, code: &str) -> T
where
T: for<'js> rquickjs::FromJs<'js> + Default,
{
let sandbox = Sandbox::with_limits(Limits::default()).expect("sandbox builds");
sandbox.eval_with_host(host, code).expect("evaluates")
}
#[test]
fn exposes_the_file_path_and_text() {
let host = host("const x = 1;");
assert_eq!(run::<String>(&host, "ctx.filePath"), "src/example.ts");
assert_eq!(run::<String>(&host, "ctx.fileText"), "const x = 1;");
}
#[test]
fn navigates_from_the_root() {
let host = host("const x = 1;\nconst y = 2;");
assert_eq!(run::<String>(&host, "ctx.kind(ctx.root)"), "program");
assert_eq!(run::<u32>(&host, "ctx.namedChildren(ctx.root).length"), 2);
assert_eq!(
run::<String>(&host, "ctx.kind(ctx.namedChildren(ctx.root)[0])"),
"lexical_declaration"
);
}
#[test]
fn reads_text_and_position() {
let host = host("const x = 1;\nconst y = 2;");
assert_eq!(
run::<String>(&host, "ctx.text(ctx.namedChildren(ctx.root)[1])"),
"const y = 2;"
);
assert_eq!(
run::<u32>(&host, "ctx.line(ctx.namedChildren(ctx.root)[1])"),
2
);
assert_eq!(
run::<u32>(&host, "ctx.column(ctx.namedChildren(ctx.root)[1])"),
1
);
}
#[test]
fn walks_up_and_back_down() {
let host = host("const x = 1;");
assert!(run::<bool>(
&host,
"const d = ctx.namedChildren(ctx.root)[0];
const inner = ctx.namedChildren(d)[0];
ctx.parent(inner) === d && ctx.parent(d) === ctx.root"
));
}
#[test]
fn handles_compare_equal_for_the_same_node() {
let host = host("const x = 1;");
assert!(run::<bool>(
&host,
"ctx.namedChildren(ctx.root)[0] === ctx.namedChildren(ctx.root)[0]"
));
}
#[test]
fn ancestors_end_at_the_root() {
let host = host("function f() { return 1; }");
assert!(run::<bool>(
&host,
"const fn = ctx.namedChildren(ctx.root)[0];
const body = ctx.namedChildren(fn).at(-1);
const stmt = ctx.namedChildren(body)[0];
const a = ctx.ancestors(stmt);
a[0] === body && a.at(-1) === ctx.root"
));
}
#[test]
fn named_children_omits_anonymous_tokens() {
let host = host("const x = 1;");
assert!(run::<bool>(
&host,
"const d = ctx.namedChildren(ctx.root)[0];
ctx.children(d).length > ctx.namedChildren(d).length"
));
}
#[test]
fn an_unresolvable_handle_returns_nothing_rather_than_throwing() {
let host = host("const x = 1;");
assert!(run::<bool>(
&host,
"ctx.kind(9999) === undefined &&
ctx.text(9999) === undefined &&
ctx.line(9999) === undefined &&
ctx.column(9999) === undefined &&
ctx.parent(9999) === undefined &&
ctx.children(9999).length === 0 &&
ctx.ancestors(9999).length === 0 &&
ctx.structureFingerprint(9999) === undefined"
));
}
#[test]
fn structure_fingerprint_is_exposed_on_ctx() {
let host = host("const x = 1;\n");
assert_eq!(
run::<String>(&host, "ctx.structureFingerprint(ctx.root).hash"),
"a0f2e92a59b964c75383ee14e32e0087bb376c7cc39572ff0b888a04d3dd9e4b"
);
assert_eq!(
run::<u32>(&host, "ctx.structureFingerprint(ctx.root).nodes"),
8
);
}
#[test]
fn structure_fingerprint_erases_identifiers_through_ctx() {
let a = host("function f() { return a + b }");
let b = host("function g() { return c + d }");
assert_eq!(
run::<String>(&a, "ctx.structureFingerprint(ctx.root).hash"),
run::<String>(&b, "ctx.structureFingerprint(ctx.root).hash")
);
}
#[test]
fn structure_fingerprint_of_a_dead_handle_is_undefined() {
let host = host("const x = 1;");
assert!(run::<bool>(
&host,
"ctx.structureFingerprint(9999) === undefined"
));
}
#[test]
fn records_a_report_at_the_node_position() {
let host = host("const x = 1;\nconst y = 2;");
let _: () = run(&host, "ctx.report(ctx.namedChildren(ctx.root)[1])");
let reports = host.take_reports();
assert_eq!(reports.len(), 1);
assert_eq!(reports[0].line, 2);
assert_eq!(reports[0].column, 1);
assert_eq!(reports[0].message, None);
}
#[test]
fn records_an_overriding_message() {
let host = host("const x = 1;");
let _: () = run(&host, "ctx.report(ctx.root, 'something specific')");
let reports = host.take_reports();
assert_eq!(reports[0].message.as_deref(), Some("something specific"));
}
#[test]
fn records_every_report_in_order() {
let host = host("const a = 1;\nconst b = 2;\nconst c = 3;");
let _: () = run(
&host,
"for (const d of ctx.namedChildren(ctx.root)) { ctx.report(d, ctx.text(d)); }",
);
let reports = host.take_reports();
let lines: Vec<u32> = reports.iter().map(|r| r.line).collect();
assert_eq!(lines, [1, 2, 3]);
assert_eq!(reports[2].message.as_deref(), Some("const c = 3;"));
}
#[test]
fn a_report_at_an_unresolvable_handle_is_dropped() {
let host = host("const x = 1;");
let _: () = run(&host, "ctx.report(9999)");
assert!(host.take_reports().is_empty());
}
#[test]
fn taking_reports_empties_the_context() {
let host = host("const x = 1;");
let _: () = run(&host, "ctx.report(ctx.root)");
assert_eq!(host.take_reports().len(), 1);
assert!(
host.take_reports().is_empty(),
"reports must not be reported twice"
);
}
#[test]
fn a_rule_that_throws_still_leaves_earlier_reports() {
let host = host("const x = 1;");
let sandbox = Sandbox::with_limits(Limits::default()).expect("sandbox builds");
let result: Result<(), _> =
sandbox.eval_with_host(&host, "ctx.report(ctx.root); throw new Error('later')");
assert!(result.is_err());
assert_eq!(host.take_reports().len(), 1);
}
#[test]
fn navigation_is_bounded_by_the_rule_timeout() {
let host = host("const x = 1;");
let sandbox = Sandbox::with_limits(
Limits::default().with_rule_timeout(std::time::Duration::from_millis(120)),
)
.expect("sandbox builds");
let result: Result<(), _> = sandbox.eval_with_host(
&host,
"for (;;) { ctx.kind(ctx.root); ctx.children(ctx.root); }",
);
assert!(
matches!(result, Err(crate::SandboxError::RuleTimeout { .. })),
"expected a timeout, got {result:?}"
);
}
#[test]
fn the_sandbox_still_withholds_everything_it_did_before() {
let host = host("const x = 1;");
assert!(run::<bool>(
&host,
"typeof Date === 'undefined' &&
typeof performance === 'undefined' &&
typeof Math.random === 'undefined' &&
typeof fetch === 'undefined' &&
typeof process === 'undefined'"
));
}
#[test]
fn resolves_an_import_through_its_alias() {
let host = host("import { makeStyles as ms } from '@rneui/themed';\nms();");
let handle = handle_of(&host, "ms");
assert!(run::<bool>(
&host,
&format!("ctx.resolvesToImport({handle}, '@rneui/themed', 'makeStyles')")
));
assert!(!run::<bool>(
&host,
&format!("ctx.resolvesToImport({handle}, 'somewhere-else', 'makeStyles')")
));
assert!(!run::<bool>(
&host,
&format!("ctx.resolvesToImport({handle}, '@rneui/themed', 'notThatOne')")
));
}
#[test]
fn a_local_declaration_does_not_resolve_to_the_import_it_shadows() {
let host = host(
"import { makeStyles } from '@rneui/themed';\n\
function f() { const makeStyles = () => {}; return makeStyles(); }",
);
let handle = handle_of(&host, "makeStyles");
assert!(!run::<bool>(
&host,
&format!("ctx.resolvesToImport({handle}, '@rneui/themed', 'makeStyles')")
));
assert_eq!(
run::<String>(&host, &format!("ctx.bindingKind({handle})")),
"const"
);
assert!(run::<bool>(&host, &format!("ctx.isShadowed({handle})")));
}
#[test]
fn omitting_the_name_matches_any_export_of_the_module() {
let host = host("import { a } from 'm';\na();");
let handle = handle_of(&host, "a");
assert!(run::<bool>(
&host,
&format!("ctx.resolvesToImport({handle}, 'm')")
));
}
#[test]
fn matches_a_module_by_glob() {
let host = host("import { a } from '@scope/pkg';\na();");
let handle = handle_of(&host, "a");
assert!(run::<bool>(
&host,
&format!("ctx.isImportedFrom({handle}, '@scope/*')")
));
assert!(run::<bool>(
&host,
&format!("ctx.isImportedFrom({handle}, '*/pkg')")
));
assert!(run::<bool>(
&host,
&format!("ctx.isImportedFrom({handle}, '@scope/pkg')")
));
assert!(!run::<bool>(
&host,
&format!("ctx.isImportedFrom({handle}, '@other/*')")
));
}
#[test]
fn reports_binding_kinds() {
for (source, name, expected) in [
("import { a } from 'm';\na();", "a", "import"),
("const b = 1;\nb;", "b", "const"),
("let c = 1;\nc;", "c", "let"),
("function d() {}\nd();", "d", "function"),
("class E {}\nnew E();", "E", "class"),
("function f(p) { return p; }", "p", "param"),
] {
let host = host(source);
let handle = handle_of(&host, name);
assert_eq!(
run::<String>(&host, &format!("ctx.bindingKind({handle})")),
expected,
"for {name} in {source}"
);
}
}
#[test]
fn an_undeclared_name_has_no_binding_kind() {
let host = host("globalThing();");
let handle = handle_of(&host, "globalThing");
assert!(run::<bool>(
&host,
&format!("ctx.bindingKind({handle}) === undefined")
));
}
#[test]
fn without_a_resolver_nothing_resolves_rather_than_throwing() {
let host = host_without_resolver("import { a } from 'm';\na();");
assert!(run::<bool>(
&host,
"ctx.resolvesToImport(0, 'm', 'a') === false &&
ctx.isImportedFrom(0, '*') === false &&
ctx.isShadowed(0) === false &&
ctx.bindingKind(0) === undefined"
));
}
#[test]
fn navigation_stays_lazy() {
let host = host("const a = 1; const b = 2; function c() { return [1,2,3] }");
assert!(
host.arena().borrow().is_empty(),
"nothing should be interned yet"
);
let _: () = run(&host, "ctx.kind(ctx.root)");
assert!(
host.arena().borrow().is_empty(),
"reading the root's kind should not intern anything new"
);
}
fn emitted(source: &str) -> Vec<EmittedFact> {
let host = host("const a = 1;");
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
sandbox
.eval_with_host::<()>(&host, source)
.expect("evaluates");
host.take_facts()
}
#[test]
fn a_fact_is_captured_with_its_kind_and_payload() {
let facts = emitted("ctx.emitFact({ kind: 'export', symbol: 'parse' })");
assert_eq!(facts.len(), 1);
assert_eq!(facts[0].kind, "export");
assert!(
facts[0].data.contains(r#""symbol":"parse""#),
"{:?}",
facts[0]
);
}
#[test]
fn facts_are_kept_in_emission_order() {
let facts = emitted(
"ctx.emitFact({ kind: 'a', n: 1 }); \
ctx.emitFact({ kind: 'b', n: 2 }); \
ctx.emitFact({ kind: 'a', n: 3 });",
);
assert_eq!(
facts.iter().map(|f| f.kind.as_str()).collect::<Vec<_>>(),
vec!["a", "b", "a"]
);
}
#[test]
fn a_fact_without_a_kind_is_rejected() {
let host = host("const a = 1;");
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
let error = sandbox
.eval_with_host::<()>(&host, "ctx.emitFact({ symbol: 'parse' })")
.expect_err("is rejected");
assert!(error.to_string().contains("kind"), "{error}");
assert!(host.take_facts().is_empty());
}
#[test]
fn a_fact_with_an_empty_kind_is_rejected() {
let host = host("const a = 1;");
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
assert!(
sandbox
.eval_with_host::<()>(&host, "ctx.emitFact({ kind: '' })")
.is_err()
);
}
#[test]
fn a_fact_that_is_not_an_object_is_rejected() {
let host = host("const a = 1;");
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
for bad in ["'export'", "42", "null", "undefined"] {
assert!(
sandbox
.eval_with_host::<()>(&host, &format!("ctx.emitFact({bad})"))
.is_err(),
"`{bad}` should not be emittable"
);
}
}
#[test]
fn a_cyclic_fact_is_rejected_rather_than_hanging() {
let host = host("const a = 1;");
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
let error = sandbox
.eval_with_host::<()>(
&host,
"const f = { kind: 'x' }; f.self = f; ctx.emitFact(f)",
)
.expect_err("is rejected");
assert!(!error.to_string().is_empty());
}
#[test]
fn the_reduce_surface_is_absent_from_the_per_file_context() {
let host = host("const a = 1;");
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
for absent in ["ctx.facts", "ctx.files"] {
let present: bool = sandbox
.eval_with_host(&host, &format!("{absent} !== undefined"))
.expect("evaluates");
assert!(
!present,
"`{absent}` must not exist during the per-file pass"
);
}
}
fn reduce_fact(kind: &str, json: &str) -> ReduceFact {
ReduceFact {
kind: kind.to_owned(),
json: json.to_owned(),
}
}
fn budget() -> std::time::Duration {
std::time::Duration::from_secs(5)
}
#[test]
fn a_reduce_report_takes_a_string_or_an_options_object() {
for expression in [
r"ctx.report({ file: 'a.ts', line: 1, column: 2 }, 'plain string')",
r"ctx.report({ file: 'a.ts', line: 1, column: 2 }, { message: 'plain string' })",
] {
let context = ReduceContext::new(vec!["a.ts".to_owned()], vec![]);
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
sandbox
.eval_with_reduce_host::<()>(&context, expression, budget())
.unwrap_or_else(|e| panic!("{expression} should be accepted: {e}"));
let reports = context.take_reports();
assert_eq!(reports.len(), 1, "{expression}");
assert_eq!(
reports[0].message.as_deref(),
Some("plain string"),
"{expression}"
);
}
}
#[test]
fn a_reduce_report_refuses_a_message_that_is_neither() {
let context = ReduceContext::new(vec!["a.ts".to_owned()], vec![]);
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
let error = sandbox
.eval_with_reduce_host::<()>(
&context,
r"ctx.report({ file: 'a.ts', line: 1, column: 2 }, { detail: 'wrong key' })",
budget(),
)
.expect_err("should refuse");
assert!(
error.to_string().contains("message"),
"the error should say what it wanted: {error}"
);
}
#[test]
fn facts_come_back_as_objects() {
let context = ReduceContext::new(
vec!["a.ts".to_owned()],
vec![reduce_fact(
"export",
r#"{"kind":"export","symbol":"parse","file":"a.ts"}"#,
)],
);
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
let symbol: String = sandbox
.eval_with_reduce_host(&context, "ctx.facts('export')[0].symbol", budget())
.expect("evaluates");
assert_eq!(symbol, "parse");
}
#[test]
fn facts_filter_by_kind_and_default_to_everything() {
let context = ReduceContext::new(
vec![],
vec![
reduce_fact("export", r#"{"kind":"export","file":"a.ts"}"#),
reduce_fact("import", r#"{"kind":"import","file":"b.ts"}"#),
reduce_fact("export", r#"{"kind":"export","file":"c.ts"}"#),
],
);
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
let counts: Vec<i32> = sandbox
.eval_with_reduce_host(
&context,
"[ctx.facts('export').length, ctx.facts('import').length, ctx.facts().length]",
budget(),
)
.expect("evaluates");
assert_eq!(counts, vec![2, 1, 3]);
}
#[test]
fn an_unknown_kind_yields_an_empty_array_rather_than_undefined() {
let context = ReduceContext::new(vec![], vec![]);
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
let length: i32 = sandbox
.eval_with_reduce_host(&context, "ctx.facts('nope').length", budget())
.expect("evaluates");
assert_eq!(length, 0);
}
#[test]
fn the_file_list_is_visible() {
let context = ReduceContext::new(vec!["a.ts".to_owned(), "b.ts".to_owned()], vec![]);
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
let files: Vec<String> = sandbox
.eval_with_reduce_host(&context, "ctx.files", budget())
.expect("evaluates");
assert_eq!(files, vec!["a.ts".to_owned(), "b.ts".to_owned()]);
}
#[test]
fn reporting_names_a_file_of_its_own() {
let context = ReduceContext::new(vec![], vec![]);
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
sandbox
.eval_with_reduce_host::<()>(
&context,
"ctx.report({ file: 'b.ts', line: 4, column: 2 }, 'unused export')",
budget(),
)
.expect("evaluates");
assert_eq!(
context.take_reports(),
vec![ReduceReport {
file: "b.ts".to_owned(),
line: 4,
column: 2,
message: Some("unused export".to_owned()),
}]
);
}
#[test]
fn the_message_is_optional() {
let context = ReduceContext::new(vec![], vec![]);
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
sandbox
.eval_with_reduce_host::<()>(
&context,
"ctx.report({ file: 'b.ts', line: 1, column: 1 })",
budget(),
)
.expect("evaluates");
let reports = context.take_reports();
assert_eq!(reports.len(), 1);
assert_eq!(reports[0].message, None);
}
#[test]
fn reporting_without_a_position_is_rejected() {
let context = ReduceContext::new(vec![], vec![]);
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
for bad in [
"ctx.report({ file: 'b.ts' })",
"ctx.report({ line: 1, column: 1 })",
"ctx.report(3)",
"ctx.report('b.ts')",
] {
let error = sandbox
.eval_with_reduce_host::<()>(&context, bad, budget())
.expect_err("is rejected");
assert!(!error.to_string().is_empty(), "`{bad}` should be rejected");
}
assert!(context.take_reports().is_empty());
}
#[test]
fn the_per_file_surface_is_absent_from_the_reduce_context() {
let context = ReduceContext::new(vec![], vec![]);
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
for absent in [
"ctx.emitFact",
"ctx.text",
"ctx.kind",
"ctx.parent",
"ctx.namedChildren",
"ctx.filePath",
"ctx.fileText",
"ctx.root",
] {
let present: bool = sandbox
.eval_with_reduce_host(&context, &format!("{absent} !== undefined"), budget())
.expect("evaluates");
assert!(
!present,
"`{absent}` must not exist during the reduce phase"
);
}
}
#[test]
fn merge_file_adds_the_field() {
assert_eq!(
merge_file(r#"{"kind":"export"}"#, "src/a.ts"),
r#"{"kind":"export","file":"src/a.ts"}"#
);
}
#[test]
fn merge_file_handles_an_empty_payload() {
assert_eq!(merge_file("{}", "a.ts"), r#"{"file":"a.ts"}"#);
}
#[test]
fn merge_file_overrides_a_file_the_rule_supplied() {
let merged = merge_file(r#"{"kind":"export","file":"lies.ts"}"#, "truth.ts");
assert!(merged.ends_with(r#""file":"truth.ts"}"#), "{merged}");
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
let file: String = sandbox
.eval(&format!("JSON.parse({merged:?}).file"))
.expect("parses");
assert_eq!(file, "truth.ts");
}
#[test]
fn merge_file_escapes_the_path() {
let awkward = "a\"b\\c\nd.ts";
let merged = merge_file("{}", awkward);
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
let file: String = sandbox
.eval(&format!("JSON.parse({merged:?}).file"))
.expect("parses");
assert_eq!(file, awkward);
}
fn host_with_language(source: &str) -> HostContext {
HostContext::new(parse(source), source.to_owned(), "src/example.ts")
.with_resolver(Arc::new(lanekeep_lang_js::binding::JsBindingResolver))
.with_language(Arc::new(TypeScript))
}
#[test]
fn a_subtree_query_finds_only_what_is_inside() {
let source = "function a() { const x = 1; }\nfunction b() { const y = 2; const z = 3; }\n";
let host = host_with_language(source);
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
let names: Vec<String> = sandbox
.eval_with_host(
&host,
"const fns = ctx.querySubtree(ctx.root, '(function_declaration) @fn');\n\
const inner = ctx.querySubtree(fns[1].fn, '(variable_declarator name: (identifier) @name)');\n\
inner.map((m) => ctx.text(m.name))",
)
.expect("evaluates");
assert_eq!(names, vec!["y".to_owned(), "z".to_owned()]);
}
#[test]
fn a_subtree_query_with_no_matches_is_an_empty_array() {
let host = host_with_language("const a = 1;\n");
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
let length: i32 = sandbox
.eval_with_host(
&host,
"ctx.querySubtree(ctx.root, '(debugger_statement) @d').length",
)
.expect("evaluates");
assert_eq!(length, 0);
}
#[test]
fn an_invalid_query_is_reported_to_the_rule() {
let host = host_with_language("const a = 1;\n");
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
let error = sandbox
.eval_with_host::<()>(&host, "ctx.querySubtree(ctx.root, '(((')")
.expect_err("is rejected");
assert!(!error.to_string().is_empty());
}
#[test]
fn a_general_predicate_is_rejected_by_both_query_functions() {
let host = host_with_language("const a = 1;\n");
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
let error = sandbox
.eval_with_host::<()>(
&host,
"ctx.querySubtree(ctx.root, '((identifier) @id (#is? @id \"a\"))')",
)
.expect_err("is rejected");
assert!(error.to_string().contains("#is?"), "{error}");
let error = sandbox
.eval_with_host::<()>(
&host,
"const d = ctx.querySubtree(ctx.root, '(variable_declarator name: (identifier) @n)');\n\
ctx.closestAncestor(d[0].n, '((program) @p (#is? @p \"x\"))')",
)
.expect_err("is rejected");
assert!(error.to_string().contains("#is?"), "{error}");
}
#[test]
fn closest_ancestor_finds_the_nearest_one() {
let source = "function outer() { function inner() { const x = 1; } }\n";
let host = host_with_language(source);
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
let name: String = sandbox
.eval_with_host(
&host,
"const decls = ctx.querySubtree(ctx.root, '(variable_declarator name: (identifier) @n)');\n\
const found = ctx.closestAncestor(decls[0].n, '(function_declaration name: (identifier) @name) @fn');\n\
ctx.text(found.name)",
)
.expect("evaluates");
assert_eq!(name, "inner");
}
#[test]
fn closest_ancestor_returns_undefined_when_nothing_matches() {
let host = host_with_language("const a = 1;\n");
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
let absent: bool = sandbox
.eval_with_host(
&host,
"const d = ctx.querySubtree(ctx.root, '(variable_declarator name: (identifier) @n)');\n\
ctx.closestAncestor(d[0].n, '(class_declaration) @c') === undefined",
)
.expect("evaluates");
assert!(absent);
}
#[test]
fn closest_ancestor_does_not_match_the_node_itself_from_inside() {
let source = "function outer() { function inner() { const x = 1; } }\n";
let host = host_with_language(source);
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
let name: String = sandbox
.eval_with_host(
&host,
"const d = ctx.querySubtree(ctx.root, '(variable_declarator name: (identifier) @n)');\n\
const found = ctx.closestAncestor(d[0].n, '(statement_block) @block');\n\
ctx.kind(found.block)",
)
.expect("evaluates");
assert_eq!(name, "statement_block");
}
#[test]
fn the_query_functions_are_absent_without_a_language() {
let host = host("const a = 1;");
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
for absent in ["ctx.querySubtree", "ctx.closestAncestor"] {
let present: bool = sandbox
.eval_with_host(&host, &format!("{absent} !== undefined"))
.expect("evaluates");
assert!(!present, "`{absent}` should not exist without a language");
}
}
#[test]
fn loc_gives_the_shape_a_fact_and_a_reduce_report_both_use() {
let source = "const alpha = 1;\nconst beta = 2;\n";
let host = host(source);
let handle = handle_of(&host, "beta");
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
let rendered: String = sandbox
.eval_with_host(
&host,
&format!("const l = ctx.loc({handle}); `${{l.file}}:${{l.line}}:${{l.column}}`"),
)
.expect("evaluates");
assert_eq!(rendered, "src/example.ts:2:7");
}
#[test]
fn loc_at_an_unresolvable_handle_is_undefined() {
let host = host("const a = 1;");
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
let absent: bool = sandbox
.eval_with_host(&host, "ctx.loc(9999) === undefined")
.expect("evaluates");
assert!(absent);
}
#[test]
fn today_is_what_the_host_supplied() {
let host = host("const a = 1;").with_today("2026-08-01");
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
let today: String = sandbox
.eval_with_host(&host, "ctx.today")
.expect("evaluates");
assert_eq!(today, "2026-08-01");
}
#[test]
fn today_is_absent_when_the_host_supplied_none() {
let host = host("const a = 1;");
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
let absent: bool = sandbox
.eval_with_host(&host, "ctx.today === undefined")
.expect("evaluates");
assert!(absent);
}
#[test]
fn reading_today_is_observed_and_not_reading_it_is_not() {
let unread = host("const a = 1;").with_today("2026-08-01");
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
sandbox
.eval_with_host::<i32>(&unread, "1 + 1")
.expect("evaluates");
assert!(!unread.date_was_read(), "nothing read the date");
let read = host("const a = 1;").with_today("2026-08-01");
sandbox
.eval_with_host::<String>(&read, "ctx.today")
.expect("evaluates");
assert!(read.date_was_read(), "the read was not observed");
}
#[test]
fn today_does_not_bring_a_clock_with_it() {
let host = host("const a = 1;").with_today("2026-08-01");
let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
for absent in ["Date", "performance"] {
let present: bool = sandbox
.eval_with_host(&host, &format!("typeof {absent} !== 'undefined'"))
.expect("evaluates");
assert!(!present, "`{absent}` must not exist");
}
}
}