1use std::collections::{BTreeMap, BTreeSet};
11
12use crate::doc_directives::{DirectiveError, parse_doc_directives};
13use crate::stdlib::capabilities;
14use crate::{
15 DocCapability, DocCapabilityBindingKind, DocCapabilityLiteral, DocComment, MethodSig,
16 NamespaceSymbol, PackageDeclaration, TypeKind, TypeSymbol, ValueKind, ValueSymbol,
17};
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum Availability {
22 Denied,
24 Available { notes: Vec<String> },
27}
28
29impl Availability {
30 pub const OPEN: Self = Availability::Available { notes: Vec::new() };
32
33 pub fn is_available(&self) -> bool {
34 matches!(self, Availability::Available { .. })
35 }
36}
37
38pub trait DocAvailability {
40 fn tag(&self, tag: &DocCapability) -> Availability;
43
44 fn capability(&self, name: &str) -> Availability;
46}
47
48pub struct Unscoped;
50
51impl DocAvailability for Unscoped {
52 fn tag(&self, _tag: &DocCapability) -> Availability {
53 Availability::OPEN
54 }
55
56 fn capability(&self, _name: &str) -> Availability {
57 Availability::OPEN
58 }
59}
60
61pub fn known_context(tag: &DocCapability) -> serde_json::Value {
66 let catalogued = capabilities::find_any(&tag.capability);
67 let mut fields = serde_json::Map::new();
68 for binding in &tag.bindings {
69 let DocCapabilityBindingKind::Literal { value, .. } = &binding.kind else {
70 continue;
71 };
72 let value = match value {
73 DocCapabilityLiteral::String(text) => {
74 let normalization = catalogued
75 .and_then(|capability| {
76 capability
77 .filter_fields
78 .iter()
79 .find(|field| field.name == binding.field)
80 })
81 .map_or(capabilities::FieldNormalization::Verbatim, |field| {
82 field.normalization
83 });
84 if normalization == capabilities::FieldNormalization::VfsPath
85 && !text.starts_with('/')
86 {
87 continue;
88 }
89 match normalization.apply(text) {
90 Ok(text) => serde_json::Value::String(text),
91 Err(_) => continue,
92 }
93 }
94 DocCapabilityLiteral::Number(number) => {
95 let Some(number) = number
96 .parse::<f64>()
97 .ok()
98 .and_then(serde_json::Number::from_f64)
99 else {
100 continue;
101 };
102 serde_json::Value::Number(number)
103 }
104 DocCapabilityLiteral::Boolean(value) => serde_json::Value::Bool(*value),
105 DocCapabilityLiteral::Null => serde_json::Value::Null,
106 };
107 fields.insert(binding.field.clone(), value);
108 }
109 serde_json::Value::Object(fields)
110}
111
112#[derive(Debug, Clone)]
114pub struct Projection {
115 pub declaration: PackageDeclaration,
118 pub usable: bool,
123}
124
125pub fn project_declaration(
130 defs: &PackageDeclaration,
131 availability: &dyn DocAvailability,
132) -> Projection {
133 let mut declaration = defs.clone();
134 let mut gated = Gated::default();
135 retain_values(&mut declaration.values, availability, &mut gated);
136 for symbol in declaration.types.values_mut() {
137 retain_members(symbol, availability, &mut gated);
138 }
139 let mut pending: Vec<&mut NamespaceSymbol> = declaration.namespaces.values_mut().collect();
140 while let Some(namespace) = pending.pop() {
141 retain_values(&mut namespace.values, availability, &mut gated);
142 for symbol in namespace.types.values_mut() {
143 retain_members(symbol, availability, &mut gated);
144 }
145 pending.extend(namespace.namespaces.values_mut());
146 }
147 if gated.removed > 0 {
148 drop_unreached_types(defs, &mut declaration);
149 }
150 Projection {
151 declaration,
152 usable: gated.kept > 0 || gated.removed == 0,
153 }
154}
155
156pub fn scoped_documentation(
159 markdown: &str,
160 availability: &dyn DocAvailability,
161) -> Result<String, Vec<DirectiveError>> {
162 let document = parse_doc_directives(markdown)?;
163 Ok(document
164 .render(&|condition| condition.holds(|name| availability.capability(name).is_available())))
165}
166
167#[derive(Default)]
169struct Gated {
170 kept: usize,
171 removed: usize,
172}
173
174fn retain_values(
175 values: &mut BTreeMap<String, ValueSymbol>,
176 availability: &dyn DocAvailability,
177 gated: &mut Gated,
178) {
179 values.retain(|_, symbol| match &mut symbol.kind {
180 ValueKind::Function { doc, .. } => keep_callable(doc, availability, gated),
181 ValueKind::Let { .. } | ValueKind::Const { .. } => true,
182 });
183}
184
185fn retain_members(symbol: &mut TypeSymbol, availability: &dyn DocAvailability, gated: &mut Gated) {
186 match &mut symbol.kind {
187 TypeKind::Interface { methods, .. } => retain_methods(methods, availability, gated),
188 TypeKind::Class {
189 methods,
190 method_visibility,
191 statics,
192 static_visibility,
193 ..
194 } => {
195 retain_methods(methods, availability, gated);
196 method_visibility.retain(|name, _| methods.contains_key(name));
197 retain_methods(statics, availability, gated);
198 static_visibility.retain(|name, _| statics.contains_key(name));
199 }
200 TypeKind::NumberEnum { .. } | TypeKind::StringEnum { .. } | TypeKind::Alias { .. } => {}
201 }
202}
203
204fn retain_methods(
205 methods: &mut BTreeMap<String, MethodSig>,
206 availability: &dyn DocAvailability,
207 gated: &mut Gated,
208) {
209 methods.retain(|_, method| keep_callable(&mut method.doc, availability, gated));
210}
211
212fn keep_callable(
214 doc: &mut Option<DocComment>,
215 availability: &dyn DocAvailability,
216 gated: &mut Gated,
217) -> bool {
218 let Some(doc) = doc.as_mut().filter(|doc| !doc.capabilities.is_empty()) else {
219 return true;
220 };
221 let mut notes: Vec<String> = Vec::new();
222 for tag in &doc.capabilities {
223 match availability.tag(tag) {
224 Availability::Denied => {
225 gated.removed = gated.removed.saturating_add(1);
226 return false;
227 }
228 Availability::Available { notes: tag_notes } => {
229 for note in tag_notes {
230 if !notes.contains(¬e) {
231 notes.push(note);
232 }
233 }
234 }
235 }
236 }
237 for note in notes {
238 if !doc.summary.is_empty() {
239 doc.summary.push('\n');
240 }
241 doc.summary.push_str(¬e);
242 }
243 gated.kept = gated.kept.saturating_add(1);
244 true
245}
246
247fn drop_unreached_types(original: &PackageDeclaration, projected: &mut PackageDeclaration) {
253 let mentioned: BTreeSet<String> = original
254 .types
255 .values()
256 .flat_map(crate::packages::type_symbol_references)
257 .collect();
258 let reached_by_values = reached_types(original, Vec::new());
259 let exported_alone: Vec<String> = original
260 .types
261 .values()
262 .map(|symbol| symbol.mangled_name.as_str().to_string())
263 .filter(|mangled| !mentioned.contains(mangled) && !reached_by_values.contains(mangled))
264 .collect();
265 let reached_before = reached_types(original, exported_alone.clone());
266 let mut roots = exported_alone;
267 roots.extend(
268 projected
269 .types
270 .values()
271 .filter(|symbol| keeps_gated_member(symbol))
272 .map(|symbol| symbol.mangled_name.as_str().to_string()),
273 );
274 let reached_after = reached_types(projected, roots);
275 let stays = |symbol: &TypeSymbol| {
276 let mangled = symbol.mangled_name.as_str();
277 !reached_before.contains(mangled) || reached_after.contains(mangled)
278 };
279 projected.types.retain(|_, symbol| stays(symbol));
280 let mut pending: Vec<&mut NamespaceSymbol> = projected.namespaces.values_mut().collect();
281 while let Some(namespace) = pending.pop() {
282 namespace.types.retain(|_, symbol| stays(symbol));
283 pending.extend(namespace.namespaces.values_mut());
284 }
285}
286
287fn keeps_gated_member(symbol: &TypeSymbol) -> bool {
288 let gated = |methods: &BTreeMap<String, MethodSig>| {
289 methods.values().any(|method| {
290 method
291 .doc
292 .as_ref()
293 .is_some_and(|doc| !doc.capabilities.is_empty())
294 })
295 };
296 match &symbol.kind {
297 TypeKind::Interface { methods, .. } => gated(methods),
298 TypeKind::Class {
299 methods, statics, ..
300 } => gated(methods) || gated(statics),
301 TypeKind::NumberEnum { .. } | TypeKind::StringEnum { .. } | TypeKind::Alias { .. } => false,
302 }
303}
304
305fn reached_types(declaration: &PackageDeclaration, roots: Vec<String>) -> BTreeSet<String> {
310 let by_mangled: BTreeMap<&str, &TypeSymbol> = declaration
311 .types
312 .values()
313 .map(|symbol| (symbol.mangled_name.as_str(), symbol))
314 .collect();
315 let mut values = vec![serde_json::to_value(&declaration.values)];
316 let mut namespaces: Vec<&NamespaceSymbol> = declaration.namespaces.values().collect();
317 while let Some(namespace) = namespaces.pop() {
318 values.push(serde_json::to_value(&namespace.values));
319 namespaces.extend(namespace.namespaces.values());
320 }
321 let mut pending = roots;
322 pending.extend(crate::packages::type_references(&values));
323 let mut reached = BTreeSet::new();
324 while let Some(mangled) = pending.pop() {
325 if !reached.insert(mangled.clone()) {
326 continue;
327 }
328 let Some(symbol) = by_mangled.get(mangled.as_str()) else {
329 continue;
330 };
331 pending.extend(crate::packages::type_symbol_references(symbol));
332 }
333 reached
334}
335
336pub fn function_index(declaration: &PackageDeclaration) -> String {
338 let mut lines = BTreeMap::new();
339 for (name, value) in &declaration.values {
340 index_function(&mut lines, name.clone(), value);
341 }
342 let mut pending: Vec<_> = declaration
343 .namespaces
344 .iter()
345 .map(|(name, ns)| (name.clone(), ns))
346 .collect();
347 while let Some((prefix, namespace)) = pending.pop() {
348 for (name, value) in &namespace.values {
349 index_function(&mut lines, format!("{prefix}.{name}"), value);
350 }
351 pending.extend(
352 namespace
353 .namespaces
354 .iter()
355 .map(|(name, ns)| (format!("{prefix}.{name}"), ns)),
356 );
357 }
358 lines.into_values().collect::<Vec<_>>().join("\n")
359}
360
361fn index_function(lines: &mut BTreeMap<String, String>, name: String, value: &ValueSymbol) {
362 if let ValueKind::Function { doc, .. } = &value.kind {
363 let description = doc
364 .as_ref()
365 .map(|doc| doc.summary.split_whitespace().collect::<Vec<_>>().join(" "))
366 .unwrap_or_default();
367 lines.insert(name.clone(), format!("{name}: {description}"));
368 }
369}
370
371pub fn select_documentation(
374 declaration: &PackageDeclaration,
375 names: &[String],
376) -> Result<PackageDeclaration, String> {
377 let mut visible: BTreeSet<String> = declaration.types.keys().cloned().collect();
378 visible.extend(
379 declaration
380 .values
381 .iter()
382 .filter(|(_, value)| matches!(value.kind, ValueKind::Function { .. }))
383 .map(|(name, _)| name.clone()),
384 );
385 let mut pending: Vec<_> = declaration
386 .namespaces
387 .iter()
388 .map(|(name, ns)| (name.clone(), ns))
389 .collect();
390 while let Some((prefix, namespace)) = pending.pop() {
391 visible.extend(
392 namespace
393 .values
394 .iter()
395 .filter(|(_, value)| matches!(value.kind, ValueKind::Function { .. }))
396 .map(|(name, _)| format!("{prefix}.{name}")),
397 );
398 visible.extend(
399 namespace
400 .types
401 .keys()
402 .map(|name| format!("{prefix}.{name}")),
403 );
404 pending.extend(
405 namespace
406 .namespaces
407 .iter()
408 .map(|(name, ns)| (format!("{prefix}.{name}"), ns)),
409 );
410 }
411 if names.is_empty() {
412 return Err("Request at least one function or type".into());
413 }
414 for name in names {
415 if !visible.contains(name) {
416 let mut closest: Vec<_> = visible.iter().collect();
417 closest.sort_by_key(|candidate| edit_distance(name, candidate));
418 return Err(format!(
419 "Unknown function or type `{name}`. Closest names: {}",
420 closest
421 .into_iter()
422 .take(3)
423 .cloned()
424 .collect::<Vec<_>>()
425 .join(", ")
426 ));
427 }
428 }
429 let requested: BTreeSet<_> = names.iter().map(String::as_str).collect();
430 let mut selected = declaration.clone();
431 selected
432 .values
433 .retain(|name, _| requested.contains(name.as_str()));
434 let mut pending: Vec<_> = selected
435 .namespaces
436 .iter_mut()
437 .map(|(name, ns)| (name.clone(), ns))
438 .collect();
439 while let Some((prefix, namespace)) = pending.pop() {
440 namespace
441 .values
442 .retain(|name, _| requested.contains(format!("{prefix}.{name}").as_str()));
443 pending.extend(
444 namespace
445 .namespaces
446 .iter_mut()
447 .map(|(name, ns)| (format!("{prefix}.{name}"), ns)),
448 );
449 }
450 let roots = declaration
451 .types
452 .iter()
453 .filter(|(name, _)| requested.contains(name.as_str()))
454 .map(|(_, symbol)| symbol.mangled_name.as_str().to_owned())
455 .collect();
456 let reached = reached_types(&selected, roots);
457 selected
458 .types
459 .retain(|_, symbol| reached.contains(symbol.mangled_name.as_str()));
460 let mut pending: Vec<_> = selected.namespaces.values_mut().collect();
461 while let Some(namespace) = pending.pop() {
462 namespace
463 .types
464 .retain(|_, symbol| reached.contains(symbol.mangled_name.as_str()));
465 pending.extend(namespace.namespaces.values_mut());
466 }
467 Ok(selected)
468}
469
470fn edit_distance(left: &str, right: &str) -> usize {
471 let right: Vec<_> = right.chars().collect();
472 let mut row: Vec<_> = (0..=right.len()).collect();
473 for (i, a) in left.chars().enumerate() {
474 let mut diagonal = row[0];
475 row[0] = i + 1;
476 for (j, b) in right.iter().enumerate() {
477 let previous = row[j + 1];
478 row[j + 1] = (row[j] + 1)
479 .min(previous + 1)
480 .min(diagonal + usize::from(a != *b));
481 diagonal = previous;
482 }
483 }
484 row.last().copied().unwrap_or(0)
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490
491 struct Fixed {
493 denied: &'static [&'static str],
494 noted: &'static [&'static str],
495 }
496
497 impl Fixed {
498 fn answer(&self, name: &str) -> Availability {
499 if self.denied.contains(&name) {
500 Availability::Denied
501 } else if self.noted.contains(&name) {
502 Availability::Available {
503 notes: vec![format!("`{name}` is restricted.")],
504 }
505 } else {
506 Availability::OPEN
507 }
508 }
509 }
510
511 impl DocAvailability for Fixed {
512 fn tag(&self, tag: &DocCapability) -> Availability {
513 self.answer(&tag.capability)
514 }
515
516 fn capability(&self, name: &str) -> Availability {
517 self.answer(name)
518 }
519 }
520
521 fn declaration(source: &str) -> PackageDeclaration {
522 crate::compile_package(
523 "@acme/pkg",
524 crate::ModulePath::from("lib"),
525 &[crate::PackageSourceModule {
526 path: crate::ModulePath::from("lib"),
527 source,
528 }],
529 &[],
530 )
531 .unwrap_or_else(|diagnostics| panic!("compile failed: {diagnostics:#?}"))
532 .declaration
533 }
534
535 const SOURCE: &str = r#"
536 export interface Issue { id: string; title: string; }
537 export interface Draft { title: string; }
538 export interface Team { name: string; }
539 export interface Standalone { note: string; }
540
541 /** List issues.
542 * @capability acme.test/read */
543 export function listIssues(team: Team): Issue[] { return []; }
544
545 /** Create an issue.
546 * @capability acme.test/write */
547 export function createIssue(draft: Draft): Issue { return { id: "1", title: draft.title }; }
548
549 /** Download an issue.
550 * @capability acme.test/read
551 * @capability acme.test/write */
552 export function exportIssue(id: string): void {}
553
554 export function helper(): string { return "x"; }
555
556 export class Client {
557 /** @capability acme.test/read */
558 get(id: string): Issue { return { id, title: "" }; }
559 /** @capability acme.test/write */
560 update(draft: Draft): void {}
561 }
562 "#;
563
564 #[test]
565 fn selected_namespace_function_is_rendered() {
566 let mut defs = declaration("export function inspect(): string { return 'ok'; }");
567 let symbol = defs.values.remove("inspect").unwrap();
568 defs.namespaces.insert(
569 "nested".into(),
570 NamespaceSymbol {
571 name: "nested".into(),
572 mangled_prefix: symbol.mangled_name.clone(),
573 declaration_span: symbol.declaration_span,
574 values: BTreeMap::from([("inspect".into(), symbol)]),
575 types: BTreeMap::new(),
576 namespaces: BTreeMap::new(),
577 doc: None,
578 },
579 );
580 assert!(function_index(&defs).contains("nested.inspect:"));
581 let selected = select_documentation(&defs, &["nested.inspect".into()]).unwrap();
582 let text = crate::packages::render_selected_declarations(&selected);
583 assert!(text.contains("namespace nested"), "{text}");
584 assert!(text.contains("function inspect("), "{text}");
585 }
586
587 #[test]
588 fn selected_docs_follow_transitive_types_and_scope() {
589 let defs = declaration(
590 r#"
591 export interface Leaf { value: string; }
592 export interface Branch { leaf: Leaf; }
593 /** First line.
594 * Second line.
595 * @param branch input */
596 export function inspect(branch: Branch): void {}
597 export function other(): void {}
598 "#,
599 );
600 let index = function_index(&defs);
601 assert!(
602 index.contains("inspect: First line. Second line."),
603 "{index}"
604 );
605 assert!(!index.contains("@param"));
606 for names in [vec!["inspect".into()], vec!["Branch".into()]] {
607 let selected = select_documentation(&defs, &names).unwrap();
608 assert!(selected.types.contains_key("Branch"));
609 assert!(selected.types.contains_key("Leaf"));
610 assert!(!selected.values.contains_key("other"));
611 }
612 let scoped = project_declaration(
613 &declaration(SOURCE),
614 &Fixed {
615 denied: &["acme.test/write"],
616 noted: &[],
617 },
618 );
619 assert!(!function_index(&scoped.declaration).contains("createIssue"));
620 let error = select_documentation(&scoped.declaration, &["createIssue".into()]).unwrap_err();
621 assert!(
622 !error
623 .split("Closest names:")
624 .nth(1)
625 .unwrap()
626 .contains("createIssue")
627 );
628 }
629
630 #[test]
631 fn denied_callables_and_the_types_only_they_reach_are_removed() {
632 let defs = declaration(SOURCE);
633 let projection = project_declaration(
634 &defs,
635 &Fixed {
636 denied: &["acme.test/write"],
637 noted: &[],
638 },
639 );
640 let declaration = &projection.declaration;
641 assert!(projection.usable);
642 assert!(declaration.values.contains_key("listIssues"));
643 assert!(declaration.values.contains_key("helper"));
644 assert!(!declaration.values.contains_key("createIssue"));
645 assert!(!declaration.values.contains_key("exportIssue"));
647 assert!(declaration.types.contains_key("Issue"));
648 assert!(declaration.types.contains_key("Team"));
649 assert!(declaration.types.contains_key("Standalone"));
650 assert!(!declaration.types.contains_key("Draft"));
651 let Some(TypeKind::Class {
652 methods,
653 method_visibility,
654 ..
655 }) = declaration.types.get("Client").map(|symbol| &symbol.kind)
656 else {
657 panic!("Client stays");
658 };
659 assert!(methods.contains_key("get"));
660 assert!(!methods.contains_key("update"));
661 assert!(!method_visibility.contains_key("update"));
662 assert!(defs.values.contains_key("createIssue"));
664 }
665
666 #[test]
667 fn a_class_keeps_its_place_by_what_it_still_offers() {
668 let defs = declaration(
669 r#"
670 export interface Secret { value: string; }
671 export interface Page { text: string; }
672 export class Session {
673 /** @capability acme.test/read */
674 read(): Page { return { text: "" }; }
675 /** @capability acme.test/write */
676 rotate(): Secret { return { value: "" }; }
677 }
678 /** @capability acme.test/connect */
679 export function connect(): Session { return new Session(); }
680 "#,
681 );
682 let projection = project_declaration(
683 &defs,
684 &Fixed {
685 denied: &["acme.test/connect", "acme.test/write"],
686 noted: &[],
687 },
688 );
689 let types = &projection.declaration.types;
690 assert!(types.contains_key("Session"), "{types:?}");
692 assert!(types.contains_key("Page"));
693 assert!(!types.contains_key("Secret"));
695 }
696
697 #[test]
698 fn unscoped_projection_keeps_everything() {
699 let defs = declaration(SOURCE);
700 let projection = project_declaration(&defs, &Unscoped);
701 assert_eq!(projection.declaration, defs);
702 assert!(projection.usable);
703 }
704
705 #[test]
706 fn types_reexported_from_sibling_modules_are_reached_by_the_signatures_naming_them() {
707 let modules = [
708 crate::PackageSourceModule {
709 path: crate::ModulePath::from("lib"),
710 source: r#"
711 import type { SearchResult, Draft } from "./model";
712 export { SearchResult, Draft } from "./model";
713 /** @capability acme.test/read */
714 export function search(q: string): SearchResult[] { return []; }
715 /** @capability acme.test/write */
716 export function save(draft: Draft): void {}
717 "#,
718 },
719 crate::PackageSourceModule {
720 path: crate::ModulePath::from("model"),
721 source: r#"
722 export interface SearchResult { title: string; }
723 export interface Draft { title: string; }
724 "#,
725 },
726 ];
727 let defs =
728 crate::compile_package("@acme/pkg", crate::ModulePath::from("lib"), &modules, &[])
729 .unwrap_or_else(|diagnostics| panic!("compile failed: {diagnostics:#?}"))
730 .declaration;
731 let Some(ValueKind::Function { ret, .. }) = defs.values.get("search").map(|v| &v.kind)
732 else {
733 panic!("search is a function");
734 };
735 let crate::Type::Array(element) = ret else {
736 panic!("search returns an array: {ret:?}");
737 };
738 let crate::Type::InterfaceRef { mangled, .. } = element.as_ref() else {
739 panic!("search returns interfaces: {element:?}");
740 };
741 assert_eq!(&defs.types["SearchResult"].mangled_name, mangled);
742
743 let projection = project_declaration(
744 &defs,
745 &Fixed {
746 denied: &["acme.test/write"],
747 noted: &[],
748 },
749 );
750 assert!(projection.declaration.types.contains_key("SearchResult"));
751 assert!(!projection.declaration.types.contains_key("Draft"));
752
753 let rendered = crate::packages::render_packages_d_ts(&[&defs], &[]);
754 assert!(rendered.contains("interface SearchResult {"), "{rendered}");
755 assert!(
756 rendered.contains("search(q: string): SearchResult[]"),
757 "{rendered}"
758 );
759 }
760
761 #[test]
762 fn notes_follow_the_summary() {
763 let defs = declaration(SOURCE);
764 let projection = project_declaration(
765 &defs,
766 &Fixed {
767 denied: &[],
768 noted: &["acme.test/read"],
769 },
770 );
771 let rendered = crate::packages::render_declarations(&projection.declaration);
772 assert!(
773 rendered.contains(" * List issues.\n * `acme.test/read` is restricted.\n"),
774 "{rendered}"
775 );
776 assert_eq!(rendered.matches("is restricted.").count(), 3, "{rendered}");
778 }
779
780 #[test]
781 fn a_package_with_nothing_left_to_call_is_not_usable() {
782 let defs = declaration(SOURCE);
783 let projection = project_declaration(
784 &defs,
785 &Fixed {
786 denied: &["acme.test/read", "acme.test/write"],
787 noted: &[],
788 },
789 );
790 assert!(!projection.usable);
792 let gated_only = declaration(
793 r#"
794 export interface Issue { id: string; }
795 /** @capability acme.test/read */
796 export function listIssues(): Issue[] { return []; }
797 "#,
798 );
799 let projection = project_declaration(
800 &gated_only,
801 &Fixed {
802 denied: &["acme.test/read"],
803 noted: &[],
804 },
805 );
806 assert!(!projection.usable);
807 assert!(projection.declaration.types.is_empty());
808 }
809
810 #[test]
811 fn recursive_and_generic_types_terminate_and_stay_whole() {
812 let defs = declaration(
813 r#"
814 export interface Node { children: Node[]; parent: Box<Node> | undefined; }
815 export interface Box<T> { value: T; }
816 /** @capability acme.test/read */
817 export function tree(): Node { return { children: [], parent: undefined }; }
818 /** @capability acme.test/write */
819 export function boxed(): Box<string> { return { value: "" }; }
820 "#,
821 );
822 let projection = project_declaration(
823 &defs,
824 &Fixed {
825 denied: &["acme.test/write"],
826 noted: &[],
827 },
828 );
829 assert!(projection.declaration.types.contains_key("Node"));
830 assert!(projection.declaration.types.contains_key("Box"));
831 }
832
833 #[test]
834 fn known_context_normalizes_literals() {
835 let defs = declaration(
836 r#"
837 /** @capability fs.read { path: "/a/../b.txt", recursive: true } */
838 export function read(): void {}
839 /** @capability fs.read { path: "notes.txt" } */
840 export function readRelative(): void {}
841 "#,
842 );
843 let Some(ValueKind::Function { doc: Some(doc), .. }) =
844 defs.values.get("read").map(|symbol| &symbol.kind)
845 else {
846 panic!("read is documented");
847 };
848 let context = known_context(&doc.capabilities[0]);
849 assert_eq!(
850 context,
851 serde_json::json!({ "path": "/b.txt", "recursive": true })
852 );
853 let Some(ValueKind::Function { doc: Some(doc), .. }) =
854 defs.values.get("readRelative").map(|symbol| &symbol.kind)
855 else {
856 panic!("readRelative is documented");
857 };
858 assert_eq!(known_context(&doc.capabilities[0]), serde_json::json!({}));
860 }
861
862 #[test]
863 fn regions_follow_availability() {
864 let markdown = "Intro.\n\n<!-- submilli:if capability=\"acme.test/write\" -->\n\nWrite.\n\n<!-- submilli:endif -->\n\nEnd.\n";
865 let denied = Fixed {
866 denied: &["acme.test/write"],
867 noted: &[],
868 };
869 assert_eq!(
870 scoped_documentation(markdown, &denied).unwrap(),
871 "Intro.\n\nEnd.\n"
872 );
873 assert_eq!(
874 scoped_documentation(markdown, &Unscoped).unwrap(),
875 "Intro.\n\nWrite.\n\nEnd.\n"
876 );
877 assert!(scoped_documentation("<!-- submilli:endif -->\n", &Unscoped).is_err());
878 }
879}