Skip to main content

submilli_engine/
docs_view.rs

1//! Documentation as one reader can use it: the callables whose capabilities
2//! the reader may hold, the types those callables still reach, and the
3//! Markdown regions whose conditions hold.
4//!
5//! The engine does not know the policy. An embedder answers through
6//! [`DocAvailability`], and [`Unscoped`] answers for an author reading
7//! everything. A projection is a copy for documentation: the declaration a
8//! program compiles against is never changed.
9
10use 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/// Whether a reader could use a capability.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum Availability {
22    /// No call can proceed.
23    Denied,
24    /// Some call could proceed. `notes` say what limits it, such as a filter
25    /// or a person's approval, as sentences documentation shows the reader.
26    Available { notes: Vec<String> },
27}
28
29impl Availability {
30    /// Available with nothing to note.
31    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
38/// The reader's side of documentation: which capabilities it could use.
39pub trait DocAvailability {
40    /// A capability a callable's `@capability` tag names. [`known_context`]
41    /// gives the context the tag's literal bindings fix ahead of the call.
42    fn tag(&self, tag: &DocCapability) -> Availability;
43
44    /// A capability a Markdown region names, with nothing known of the call.
45    fn capability(&self, name: &str) -> Availability;
46}
47
48/// Everything available: documentation as its author reads it.
49pub 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
61/// The context fields a tag's literal bindings fix, in the form the runtime
62/// checks them. A literal the runtime would refuse, a relative path, which
63/// the runtime resolves against the session's working directory, or a number
64/// that does not parse, is left out, which leaves the field unknown.
65pub 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/// A declaration narrowed to what a reader can use.
113#[derive(Debug, Clone)]
114pub struct Projection {
115    /// The callables the reader could call, each tagged one's documentation
116    /// noting what limits it, and the types they reach.
117    pub declaration: PackageDeclaration,
118    /// Whether the package is worth showing: some callable that needs a
119    /// capability is left, or none ever needed one, as in a package of pure
120    /// helpers or only types. The ungated helpers of a package whose every
121    /// gated callable was removed, such as its working directory, do not count.
122    pub usable: bool,
123}
124
125/// Narrow `defs` to what `availability` allows. A callable stays when every
126/// capability its tags name is available, since a call checks each of them.
127/// A type stays when a remaining callable reaches it, or when no callable
128/// ever did, as with a type a package exports on its own.
129pub 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
156/// `markdown` with the regions whose conditions fail removed, or the
157/// directives that make it malformed.
158pub 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/// How many callables that need a capability a projection kept and removed.
168#[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
212/// Whether a callable with `doc` stays, adding to its summary what limits it.
213fn 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(&note) {
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(&note);
242    }
243    gated.kept = gated.kept.saturating_add(1);
244    true
245}
246
247/// Remove the types only removed callables reached. Reach starts from the
248/// values, from the types neither a value nor another type mentions, which a
249/// package exports on their own, and, after projection, from a class or interface that kept a
250/// callable needing a capability, which a reader can still use whatever
251/// returned it. A type `original` never reached stays.
252fn 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
305/// The mangled names of the types reached from `declaration`'s values, in
306/// every namespace, and from `roots`, through the types they mention.
307/// Namespace types are mirrored into `declaration.types`, so they are found
308/// there.
309fn 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
336/// Function names and complete descriptions in an already scoped declaration.
337pub 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
371/// Select visible symbols and the transitive closure of their referenced types.
372/// Callers must apply capability projection before selecting symbols.
373pub 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    /// Capabilities in `denied` are unavailable, ones in `noted` carry a note.
492    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        // Every tag counts: one denied capability removes the callable.
646        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        // The compiled declaration is untouched.
663        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        // Only the removed `connect` reached `Session`, but `read` still works.
691        assert!(types.contains_key("Session"), "{types:?}");
692        assert!(types.contains_key("Page"));
693        // Only the removed `rotate` reached `Secret`.
694        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        // One note per distinct sentence, however many tags repeat it.
777        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        // `helper` is left, but it needs no capability.
791        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        // The runtime resolves a relative path against the working directory.
859        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}