1use 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
41pub const HOST_API_VERSION: u32 = 1;
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct EmittedFact {
65 pub kind: String,
67 pub data: String,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct Report {
78 pub node: Option<Handle>,
80 pub line: u32,
82 pub column: u32,
84 pub message: Option<String>,
86 pub fix: Option<Fix>,
88}
89
90#[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 files: Option<Arc<FileAccess>>,
110 language: Option<Arc<dyn lanekeep_lang::Language>>,
112 today: Option<Rc<str>>,
114 date_read: Rc<Cell<bool>>,
120 queries: QueryCache,
127}
128
129type 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 #[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 #[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 #[must_use]
187 pub fn with_today(mut self, today: &str) -> Self {
188 self.today = Some(Rc::from(today));
189 self
190 }
191
192 #[must_use]
196 pub fn date_was_read(&self) -> bool {
197 self.date_read.get()
198 }
199
200 #[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 #[must_use]
218 pub fn with_resolver(mut self, resolver: Arc<dyn BindingResolver>) -> Self {
219 self.resolver = Some(resolver);
220 self
221 }
222
223 #[must_use]
235 pub fn with_file_access(mut self, files: Arc<FileAccess>) -> Self {
236 self.files = Some(files);
237 self
238 }
239
240 #[must_use]
242 pub fn arena(&self) -> &Rc<RefCell<NodeArena>> {
243 &self.arena
244 }
245
246 #[must_use]
248 pub fn take_reports(&self) -> Vec<Report> {
249 std::mem::take(&mut self.reports.borrow_mut())
250 }
251
252 #[must_use]
254 pub fn take_facts(&self) -> Vec<EmittedFact> {
255 std::mem::take(&mut self.facts.borrow_mut())
256 }
257
258 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 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 fn install_navigation<'js>(
300 &self,
301 ctx: &Ctx<'js>,
302 object: &Object<'js>,
303 ) -> rquickjs::Result<()> {
304 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 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 fn install_bindings<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
398 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 fn install_reporting<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
471 let arena = Rc::clone(&self.arena);
476 let file_path = Rc::clone(&self.file_path);
477 object.set(
478 "loc",
479 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 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 Function::new(
509 ctx.clone(),
510 move |ctx: Ctx<'js>,
511 handle: Handle,
512 options: Opt<Value<'js>>|
513 -> rquickjs::Result<()> {
514 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 fn install_facts<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
555 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 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 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 fn install_queries<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
609 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 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 fn install_reads<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
673 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 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#[derive(Debug, Clone, PartialEq, Eq)]
715pub struct ReduceReport {
716 pub file: String,
718 pub line: u32,
720 pub column: u32,
722 pub message: Option<String>,
724}
725
726#[derive(Debug, Clone, PartialEq, Eq)]
728pub struct ReduceFact {
729 pub kind: String,
731 pub json: String,
733}
734
735#[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 #[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 #[must_use]
759 pub fn take_reports(&self) -> Vec<ReduceReport> {
760 std::mem::take(&mut self.reports.borrow_mut())
761 }
762
763 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 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 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 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
883fn 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 return Ok(None);
920 };
921
922 Ok(Some(Fix {
923 start,
924 end,
925 replacement,
926 safe: fix.get::<_, bool>("safe").unwrap_or(false),
929 }))
930}
931
932fn 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
951fn 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
968fn 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
984fn throw(ctx: &Ctx<'_>, message: &str) -> rquickjs::Error {
990 rquickjs::Exception::throw_type(ctx, message)
991}
992
993#[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
1023fn 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 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 fn host_without_resolver(source: &str) -> HostContext {
1067 HostContext::new(parse(source), source.to_owned(), "src/example.ts")
1068 }
1069
1070 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 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 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 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 #[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 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 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 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 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 #[test]
1313 fn resolves_an_import_through_its_alias() {
1314 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 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 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 #[test]
1436 fn navigation_stays_lazy() {
1437 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 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 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 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 assert!(!error.to_string().is_empty());
1540 }
1541
1542 #[test]
1543 fn the_reduce_surface_is_absent_from_the_per_file_context() {
1544 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 fn reduce_fact(kind: &str, json: &str) -> ReduceFact {
1562 ReduceFact {
1563 kind: kind.to_owned(),
1564 json: json.to_owned(),
1565 }
1566 }
1567
1568 fn budget() -> std::time::Duration {
1570 std::time::Duration::from_secs(5)
1571 }
1572
1573 #[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 #[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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 #[test]
1926 fn loc_gives_the_shape_a_fact_and_a_reduce_report_both_use() {
1927 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 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 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 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 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}