Skip to main content

bynk_ide/
documentation.rs

1//! #847: the documentation-model query — a file's declarations aggregated into
2//! a rendered reference page ("live rustdoc for Bynk"), for the VS Code
3//! "Show Documentation" webview.
4//!
5//! A pure, read-only IDE query. It does two things and reuses everything else:
6//!
7//! - **Traversal.** It walks the parsed unit's `items` with an *exhaustive*
8//!   match on [`CommonsItem`] — the same shape `bynk-lsp`'s `document_symbols`
9//!   walk uses — so a new declaration kind is a compile error here, in
10//!   `push_item`, not a silently-missed row on the page. (Risk: "a new
11//!   doc-bearing node kind is added and silently missed by the aggregator.")
12//!
13//! - **Per-declaration rendering.** Each entry's Markdown (its fenced signature
14//!   plus its doc-comment prose) is produced by hover's own `describe_*`
15//!   assembly in [`crate::symbols`] — not a parallel renderer. Sharing that code
16//!   path is what keeps the doc page from drifting from hover. (Risk:
17//!   "divergence from hover — two code paths formatting the same doc
18//!   differently.")
19//!
20//! What this module adds on top is the page *structure*: the declaration's
21//! heading name, its nesting depth (top-level item → its ops/handlers), a
22//! `documented` flag driving the "no documentation" coverage placeholder, and
23//! the name span each heading links back to (click-to-code). The Markdown is
24//! rendered — HTML-disabled — by the webview; nothing here emits HTML.
25//!
26//! Tier 1 (Decision A) is **file-scoped**: the model is built from one file's
27//! text, exactly like `document_symbols`. Context-aggregation (merging every
28//! file of a multi-file `context`) is the deferred follow-up.
29
30use bynk_syntax::ast::*;
31use bynk_syntax::lexer::tokenize;
32use bynk_syntax::parser::parse_unit_with_recovery;
33use bynk_syntax::span::Span;
34
35use crate::symbols;
36
37/// A file's declarations rendered as an ordered, hierarchical reference page.
38#[derive(Debug, Clone, PartialEq)]
39pub struct DocModel {
40    /// The declared unit name (the page title) — `demo.app` for a
41    /// `context demo.app`, `tokens` for an `adapter tokens`.
42    pub unit_name: String,
43    /// `"commons"` / `"context"` / `"adapter"` — the unit's own keyword, shown
44    /// as the page's kind.
45    pub unit_kind: &'static str,
46    /// The unit declaration's own doc comment, if any (rendered above the
47    /// entries as the page's lede).
48    pub unit_doc: Option<String>,
49    /// The unit-name span — the page title links back to the header.
50    pub unit_span: Span,
51    /// Declarations in source order, each carrying its nesting `depth`.
52    pub entries: Vec<DocEntry>,
53}
54
55/// One declaration on the page: a heading, its rendered signature+doc Markdown,
56/// and where it lives in the source.
57#[derive(Debug, Clone, PartialEq)]
58pub struct DocEntry {
59    /// The heading text — a bare name (`Api`), a compound member key
60    /// (`Counter.bump`, `Clock.now`), or a provider's `Cap = Provider`.
61    pub name: String,
62    /// A short kind label for the heading badge (`"service"`, `"handler"`, …).
63    pub kind: &'static str,
64    /// Nesting level: top-level declarations are `0`; a capability's ops and a
65    /// service/agent's handlers are `1`.
66    pub depth: u32,
67    /// The declaration's Markdown — a fenced `bynk` signature, followed by its
68    /// doc-comment prose when documented. Produced by hover's `describe_*`
69    /// (see the module doc).
70    pub markdown: String,
71    /// Whether this declaration carries a doc comment. Drives the webview's
72    /// "no documentation" placeholder (Decision B: the page doubles as a
73    /// doc-coverage view, with a toggle to hide the undocumented).
74    pub documented: bool,
75    /// The declaration's name span — the heading and signature link here.
76    pub span: Span,
77}
78
79/// Build the documentation model for a single file's `text`. Returns `None`
80/// when the file has no recognisable unit header, or is a test suite (`suite`
81/// units are not a documentation unit in Tier 1 — their `case`/`stub` members
82/// have no `describe_*` renderer, and a doc page for tests is out of scope).
83pub fn documentation_model(text: &str) -> Option<DocModel> {
84    let tokens = tokenize(text).ok()?;
85    let (unit, _errs) = parse_unit_with_recovery(&tokens, text);
86    let unit = unit?;
87    let (unit_kind, unit_name, unit_span, unit_doc, items) = match &unit {
88        SourceUnit::Commons(c) => (
89            "commons",
90            c.name.joined(),
91            c.name.span,
92            &c.documentation,
93            &c.items,
94        ),
95        SourceUnit::Context(c) => (
96            "context",
97            c.name.joined(),
98            c.name.span,
99            &c.documentation,
100            &c.items,
101        ),
102        SourceUnit::Adapter(a) => (
103            "adapter",
104            a.name.joined(),
105            a.name.span,
106            &a.documentation,
107            &a.items,
108        ),
109        SourceUnit::Suite(_) => return None,
110    };
111    let mut entries = Vec::new();
112    for item in items {
113        push_item(&mut entries, item);
114    }
115    Some(DocModel {
116        unit_name,
117        unit_kind,
118        unit_doc: unit_doc.clone(),
119        unit_span,
120        entries,
121    })
122}
123
124/// Append `item`'s entry — and any nested member entries (capability ops,
125/// service/agent handlers) — to `out`, in source order.
126///
127/// The `match` is exhaustive over [`CommonsItem`] on purpose: a new item kind
128/// will not compile until it is given a page entry, which is the guard against
129/// the "silently-missed declaration kind" risk. Nested members reuse the same
130/// discipline against their own child lists.
131fn push_item(out: &mut Vec<DocEntry>, item: &CommonsItem) {
132    match item {
133        CommonsItem::Type(t) => out.push(DocEntry {
134            name: t.name.name.clone(),
135            kind: "type",
136            depth: 0,
137            markdown: symbols::describe_type(t),
138            documented: t.documentation.is_some(),
139            span: t.name.span,
140        }),
141        CommonsItem::Fn(f) => out.push(DocEntry {
142            name: f.name.display(),
143            kind: match f.name {
144                FnName::Free(_) => "function",
145                FnName::Method { .. } => "method",
146            },
147            depth: 0,
148            markdown: symbols::describe_fn(f),
149            documented: f.documentation.is_some(),
150            span: f.name.ident().span,
151        }),
152        CommonsItem::Capability(c) => {
153            out.push(DocEntry {
154                name: c.name.name.clone(),
155                kind: "capability",
156                depth: 0,
157                markdown: symbols::describe_capability(c),
158                documented: c.documentation.is_some(),
159                span: c.name.span,
160            });
161            for op in &c.ops {
162                out.push(DocEntry {
163                    name: format!("{}.{}", c.name.name, op.name.name),
164                    kind: "operation",
165                    depth: 1,
166                    markdown: symbols::describe_capability_op(c, op),
167                    documented: op.documentation.is_some(),
168                    span: op.name.span,
169                });
170            }
171        }
172        CommonsItem::Provider(p) => out.push(DocEntry {
173            name: format!("{} = {}", p.capability.name, p.provider_name.name),
174            kind: "provider",
175            depth: 0,
176            markdown: symbols::describe_provider(p),
177            documented: p.documentation.is_some(),
178            span: p.provider_name.span,
179        }),
180        CommonsItem::Service(s) => {
181            out.push(DocEntry {
182                name: s.name.name.clone(),
183                kind: "service",
184                depth: 0,
185                markdown: symbols::describe_service(s),
186                documented: s.documentation.is_some(),
187                span: s.name.span,
188            });
189            for h in &s.handlers {
190                out.push(DocEntry {
191                    // A service handler is identified by its route, not a
192                    // dispatch name (`on GET("/x")`), so the heading is the
193                    // route line itself.
194                    name: symbols::handler_line(h),
195                    kind: "handler",
196                    depth: 1,
197                    markdown: symbols::describe_service_handler(s, h),
198                    documented: h.documentation.is_some(),
199                    span: h.span,
200                });
201            }
202        }
203        CommonsItem::Agent(a) => {
204            out.push(DocEntry {
205                name: a.name.name.clone(),
206                kind: "agent",
207                depth: 0,
208                markdown: symbols::describe_agent(a),
209                documented: a.documentation.is_some(),
210                span: a.name.span,
211            });
212            for h in &a.handlers {
213                let handler = h
214                    .method_name
215                    .as_ref()
216                    .map(|m| m.name.clone())
217                    .unwrap_or_else(|| "call".to_string());
218                out.push(DocEntry {
219                    name: format!("{}.{}", a.name.name, handler),
220                    kind: "handler",
221                    depth: 1,
222                    markdown: symbols::describe_agent_handler(a, h, &handler),
223                    documented: h.documentation.is_some(),
224                    span: h.method_name.as_ref().map(|m| m.span).unwrap_or(h.span),
225                });
226            }
227        }
228        CommonsItem::Actor(a) => out.push(DocEntry {
229            name: a.name.name.clone(),
230            kind: "actor",
231            depth: 0,
232            markdown: symbols::describe_actor(a),
233            documented: a.documentation.is_some(),
234            span: a.name.span,
235        }),
236        // message-bundles slice 1 (#859): a messages block, keyed by its tag.
237        CommonsItem::Messages(m) => out.push(DocEntry {
238            name: m.tag.clone(),
239            kind: "messages",
240            depth: 0,
241            markdown: symbols::describe_messages(m),
242            documented: m.documentation.is_some(),
243            span: m.tag_span,
244        }),
245        // Events track, slice 0 (spine #936): an `event` documents exactly
246        // like a `type` whose body is a record — same synthetic `TypeDecl`
247        // `EventDecl::as_type_decl` builds elsewhere.
248        CommonsItem::Event(e) => out.push(DocEntry {
249            name: e.name.name.clone(),
250            kind: "event",
251            depth: 0,
252            markdown: symbols::describe_type(&e.as_type_decl()),
253            documented: e.documentation.is_some(),
254            span: e.name.span,
255        }),
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    /// Fixture: a fully-documented context with a nested hierarchy — a
264    /// capability with an op, a service with a handler, and an agent with a
265    /// handler. Asserts every declaration appears, in source order, with its
266    /// doc rendered and its depth reflecting the hierarchy.
267    const DOCUMENTED_CTX: &str = r#"---
268The demo application context.
269---
270context demo.app
271
272---
273Reads the wall clock.
274---
275capability Clock {
276  ---
277  Milliseconds since the Unix epoch.
278  ---
279  fn now() -> Int
280}
281
282---
283An amount in the smallest currency unit.
284---
285type Money = Int where NonNegative
286
287---
288The public HTTP surface.
289---
290service Api from http {
291  ---
292  Returns the current instant.
293  ---
294  on GET("/now") () -> Effect[Int] given Clock {
295    Clock.now()
296  }
297}
298
299---
300A per-key running total.
301---
302agent Counter {
303  key id: Int
304  store value: Cell[Int]
305  ---
306  Adds `amount` to the total and returns the new value.
307  ---
308  on call bump(amount: Int) -> Effect[Int] {
309    let _ <- value.update((v) => v + amount)
310    value
311  }
312}
313"#;
314
315    #[test]
316    fn documented_context_aggregates_every_declaration_in_order_and_hierarchy() {
317        let model = documentation_model(DOCUMENTED_CTX).expect("a context model");
318        assert_eq!(model.unit_name, "demo.app");
319        assert_eq!(model.unit_kind, "context");
320        assert_eq!(
321            model.unit_doc.as_deref(),
322            Some("The demo application context.")
323        );
324
325        // Names, in source order, with the nested members interleaved after
326        // their owners.
327        let names: Vec<(&str, u32, &str)> = model
328            .entries
329            .iter()
330            .map(|e| (e.name.as_str(), e.depth, e.kind))
331            .collect();
332        assert_eq!(
333            names,
334            vec![
335                ("Clock", 0, "capability"),
336                ("Clock.now", 1, "operation"),
337                ("Money", 0, "type"),
338                ("Api", 0, "service"),
339                ("on GET(\"/now\")", 1, "handler"),
340                ("Counter", 0, "agent"),
341                ("Counter.bump", 1, "handler"),
342            ]
343        );
344
345        // Every declaration here is documented, and the doc prose reaches the
346        // rendered Markdown (proving the reuse of hover's assembly).
347        assert!(model.entries.iter().all(|e| e.documented));
348        let money = model.entries.iter().find(|e| e.name == "Money").unwrap();
349        assert!(
350            money
351                .markdown
352                .contains("An amount in the smallest currency unit.")
353        );
354        assert!(money.markdown.contains("```bynk"));
355        let bump = model
356            .entries
357            .iter()
358            .find(|e| e.name == "Counter.bump")
359            .unwrap();
360        assert!(bump.markdown.contains("Adds `amount` to the total"));
361    }
362
363    #[test]
364    fn undocumented_declarations_are_flagged_for_the_coverage_placeholder() {
365        let src = "commons demo.x {\n\
366                   ---\n\
367                   Has docs.\n\
368                   ---\n\
369                   type Documented = Int\n\
370                   type Undocumented = Int\n\
371                   fn helper(n: Int) -> Int { n }\n\
372                   }";
373        let model = documentation_model(src).expect("a commons model");
374        assert_eq!(model.unit_kind, "commons");
375        let documented = model
376            .entries
377            .iter()
378            .find(|e| e.name == "Documented")
379            .unwrap();
380        assert!(documented.documented);
381        let undoc = model
382            .entries
383            .iter()
384            .find(|e| e.name == "Undocumented")
385            .unwrap();
386        assert!(!undoc.documented);
387        // An undocumented declaration still renders its signature — the page is
388        // a reference, not only a comment dump (Decision C).
389        assert!(undoc.markdown.contains("```bynk"));
390        let helper = model.entries.iter().find(|e| e.name == "helper").unwrap();
391        assert!(!helper.documented);
392    }
393
394    #[test]
395    fn suite_units_have_no_documentation_page() {
396        let src = "suite for demo.app {\n\
397                   case works {\n\
398                   expect true\n\
399                   }\n\
400                   }";
401        // A `suite` unit is not a documentation unit in Tier 1.
402        assert!(documentation_model(src).is_none());
403    }
404
405    #[test]
406    fn empty_input_yields_no_model() {
407        assert!(documentation_model("").is_none());
408    }
409
410    #[test]
411    fn adapter_unit_documents_its_items() {
412        let src = "adapter tokens {\n\
413                   binding \"./tokens.binding.ts\"\n\
414                   exports capability { Jwt }\n\
415                   capability Jwt {\n\
416                   fn sign(secret: String) -> Effect[String]\n\
417                   }\n\
418                   provides Jwt = JoseJwt\n\
419                   }";
420        let model = documentation_model(src).expect("an adapter model");
421        assert_eq!(model.unit_name, "tokens");
422        assert_eq!(model.unit_kind, "adapter");
423        assert!(
424            model
425                .entries
426                .iter()
427                .any(|e| e.name == "Jwt" && e.kind == "capability")
428        );
429        assert!(
430            model
431                .entries
432                .iter()
433                .any(|e| e.name == "Jwt.sign" && e.kind == "operation")
434        );
435        assert!(
436            model
437                .entries
438                .iter()
439                .any(|e| e.name == "Jwt = JoseJwt" && e.kind == "provider")
440        );
441    }
442}