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 = 2;
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct EmittedFact {
67 pub kind: String,
69 pub data: String,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct Report {
80 pub node: Option<Handle>,
82 pub line: u32,
84 pub column: u32,
86 pub message: Option<String>,
88 pub fix: Option<Fix>,
90}
91
92#[derive(Clone)]
97pub struct HostContext {
98 arena: Rc<RefCell<NodeArena>>,
99 reports: Rc<RefCell<Vec<Report>>>,
100 facts: Rc<RefCell<Vec<EmittedFact>>>,
101 file_path: Rc<str>,
102 resolver: Option<Arc<dyn BindingResolver>>,
103 files: Option<Arc<FileAccess>>,
112 language: Option<Arc<dyn lanekeep_lang::Language>>,
114 today: Option<Rc<str>>,
116 date_read: Rc<Cell<bool>>,
122 queries: QueryCache,
129}
130
131type QueryCache = Rc<RefCell<BTreeMap<String, Result<Rc<CompiledQuery>, String>>>>;
137
138impl std::fmt::Debug for HostContext {
139 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140 f.debug_struct("HostContext")
141 .field("file_path", &self.file_path)
142 .field("interned_nodes", &self.arena.borrow().len())
143 .field("reports", &self.reports.borrow().len())
144 .field("facts", &self.facts.borrow().len())
145 .field("has_resolver", &self.resolver.is_some())
146 .field("has_file_access", &self.files.is_some())
147 .field("has_language", &self.language.is_some())
148 .field("has_today", &self.today.is_some())
149 .field("date_read", &self.date_read.get())
150 .field("compiled_queries", &self.queries.borrow().len())
151 .finish()
152 }
153}
154
155impl HostContext {
156 #[must_use]
158 pub fn new(tree: tree_sitter::Tree, source: String, file_path: &str) -> Self {
159 Self {
160 arena: Rc::new(RefCell::new(NodeArena::new(tree, source))),
161 reports: Rc::new(RefCell::new(Vec::new())),
162 facts: Rc::new(RefCell::new(Vec::new())),
163 file_path: Rc::from(file_path),
164 resolver: None,
165 files: None,
166 language: None,
167 today: None,
168 date_read: Rc::new(Cell::new(false)),
169 queries: Rc::new(RefCell::new(BTreeMap::new())),
170 }
171 }
172
173 #[must_use]
175 pub fn with_resolver_from(self, language: &dyn lanekeep_lang::Language) -> Self {
176 match language.resolver() {
177 Some(resolver) => self.with_resolver(resolver),
178 None => self,
179 }
180 }
181
182 #[must_use]
189 pub fn with_today(mut self, today: &str) -> Self {
190 self.today = Some(Rc::from(today));
191 self
192 }
193
194 #[must_use]
198 pub fn date_was_read(&self) -> bool {
199 self.date_read.get()
200 }
201
202 #[must_use]
208 pub fn with_language(mut self, language: Arc<dyn lanekeep_lang::Language>) -> Self {
209 self.language = Some(language);
210 self
211 }
212
213 #[must_use]
220 pub fn with_resolver(mut self, resolver: Arc<dyn BindingResolver>) -> Self {
221 self.resolver = Some(resolver);
222 self
223 }
224
225 #[must_use]
237 pub fn with_file_access(mut self, files: Arc<FileAccess>) -> Self {
238 self.files = Some(files);
239 self
240 }
241
242 #[must_use]
244 pub fn arena(&self) -> &Rc<RefCell<NodeArena>> {
245 &self.arena
246 }
247
248 #[must_use]
250 pub fn take_reports(&self) -> Vec<Report> {
251 std::mem::take(&mut self.reports.borrow_mut())
252 }
253
254 #[must_use]
256 pub fn take_facts(&self) -> Vec<EmittedFact> {
257 std::mem::take(&mut self.facts.borrow_mut())
258 }
259
260 pub fn build<'js>(&self, ctx: &Ctx<'js>) -> rquickjs::Result<Object<'js>> {
267 let object = Object::new(ctx.clone())?;
268
269 object.set("filePath", &*self.file_path)?;
270 object.set("root", NodeArena::ROOT)?;
271 {
272 let arena = self.arena.borrow();
273 object.set("fileText", arena.source())?;
274 }
275
276 self.install_navigation(ctx, &object)?;
277 self.install_bindings(ctx, &object)?;
278 self.install_reporting(ctx, &object)?;
279 self.install_facts(ctx, &object)?;
280 self.install_reads(ctx, &object)?;
281 self.install_queries(ctx, &object)?;
282
283 if let Some(today) = self.today.clone() {
287 let date_read = Rc::clone(&self.date_read);
288 object.prop(
289 "today",
290 Accessor::from(move || {
291 date_read.set(true);
292 today.to_string()
293 }),
294 )?;
295 }
296
297 Ok(object)
298 }
299
300 fn install_navigation<'js>(
302 &self,
303 ctx: &Ctx<'js>,
304 object: &Object<'js>,
305 ) -> rquickjs::Result<()> {
306 let arena = Rc::clone(&self.arena);
314 object.set(
315 "kind",
316 Function::new(ctx.clone(), move |handle: Handle| {
317 arena.borrow().kind(handle).map(ToOwned::to_owned)
318 })?,
319 )?;
320
321 let arena = Rc::clone(&self.arena);
322 object.set(
323 "text",
324 Function::new(ctx.clone(), move |handle: Handle| {
325 arena.borrow().text(handle).map(ToOwned::to_owned)
326 })?,
327 )?;
328
329 let arena = Rc::clone(&self.arena);
330 object.set(
331 "isNamed",
332 Function::new(ctx.clone(), move |handle: Handle| {
333 arena.borrow().is_named(handle)
334 })?,
335 )?;
336
337 let arena = Rc::clone(&self.arena);
348 object.set(
349 "line",
350 Function::new(ctx.clone(), move |handle: Handle| {
351 arena.borrow().position(handle).map(|(line, _)| line)
352 })?,
353 )?;
354
355 let arena = Rc::clone(&self.arena);
356 object.set(
357 "column",
358 Function::new(ctx.clone(), move |handle: Handle| {
359 arena.borrow().position(handle).map(|(_, column)| column)
360 })?,
361 )?;
362
363 let arena = Rc::clone(&self.arena);
364 object.set(
365 "parent",
366 Function::new(ctx.clone(), move |handle: Handle| {
367 arena.borrow_mut().parent(handle)
368 })?,
369 )?;
370
371 let arena = Rc::clone(&self.arena);
372 object.set(
373 "children",
374 Function::new(ctx.clone(), move |handle: Handle| {
375 arena.borrow_mut().children(handle)
376 })?,
377 )?;
378
379 let arena = Rc::clone(&self.arena);
380 object.set(
381 "namedChildren",
382 Function::new(ctx.clone(), move |handle: Handle| {
383 arena.borrow_mut().named_children(handle)
384 })?,
385 )?;
386
387 let arena = Rc::clone(&self.arena);
388 object.set(
389 "ancestors",
390 Function::new(ctx.clone(), move |handle: Handle| {
391 arena.borrow_mut().ancestors(handle)
392 })?,
393 )?;
394
395 let arena = Rc::clone(&self.arena);
404 object.set(
405 "structureFingerprint",
406 Function::new(
407 ctx.clone(),
408 move |ctx: Ctx<'js>, handle: Handle| -> rquickjs::Result<Value<'js>> {
409 let Some(fingerprint) = arena.borrow().structure_fingerprint(handle) else {
410 return Ok(Value::new_undefined(ctx.clone()));
411 };
412 let object = Object::new(ctx.clone())?;
413 object.set("hash", fingerprint.hash)?;
414 object.set("nodes", fingerprint.nodes)?;
415 Ok(object.into_value())
416 },
417 )?,
418 )?;
419
420 Ok(())
421 }
422
423 fn install_bindings<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
425 let arena = Rc::clone(&self.arena);
437 let resolver = self.resolver.clone();
438 object.set(
439 "resolvesToImport",
440 Function::new(
441 ctx.clone(),
442 move |handle: Handle, module: String, name: Opt<String>| {
443 let Some(resolver) = resolver.as_deref() else {
444 return false;
445 };
446 arena
447 .borrow()
448 .resolve_binding(handle, resolver)
449 .is_some_and(|binding| binding.is_import_of(&module, name.0.as_deref()))
450 },
451 )?,
452 )?;
453
454 let arena = Rc::clone(&self.arena);
455 let resolver = self.resolver.clone();
456 object.set(
457 "isImportedFrom",
458 Function::new(ctx.clone(), move |handle: Handle, pattern: String| {
459 let Some(resolver) = resolver.as_deref() else {
460 return false;
461 };
462 arena
463 .borrow()
464 .resolve_binding(handle, resolver)
465 .is_some_and(|binding| binding.is_imported_from(&pattern))
466 })?,
467 )?;
468
469 let arena = Rc::clone(&self.arena);
470 let resolver = self.resolver.clone();
471 object.set(
472 "bindingKind",
473 Function::new(ctx.clone(), move |handle: Handle| {
474 let resolver = resolver.as_deref()?;
475 arena
476 .borrow()
477 .resolve_binding(handle, resolver)
478 .map(|binding| binding.kind_str().to_owned())
479 })?,
480 )?;
481
482 let arena = Rc::clone(&self.arena);
483 let resolver = self.resolver.clone();
484 object.set(
485 "isShadowed",
486 Function::new(ctx.clone(), move |handle: Handle| {
487 resolver
488 .as_deref()
489 .is_some_and(|resolver| arena.borrow().is_shadowed(handle, resolver))
490 })?,
491 )?;
492
493 Ok(())
494 }
495
496 fn install_reporting<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
498 let arena = Rc::clone(&self.arena);
503 let file_path = Rc::clone(&self.file_path);
504 object.set(
505 "loc",
506 Function::new(
510 ctx.clone(),
511 move |ctx: Ctx<'js>, handle: Handle| -> rquickjs::Result<Value<'js>> {
512 let Some((line, column)) = arena.borrow().position(handle) else {
513 return Ok(Value::new_undefined(ctx.clone()));
516 };
517
518 let object = Object::new(ctx.clone())?;
519 object.set("file", &*file_path)?;
520 object.set("line", line)?;
521 object.set("column", column)?;
522 Ok(object.into_value())
523 },
524 )?,
525 )?;
526
527 let arena = Rc::clone(&self.arena);
528 let reports = Rc::clone(&self.reports);
529 object.set(
530 "report",
531 Function::new(
536 ctx.clone(),
537 move |ctx: Ctx<'js>,
538 handle: Handle,
539 options: Opt<Value<'js>>|
540 -> rquickjs::Result<()> {
541 let Some((line, column)) = arena.borrow().position(handle) else {
545 return Ok(());
546 };
547
548 let (message, fix) = match options.0 {
549 None => (None, None),
550 Some(value) if value.is_string() => (value.get::<String>().ok(), None),
551 Some(value) => {
552 let Some(object) = value.as_object() else {
553 return Err(throw(
554 &ctx,
555 "ctx.report expects a message string or an options \
556 object — { message?, fix? }",
557 ));
558 };
559 let message = object.get::<_, String>("message").ok();
560 let fix = read_fix(&ctx, object, &arena)?;
561 (message, fix)
562 }
563 };
564
565 reports.borrow_mut().push(Report {
566 node: Some(handle),
567 line,
568 column,
569 message,
570 fix,
571 });
572 Ok(())
573 },
574 )?,
575 )?;
576
577 Ok(())
578 }
579
580 fn install_facts<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
582 let facts = Rc::clone(&self.facts);
585 object.set(
586 "emitFact",
587 Function::new(
588 ctx.clone(),
589 move |ctx: Ctx<'js>, fact: Value<'js>| -> rquickjs::Result<()> {
590 let Some(fact_object) = fact.as_object() else {
591 return Err(throw(&ctx, "ctx.emitFact expects an object"));
592 };
593
594 let kind = match fact_object.get::<_, String>("kind") {
599 Ok(kind) if !kind.is_empty() => kind,
600 _ => {
601 return Err(throw(
602 &ctx,
603 "ctx.emitFact requires a non-empty string `kind` — it is what \
604 ctx.facts(kind) selects on, so a fact without one can never \
605 be read back",
606 ));
607 }
608 };
609
610 let Some(json) = ctx.json_stringify(fact)? else {
615 return Err(throw(
616 &ctx,
617 "ctx.emitFact could not serialize this fact — facts are cached, \
618 so they have to survive JSON",
619 ));
620 };
621
622 facts.borrow_mut().push(EmittedFact {
623 kind,
624 data: json.to_string()?,
625 });
626 Ok(())
627 },
628 )?,
629 )?;
630
631 Ok(())
632 }
633
634 fn install_queries<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
636 let Some(language) = self.language.clone() else {
639 return Ok(());
640 };
641
642 let arena = Rc::clone(&self.arena);
643 let queries = Rc::clone(&self.queries);
644 let grammar = Arc::clone(&language);
645 object.set(
646 "querySubtree",
647 Function::new(
648 ctx.clone(),
649 move |ctx: Ctx<'js>,
650 handle: Handle,
651 source: String|
652 -> rquickjs::Result<Value<'js>> {
653 let compiled = compile(&queries, grammar.as_ref(), &source)
654 .map_err(|problem| throw(&ctx, &problem))?;
655
656 let matches = arena.borrow().query_subtree(handle, &compiled);
657 let interned = intern_matches(&arena, matches);
658 captures_to_js(&ctx, interned)
659 },
660 )?,
661 )?;
662
663 let arena = Rc::clone(&self.arena);
664 let queries = Rc::clone(&self.queries);
665 object.set(
666 "closestAncestor",
667 Function::new(
668 ctx.clone(),
669 move |ctx: Ctx<'js>,
670 handle: Handle,
671 source: String|
672 -> rquickjs::Result<Value<'js>> {
673 let compiled = compile(&queries, language.as_ref(), &source)
674 .map_err(|problem| throw(&ctx, &problem))?;
675
676 let found = arena.borrow().closest_ancestor_paths(handle, &compiled);
677 let Some(captures) = found else {
678 return Ok(Value::new_undefined(ctx.clone()));
682 };
683
684 let interned = intern_matches(&arena, vec![captures]);
685 let one = interned.into_iter().next().unwrap_or_default();
686 let object = Object::new(ctx.clone())?;
687 for (name, handle) in one {
688 object.set(name, handle)?;
689 }
690 Ok(object.into_value())
691 },
692 )?,
693 )?;
694
695 Ok(())
696 }
697
698 fn install_reads<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
700 let Some(files) = self.files.clone() else {
703 return Ok(());
704 };
705
706 let reader = Arc::clone(&files);
707 object.set(
708 "readFile",
709 Function::new(
710 ctx.clone(),
711 move |ctx: Ctx<'js>, path: String| -> rquickjs::Result<Option<String>> {
712 reader.read(&path).map_err(|e| throw(&ctx, &e.to_string()))
716 },
717 )?,
718 )?;
719
720 let reader = Arc::clone(&files);
721 object.set(
722 "fileExists",
723 Function::new(
724 ctx.clone(),
725 move |ctx: Ctx<'js>, path: String| -> rquickjs::Result<bool> {
726 reader
727 .exists(&path)
728 .map_err(|e| throw(&ctx, &e.to_string()))
729 },
730 )?,
731 )?;
732
733 Ok(())
734 }
735}
736
737#[derive(Debug, Clone, PartialEq, Eq)]
742pub struct ReduceReport {
743 pub file: String,
745 pub line: u32,
747 pub column: u32,
749 pub message: Option<String>,
751}
752
753#[derive(Debug, Clone, PartialEq, Eq)]
755pub struct ReduceFact {
756 pub kind: String,
758 pub json: String,
760}
761
762#[derive(Debug, Clone)]
767pub struct ReduceContext {
768 files: Rc<[String]>,
769 facts: Rc<[ReduceFact]>,
770 reports: Rc<RefCell<Vec<ReduceReport>>>,
771}
772
773impl ReduceContext {
774 #[must_use]
776 pub fn new(files: Vec<String>, facts: Vec<ReduceFact>) -> Self {
777 Self {
778 files: files.into(),
779 facts: facts.into(),
780 reports: Rc::new(RefCell::new(Vec::new())),
781 }
782 }
783
784 #[must_use]
786 pub fn take_reports(&self) -> Vec<ReduceReport> {
787 std::mem::take(&mut self.reports.borrow_mut())
788 }
789
790 pub fn build<'js>(&self, ctx: &Ctx<'js>) -> rquickjs::Result<Object<'js>> {
797 let object = Object::new(ctx.clone())?;
798
799 object.set("files", &*self.files)?;
800
801 let facts = Rc::clone(&self.facts);
806 object.set(
807 "facts",
808 Function::new(
809 ctx.clone(),
810 move |ctx: Ctx<'js>, kind: Opt<String>| -> rquickjs::Result<Value<'js>> {
811 let wanted = kind.0;
814 let mut json = String::from("[");
815 for fact in facts
816 .iter()
817 .filter(|f| wanted.as_ref().is_none_or(|k| *k == f.kind))
818 {
819 if json.len() > 1 {
820 json.push(',');
821 }
822 json.push_str(&fact.json);
823 }
824 json.push(']');
825
826 ctx.json_parse(json)
827 },
828 )?,
829 )?;
830
831 let reports = Rc::clone(&self.reports);
832 object.set(
833 "report",
834 Function::new(
835 ctx.clone(),
836 move |ctx: Ctx<'js>,
837 at: Value<'js>,
838 message: Opt<Value<'js>>|
839 -> rquickjs::Result<()> {
840 let Some(at) = at.as_object() else {
841 return Err(throw(
842 &ctx,
843 "ctx.report in a reduce phase expects { file, line, column } — \
844 there is no parse tree here, so there are no nodes to report at",
845 ));
846 };
847
848 let (Ok(file), Ok(line), Ok(column)) = (
849 at.get::<_, String>("file"),
850 at.get::<_, u32>("line"),
851 at.get::<_, u32>("column"),
852 ) else {
853 return Err(throw(
854 &ctx,
855 "ctx.report in a reduce phase needs `file`, `line` and `column` — \
856 emit them on the fact during the per-file pass, where the node \
857 positions are still available",
858 ));
859 };
860
861 let message = match message.0 {
866 None => None,
867 Some(value) if value.is_undefined() || value.is_null() => None,
868 Some(value) => {
869 if let Some(text) = value.as_string() {
870 Some(text.to_string()?)
871 } else if let Some(options) = value.as_object() {
872 match options.get::<_, Value<'js>>("message") {
873 Ok(found) if found.is_string() => found
874 .as_string()
875 .map(rquickjs::String::to_string)
876 .transpose()?,
877 _ => {
878 return Err(throw(
879 &ctx,
880 "ctx.report in a reduce phase takes a message: \
881 either a string, or { message }",
882 ));
883 }
884 }
885 } else {
886 return Err(throw(
887 &ctx,
888 "ctx.report in a reduce phase takes a message: either a \
889 string, or { message }",
890 ));
891 }
892 }
893 };
894
895 reports.borrow_mut().push(ReduceReport {
896 file,
897 line,
898 column,
899 message,
900 });
901 Ok(())
902 },
903 )?,
904 )?;
905
906 Ok(object)
907 }
908}
909
910fn read_fix<'js>(
916 ctx: &Ctx<'js>,
917 options: &Object<'js>,
918 arena: &Rc<RefCell<NodeArena>>,
919) -> rquickjs::Result<Option<Fix>> {
920 let Ok(value) = options.get::<_, Value<'js>>("fix") else {
921 return Ok(None);
922 };
923 if value.is_undefined() || value.is_null() {
924 return Ok(None);
925 }
926
927 let Some(fix) = value.as_object() else {
928 return Err(throw(
929 &ctx.clone(),
930 "ctx.report's `fix` expects { node, text, safe? }",
931 ));
932 };
933
934 let (Ok(handle), Ok(replacement)) =
935 (fix.get::<_, Handle>("node"), fix.get::<_, String>("text"))
936 else {
937 return Err(throw(
938 &ctx.clone(),
939 "ctx.report's `fix` needs a `node` to replace and the `text` to put there",
940 ));
941 };
942
943 let Some((start, end)) = arena.borrow().byte_range(handle) else {
944 return Ok(None);
947 };
948
949 Ok(Some(Fix {
950 start,
951 end,
952 replacement,
953 safe: fix.get::<_, bool>("safe").unwrap_or(false),
956 }))
957}
958
959fn compile(
961 cache: &QueryCache,
962 language: &dyn lanekeep_lang::Language,
963 source: &str,
964) -> Result<Rc<CompiledQuery>, String> {
965 if let Some(found) = cache.borrow().get(source) {
966 return found.clone();
967 }
968
969 let compiled = CompiledQuery::compile(language, source)
970 .map(Rc::new)
971 .map_err(|e| e.to_string());
972 cache
973 .borrow_mut()
974 .insert(source.to_owned(), compiled.clone());
975 compiled
976}
977
978fn intern_matches(
980 arena: &Rc<RefCell<NodeArena>>,
981 matches: Vec<Vec<(String, Vec<u32>)>>,
982) -> Vec<Vec<(String, Handle)>> {
983 let mut arena = arena.borrow_mut();
984 matches
985 .into_iter()
986 .map(|captures| {
987 captures
988 .into_iter()
989 .filter_map(|(name, path)| arena.intern_path(path).map(|handle| (name, handle)))
990 .collect()
991 })
992 .collect()
993}
994
995fn captures_to_js<'js>(
997 ctx: &Ctx<'js>,
998 matches: Vec<Vec<(String, Handle)>>,
999) -> rquickjs::Result<Value<'js>> {
1000 let array = rquickjs::Array::new(ctx.clone())?;
1001 for (index, captures) in matches.into_iter().enumerate() {
1002 let object = Object::new(ctx.clone())?;
1003 for (name, handle) in captures {
1004 object.set(name, handle)?;
1005 }
1006 array.set(index, object)?;
1007 }
1008 Ok(array.into_value())
1009}
1010
1011fn throw(ctx: &Ctx<'_>, message: &str) -> rquickjs::Error {
1017 rquickjs::Exception::throw_type(ctx, message)
1018}
1019
1020#[must_use]
1030pub fn merge_file(data: &str, file: &str) -> String {
1031 let inner = data
1032 .trim()
1033 .strip_prefix('{')
1034 .and_then(|rest| rest.strip_suffix('}'))
1035 .unwrap_or_default()
1036 .trim();
1037
1038 let mut out = String::with_capacity(data.len() + file.len() + 12);
1039 out.push('{');
1040 if !inner.is_empty() {
1041 out.push_str(inner);
1042 out.push(',');
1043 }
1044 out.push_str("\"file\":");
1045 escape_json_string(file, &mut out);
1046 out.push('}');
1047 out
1048}
1049
1050fn escape_json_string(text: &str, out: &mut String) {
1052 out.push('"');
1053 for ch in text.chars() {
1054 match ch {
1055 '"' => out.push_str("\\\""),
1056 '\\' => out.push_str("\\\\"),
1057 '\n' => out.push_str("\\n"),
1058 '\r' => out.push_str("\\r"),
1059 '\t' => out.push_str("\\t"),
1060 c if (c as u32) < 0x20 => {
1061 let _ = write!(out, "\\u{:04x}", c as u32);
1064 }
1065 c => out.push(c),
1066 }
1067 }
1068 out.push('"');
1069}
1070
1071#[cfg(test)]
1072mod tests {
1073 use lanekeep_lang::Language;
1074 use lanekeep_lang_js::TypeScript;
1075
1076 use super::*;
1077 use crate::{Limits, Sandbox};
1078
1079 fn parse(source: &str) -> tree_sitter::Tree {
1080 let mut parser = tree_sitter::Parser::new();
1081 parser
1082 .set_language(&TypeScript.grammar())
1083 .expect("grammar loads");
1084 parser.parse(source, None).expect("parses")
1085 }
1086
1087 fn host(source: &str) -> HostContext {
1088 HostContext::new(parse(source), source.to_owned(), "src/example.ts")
1089 .with_resolver(Arc::new(lanekeep_lang_js::binding::JsBindingResolver))
1090 }
1091
1092 fn host_without_resolver(source: &str) -> HostContext {
1094 HostContext::new(parse(source), source.to_owned(), "src/example.ts")
1095 }
1096
1097 fn handle_of(host: &HostContext, name: &str) -> Handle {
1099 let mut arena = host.arena().borrow_mut();
1100 let source = arena.source().to_owned();
1101
1102 let path = {
1103 let mut best: Option<tree_sitter::Node<'_>> = None;
1104 let mut stack = vec![arena.tree().root_node()];
1105 while let Some(node) = stack.pop() {
1106 if node.kind() == "identifier"
1107 && source.get(node.byte_range()) == Some(name)
1108 && best.is_none_or(|b| node.start_byte() > b.start_byte())
1109 {
1110 best = Some(node);
1111 }
1112 let mut cursor = node.walk();
1113 stack.extend(node.children(&mut cursor));
1114 }
1115 arena
1116 .path_of(best.unwrap_or_else(|| panic!("no identifier `{name}`")))
1117 .expect("has a path")
1118 };
1119
1120 arena.intern_path(path).expect("interns")
1121 }
1122
1123 fn run<T>(host: &HostContext, code: &str) -> T
1125 where
1126 T: for<'js> rquickjs::FromJs<'js> + Default,
1127 {
1128 let sandbox = Sandbox::with_limits(Limits::default()).expect("sandbox builds");
1129 sandbox.eval_with_host(host, code).expect("evaluates")
1130 }
1131
1132 #[test]
1133 fn exposes_the_file_path_and_text() {
1134 let host = host("const x = 1;");
1135 assert_eq!(run::<String>(&host, "ctx.filePath"), "src/example.ts");
1136 assert_eq!(run::<String>(&host, "ctx.fileText"), "const x = 1;");
1137 }
1138
1139 #[test]
1140 fn navigates_from_the_root() {
1141 let host = host("const x = 1;\nconst y = 2;");
1142 assert_eq!(run::<String>(&host, "ctx.kind(ctx.root)"), "program");
1143 assert_eq!(run::<u32>(&host, "ctx.namedChildren(ctx.root).length"), 2);
1144 assert_eq!(
1145 run::<String>(&host, "ctx.kind(ctx.namedChildren(ctx.root)[0])"),
1146 "lexical_declaration"
1147 );
1148 }
1149
1150 #[test]
1151 fn reads_text_and_position() {
1152 let host = host("const x = 1;\nconst y = 2;");
1153 assert_eq!(
1154 run::<String>(&host, "ctx.text(ctx.namedChildren(ctx.root)[1])"),
1155 "const y = 2;"
1156 );
1157 assert_eq!(
1158 run::<u32>(&host, "ctx.line(ctx.namedChildren(ctx.root)[1])"),
1159 2
1160 );
1161 assert_eq!(
1162 run::<u32>(&host, "ctx.column(ctx.namedChildren(ctx.root)[1])"),
1163 1
1164 );
1165 }
1166
1167 #[test]
1168 fn walks_up_and_back_down() {
1169 let host = host("const x = 1;");
1170 assert!(run::<bool>(
1171 &host,
1172 "const d = ctx.namedChildren(ctx.root)[0];
1173 const inner = ctx.namedChildren(d)[0];
1174 ctx.parent(inner) === d && ctx.parent(d) === ctx.root"
1175 ));
1176 }
1177
1178 #[test]
1179 fn handles_compare_equal_for_the_same_node() {
1180 let host = host("const x = 1;");
1184 assert!(run::<bool>(
1185 &host,
1186 "ctx.namedChildren(ctx.root)[0] === ctx.namedChildren(ctx.root)[0]"
1187 ));
1188 }
1189
1190 #[test]
1191 fn ancestors_end_at_the_root() {
1192 let host = host("function f() { return 1; }");
1193 assert!(run::<bool>(
1194 &host,
1195 "const fn = ctx.namedChildren(ctx.root)[0];
1196 const body = ctx.namedChildren(fn).at(-1);
1197 const stmt = ctx.namedChildren(body)[0];
1198 const a = ctx.ancestors(stmt);
1199 a[0] === body && a.at(-1) === ctx.root"
1200 ));
1201 }
1202
1203 #[test]
1204 fn named_children_omits_anonymous_tokens() {
1205 let host = host("const x = 1;");
1206 assert!(run::<bool>(
1207 &host,
1208 "const d = ctx.namedChildren(ctx.root)[0];
1209 ctx.children(d).length > ctx.namedChildren(d).length"
1210 ));
1211 }
1212
1213 #[test]
1214 fn an_unresolvable_handle_returns_nothing_rather_than_throwing() {
1215 let host = host("const x = 1;");
1218 assert!(run::<bool>(
1219 &host,
1220 "ctx.kind(9999) === undefined &&
1221 ctx.text(9999) === undefined &&
1222 ctx.line(9999) === undefined &&
1223 ctx.column(9999) === undefined &&
1224 ctx.parent(9999) === undefined &&
1225 ctx.children(9999).length === 0 &&
1226 ctx.ancestors(9999).length === 0 &&
1227 ctx.structureFingerprint(9999) === undefined"
1228 ));
1229 }
1230
1231 #[test]
1234 fn structure_fingerprint_is_exposed_on_ctx() {
1235 let host = host("const x = 1;\n");
1239 assert_eq!(
1240 run::<String>(&host, "ctx.structureFingerprint(ctx.root).hash"),
1241 "a0f2e92a59b964c75383ee14e32e0087bb376c7cc39572ff0b888a04d3dd9e4b"
1242 );
1243 assert_eq!(
1244 run::<u32>(&host, "ctx.structureFingerprint(ctx.root).nodes"),
1245 8
1246 );
1247 }
1248
1249 #[test]
1250 fn structure_fingerprint_erases_identifiers_through_ctx() {
1251 let a = host("function f() { return a + b }");
1252 let b = host("function g() { return c + d }");
1253 assert_eq!(
1254 run::<String>(&a, "ctx.structureFingerprint(ctx.root).hash"),
1255 run::<String>(&b, "ctx.structureFingerprint(ctx.root).hash")
1256 );
1257 }
1258
1259 #[test]
1260 fn structure_fingerprint_of_a_dead_handle_is_undefined() {
1261 let host = host("const x = 1;");
1262 assert!(run::<bool>(
1263 &host,
1264 "ctx.structureFingerprint(9999) === undefined"
1265 ));
1266 }
1267
1268 #[test]
1271 fn records_a_report_at_the_node_position() {
1272 let host = host("const x = 1;\nconst y = 2;");
1273 let _: () = run(&host, "ctx.report(ctx.namedChildren(ctx.root)[1])");
1274
1275 let reports = host.take_reports();
1276 assert_eq!(reports.len(), 1);
1277 assert_eq!(reports[0].line, 2);
1278 assert_eq!(reports[0].column, 1);
1279 assert_eq!(reports[0].message, None);
1280 }
1281
1282 #[test]
1283 fn records_an_overriding_message() {
1284 let host = host("const x = 1;");
1285 let _: () = run(&host, "ctx.report(ctx.root, 'something specific')");
1286
1287 let reports = host.take_reports();
1288 assert_eq!(reports[0].message.as_deref(), Some("something specific"));
1289 }
1290
1291 #[test]
1292 fn records_every_report_in_order() {
1293 let host = host("const a = 1;\nconst b = 2;\nconst c = 3;");
1294 let _: () = run(
1295 &host,
1296 "for (const d of ctx.namedChildren(ctx.root)) { ctx.report(d, ctx.text(d)); }",
1297 );
1298
1299 let reports = host.take_reports();
1300 let lines: Vec<u32> = reports.iter().map(|r| r.line).collect();
1301 assert_eq!(lines, [1, 2, 3]);
1302 assert_eq!(reports[2].message.as_deref(), Some("const c = 3;"));
1303 }
1304
1305 #[test]
1306 fn a_report_at_an_unresolvable_handle_is_dropped() {
1307 let host = host("const x = 1;");
1310 let _: () = run(&host, "ctx.report(9999)");
1311 assert!(host.take_reports().is_empty());
1312 }
1313
1314 #[test]
1315 fn taking_reports_empties_the_context() {
1316 let host = host("const x = 1;");
1317 let _: () = run(&host, "ctx.report(ctx.root)");
1318
1319 assert_eq!(host.take_reports().len(), 1);
1320 assert!(
1321 host.take_reports().is_empty(),
1322 "reports must not be reported twice"
1323 );
1324 }
1325
1326 #[test]
1327 fn a_rule_that_throws_still_leaves_earlier_reports() {
1328 let host = host("const x = 1;");
1332 let sandbox = Sandbox::with_limits(Limits::default()).expect("sandbox builds");
1333 let result: Result<(), _> =
1334 sandbox.eval_with_host(&host, "ctx.report(ctx.root); throw new Error('later')");
1335
1336 assert!(result.is_err());
1337 assert_eq!(host.take_reports().len(), 1);
1338 }
1339
1340 #[test]
1341 fn navigation_is_bounded_by_the_rule_timeout() {
1342 let host = host("const x = 1;");
1346 let sandbox = Sandbox::with_limits(
1347 Limits::default().with_rule_timeout(std::time::Duration::from_millis(120)),
1348 )
1349 .expect("sandbox builds");
1350
1351 let result: Result<(), _> = sandbox.eval_with_host(
1352 &host,
1353 "for (;;) { ctx.kind(ctx.root); ctx.children(ctx.root); }",
1354 );
1355 assert!(
1356 matches!(result, Err(crate::SandboxError::RuleTimeout { .. })),
1357 "expected a timeout, got {result:?}"
1358 );
1359 }
1360
1361 #[test]
1362 fn the_sandbox_still_withholds_everything_it_did_before() {
1363 let host = host("const x = 1;");
1365 assert!(run::<bool>(
1366 &host,
1367 "typeof Date === 'undefined' &&
1368 typeof performance === 'undefined' &&
1369 typeof Math.random === 'undefined' &&
1370 typeof fetch === 'undefined' &&
1371 typeof process === 'undefined'"
1372 ));
1373 }
1374
1375 #[test]
1378 fn resolves_an_import_through_its_alias() {
1379 let host = host("import { makeStyles as ms } from '@rneui/themed';\nms();");
1381 let handle = handle_of(&host, "ms");
1382
1383 assert!(run::<bool>(
1384 &host,
1385 &format!("ctx.resolvesToImport({handle}, '@rneui/themed', 'makeStyles')")
1386 ));
1387 assert!(!run::<bool>(
1388 &host,
1389 &format!("ctx.resolvesToImport({handle}, 'somewhere-else', 'makeStyles')")
1390 ));
1391 assert!(!run::<bool>(
1392 &host,
1393 &format!("ctx.resolvesToImport({handle}, '@rneui/themed', 'notThatOne')")
1394 ));
1395 }
1396
1397 #[test]
1398 fn a_local_declaration_does_not_resolve_to_the_import_it_shadows() {
1399 let host = host(
1402 "import { makeStyles } from '@rneui/themed';\n\
1403 function f() { const makeStyles = () => {}; return makeStyles(); }",
1404 );
1405 let handle = handle_of(&host, "makeStyles");
1406
1407 assert!(!run::<bool>(
1408 &host,
1409 &format!("ctx.resolvesToImport({handle}, '@rneui/themed', 'makeStyles')")
1410 ));
1411 assert_eq!(
1412 run::<String>(&host, &format!("ctx.bindingKind({handle})")),
1413 "const"
1414 );
1415 assert!(run::<bool>(&host, &format!("ctx.isShadowed({handle})")));
1416 }
1417
1418 #[test]
1419 fn omitting_the_name_matches_any_export_of_the_module() {
1420 let host = host("import { a } from 'm';\na();");
1421 let handle = handle_of(&host, "a");
1422 assert!(run::<bool>(
1423 &host,
1424 &format!("ctx.resolvesToImport({handle}, 'm')")
1425 ));
1426 }
1427
1428 #[test]
1429 fn matches_a_module_by_glob() {
1430 let host = host("import { a } from '@scope/pkg';\na();");
1431 let handle = handle_of(&host, "a");
1432
1433 assert!(run::<bool>(
1434 &host,
1435 &format!("ctx.isImportedFrom({handle}, '@scope/*')")
1436 ));
1437 assert!(run::<bool>(
1438 &host,
1439 &format!("ctx.isImportedFrom({handle}, '*/pkg')")
1440 ));
1441 assert!(run::<bool>(
1442 &host,
1443 &format!("ctx.isImportedFrom({handle}, '@scope/pkg')")
1444 ));
1445 assert!(!run::<bool>(
1446 &host,
1447 &format!("ctx.isImportedFrom({handle}, '@other/*')")
1448 ));
1449 }
1450
1451 #[test]
1452 fn reports_binding_kinds() {
1453 for (source, name, expected) in [
1454 ("import { a } from 'm';\na();", "a", "import"),
1455 ("const b = 1;\nb;", "b", "const"),
1456 ("let c = 1;\nc;", "c", "let"),
1457 ("function d() {}\nd();", "d", "function"),
1458 ("class E {}\nnew E();", "E", "class"),
1459 ("function f(p) { return p; }", "p", "param"),
1460 ] {
1461 let host = host(source);
1462 let handle = handle_of(&host, name);
1463 assert_eq!(
1464 run::<String>(&host, &format!("ctx.bindingKind({handle})")),
1465 expected,
1466 "for {name} in {source}"
1467 );
1468 }
1469 }
1470
1471 #[test]
1472 fn an_undeclared_name_has_no_binding_kind() {
1473 let host = host("globalThing();");
1474 let handle = handle_of(&host, "globalThing");
1475 assert!(run::<bool>(
1476 &host,
1477 &format!("ctx.bindingKind({handle}) === undefined")
1478 ));
1479 }
1480
1481 #[test]
1482 fn without_a_resolver_nothing_resolves_rather_than_throwing() {
1483 let host = host_without_resolver("import { a } from 'm';\na();");
1486 assert!(run::<bool>(
1487 &host,
1488 "ctx.resolvesToImport(0, 'm', 'a') === false &&
1489 ctx.isImportedFrom(0, '*') === false &&
1490 ctx.isShadowed(0) === false &&
1491 ctx.bindingKind(0) === undefined"
1492 ));
1493 }
1494
1495 #[test]
1501 fn navigation_stays_lazy() {
1502 let host = host("const a = 1; const b = 2; function c() { return [1,2,3] }");
1504 assert!(
1505 host.arena().borrow().is_empty(),
1506 "nothing should be interned yet"
1507 );
1508
1509 let _: () = run(&host, "ctx.kind(ctx.root)");
1510 assert!(
1511 host.arena().borrow().is_empty(),
1512 "reading the root's kind should not intern anything new"
1513 );
1514 }
1515
1516 fn emitted(source: &str) -> Vec<EmittedFact> {
1520 let host = host("const a = 1;");
1521 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1522 sandbox
1523 .eval_with_host::<()>(&host, source)
1524 .expect("evaluates");
1525 host.take_facts()
1526 }
1527
1528 #[test]
1529 fn a_fact_is_captured_with_its_kind_and_payload() {
1530 let facts = emitted("ctx.emitFact({ kind: 'export', symbol: 'parse' })");
1531 assert_eq!(facts.len(), 1);
1532 assert_eq!(facts[0].kind, "export");
1533 assert!(
1534 facts[0].data.contains(r#""symbol":"parse""#),
1535 "{:?}",
1536 facts[0]
1537 );
1538 }
1539
1540 #[test]
1541 fn facts_are_kept_in_emission_order() {
1542 let facts = emitted(
1544 "ctx.emitFact({ kind: 'a', n: 1 }); \
1545 ctx.emitFact({ kind: 'b', n: 2 }); \
1546 ctx.emitFact({ kind: 'a', n: 3 });",
1547 );
1548 assert_eq!(
1549 facts.iter().map(|f| f.kind.as_str()).collect::<Vec<_>>(),
1550 vec!["a", "b", "a"]
1551 );
1552 }
1553
1554 #[test]
1555 fn a_fact_without_a_kind_is_rejected() {
1556 let host = host("const a = 1;");
1559 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1560 let error = sandbox
1561 .eval_with_host::<()>(&host, "ctx.emitFact({ symbol: 'parse' })")
1562 .expect_err("is rejected");
1563 assert!(error.to_string().contains("kind"), "{error}");
1564 assert!(host.take_facts().is_empty());
1565 }
1566
1567 #[test]
1568 fn a_fact_with_an_empty_kind_is_rejected() {
1569 let host = host("const a = 1;");
1570 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1571 assert!(
1572 sandbox
1573 .eval_with_host::<()>(&host, "ctx.emitFact({ kind: '' })")
1574 .is_err()
1575 );
1576 }
1577
1578 #[test]
1579 fn a_fact_that_is_not_an_object_is_rejected() {
1580 let host = host("const a = 1;");
1581 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1582 for bad in ["'export'", "42", "null", "undefined"] {
1583 assert!(
1584 sandbox
1585 .eval_with_host::<()>(&host, &format!("ctx.emitFact({bad})"))
1586 .is_err(),
1587 "`{bad}` should not be emittable"
1588 );
1589 }
1590 }
1591
1592 #[test]
1593 fn a_cyclic_fact_is_rejected_rather_than_hanging() {
1594 let host = host("const a = 1;");
1595 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1596 let error = sandbox
1597 .eval_with_host::<()>(
1598 &host,
1599 "const f = { kind: 'x' }; f.self = f; ctx.emitFact(f)",
1600 )
1601 .expect_err("is rejected");
1602 assert!(!error.to_string().is_empty());
1605 }
1606
1607 #[test]
1608 fn the_reduce_surface_is_absent_from_the_per_file_context() {
1609 let host = host("const a = 1;");
1612 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1613 for absent in ["ctx.facts", "ctx.files"] {
1614 let present: bool = sandbox
1615 .eval_with_host(&host, &format!("{absent} !== undefined"))
1616 .expect("evaluates");
1617 assert!(
1618 !present,
1619 "`{absent}` must not exist during the per-file pass"
1620 );
1621 }
1622 }
1623
1624 fn reduce_fact(kind: &str, json: &str) -> ReduceFact {
1627 ReduceFact {
1628 kind: kind.to_owned(),
1629 json: json.to_owned(),
1630 }
1631 }
1632
1633 fn budget() -> std::time::Duration {
1635 std::time::Duration::from_secs(5)
1636 }
1637
1638 #[test]
1643 fn a_reduce_report_takes_a_string_or_an_options_object() {
1644 for expression in [
1645 r"ctx.report({ file: 'a.ts', line: 1, column: 2 }, 'plain string')",
1646 r"ctx.report({ file: 'a.ts', line: 1, column: 2 }, { message: 'plain string' })",
1647 ] {
1648 let context = ReduceContext::new(vec!["a.ts".to_owned()], vec![]);
1649 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1650 sandbox
1651 .eval_with_reduce_host::<()>(&context, expression, budget())
1652 .unwrap_or_else(|e| panic!("{expression} should be accepted: {e}"));
1653
1654 let reports = context.take_reports();
1655 assert_eq!(reports.len(), 1, "{expression}");
1656 assert_eq!(
1657 reports[0].message.as_deref(),
1658 Some("plain string"),
1659 "{expression}"
1660 );
1661 }
1662 }
1663
1664 #[test]
1667 fn a_reduce_report_refuses_a_message_that_is_neither() {
1668 let context = ReduceContext::new(vec!["a.ts".to_owned()], vec![]);
1669 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1670 let error = sandbox
1671 .eval_with_reduce_host::<()>(
1672 &context,
1673 r"ctx.report({ file: 'a.ts', line: 1, column: 2 }, { detail: 'wrong key' })",
1674 budget(),
1675 )
1676 .expect_err("should refuse");
1677 assert!(
1678 error.to_string().contains("message"),
1679 "the error should say what it wanted: {error}"
1680 );
1681 }
1682
1683 #[test]
1684 fn facts_come_back_as_objects() {
1685 let context = ReduceContext::new(
1686 vec!["a.ts".to_owned()],
1687 vec![reduce_fact(
1688 "export",
1689 r#"{"kind":"export","symbol":"parse","file":"a.ts"}"#,
1690 )],
1691 );
1692 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1693 let symbol: String = sandbox
1694 .eval_with_reduce_host(&context, "ctx.facts('export')[0].symbol", budget())
1695 .expect("evaluates");
1696 assert_eq!(symbol, "parse");
1697 }
1698
1699 #[test]
1700 fn facts_filter_by_kind_and_default_to_everything() {
1701 let context = ReduceContext::new(
1702 vec![],
1703 vec![
1704 reduce_fact("export", r#"{"kind":"export","file":"a.ts"}"#),
1705 reduce_fact("import", r#"{"kind":"import","file":"b.ts"}"#),
1706 reduce_fact("export", r#"{"kind":"export","file":"c.ts"}"#),
1707 ],
1708 );
1709 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1710 let counts: Vec<i32> = sandbox
1711 .eval_with_reduce_host(
1712 &context,
1713 "[ctx.facts('export').length, ctx.facts('import').length, ctx.facts().length]",
1714 budget(),
1715 )
1716 .expect("evaluates");
1717 assert_eq!(counts, vec![2, 1, 3]);
1718 }
1719
1720 #[test]
1721 fn an_unknown_kind_yields_an_empty_array_rather_than_undefined() {
1722 let context = ReduceContext::new(vec![], vec![]);
1724 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1725 let length: i32 = sandbox
1726 .eval_with_reduce_host(&context, "ctx.facts('nope').length", budget())
1727 .expect("evaluates");
1728 assert_eq!(length, 0);
1729 }
1730
1731 #[test]
1732 fn the_file_list_is_visible() {
1733 let context = ReduceContext::new(vec!["a.ts".to_owned(), "b.ts".to_owned()], vec![]);
1734 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1735 let files: Vec<String> = sandbox
1736 .eval_with_reduce_host(&context, "ctx.files", budget())
1737 .expect("evaluates");
1738 assert_eq!(files, vec!["a.ts".to_owned(), "b.ts".to_owned()]);
1739 }
1740
1741 #[test]
1742 fn reporting_names_a_file_of_its_own() {
1743 let context = ReduceContext::new(vec![], vec![]);
1744 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1745 sandbox
1746 .eval_with_reduce_host::<()>(
1747 &context,
1748 "ctx.report({ file: 'b.ts', line: 4, column: 2 }, 'unused export')",
1749 budget(),
1750 )
1751 .expect("evaluates");
1752 assert_eq!(
1753 context.take_reports(),
1754 vec![ReduceReport {
1755 file: "b.ts".to_owned(),
1756 line: 4,
1757 column: 2,
1758 message: Some("unused export".to_owned()),
1759 }]
1760 );
1761 }
1762
1763 #[test]
1764 fn the_message_is_optional() {
1765 let context = ReduceContext::new(vec![], vec![]);
1766 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1767 sandbox
1768 .eval_with_reduce_host::<()>(
1769 &context,
1770 "ctx.report({ file: 'b.ts', line: 1, column: 1 })",
1771 budget(),
1772 )
1773 .expect("evaluates");
1774 let reports = context.take_reports();
1775 assert_eq!(reports.len(), 1);
1776 assert_eq!(reports[0].message, None);
1777 }
1778
1779 #[test]
1780 fn reporting_without_a_position_is_rejected() {
1781 let context = ReduceContext::new(vec![], vec![]);
1784 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1785 for bad in [
1786 "ctx.report({ file: 'b.ts' })",
1787 "ctx.report({ line: 1, column: 1 })",
1788 "ctx.report(3)",
1789 "ctx.report('b.ts')",
1790 ] {
1791 let error = sandbox
1792 .eval_with_reduce_host::<()>(&context, bad, budget())
1793 .expect_err("is rejected");
1794 assert!(!error.to_string().is_empty(), "`{bad}` should be rejected");
1795 }
1796 assert!(context.take_reports().is_empty());
1797 }
1798
1799 #[test]
1800 fn the_per_file_surface_is_absent_from_the_reduce_context() {
1801 let context = ReduceContext::new(vec![], vec![]);
1804 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1805 for absent in [
1806 "ctx.emitFact",
1807 "ctx.text",
1808 "ctx.kind",
1809 "ctx.parent",
1810 "ctx.namedChildren",
1811 "ctx.filePath",
1812 "ctx.fileText",
1813 "ctx.root",
1814 ] {
1815 let present: bool = sandbox
1816 .eval_with_reduce_host(&context, &format!("{absent} !== undefined"), budget())
1817 .expect("evaluates");
1818 assert!(
1819 !present,
1820 "`{absent}` must not exist during the reduce phase"
1821 );
1822 }
1823 }
1824
1825 #[test]
1828 fn merge_file_adds_the_field() {
1829 assert_eq!(
1830 merge_file(r#"{"kind":"export"}"#, "src/a.ts"),
1831 r#"{"kind":"export","file":"src/a.ts"}"#
1832 );
1833 }
1834
1835 #[test]
1836 fn merge_file_handles_an_empty_payload() {
1837 assert_eq!(merge_file("{}", "a.ts"), r#"{"file":"a.ts"}"#);
1838 }
1839
1840 #[test]
1841 fn merge_file_overrides_a_file_the_rule_supplied() {
1842 let merged = merge_file(r#"{"kind":"export","file":"lies.ts"}"#, "truth.ts");
1845 assert!(merged.ends_with(r#""file":"truth.ts"}"#), "{merged}");
1846
1847 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1848 let file: String = sandbox
1849 .eval(&format!("JSON.parse({merged:?}).file"))
1850 .expect("parses");
1851 assert_eq!(file, "truth.ts");
1852 }
1853
1854 #[test]
1855 fn merge_file_escapes_the_path() {
1856 let awkward = "a\"b\\c\nd.ts";
1860 let merged = merge_file("{}", awkward);
1861 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1862 let file: String = sandbox
1863 .eval(&format!("JSON.parse({merged:?}).file"))
1864 .expect("parses");
1865 assert_eq!(file, awkward);
1866 }
1867
1868 fn host_with_language(source: &str) -> HostContext {
1872 HostContext::new(parse(source), source.to_owned(), "src/example.ts")
1873 .with_resolver(Arc::new(lanekeep_lang_js::binding::JsBindingResolver))
1874 .with_language(Arc::new(TypeScript))
1875 }
1876
1877 #[test]
1878 fn a_subtree_query_finds_only_what_is_inside() {
1879 let source = "function a() { const x = 1; }\nfunction b() { const y = 2; const z = 3; }\n";
1882 let host = host_with_language(source);
1883 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1884
1885 let names: Vec<String> = sandbox
1886 .eval_with_host(
1887 &host,
1888 "const fns = ctx.querySubtree(ctx.root, '(function_declaration) @fn');\n\
1889 const inner = ctx.querySubtree(fns[1].fn, '(variable_declarator name: (identifier) @name)');\n\
1890 inner.map((m) => ctx.text(m.name))",
1891 )
1892 .expect("evaluates");
1893
1894 assert_eq!(names, vec!["y".to_owned(), "z".to_owned()]);
1895 }
1896
1897 #[test]
1898 fn a_subtree_query_with_no_matches_is_an_empty_array() {
1899 let host = host_with_language("const a = 1;\n");
1901 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1902 let length: i32 = sandbox
1903 .eval_with_host(
1904 &host,
1905 "ctx.querySubtree(ctx.root, '(debugger_statement) @d').length",
1906 )
1907 .expect("evaluates");
1908 assert_eq!(length, 0);
1909 }
1910
1911 #[test]
1912 fn an_invalid_query_is_reported_to_the_rule() {
1913 let host = host_with_language("const a = 1;\n");
1914 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1915 let error = sandbox
1916 .eval_with_host::<()>(&host, "ctx.querySubtree(ctx.root, '(((')")
1917 .expect_err("is rejected");
1918 assert!(!error.to_string().is_empty());
1919 }
1920
1921 #[test]
1922 fn a_general_predicate_is_rejected_by_both_query_functions() {
1923 let host = host_with_language("const a = 1;\n");
1927 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1928
1929 let error = sandbox
1930 .eval_with_host::<()>(
1931 &host,
1932 "ctx.querySubtree(ctx.root, '((identifier) @id (#is? @id \"a\"))')",
1933 )
1934 .expect_err("is rejected");
1935 assert!(error.to_string().contains("#is?"), "{error}");
1936
1937 let error = sandbox
1938 .eval_with_host::<()>(
1939 &host,
1940 "const d = ctx.querySubtree(ctx.root, '(variable_declarator name: (identifier) @n)');\n\
1941 ctx.closestAncestor(d[0].n, '((program) @p (#is? @p \"x\"))')",
1942 )
1943 .expect_err("is rejected");
1944 assert!(error.to_string().contains("#is?"), "{error}");
1945 }
1946
1947 #[test]
1948 fn closest_ancestor_finds_the_nearest_one() {
1949 let source = "function outer() { function inner() { const x = 1; } }\n";
1951 let host = host_with_language(source);
1952 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1953
1954 let name: String = sandbox
1955 .eval_with_host(
1956 &host,
1957 "const decls = ctx.querySubtree(ctx.root, '(variable_declarator name: (identifier) @n)');\n\
1958 const found = ctx.closestAncestor(decls[0].n, '(function_declaration name: (identifier) @name) @fn');\n\
1959 ctx.text(found.name)",
1960 )
1961 .expect("evaluates");
1962
1963 assert_eq!(name, "inner");
1964 }
1965
1966 #[test]
1967 fn closest_ancestor_returns_undefined_when_nothing_matches() {
1968 let host = host_with_language("const a = 1;\n");
1971 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1972 let absent: bool = sandbox
1973 .eval_with_host(
1974 &host,
1975 "const d = ctx.querySubtree(ctx.root, '(variable_declarator name: (identifier) @n)');\n\
1976 ctx.closestAncestor(d[0].n, '(class_declaration) @c') === undefined",
1977 )
1978 .expect("evaluates");
1979 assert!(absent);
1980 }
1981
1982 #[test]
1983 fn closest_ancestor_does_not_match_the_node_itself_from_inside() {
1984 let source = "function outer() { function inner() { const x = 1; } }\n";
1987 let host = host_with_language(source);
1988 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1989
1990 let name: String = sandbox
1991 .eval_with_host(
1992 &host,
1993 "const d = ctx.querySubtree(ctx.root, '(variable_declarator name: (identifier) @n)');\n\
1994 const found = ctx.closestAncestor(d[0].n, '(statement_block) @block');\n\
1995 ctx.kind(found.block)",
1996 )
1997 .expect("evaluates");
1998 assert_eq!(name, "statement_block");
1999 }
2000
2001 #[test]
2002 fn the_query_functions_are_absent_without_a_language() {
2003 let host = host("const a = 1;");
2005 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
2006 for absent in ["ctx.querySubtree", "ctx.closestAncestor"] {
2007 let present: bool = sandbox
2008 .eval_with_host(&host, &format!("{absent} !== undefined"))
2009 .expect("evaluates");
2010 assert!(!present, "`{absent}` should not exist without a language");
2011 }
2012 }
2013
2014 #[test]
2017 fn loc_gives_the_shape_a_fact_and_a_reduce_report_both_use() {
2018 let source = "const alpha = 1;\nconst beta = 2;\n";
2021 let host = host(source);
2022 let handle = handle_of(&host, "beta");
2023 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
2024
2025 let rendered: String = sandbox
2026 .eval_with_host(
2027 &host,
2028 &format!("const l = ctx.loc({handle}); `${{l.file}}:${{l.line}}:${{l.column}}`"),
2029 )
2030 .expect("evaluates");
2031 assert_eq!(rendered, "src/example.ts:2:7");
2032 }
2033
2034 #[test]
2035 fn loc_at_an_unresolvable_handle_is_undefined() {
2036 let host = host("const a = 1;");
2039 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
2040 let absent: bool = sandbox
2041 .eval_with_host(&host, "ctx.loc(9999) === undefined")
2042 .expect("evaluates");
2043 assert!(absent);
2044 }
2045
2046 #[test]
2047 fn today_is_what_the_host_supplied() {
2048 let host = host("const a = 1;").with_today("2026-08-01");
2049 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
2050 let today: String = sandbox
2051 .eval_with_host(&host, "ctx.today")
2052 .expect("evaluates");
2053 assert_eq!(today, "2026-08-01");
2054 }
2055
2056 #[test]
2057 fn today_is_absent_when_the_host_supplied_none() {
2058 let host = host("const a = 1;");
2062 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
2063 let absent: bool = sandbox
2064 .eval_with_host(&host, "ctx.today === undefined")
2065 .expect("evaluates");
2066 assert!(absent);
2067 }
2068
2069 #[test]
2070 fn reading_today_is_observed_and_not_reading_it_is_not() {
2071 let unread = host("const a = 1;").with_today("2026-08-01");
2074 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
2075 sandbox
2076 .eval_with_host::<i32>(&unread, "1 + 1")
2077 .expect("evaluates");
2078 assert!(!unread.date_was_read(), "nothing read the date");
2079
2080 let read = host("const a = 1;").with_today("2026-08-01");
2081 sandbox
2082 .eval_with_host::<String>(&read, "ctx.today")
2083 .expect("evaluates");
2084 assert!(read.date_was_read(), "the read was not observed");
2085 }
2086
2087 #[test]
2088 fn today_does_not_bring_a_clock_with_it() {
2089 let host = host("const a = 1;").with_today("2026-08-01");
2091 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
2092 for absent in ["Date", "performance"] {
2093 let present: bool = sandbox
2094 .eval_with_host(&host, &format!("typeof {absent} !== 'undefined'"))
2095 .expect("evaluates");
2096 assert!(!present, "`{absent}` must not exist");
2097 }
2098 }
2099}