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::{Binding, BindingResolver, ImportedName};
35
36use lanekeep_core::fix::Fix;
37use lanekeep_query::CompiledQuery;
38
39use crate::files::FileAccess;
40use crate::nodes::{Handle, NodeArena};
41
42pub const HOST_API_VERSION: u32 = 1;
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct EmittedFact {
59 pub kind: String,
61 pub data: String,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct Report {
72 pub node: Option<Handle>,
74 pub line: u32,
76 pub column: u32,
78 pub message: Option<String>,
80 pub fix: Option<Fix>,
82}
83
84#[derive(Clone)]
89pub struct HostContext {
90 arena: Rc<RefCell<NodeArena>>,
91 reports: Rc<RefCell<Vec<Report>>>,
92 facts: Rc<RefCell<Vec<EmittedFact>>>,
93 file_path: Rc<str>,
94 resolver: Option<Arc<dyn BindingResolver>>,
95 files: Option<Rc<FileAccess>>,
96 language: Option<Arc<dyn lanekeep_lang::Language>>,
98 today: Option<Rc<str>>,
100 date_read: Rc<Cell<bool>>,
106 queries: QueryCache,
113}
114
115type QueryCache = Rc<RefCell<BTreeMap<String, Result<Rc<CompiledQuery>, String>>>>;
121
122impl std::fmt::Debug for HostContext {
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 f.debug_struct("HostContext")
125 .field("file_path", &self.file_path)
126 .field("interned_nodes", &self.arena.borrow().len())
127 .field("reports", &self.reports.borrow().len())
128 .field("facts", &self.facts.borrow().len())
129 .field("has_resolver", &self.resolver.is_some())
130 .field("has_file_access", &self.files.is_some())
131 .field("has_language", &self.language.is_some())
132 .field("has_today", &self.today.is_some())
133 .field("date_read", &self.date_read.get())
134 .field("compiled_queries", &self.queries.borrow().len())
135 .finish()
136 }
137}
138
139impl HostContext {
140 #[must_use]
142 pub fn new(tree: tree_sitter::Tree, source: String, file_path: &str) -> Self {
143 Self {
144 arena: Rc::new(RefCell::new(NodeArena::new(tree, source))),
145 reports: Rc::new(RefCell::new(Vec::new())),
146 facts: Rc::new(RefCell::new(Vec::new())),
147 file_path: Rc::from(file_path),
148 resolver: None,
149 files: None,
150 language: None,
151 today: None,
152 date_read: Rc::new(Cell::new(false)),
153 queries: Rc::new(RefCell::new(BTreeMap::new())),
154 }
155 }
156
157 #[must_use]
159 pub fn with_resolver_from(self, language: &dyn lanekeep_lang::Language) -> Self {
160 match language.resolver() {
161 Some(resolver) => self.with_resolver(resolver),
162 None => self,
163 }
164 }
165
166 #[must_use]
173 pub fn with_today(mut self, today: &str) -> Self {
174 self.today = Some(Rc::from(today));
175 self
176 }
177
178 #[must_use]
182 pub fn date_was_read(&self) -> bool {
183 self.date_read.get()
184 }
185
186 #[must_use]
192 pub fn with_language(mut self, language: Arc<dyn lanekeep_lang::Language>) -> Self {
193 self.language = Some(language);
194 self
195 }
196
197 #[must_use]
204 pub fn with_resolver(mut self, resolver: Arc<dyn BindingResolver>) -> Self {
205 self.resolver = Some(resolver);
206 self
207 }
208
209 #[must_use]
216 pub fn with_file_access(mut self, files: Rc<FileAccess>) -> Self {
217 self.files = Some(files);
218 self
219 }
220
221 #[must_use]
223 pub fn arena(&self) -> &Rc<RefCell<NodeArena>> {
224 &self.arena
225 }
226
227 #[must_use]
229 pub fn take_reports(&self) -> Vec<Report> {
230 std::mem::take(&mut self.reports.borrow_mut())
231 }
232
233 #[must_use]
235 pub fn take_facts(&self) -> Vec<EmittedFact> {
236 std::mem::take(&mut self.facts.borrow_mut())
237 }
238
239 pub fn build<'js>(&self, ctx: &Ctx<'js>) -> rquickjs::Result<Object<'js>> {
246 let object = Object::new(ctx.clone())?;
247
248 object.set("filePath", &*self.file_path)?;
249 object.set("root", NodeArena::ROOT)?;
250 {
251 let arena = self.arena.borrow();
252 object.set("fileText", arena.source())?;
253 }
254
255 self.install_navigation(ctx, &object)?;
256 self.install_bindings(ctx, &object)?;
257 self.install_reporting(ctx, &object)?;
258 self.install_facts(ctx, &object)?;
259 self.install_reads(ctx, &object)?;
260 self.install_queries(ctx, &object)?;
261
262 if let Some(today) = self.today.clone() {
266 let date_read = Rc::clone(&self.date_read);
267 object.prop(
268 "today",
269 Accessor::from(move || {
270 date_read.set(true);
271 today.to_string()
272 }),
273 )?;
274 }
275
276 Ok(object)
277 }
278
279 fn install_navigation<'js>(
281 &self,
282 ctx: &Ctx<'js>,
283 object: &Object<'js>,
284 ) -> rquickjs::Result<()> {
285 let arena = Rc::clone(&self.arena);
293 object.set(
294 "kind",
295 Function::new(ctx.clone(), move |handle: Handle| {
296 arena.borrow().kind(handle).map(ToOwned::to_owned)
297 })?,
298 )?;
299
300 let arena = Rc::clone(&self.arena);
301 object.set(
302 "text",
303 Function::new(ctx.clone(), move |handle: Handle| {
304 arena.borrow().text(handle).map(ToOwned::to_owned)
305 })?,
306 )?;
307
308 let arena = Rc::clone(&self.arena);
309 object.set(
310 "isNamed",
311 Function::new(ctx.clone(), move |handle: Handle| {
312 arena.borrow().is_named(handle)
313 })?,
314 )?;
315
316 let arena = Rc::clone(&self.arena);
327 object.set(
328 "line",
329 Function::new(ctx.clone(), move |handle: Handle| {
330 arena.borrow().position(handle).map(|(line, _)| line)
331 })?,
332 )?;
333
334 let arena = Rc::clone(&self.arena);
335 object.set(
336 "column",
337 Function::new(ctx.clone(), move |handle: Handle| {
338 arena.borrow().position(handle).map(|(_, column)| column)
339 })?,
340 )?;
341
342 let arena = Rc::clone(&self.arena);
343 object.set(
344 "parent",
345 Function::new(ctx.clone(), move |handle: Handle| {
346 arena.borrow_mut().parent(handle)
347 })?,
348 )?;
349
350 let arena = Rc::clone(&self.arena);
351 object.set(
352 "children",
353 Function::new(ctx.clone(), move |handle: Handle| {
354 arena.borrow_mut().children(handle)
355 })?,
356 )?;
357
358 let arena = Rc::clone(&self.arena);
359 object.set(
360 "namedChildren",
361 Function::new(ctx.clone(), move |handle: Handle| {
362 arena.borrow_mut().named_children(handle)
363 })?,
364 )?;
365
366 let arena = Rc::clone(&self.arena);
367 object.set(
368 "ancestors",
369 Function::new(ctx.clone(), move |handle: Handle| {
370 arena.borrow_mut().ancestors(handle)
371 })?,
372 )?;
373
374 Ok(())
375 }
376
377 fn install_bindings<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
379 let arena = Rc::clone(&self.arena);
386 let resolver = self.resolver.clone();
387 object.set(
388 "resolvesToImport",
389 Function::new(
390 ctx.clone(),
391 move |handle: Handle, module: String, name: Opt<String>| {
392 let Some(resolver) = resolver.as_deref() else {
393 return false;
394 };
395 match arena.borrow().resolve_binding(handle, resolver) {
396 Some(Binding::Import {
397 module: from,
398 name: imported,
399 }) => {
400 from == module
401 && name.0.is_none_or(|wanted| match &imported {
402 ImportedName::Named(actual) => *actual == wanted,
403 ImportedName::Default => wanted == "default",
404 ImportedName::Namespace => wanted == "*",
405 })
406 }
407 _ => false,
408 }
409 },
410 )?,
411 )?;
412
413 let arena = Rc::clone(&self.arena);
414 let resolver = self.resolver.clone();
415 object.set(
416 "isImportedFrom",
417 Function::new(ctx.clone(), move |handle: Handle, pattern: String| {
418 let Some(resolver) = resolver.as_deref() else {
419 return false;
420 };
421 match arena.borrow().resolve_binding(handle, resolver) {
422 Some(Binding::Import { module, .. }) => glob_matches(&pattern, &module),
423 _ => false,
424 }
425 })?,
426 )?;
427
428 let arena = Rc::clone(&self.arena);
429 let resolver = self.resolver.clone();
430 object.set(
431 "bindingKind",
432 Function::new(ctx.clone(), move |handle: Handle| {
433 let resolver = resolver.as_deref()?;
434 arena
435 .borrow()
436 .resolve_binding(handle, resolver)
437 .map(|binding| binding.kind_str().to_owned())
438 })?,
439 )?;
440
441 let arena = Rc::clone(&self.arena);
442 let resolver = self.resolver.clone();
443 object.set(
444 "isShadowed",
445 Function::new(ctx.clone(), move |handle: Handle| {
446 resolver
447 .as_deref()
448 .is_some_and(|resolver| arena.borrow().is_shadowed(handle, resolver))
449 })?,
450 )?;
451
452 Ok(())
453 }
454
455 fn install_reporting<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
457 let arena = Rc::clone(&self.arena);
462 let file_path = Rc::clone(&self.file_path);
463 object.set(
464 "loc",
465 Function::new(
469 ctx.clone(),
470 move |ctx: Ctx<'js>, handle: Handle| -> rquickjs::Result<Value<'js>> {
471 let Some((line, column)) = arena.borrow().position(handle) else {
472 return Ok(Value::new_undefined(ctx.clone()));
475 };
476
477 let object = Object::new(ctx.clone())?;
478 object.set("file", &*file_path)?;
479 object.set("line", line)?;
480 object.set("column", column)?;
481 Ok(object.into_value())
482 },
483 )?,
484 )?;
485
486 let arena = Rc::clone(&self.arena);
487 let reports = Rc::clone(&self.reports);
488 object.set(
489 "report",
490 Function::new(
495 ctx.clone(),
496 move |ctx: Ctx<'js>,
497 handle: Handle,
498 options: Opt<Value<'js>>|
499 -> rquickjs::Result<()> {
500 let Some((line, column)) = arena.borrow().position(handle) else {
504 return Ok(());
505 };
506
507 let (message, fix) = match options.0 {
508 None => (None, None),
509 Some(value) if value.is_string() => (value.get::<String>().ok(), None),
510 Some(value) => {
511 let Some(object) = value.as_object() else {
512 return Err(throw(
513 &ctx,
514 "ctx.report expects a message string or an options \
515 object — { message?, fix? }",
516 ));
517 };
518 let message = object.get::<_, String>("message").ok();
519 let fix = read_fix(&ctx, object, &arena)?;
520 (message, fix)
521 }
522 };
523
524 reports.borrow_mut().push(Report {
525 node: Some(handle),
526 line,
527 column,
528 message,
529 fix,
530 });
531 Ok(())
532 },
533 )?,
534 )?;
535
536 Ok(())
537 }
538
539 fn install_facts<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
541 let facts = Rc::clone(&self.facts);
544 object.set(
545 "emitFact",
546 Function::new(
547 ctx.clone(),
548 move |ctx: Ctx<'js>, fact: Value<'js>| -> rquickjs::Result<()> {
549 let Some(fact_object) = fact.as_object() else {
550 return Err(throw(&ctx, "ctx.emitFact expects an object"));
551 };
552
553 let kind = match fact_object.get::<_, String>("kind") {
558 Ok(kind) if !kind.is_empty() => kind,
559 _ => {
560 return Err(throw(
561 &ctx,
562 "ctx.emitFact requires a non-empty string `kind` — it is what \
563 ctx.facts(kind) selects on, so a fact without one can never \
564 be read back",
565 ));
566 }
567 };
568
569 let Some(json) = ctx.json_stringify(fact)? else {
574 return Err(throw(
575 &ctx,
576 "ctx.emitFact could not serialize this fact — facts are cached, \
577 so they have to survive JSON",
578 ));
579 };
580
581 facts.borrow_mut().push(EmittedFact {
582 kind,
583 data: json.to_string()?,
584 });
585 Ok(())
586 },
587 )?,
588 )?;
589
590 Ok(())
591 }
592
593 fn install_queries<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
595 let Some(language) = self.language.clone() else {
598 return Ok(());
599 };
600
601 let arena = Rc::clone(&self.arena);
602 let queries = Rc::clone(&self.queries);
603 let grammar = Arc::clone(&language);
604 object.set(
605 "querySubtree",
606 Function::new(
607 ctx.clone(),
608 move |ctx: Ctx<'js>,
609 handle: Handle,
610 source: String|
611 -> rquickjs::Result<Value<'js>> {
612 let compiled = compile(&queries, grammar.as_ref(), &source)
613 .map_err(|problem| throw(&ctx, &problem))?;
614
615 let matches = arena.borrow().query_subtree(handle, &compiled);
616 let interned = intern_matches(&arena, matches);
617 captures_to_js(&ctx, interned)
618 },
619 )?,
620 )?;
621
622 let arena = Rc::clone(&self.arena);
623 let queries = Rc::clone(&self.queries);
624 object.set(
625 "closestAncestor",
626 Function::new(
627 ctx.clone(),
628 move |ctx: Ctx<'js>,
629 handle: Handle,
630 source: String|
631 -> rquickjs::Result<Value<'js>> {
632 let compiled = compile(&queries, language.as_ref(), &source)
633 .map_err(|problem| throw(&ctx, &problem))?;
634
635 let found = arena.borrow().closest_ancestor_paths(handle, &compiled);
636 let Some(captures) = found else {
637 return Ok(Value::new_undefined(ctx.clone()));
641 };
642
643 let interned = intern_matches(&arena, vec![captures]);
644 let one = interned.into_iter().next().unwrap_or_default();
645 let object = Object::new(ctx.clone())?;
646 for (name, handle) in one {
647 object.set(name, handle)?;
648 }
649 Ok(object.into_value())
650 },
651 )?,
652 )?;
653
654 Ok(())
655 }
656
657 fn install_reads<'js>(&self, ctx: &Ctx<'js>, object: &Object<'js>) -> rquickjs::Result<()> {
659 let Some(files) = self.files.clone() else {
662 return Ok(());
663 };
664
665 let reader = Rc::clone(&files);
666 object.set(
667 "readFile",
668 Function::new(
669 ctx.clone(),
670 move |ctx: Ctx<'js>, path: String| -> rquickjs::Result<Option<String>> {
671 reader.read(&path).map_err(|e| throw(&ctx, &e.to_string()))
675 },
676 )?,
677 )?;
678
679 let reader = Rc::clone(&files);
680 object.set(
681 "fileExists",
682 Function::new(
683 ctx.clone(),
684 move |ctx: Ctx<'js>, path: String| -> rquickjs::Result<bool> {
685 reader
686 .exists(&path)
687 .map_err(|e| throw(&ctx, &e.to_string()))
688 },
689 )?,
690 )?;
691
692 Ok(())
693 }
694}
695
696#[derive(Debug, Clone, PartialEq, Eq)]
701pub struct ReduceReport {
702 pub file: String,
704 pub line: u32,
706 pub column: u32,
708 pub message: Option<String>,
710}
711
712#[derive(Debug, Clone, PartialEq, Eq)]
714pub struct ReduceFact {
715 pub kind: String,
717 pub json: String,
719}
720
721#[derive(Debug, Clone)]
726pub struct ReduceContext {
727 files: Rc<[String]>,
728 facts: Rc<[ReduceFact]>,
729 reports: Rc<RefCell<Vec<ReduceReport>>>,
730}
731
732impl ReduceContext {
733 #[must_use]
735 pub fn new(files: Vec<String>, facts: Vec<ReduceFact>) -> Self {
736 Self {
737 files: files.into(),
738 facts: facts.into(),
739 reports: Rc::new(RefCell::new(Vec::new())),
740 }
741 }
742
743 #[must_use]
745 pub fn take_reports(&self) -> Vec<ReduceReport> {
746 std::mem::take(&mut self.reports.borrow_mut())
747 }
748
749 pub fn build<'js>(&self, ctx: &Ctx<'js>) -> rquickjs::Result<Object<'js>> {
756 let object = Object::new(ctx.clone())?;
757
758 object.set("files", &*self.files)?;
759
760 let facts = Rc::clone(&self.facts);
765 object.set(
766 "facts",
767 Function::new(
768 ctx.clone(),
769 move |ctx: Ctx<'js>, kind: Opt<String>| -> rquickjs::Result<Value<'js>> {
770 let wanted = kind.0;
773 let mut json = String::from("[");
774 for fact in facts
775 .iter()
776 .filter(|f| wanted.as_ref().is_none_or(|k| *k == f.kind))
777 {
778 if json.len() > 1 {
779 json.push(',');
780 }
781 json.push_str(&fact.json);
782 }
783 json.push(']');
784
785 ctx.json_parse(json)
786 },
787 )?,
788 )?;
789
790 let reports = Rc::clone(&self.reports);
791 object.set(
792 "report",
793 Function::new(
794 ctx.clone(),
795 move |ctx: Ctx<'js>,
796 at: Value<'js>,
797 message: Opt<Value<'js>>|
798 -> rquickjs::Result<()> {
799 let Some(at) = at.as_object() else {
800 return Err(throw(
801 &ctx,
802 "ctx.report in a reduce phase expects { file, line, column } — \
803 there is no parse tree here, so there are no nodes to report at",
804 ));
805 };
806
807 let (Ok(file), Ok(line), Ok(column)) = (
808 at.get::<_, String>("file"),
809 at.get::<_, u32>("line"),
810 at.get::<_, u32>("column"),
811 ) else {
812 return Err(throw(
813 &ctx,
814 "ctx.report in a reduce phase needs `file`, `line` and `column` — \
815 emit them on the fact during the per-file pass, where the node \
816 positions are still available",
817 ));
818 };
819
820 let message = match message.0 {
825 None => None,
826 Some(value) if value.is_undefined() || value.is_null() => None,
827 Some(value) => {
828 if let Some(text) = value.as_string() {
829 Some(text.to_string()?)
830 } else if let Some(options) = value.as_object() {
831 match options.get::<_, Value<'js>>("message") {
832 Ok(found) if found.is_string() => found
833 .as_string()
834 .map(rquickjs::String::to_string)
835 .transpose()?,
836 _ => {
837 return Err(throw(
838 &ctx,
839 "ctx.report in a reduce phase takes a message: \
840 either a string, or { message }",
841 ));
842 }
843 }
844 } else {
845 return Err(throw(
846 &ctx,
847 "ctx.report in a reduce phase takes a message: either a \
848 string, or { message }",
849 ));
850 }
851 }
852 };
853
854 reports.borrow_mut().push(ReduceReport {
855 file,
856 line,
857 column,
858 message,
859 });
860 Ok(())
861 },
862 )?,
863 )?;
864
865 Ok(object)
866 }
867}
868
869fn read_fix<'js>(
875 ctx: &Ctx<'js>,
876 options: &Object<'js>,
877 arena: &Rc<RefCell<NodeArena>>,
878) -> rquickjs::Result<Option<Fix>> {
879 let Ok(value) = options.get::<_, Value<'js>>("fix") else {
880 return Ok(None);
881 };
882 if value.is_undefined() || value.is_null() {
883 return Ok(None);
884 }
885
886 let Some(fix) = value.as_object() else {
887 return Err(throw(
888 &ctx.clone(),
889 "ctx.report's `fix` expects { node, text, safe? }",
890 ));
891 };
892
893 let (Ok(handle), Ok(replacement)) =
894 (fix.get::<_, Handle>("node"), fix.get::<_, String>("text"))
895 else {
896 return Err(throw(
897 &ctx.clone(),
898 "ctx.report's `fix` needs a `node` to replace and the `text` to put there",
899 ));
900 };
901
902 let Some((start, end)) = arena.borrow().byte_range(handle) else {
903 return Ok(None);
906 };
907
908 Ok(Some(Fix {
909 start,
910 end,
911 replacement,
912 safe: fix.get::<_, bool>("safe").unwrap_or(false),
915 }))
916}
917
918fn compile(
920 cache: &QueryCache,
921 language: &dyn lanekeep_lang::Language,
922 source: &str,
923) -> Result<Rc<CompiledQuery>, String> {
924 if let Some(found) = cache.borrow().get(source) {
925 return found.clone();
926 }
927
928 let compiled = CompiledQuery::compile(language, source)
929 .map(Rc::new)
930 .map_err(|e| e.to_string());
931 cache
932 .borrow_mut()
933 .insert(source.to_owned(), compiled.clone());
934 compiled
935}
936
937fn intern_matches(
939 arena: &Rc<RefCell<NodeArena>>,
940 matches: Vec<Vec<(String, Vec<u32>)>>,
941) -> Vec<Vec<(String, Handle)>> {
942 let mut arena = arena.borrow_mut();
943 matches
944 .into_iter()
945 .map(|captures| {
946 captures
947 .into_iter()
948 .filter_map(|(name, path)| arena.intern_path(path).map(|handle| (name, handle)))
949 .collect()
950 })
951 .collect()
952}
953
954fn captures_to_js<'js>(
956 ctx: &Ctx<'js>,
957 matches: Vec<Vec<(String, Handle)>>,
958) -> rquickjs::Result<Value<'js>> {
959 let array = rquickjs::Array::new(ctx.clone())?;
960 for (index, captures) in matches.into_iter().enumerate() {
961 let object = Object::new(ctx.clone())?;
962 for (name, handle) in captures {
963 object.set(name, handle)?;
964 }
965 array.set(index, object)?;
966 }
967 Ok(array.into_value())
968}
969
970fn throw(ctx: &Ctx<'_>, message: &str) -> rquickjs::Error {
976 rquickjs::Exception::throw_type(ctx, message)
977}
978
979#[must_use]
989pub fn merge_file(data: &str, file: &str) -> String {
990 let inner = data
991 .trim()
992 .strip_prefix('{')
993 .and_then(|rest| rest.strip_suffix('}'))
994 .unwrap_or_default()
995 .trim();
996
997 let mut out = String::with_capacity(data.len() + file.len() + 12);
998 out.push('{');
999 if !inner.is_empty() {
1000 out.push_str(inner);
1001 out.push(',');
1002 }
1003 out.push_str("\"file\":");
1004 escape_json_string(file, &mut out);
1005 out.push('}');
1006 out
1007}
1008
1009fn escape_json_string(text: &str, out: &mut String) {
1011 out.push('"');
1012 for ch in text.chars() {
1013 match ch {
1014 '"' => out.push_str("\\\""),
1015 '\\' => out.push_str("\\\\"),
1016 '\n' => out.push_str("\\n"),
1017 '\r' => out.push_str("\\r"),
1018 '\t' => out.push_str("\\t"),
1019 c if (c as u32) < 0x20 => {
1020 let _ = write!(out, "\\u{:04x}", c as u32);
1023 }
1024 c => out.push(c),
1025 }
1026 }
1027 out.push('"');
1028}
1029
1030fn glob_matches(pattern: &str, text: &str) -> bool {
1036 let mut parts = pattern.split('*');
1037 let Some(first) = parts.next() else {
1038 return true;
1039 };
1040 if !text.starts_with(first) {
1041 return false;
1042 }
1043
1044 let mut rest = &text[first.len()..];
1045 let segments: Vec<&str> = parts.collect();
1046
1047 if segments.is_empty() {
1049 return rest.is_empty();
1050 }
1051
1052 for (index, segment) in segments.iter().enumerate() {
1053 if segment.is_empty() {
1054 continue;
1055 }
1056 if index == segments.len() - 1 {
1059 return rest.ends_with(segment);
1060 }
1061 match rest.find(segment) {
1062 Some(at) => rest = &rest[at + segment.len()..],
1063 None => return false,
1064 }
1065 }
1066
1067 true
1069}
1070
1071#[cfg(test)]
1072mod tests {
1073 use lanekeep_lang::Language;
1074 use lanekeep_lang_js::TypeScript;
1075
1076 use super::*;
1077 use crate::{Limits, Sandbox};
1078
1079 fn parse(source: &str) -> tree_sitter::Tree {
1080 let mut parser = tree_sitter::Parser::new();
1081 parser
1082 .set_language(&TypeScript.grammar())
1083 .expect("grammar loads");
1084 parser.parse(source, None).expect("parses")
1085 }
1086
1087 fn host(source: &str) -> HostContext {
1088 HostContext::new(parse(source), source.to_owned(), "src/example.ts")
1089 .with_resolver(Arc::new(lanekeep_lang_js::binding::JsBindingResolver))
1090 }
1091
1092 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 ));
1228 }
1229
1230 #[test]
1233 fn records_a_report_at_the_node_position() {
1234 let host = host("const x = 1;\nconst y = 2;");
1235 let _: () = run(&host, "ctx.report(ctx.namedChildren(ctx.root)[1])");
1236
1237 let reports = host.take_reports();
1238 assert_eq!(reports.len(), 1);
1239 assert_eq!(reports[0].line, 2);
1240 assert_eq!(reports[0].column, 1);
1241 assert_eq!(reports[0].message, None);
1242 }
1243
1244 #[test]
1245 fn records_an_overriding_message() {
1246 let host = host("const x = 1;");
1247 let _: () = run(&host, "ctx.report(ctx.root, 'something specific')");
1248
1249 let reports = host.take_reports();
1250 assert_eq!(reports[0].message.as_deref(), Some("something specific"));
1251 }
1252
1253 #[test]
1254 fn records_every_report_in_order() {
1255 let host = host("const a = 1;\nconst b = 2;\nconst c = 3;");
1256 let _: () = run(
1257 &host,
1258 "for (const d of ctx.namedChildren(ctx.root)) { ctx.report(d, ctx.text(d)); }",
1259 );
1260
1261 let reports = host.take_reports();
1262 let lines: Vec<u32> = reports.iter().map(|r| r.line).collect();
1263 assert_eq!(lines, [1, 2, 3]);
1264 assert_eq!(reports[2].message.as_deref(), Some("const c = 3;"));
1265 }
1266
1267 #[test]
1268 fn a_report_at_an_unresolvable_handle_is_dropped() {
1269 let host = host("const x = 1;");
1272 let _: () = run(&host, "ctx.report(9999)");
1273 assert!(host.take_reports().is_empty());
1274 }
1275
1276 #[test]
1277 fn taking_reports_empties_the_context() {
1278 let host = host("const x = 1;");
1279 let _: () = run(&host, "ctx.report(ctx.root)");
1280
1281 assert_eq!(host.take_reports().len(), 1);
1282 assert!(
1283 host.take_reports().is_empty(),
1284 "reports must not be reported twice"
1285 );
1286 }
1287
1288 #[test]
1289 fn a_rule_that_throws_still_leaves_earlier_reports() {
1290 let host = host("const x = 1;");
1294 let sandbox = Sandbox::with_limits(Limits::default()).expect("sandbox builds");
1295 let result: Result<(), _> =
1296 sandbox.eval_with_host(&host, "ctx.report(ctx.root); throw new Error('later')");
1297
1298 assert!(result.is_err());
1299 assert_eq!(host.take_reports().len(), 1);
1300 }
1301
1302 #[test]
1303 fn navigation_is_bounded_by_the_rule_timeout() {
1304 let host = host("const x = 1;");
1308 let sandbox = Sandbox::with_limits(
1309 Limits::default().with_rule_timeout(std::time::Duration::from_millis(120)),
1310 )
1311 .expect("sandbox builds");
1312
1313 let result: Result<(), _> = sandbox.eval_with_host(
1314 &host,
1315 "for (;;) { ctx.kind(ctx.root); ctx.children(ctx.root); }",
1316 );
1317 assert!(
1318 matches!(result, Err(crate::SandboxError::RuleTimeout { .. })),
1319 "expected a timeout, got {result:?}"
1320 );
1321 }
1322
1323 #[test]
1324 fn the_sandbox_still_withholds_everything_it_did_before() {
1325 let host = host("const x = 1;");
1327 assert!(run::<bool>(
1328 &host,
1329 "typeof Date === 'undefined' &&
1330 typeof performance === 'undefined' &&
1331 typeof Math.random === 'undefined' &&
1332 typeof fetch === 'undefined' &&
1333 typeof process === 'undefined'"
1334 ));
1335 }
1336
1337 #[test]
1340 fn resolves_an_import_through_its_alias() {
1341 let host = host("import { makeStyles as ms } from '@rneui/themed';\nms();");
1343 let handle = handle_of(&host, "ms");
1344
1345 assert!(run::<bool>(
1346 &host,
1347 &format!("ctx.resolvesToImport({handle}, '@rneui/themed', 'makeStyles')")
1348 ));
1349 assert!(!run::<bool>(
1350 &host,
1351 &format!("ctx.resolvesToImport({handle}, 'somewhere-else', 'makeStyles')")
1352 ));
1353 assert!(!run::<bool>(
1354 &host,
1355 &format!("ctx.resolvesToImport({handle}, '@rneui/themed', 'notThatOne')")
1356 ));
1357 }
1358
1359 #[test]
1360 fn a_local_declaration_does_not_resolve_to_the_import_it_shadows() {
1361 let host = host(
1364 "import { makeStyles } from '@rneui/themed';\n\
1365 function f() { const makeStyles = () => {}; return makeStyles(); }",
1366 );
1367 let handle = handle_of(&host, "makeStyles");
1368
1369 assert!(!run::<bool>(
1370 &host,
1371 &format!("ctx.resolvesToImport({handle}, '@rneui/themed', 'makeStyles')")
1372 ));
1373 assert_eq!(
1374 run::<String>(&host, &format!("ctx.bindingKind({handle})")),
1375 "const"
1376 );
1377 assert!(run::<bool>(&host, &format!("ctx.isShadowed({handle})")));
1378 }
1379
1380 #[test]
1381 fn omitting_the_name_matches_any_export_of_the_module() {
1382 let host = host("import { a } from 'm';\na();");
1383 let handle = handle_of(&host, "a");
1384 assert!(run::<bool>(
1385 &host,
1386 &format!("ctx.resolvesToImport({handle}, 'm')")
1387 ));
1388 }
1389
1390 #[test]
1391 fn matches_a_module_by_glob() {
1392 let host = host("import { a } from '@scope/pkg';\na();");
1393 let handle = handle_of(&host, "a");
1394
1395 assert!(run::<bool>(
1396 &host,
1397 &format!("ctx.isImportedFrom({handle}, '@scope/*')")
1398 ));
1399 assert!(run::<bool>(
1400 &host,
1401 &format!("ctx.isImportedFrom({handle}, '*/pkg')")
1402 ));
1403 assert!(run::<bool>(
1404 &host,
1405 &format!("ctx.isImportedFrom({handle}, '@scope/pkg')")
1406 ));
1407 assert!(!run::<bool>(
1408 &host,
1409 &format!("ctx.isImportedFrom({handle}, '@other/*')")
1410 ));
1411 }
1412
1413 #[test]
1414 fn reports_binding_kinds() {
1415 for (source, name, expected) in [
1416 ("import { a } from 'm';\na();", "a", "import"),
1417 ("const b = 1;\nb;", "b", "const"),
1418 ("let c = 1;\nc;", "c", "let"),
1419 ("function d() {}\nd();", "d", "function"),
1420 ("class E {}\nnew E();", "E", "class"),
1421 ("function f(p) { return p; }", "p", "param"),
1422 ] {
1423 let host = host(source);
1424 let handle = handle_of(&host, name);
1425 assert_eq!(
1426 run::<String>(&host, &format!("ctx.bindingKind({handle})")),
1427 expected,
1428 "for {name} in {source}"
1429 );
1430 }
1431 }
1432
1433 #[test]
1434 fn an_undeclared_name_has_no_binding_kind() {
1435 let host = host("globalThing();");
1436 let handle = handle_of(&host, "globalThing");
1437 assert!(run::<bool>(
1438 &host,
1439 &format!("ctx.bindingKind({handle}) === undefined")
1440 ));
1441 }
1442
1443 #[test]
1444 fn without_a_resolver_nothing_resolves_rather_than_throwing() {
1445 let host = host_without_resolver("import { a } from 'm';\na();");
1448 assert!(run::<bool>(
1449 &host,
1450 "ctx.resolvesToImport(0, 'm', 'a') === false &&
1451 ctx.isImportedFrom(0, '*') === false &&
1452 ctx.isShadowed(0) === false &&
1453 ctx.bindingKind(0) === undefined"
1454 ));
1455 }
1456
1457 #[test]
1458 fn glob_matching_handles_the_shapes_that_appear_in_rules() {
1459 assert!(glob_matches("m", "m"));
1460 assert!(!glob_matches("m", "mm"));
1461 assert!(glob_matches("*", "anything"));
1462 assert!(glob_matches("@scope/*", "@scope/pkg"));
1463 assert!(!glob_matches("@scope/*", "@other/pkg"));
1464 assert!(glob_matches("*/themed", "@rneui/themed"));
1465 assert!(!glob_matches("*/themed", "@rneui/other"));
1466 assert!(glob_matches("@a/*/c", "@a/b/c"));
1467 assert!(!glob_matches("@a/*/c", "@a/b/d"));
1468 assert!(glob_matches("", ""));
1469 assert!(!glob_matches("", "x"));
1470 }
1471
1472 #[test]
1473 fn navigation_stays_lazy() {
1474 let host = host("const a = 1; const b = 2; function c() { return [1,2,3] }");
1476 assert!(
1477 host.arena().borrow().is_empty(),
1478 "nothing should be interned yet"
1479 );
1480
1481 let _: () = run(&host, "ctx.kind(ctx.root)");
1482 assert!(
1483 host.arena().borrow().is_empty(),
1484 "reading the root's kind should not intern anything new"
1485 );
1486 }
1487
1488 fn emitted(source: &str) -> Vec<EmittedFact> {
1492 let host = host("const a = 1;");
1493 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1494 sandbox
1495 .eval_with_host::<()>(&host, source)
1496 .expect("evaluates");
1497 host.take_facts()
1498 }
1499
1500 #[test]
1501 fn a_fact_is_captured_with_its_kind_and_payload() {
1502 let facts = emitted("ctx.emitFact({ kind: 'export', symbol: 'parse' })");
1503 assert_eq!(facts.len(), 1);
1504 assert_eq!(facts[0].kind, "export");
1505 assert!(
1506 facts[0].data.contains(r#""symbol":"parse""#),
1507 "{:?}",
1508 facts[0]
1509 );
1510 }
1511
1512 #[test]
1513 fn facts_are_kept_in_emission_order() {
1514 let facts = emitted(
1516 "ctx.emitFact({ kind: 'a', n: 1 }); \
1517 ctx.emitFact({ kind: 'b', n: 2 }); \
1518 ctx.emitFact({ kind: 'a', n: 3 });",
1519 );
1520 assert_eq!(
1521 facts.iter().map(|f| f.kind.as_str()).collect::<Vec<_>>(),
1522 vec!["a", "b", "a"]
1523 );
1524 }
1525
1526 #[test]
1527 fn a_fact_without_a_kind_is_rejected() {
1528 let host = host("const a = 1;");
1531 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1532 let error = sandbox
1533 .eval_with_host::<()>(&host, "ctx.emitFact({ symbol: 'parse' })")
1534 .expect_err("is rejected");
1535 assert!(error.to_string().contains("kind"), "{error}");
1536 assert!(host.take_facts().is_empty());
1537 }
1538
1539 #[test]
1540 fn a_fact_with_an_empty_kind_is_rejected() {
1541 let host = host("const a = 1;");
1542 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1543 assert!(
1544 sandbox
1545 .eval_with_host::<()>(&host, "ctx.emitFact({ kind: '' })")
1546 .is_err()
1547 );
1548 }
1549
1550 #[test]
1551 fn a_fact_that_is_not_an_object_is_rejected() {
1552 let host = host("const a = 1;");
1553 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1554 for bad in ["'export'", "42", "null", "undefined"] {
1555 assert!(
1556 sandbox
1557 .eval_with_host::<()>(&host, &format!("ctx.emitFact({bad})"))
1558 .is_err(),
1559 "`{bad}` should not be emittable"
1560 );
1561 }
1562 }
1563
1564 #[test]
1565 fn a_cyclic_fact_is_rejected_rather_than_hanging() {
1566 let host = host("const a = 1;");
1567 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1568 let error = sandbox
1569 .eval_with_host::<()>(
1570 &host,
1571 "const f = { kind: 'x' }; f.self = f; ctx.emitFact(f)",
1572 )
1573 .expect_err("is rejected");
1574 assert!(!error.to_string().is_empty());
1577 }
1578
1579 #[test]
1580 fn the_reduce_surface_is_absent_from_the_per_file_context() {
1581 let host = host("const a = 1;");
1584 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1585 for absent in ["ctx.facts", "ctx.files"] {
1586 let present: bool = sandbox
1587 .eval_with_host(&host, &format!("{absent} !== undefined"))
1588 .expect("evaluates");
1589 assert!(
1590 !present,
1591 "`{absent}` must not exist during the per-file pass"
1592 );
1593 }
1594 }
1595
1596 fn reduce_fact(kind: &str, json: &str) -> ReduceFact {
1599 ReduceFact {
1600 kind: kind.to_owned(),
1601 json: json.to_owned(),
1602 }
1603 }
1604
1605 fn budget() -> std::time::Duration {
1607 std::time::Duration::from_secs(5)
1608 }
1609
1610 #[test]
1615 fn a_reduce_report_takes_a_string_or_an_options_object() {
1616 for expression in [
1617 r"ctx.report({ file: 'a.ts', line: 1, column: 2 }, 'plain string')",
1618 r"ctx.report({ file: 'a.ts', line: 1, column: 2 }, { message: 'plain string' })",
1619 ] {
1620 let context = ReduceContext::new(vec!["a.ts".to_owned()], vec![]);
1621 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1622 sandbox
1623 .eval_with_reduce_host::<()>(&context, expression, budget())
1624 .unwrap_or_else(|e| panic!("{expression} should be accepted: {e}"));
1625
1626 let reports = context.take_reports();
1627 assert_eq!(reports.len(), 1, "{expression}");
1628 assert_eq!(
1629 reports[0].message.as_deref(),
1630 Some("plain string"),
1631 "{expression}"
1632 );
1633 }
1634 }
1635
1636 #[test]
1639 fn a_reduce_report_refuses_a_message_that_is_neither() {
1640 let context = ReduceContext::new(vec!["a.ts".to_owned()], vec![]);
1641 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1642 let error = sandbox
1643 .eval_with_reduce_host::<()>(
1644 &context,
1645 r"ctx.report({ file: 'a.ts', line: 1, column: 2 }, { detail: 'wrong key' })",
1646 budget(),
1647 )
1648 .expect_err("should refuse");
1649 assert!(
1650 error.to_string().contains("message"),
1651 "the error should say what it wanted: {error}"
1652 );
1653 }
1654
1655 #[test]
1656 fn facts_come_back_as_objects() {
1657 let context = ReduceContext::new(
1658 vec!["a.ts".to_owned()],
1659 vec![reduce_fact(
1660 "export",
1661 r#"{"kind":"export","symbol":"parse","file":"a.ts"}"#,
1662 )],
1663 );
1664 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1665 let symbol: String = sandbox
1666 .eval_with_reduce_host(&context, "ctx.facts('export')[0].symbol", budget())
1667 .expect("evaluates");
1668 assert_eq!(symbol, "parse");
1669 }
1670
1671 #[test]
1672 fn facts_filter_by_kind_and_default_to_everything() {
1673 let context = ReduceContext::new(
1674 vec![],
1675 vec![
1676 reduce_fact("export", r#"{"kind":"export","file":"a.ts"}"#),
1677 reduce_fact("import", r#"{"kind":"import","file":"b.ts"}"#),
1678 reduce_fact("export", r#"{"kind":"export","file":"c.ts"}"#),
1679 ],
1680 );
1681 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1682 let counts: Vec<i32> = sandbox
1683 .eval_with_reduce_host(
1684 &context,
1685 "[ctx.facts('export').length, ctx.facts('import').length, ctx.facts().length]",
1686 budget(),
1687 )
1688 .expect("evaluates");
1689 assert_eq!(counts, vec![2, 1, 3]);
1690 }
1691
1692 #[test]
1693 fn an_unknown_kind_yields_an_empty_array_rather_than_undefined() {
1694 let context = ReduceContext::new(vec![], vec![]);
1696 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1697 let length: i32 = sandbox
1698 .eval_with_reduce_host(&context, "ctx.facts('nope').length", budget())
1699 .expect("evaluates");
1700 assert_eq!(length, 0);
1701 }
1702
1703 #[test]
1704 fn the_file_list_is_visible() {
1705 let context = ReduceContext::new(vec!["a.ts".to_owned(), "b.ts".to_owned()], vec![]);
1706 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1707 let files: Vec<String> = sandbox
1708 .eval_with_reduce_host(&context, "ctx.files", budget())
1709 .expect("evaluates");
1710 assert_eq!(files, vec!["a.ts".to_owned(), "b.ts".to_owned()]);
1711 }
1712
1713 #[test]
1714 fn reporting_names_a_file_of_its_own() {
1715 let context = ReduceContext::new(vec![], vec![]);
1716 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1717 sandbox
1718 .eval_with_reduce_host::<()>(
1719 &context,
1720 "ctx.report({ file: 'b.ts', line: 4, column: 2 }, 'unused export')",
1721 budget(),
1722 )
1723 .expect("evaluates");
1724 assert_eq!(
1725 context.take_reports(),
1726 vec![ReduceReport {
1727 file: "b.ts".to_owned(),
1728 line: 4,
1729 column: 2,
1730 message: Some("unused export".to_owned()),
1731 }]
1732 );
1733 }
1734
1735 #[test]
1736 fn the_message_is_optional() {
1737 let context = ReduceContext::new(vec![], vec![]);
1738 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1739 sandbox
1740 .eval_with_reduce_host::<()>(
1741 &context,
1742 "ctx.report({ file: 'b.ts', line: 1, column: 1 })",
1743 budget(),
1744 )
1745 .expect("evaluates");
1746 let reports = context.take_reports();
1747 assert_eq!(reports.len(), 1);
1748 assert_eq!(reports[0].message, None);
1749 }
1750
1751 #[test]
1752 fn reporting_without_a_position_is_rejected() {
1753 let context = ReduceContext::new(vec![], vec![]);
1756 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1757 for bad in [
1758 "ctx.report({ file: 'b.ts' })",
1759 "ctx.report({ line: 1, column: 1 })",
1760 "ctx.report(3)",
1761 "ctx.report('b.ts')",
1762 ] {
1763 let error = sandbox
1764 .eval_with_reduce_host::<()>(&context, bad, budget())
1765 .expect_err("is rejected");
1766 assert!(!error.to_string().is_empty(), "`{bad}` should be rejected");
1767 }
1768 assert!(context.take_reports().is_empty());
1769 }
1770
1771 #[test]
1772 fn the_per_file_surface_is_absent_from_the_reduce_context() {
1773 let context = ReduceContext::new(vec![], vec![]);
1776 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1777 for absent in [
1778 "ctx.emitFact",
1779 "ctx.text",
1780 "ctx.kind",
1781 "ctx.parent",
1782 "ctx.namedChildren",
1783 "ctx.filePath",
1784 "ctx.fileText",
1785 "ctx.root",
1786 ] {
1787 let present: bool = sandbox
1788 .eval_with_reduce_host(&context, &format!("{absent} !== undefined"), budget())
1789 .expect("evaluates");
1790 assert!(
1791 !present,
1792 "`{absent}` must not exist during the reduce phase"
1793 );
1794 }
1795 }
1796
1797 #[test]
1800 fn merge_file_adds_the_field() {
1801 assert_eq!(
1802 merge_file(r#"{"kind":"export"}"#, "src/a.ts"),
1803 r#"{"kind":"export","file":"src/a.ts"}"#
1804 );
1805 }
1806
1807 #[test]
1808 fn merge_file_handles_an_empty_payload() {
1809 assert_eq!(merge_file("{}", "a.ts"), r#"{"file":"a.ts"}"#);
1810 }
1811
1812 #[test]
1813 fn merge_file_overrides_a_file_the_rule_supplied() {
1814 let merged = merge_file(r#"{"kind":"export","file":"lies.ts"}"#, "truth.ts");
1817 assert!(merged.ends_with(r#""file":"truth.ts"}"#), "{merged}");
1818
1819 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1820 let file: String = sandbox
1821 .eval(&format!("JSON.parse({merged:?}).file"))
1822 .expect("parses");
1823 assert_eq!(file, "truth.ts");
1824 }
1825
1826 #[test]
1827 fn merge_file_escapes_the_path() {
1828 let awkward = "a\"b\\c\nd.ts";
1832 let merged = merge_file("{}", awkward);
1833 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1834 let file: String = sandbox
1835 .eval(&format!("JSON.parse({merged:?}).file"))
1836 .expect("parses");
1837 assert_eq!(file, awkward);
1838 }
1839
1840 fn host_with_language(source: &str) -> HostContext {
1844 HostContext::new(parse(source), source.to_owned(), "src/example.ts")
1845 .with_resolver(Arc::new(lanekeep_lang_js::binding::JsBindingResolver))
1846 .with_language(Arc::new(TypeScript))
1847 }
1848
1849 #[test]
1850 fn a_subtree_query_finds_only_what_is_inside() {
1851 let source = "function a() { const x = 1; }\nfunction b() { const y = 2; const z = 3; }\n";
1854 let host = host_with_language(source);
1855 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1856
1857 let names: Vec<String> = sandbox
1858 .eval_with_host(
1859 &host,
1860 "const fns = ctx.querySubtree(ctx.root, '(function_declaration) @fn');\n\
1861 const inner = ctx.querySubtree(fns[1].fn, '(variable_declarator name: (identifier) @name)');\n\
1862 inner.map((m) => ctx.text(m.name))",
1863 )
1864 .expect("evaluates");
1865
1866 assert_eq!(names, vec!["y".to_owned(), "z".to_owned()]);
1867 }
1868
1869 #[test]
1870 fn a_subtree_query_with_no_matches_is_an_empty_array() {
1871 let host = host_with_language("const a = 1;\n");
1873 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1874 let length: i32 = sandbox
1875 .eval_with_host(
1876 &host,
1877 "ctx.querySubtree(ctx.root, '(debugger_statement) @d').length",
1878 )
1879 .expect("evaluates");
1880 assert_eq!(length, 0);
1881 }
1882
1883 #[test]
1884 fn an_invalid_query_is_reported_to_the_rule() {
1885 let host = host_with_language("const a = 1;\n");
1886 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1887 let error = sandbox
1888 .eval_with_host::<()>(&host, "ctx.querySubtree(ctx.root, '(((')")
1889 .expect_err("is rejected");
1890 assert!(!error.to_string().is_empty());
1891 }
1892
1893 #[test]
1894 fn closest_ancestor_finds_the_nearest_one() {
1895 let source = "function outer() { function inner() { const x = 1; } }\n";
1897 let host = host_with_language(source);
1898 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1899
1900 let name: String = sandbox
1901 .eval_with_host(
1902 &host,
1903 "const decls = ctx.querySubtree(ctx.root, '(variable_declarator name: (identifier) @n)');\n\
1904 const found = ctx.closestAncestor(decls[0].n, '(function_declaration name: (identifier) @name) @fn');\n\
1905 ctx.text(found.name)",
1906 )
1907 .expect("evaluates");
1908
1909 assert_eq!(name, "inner");
1910 }
1911
1912 #[test]
1913 fn closest_ancestor_returns_undefined_when_nothing_matches() {
1914 let host = host_with_language("const a = 1;\n");
1917 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1918 let absent: bool = sandbox
1919 .eval_with_host(
1920 &host,
1921 "const d = ctx.querySubtree(ctx.root, '(variable_declarator name: (identifier) @n)');\n\
1922 ctx.closestAncestor(d[0].n, '(class_declaration) @c') === undefined",
1923 )
1924 .expect("evaluates");
1925 assert!(absent);
1926 }
1927
1928 #[test]
1929 fn closest_ancestor_does_not_match_the_node_itself_from_inside() {
1930 let source = "function outer() { function inner() { const x = 1; } }\n";
1933 let host = host_with_language(source);
1934 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1935
1936 let name: String = sandbox
1937 .eval_with_host(
1938 &host,
1939 "const d = ctx.querySubtree(ctx.root, '(variable_declarator name: (identifier) @n)');\n\
1940 const found = ctx.closestAncestor(d[0].n, '(statement_block) @block');\n\
1941 ctx.kind(found.block)",
1942 )
1943 .expect("evaluates");
1944 assert_eq!(name, "statement_block");
1945 }
1946
1947 #[test]
1948 fn the_query_functions_are_absent_without_a_language() {
1949 let host = host("const a = 1;");
1951 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1952 for absent in ["ctx.querySubtree", "ctx.closestAncestor"] {
1953 let present: bool = sandbox
1954 .eval_with_host(&host, &format!("{absent} !== undefined"))
1955 .expect("evaluates");
1956 assert!(!present, "`{absent}` should not exist without a language");
1957 }
1958 }
1959
1960 #[test]
1963 fn loc_gives_the_shape_a_fact_and_a_reduce_report_both_use() {
1964 let source = "const alpha = 1;\nconst beta = 2;\n";
1967 let host = host(source);
1968 let handle = handle_of(&host, "beta");
1969 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1970
1971 let rendered: String = sandbox
1972 .eval_with_host(
1973 &host,
1974 &format!("const l = ctx.loc({handle}); `${{l.file}}:${{l.line}}:${{l.column}}`"),
1975 )
1976 .expect("evaluates");
1977 assert_eq!(rendered, "src/example.ts:2:7");
1978 }
1979
1980 #[test]
1981 fn loc_at_an_unresolvable_handle_is_undefined() {
1982 let host = host("const a = 1;");
1985 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1986 let absent: bool = sandbox
1987 .eval_with_host(&host, "ctx.loc(9999) === undefined")
1988 .expect("evaluates");
1989 assert!(absent);
1990 }
1991
1992 #[test]
1993 fn today_is_what_the_host_supplied() {
1994 let host = host("const a = 1;").with_today("2026-08-01");
1995 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
1996 let today: String = sandbox
1997 .eval_with_host(&host, "ctx.today")
1998 .expect("evaluates");
1999 assert_eq!(today, "2026-08-01");
2000 }
2001
2002 #[test]
2003 fn today_is_absent_when_the_host_supplied_none() {
2004 let host = host("const a = 1;");
2008 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
2009 let absent: bool = sandbox
2010 .eval_with_host(&host, "ctx.today === undefined")
2011 .expect("evaluates");
2012 assert!(absent);
2013 }
2014
2015 #[test]
2016 fn reading_today_is_observed_and_not_reading_it_is_not() {
2017 let unread = host("const a = 1;").with_today("2026-08-01");
2020 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
2021 sandbox
2022 .eval_with_host::<i32>(&unread, "1 + 1")
2023 .expect("evaluates");
2024 assert!(!unread.date_was_read(), "nothing read the date");
2025
2026 let read = host("const a = 1;").with_today("2026-08-01");
2027 sandbox
2028 .eval_with_host::<String>(&read, "ctx.today")
2029 .expect("evaluates");
2030 assert!(read.date_was_read(), "the read was not observed");
2031 }
2032
2033 #[test]
2034 fn today_does_not_bring_a_clock_with_it() {
2035 let host = host("const a = 1;").with_today("2026-08-01");
2037 let sandbox = Sandbox::with_limits(Limits::default()).expect("builds");
2038 for absent in ["Date", "performance"] {
2039 let present: bool = sandbox
2040 .eval_with_host(&host, &format!("typeof {absent} !== 'undefined'"))
2041 .expect("evaluates");
2042 assert!(!present, "`{absent}` must not exist");
2043 }
2044 }
2045}