Skip to main content

lattice_plugin_api/
render.rs

1//! PI.6 / AD.1: rendering the plugin-API catalog for humans.
2//!
3//! Moved out of the host at PI.6. It renders the CATALOG, which is this
4//! crate's, and the move is what lets the site's reference page be generated
5//! by a test here rather than only by `:export-plugin-api` in a running
6//! editor.
7//!
8//! AD.1 extends every seam from a list of function names to its full surface:
9//! signatures, resources and their methods, and every type with its fields or
10//! cases. The same [`seam`] renderer backs the single-document form
11//! ([`markdown`]) and, from AD.2, one page per seam — so the two cannot
12//! disagree about what a seam contains.
13
14use crate::examples::{ApiExample, Examples};
15use crate::{
16    ApiFunction, ApiFunctionKind, ApiInterface, ApiType, ApiTypeKind, Capability, Direction,
17    PluginApiCatalog,
18};
19
20/// A seam's [`Direction`] as a phrase for prose: "guest implements this
21/// interface". Used by the reference pages and `:describe-plugin-api`.
22pub fn direction_prose(d: Direction) -> &'static str {
23    match d {
24        Direction::GuestExport => "guest implements this interface",
25        Direction::GuestImport => "guest calls into the host through it",
26        Direction::Both => "guest both implements and calls it",
27        Direction::TypesOnly => "shared types only (not called directly)",
28    }
29}
30
31/// A seam's [`Direction`] as one word for a table cell: `exports`, `imports`,
32/// `both`, `types`.
33pub fn direction_short(d: Direction) -> &'static str {
34    match d {
35        Direction::GuestExport => "exports",
36        Direction::GuestImport => "imports",
37        Direction::Both => "both",
38        Direction::TypesOnly => "types",
39    }
40}
41
42/// A seam's [`Capability`] as a phrase for prose: `filesystem`, `network`, …
43pub fn capability_prose(c: Capability) -> &'static str {
44    match c {
45        Capability::Fs => "filesystem",
46        Capability::Net => "network",
47        Capability::Proc => "subprocess",
48        Capability::None => "none (pure data / dispatch)",
49    }
50}
51
52/// A seam's [`Capability`] as one token for a table cell: `fs`, `net`,
53/// `proc`, or `-` for none.
54pub fn capability_short(c: Capability) -> &'static str {
55    match c {
56        Capability::Fs => "fs",
57        Capability::Net => "net",
58        Capability::Proc => "proc",
59        Capability::None => "-",
60    }
61}
62
63/// PI.6: the whole plugin API as markdown, rendered from the canonical `wit/`
64/// package.
65///
66/// Lives here rather than in the host because it is a rendering of the
67/// CATALOG, and the catalog is this crate's. That placement is what lets the
68/// site's reference page be generated by a test in this crate instead of only
69/// by `:export-plugin-api` inside a running editor — a reference a developer
70/// can only obtain by launching the editor is one they will not read.
71pub fn markdown() -> String {
72    let cat = crate::catalog();
73    let mut out = String::new();
74    out.push_str("# Lattice Plugin API\n\n");
75    out.push_str(&format!(
76        "Derived from the canonical `wit/` package — {} seam(s).\n",
77        cat.interfaces.len()
78    ));
79    for iface in &cat.interfaces {
80        out.push('\n');
81        out.push_str(&seam(cat, iface, 2));
82    }
83    out.push('\n');
84    out.push_str(&worlds_page(cat, 2));
85    out
86}
87
88/// The first line of every generated file. Says what generated it and how
89/// to regenerate it, so nobody hand-edits a file the next test run reverts.
90pub const GENERATED_HEADER: &str = "\
91<!-- @generated from wit/ by crates/lattice-plugin-api (render.rs).
92     Do not edit: run `UPDATE_SITE_REFERENCE=1 cargo test -p lattice-plugin-api`. -->
93
94";
95
96/// AD.2: the reference as a set of files, relative to `docs/dev/reference/`:
97/// the index `plugin-api.md`, one `plugin-api/<seam>.md` per interface, and
98/// the machine-readable `plugin-api.json`.
99///
100/// One page per seam serves both readers: a person lands on the seam they
101/// searched for, and an agent loads the one page it needs instead of the
102/// whole API.
103///
104/// `examples` is the scan of the guests ([`crate::examples::scan`]); each one
105/// is rendered under the function, type or seam it targets.
106pub fn pages(examples: &Examples) -> Vec<(String, String)> {
107    let cat = crate::catalog();
108    let mut out = vec![(
109        "plugin-api.md".to_string(),
110        format!("{GENERATED_HEADER}{}", index(cat)),
111    )];
112    out.push((
113        "plugin-api/worlds.md".to_string(),
114        format!("{GENERATED_HEADER}{}", worlds_page(cat, 1)),
115    ));
116    for iface in &cat.interfaces {
117        out.push((
118            format!("plugin-api/{}.md", iface.name),
119            format!(
120                "{GENERATED_HEADER}{}",
121                seam_with(cat, iface, 1, Links::Pages, Some(examples))
122            ),
123        ));
124    }
125    out.push((
126        "plugin-api.json".to_string(),
127        crate::json::to_json(cat, Some(examples)),
128    ));
129    out
130}
131
132/// The index page: what the API is, how to read it, every world a plugin can
133/// target, and every seam with a one-line summary.
134fn index(cat: &PluginApiCatalog) -> String {
135    let mut out = String::new();
136    out.push_str("# Lattice Plugin API\n\n");
137    out.push_str(&format!(
138        "The plugin API is the WIT package `{}` — {} interfaces (\"seams\") and \
139         {} worlds. It is the whole contract: a plugin written in any language \
140         with Component-Model tooling (Rust, Go, Zig, JavaScript, …) sees \
141         exactly what is on these pages and nothing else. This reference is \
142         generated from the `.wit` files in `crates/lattice-wit/wit/`, so it \
143         cannot disagree with them.\n\n",
144        crate::PACKAGE,
145        cat.interfaces.len(),
146        cat.worlds.len(),
147    ));
148    out.push_str(
149        "New to writing plugins? Start with the \
150         [plugin authoring guide](../../dev/guides/plugin-authoring.md), then \
151         come back here for the detail. The same reference in machine-readable \
152         form — every seam, signature, type and member — is \
153         `docs/dev/reference/plugin-api.json` in the repository and \
154         `/plugin-api.json` on the documentation site.\n\n",
155    );
156
157    out.push_str("## How to read this reference\n\n");
158    out.push_str(
159        "- **A plugin targets one world.** The world decides which seams the \
160         plugin *exports* (implements — the host calls it) and which it \
161         *imports* (calls into the host). In Rust: \
162         `wit_bindgen::generate!({ world: \"comment-plugin\", path: \"…/wit\" })`.\n\
163         - **Direction** on each seam says which of those it is. A seam marked \
164         *shared types only* is never called; other seams `use` its types.\n\
165         - **Capability** is what the seam requires of a plugin's grant. Most \
166         are `none`: the host does the I/O and hands the guest data.\n\
167         - **Resources** (`resource document`) are handles to host-owned state. \
168         A `borrow<document>` parameter is valid for that call only.\n\
169         - **Errors** are `result<T, string>`: an `err` carries a message the \
170         host surfaces to the user, so it should say what went wrong.\n\
171         - **WIT to Rust** (wit-bindgen): kebab-case becomes `snake_case` for \
172         functions and fields and `UpperCamelCase` for types; `list<T>` is \
173         `Vec<T>`, `option<T>` is `Option<T>`, `result<T, E>` is \
174         `Result<T, E>`, `borrow<r>` is `&R`.\n\n",
175    );
176
177    out.push_str(&format!("## Worlds ({})\n\n", cat.worlds.len()));
178    out.push_str(
179        "Each world's entry points — the `register-*` functions the host calls \
180         on load — are on the [worlds page](plugin-api/worlds.md).\n\n",
181    );
182    out.push_str("| World | Exports (you implement) | Imports (you may call) |\n");
183    out.push_str("|---|---|---|\n");
184    for w in &cat.worlds {
185        let list = |names: &[String]| {
186            if names.is_empty() {
187                "—".to_string()
188            } else {
189                names
190                    .iter()
191                    .map(|n| format!("[`{n}`](plugin-api/{n}.md)"))
192                    .collect::<Vec<_>>()
193                    .join(", ")
194            }
195        };
196        let mut exports = list(&w.exports);
197        if !w.export_functions.is_empty() {
198            let fns = w
199                .export_functions
200                .iter()
201                .map(|f| format!("`{}`", f.name))
202                .collect::<Vec<_>>()
203                .join(", ");
204            exports = if w.exports.is_empty() {
205                fns
206            } else {
207                format!("{exports}; {fns}")
208            };
209        }
210        out.push_str(&format!(
211            "| [`{}`](plugin-api/worlds.md#world-{}) | {} | {} |\n",
212            w.name,
213            w.name,
214            exports,
215            list(&w.imports)
216        ));
217    }
218    out.push('\n');
219
220    out.push_str(&format!("## Seams ({})\n\n", cat.interfaces.len()));
221    out.push_str("| Seam | Direction | Capability | Functions | Types | Summary |\n");
222    out.push_str("|---|---|---|---|---|---|\n");
223    for i in &cat.interfaces {
224        out.push_str(&format!(
225            "| [`{}`](plugin-api/{}.md) | {} | {} | {} | {} | {} |\n",
226            i.name,
227            i.name,
228            direction_short(i.direction),
229            capability_short(i.capability),
230            i.functions.len(),
231            i.types.len(),
232            summary(i.doc.as_deref()).replace('|', "\\|"),
233        ));
234    }
235    out
236}
237
238/// Every world a plugin can target: what it imports and exports, and the
239/// freestanding functions it declares — most importantly the entry points the
240/// host calls on the guest at load. Those belong to no interface, so this is
241/// the only page they appear on.
242fn worlds_page(cat: &PluginApiCatalog, level: usize) -> String {
243    let h = |n: usize| "#".repeat((level + n).min(6));
244    let mut out = format!("{} Worlds\n\n", h(0));
245    out.push_str(
246        "A plugin component targets exactly one world. The world names the \
247         seams the plugin *imports* (host functions it may call) and *exports* \
248         (interfaces the host calls on it), plus freestanding functions — above \
249         all the `register-*` entry points the host calls once at load, where \
250         the plugin declares what it contributes. A plugin that needs seams from \
251         two worlds declares its own world that `include`s both.\n",
252    );
253    let links = |names: &[String]| {
254        if names.is_empty() {
255            "—".to_string()
256        } else {
257            names
258                .iter()
259                .map(|n| format!("[`{n}`]({n}.md)"))
260                .collect::<Vec<_>>()
261                .join(", ")
262        }
263    };
264    for w in &cat.worlds {
265        out.push_str(&format!("\n{} world `{}`\n\n", h(1), w.name));
266        if let Some(doc) = &w.doc {
267            out.push_str(&demote_headings(doc, level + 2));
268            out.push_str("\n\n");
269        }
270        out.push_str(&format!(
271            "**Imports:** {}  \n**Exports:** {}\n\n",
272            links(&w.imports),
273            links(&w.exports)
274        ));
275        for (label, fns) in [
276            ("Entry points it exports", &w.export_functions),
277            ("Functions it imports", &w.import_functions),
278        ] {
279            if fns.is_empty() {
280                continue;
281            }
282            out.push_str(&format!("**{label}**\n\n"));
283            for f in fns {
284                function(&mut out, f, &h(2));
285            }
286        }
287    }
288    out
289}
290
291/// The first sentence of a doc's first paragraph, on one line.
292pub fn summary(doc: Option<&str>) -> String {
293    let Some(doc) = doc else {
294        return String::new();
295    };
296    let para = doc
297        .split("\n\n")
298        .next()
299        .unwrap_or("")
300        .lines()
301        .map(str::trim)
302        .collect::<Vec<_>>()
303        .join(" ");
304    match para.find(". ") {
305        Some(end) => para[..=end].to_string(),
306        None => para,
307    }
308}
309
310/// One seam's full reference, its title at heading level `level` (1 for a
311/// page of its own, 2 inside the single document).
312///
313/// Order: what it is (direction, capability, worlds), its prose, the types it
314/// borrows, its functions, its resources with their methods, then the types it
315/// defines in WIT source order. Functions come before types because a reader
316/// arrives asking "what can I call"; the types answer the follow-up question.
317pub fn seam(cat: &PluginApiCatalog, iface: &ApiInterface, level: usize) -> String {
318    seam_with(cat, iface, level, Links::None, None)
319}
320
321/// Whether type names become links. The single document is read as one
322/// stream (the editor's export buffer, the agent bundle), where a link to a
323/// sibling file goes nowhere; the per-seam pages link across files.
324#[derive(Clone, Copy, PartialEq, Eq)]
325enum Links {
326    None,
327    /// Per-seam pages that sit side by side: `<seam>.md#<anchor>`.
328    Pages,
329}
330
331fn seam_with(
332    cat: &PluginApiCatalog,
333    iface: &ApiInterface,
334    level: usize,
335    links: Links,
336    examples: Option<&Examples>,
337) -> String {
338    // Examples for `<seam>` or `<seam>.<item>`; none when rendering without
339    // a scan (the editor's in-app export — see `examples.rs` for why).
340    let examples_for = |item: Option<&str>| -> Vec<&ApiExample> {
341        let target = match item {
342            Some(item) => format!("{}.{item}", iface.name),
343            None => iface.name.clone(),
344        };
345        examples
346            .map(|ex| ex.for_target(&target))
347            .unwrap_or_default()
348    };
349    let h = |n: usize| "#".repeat((level + n).min(6));
350    let mut out = String::new();
351
352    out.push_str(&format!("{} `{}`\n\n", h(0), iface.name));
353    out.push_str(&format!(
354        "**Direction:** {} · **Capability:** {}",
355        direction_prose(iface.direction),
356        capability_prose(iface.capability),
357    ));
358    let worlds = worlds_of(cat, &iface.name);
359    if !worlds.is_empty() {
360        out.push_str(" · **Worlds:** ");
361        out.push_str(&worlds.join(", "));
362    }
363    out.push_str("\n\n");
364
365    if let Some(doc) = &iface.doc {
366        out.push_str(&demote_headings(doc, level + 1));
367        out.push_str("\n\n");
368    }
369    example_blocks(&mut out, &examples_for(None), links);
370
371    if !iface.uses.is_empty() {
372        out.push_str(&format!("{} Uses\n\n", h(1)));
373        for u in &iface.uses {
374            let target = type_link(cat, iface, &u.name, links);
375            let name = match &target {
376                Some(href) => format!("[`{}`]({href})", u.name),
377                None => format!("`{}`", u.name),
378            };
379            let from = match links {
380                Links::Pages => format!("[`{}`]({}.md)", u.from, u.from),
381                Links::None => format!("`{}`", u.from),
382            };
383            if u.name == u.original {
384                out.push_str(&format!("- {name} from {from}\n"));
385            } else {
386                out.push_str(&format!("- {name} (`{}` from {from})\n", u.original));
387            }
388        }
389        out.push('\n');
390    }
391
392    let freestanding: Vec<&ApiFunction> = iface
393        .functions
394        .iter()
395        .filter(|f| f.kind == ApiFunctionKind::Freestanding)
396        .collect();
397    let resources: Vec<&ApiType> = iface
398        .types
399        .iter()
400        .filter(|t| t.kind == ApiTypeKind::Resource)
401        .collect();
402
403    out.push_str(&format!("{} Functions ({})\n\n", h(1), freestanding.len()));
404    if freestanding.is_empty() {
405        if resources.is_empty() {
406            out.push_str("_(none — a shared type interface)_\n\n");
407        } else {
408            out.push_str("_(none outside its resources — see Resources below)_\n\n");
409        }
410    }
411    for f in freestanding {
412        function(&mut out, f, &h(2));
413        example_blocks(&mut out, &examples_for(Some(&f.display_name())), links);
414    }
415
416    if !resources.is_empty() {
417        out.push_str(&format!("{} Resources\n\n", h(1)));
418        for r in resources {
419            out.push_str(&format!("{} resource `{}`\n\n", h(2), r.name));
420            if let Some(doc) = &r.doc {
421                out.push_str(&demote_headings(doc, level + 3));
422                out.push_str("\n\n");
423            }
424            example_blocks(&mut out, &examples_for(Some(&r.name)), links);
425            for f in iface.functions.iter().filter(|f| belongs_to(f, &r.name)) {
426                function(&mut out, f, &h(3));
427                example_blocks(&mut out, &examples_for(Some(&f.display_name())), links);
428            }
429        }
430    }
431
432    let types: Vec<&ApiType> = iface
433        .types
434        .iter()
435        .filter(|t| t.kind != ApiTypeKind::Resource)
436        .collect();
437    if !types.is_empty() {
438        out.push_str(&format!("{} Types ({})\n\n", h(1), types.len()));
439        for t in types {
440            type_def(&mut out, t, &h(2), level + 3, &|ty| {
441                type_link(cat, iface, ty, links)
442            });
443            example_blocks(&mut out, &examples_for(Some(&t.name)), links);
444        }
445    }
446
447    out
448}
449
450/// The anchor a type's heading gets: `record raw-candidate` →
451/// `record-raw-candidate`. Zola and GitHub slug the heading
452/// ``record `raw-candidate` `` to the same string, which is what lets one
453/// generated link work in both.
454pub fn type_anchor(t: &ApiType) -> String {
455    format!("{}-{}", t.kind.keyword(), t.name)
456}
457
458/// Where the type called `name` in `iface` is defined, as a link, if links are
459/// on and `name` is a single named type (a composite like `list<x>` is left as
460/// code — it cannot be one link).
461fn type_link(
462    cat: &PluginApiCatalog,
463    iface: &ApiInterface,
464    name: &str,
465    links: Links,
466) -> Option<String> {
467    if links == Links::None {
468        return None;
469    }
470    if let Some(t) = iface.types.iter().find(|t| t.name == name) {
471        return Some(format!("#{}", type_anchor(t)));
472    }
473    let u = iface.uses.iter().find(|u| u.name == name)?;
474    let def = cat
475        .interface(&u.from)?
476        .types
477        .iter()
478        .find(|t| t.name == u.original)?;
479    Some(format!("{}.md#{}", u.from, type_anchor(def)))
480}
481
482/// Each example: its caption, where it lives, and the code. On the per-seam
483/// pages the source path links to the file (the site turns the repo-relative
484/// link into a GitHub URL); no line number, because a line number goes stale
485/// with every edit above the region and would churn the reference.
486fn example_blocks(out: &mut String, examples: &[&ApiExample], links: Links) {
487    for e in examples {
488        let source = match links {
489            Links::Pages => format!("[`{}`](../../../../{})", e.source, e.source),
490            Links::None => format!("`{}`", e.source),
491        };
492        out.push_str(&format!(
493            "**Example — {}** · {source}\n\n```rust\n{}\n```\n\n",
494            e.caption, e.code
495        ));
496    }
497}
498
499/// A function: heading, WIT signature, full doc.
500fn function(out: &mut String, f: &ApiFunction, h: &str) {
501    out.push_str(&format!("{h} `{}`\n\n", f.display_name()));
502    out.push_str(&format!("```wit\n{}\n```\n\n", f.signature()));
503    if let Some(doc) = &f.doc {
504        out.push_str(&demote_headings(doc, h.len() + 1));
505        out.push_str("\n\n");
506    }
507}
508
509/// A type: heading, its WIT definition, its doc, then each member's doc.
510fn type_def(
511    out: &mut String,
512    t: &ApiType,
513    h: &str,
514    doc_level: usize,
515    link: &dyn Fn(&str) -> Option<String>,
516) {
517    out.push_str(&format!("{h} {} `{}`\n\n", t.kind.keyword(), t.name));
518    out.push_str(&format!("```wit\n{}\n```\n\n", wit_definition(t)));
519    if let Some(doc) = &t.doc {
520        out.push_str(&demote_headings(doc, doc_level));
521        out.push_str("\n\n");
522    }
523    // The member list repeats the definition block's names, so it earns its
524    // place only by adding something: a member's prose, or a link to a
525    // member's type (the code block above cannot link).
526    let members = t.kind.members();
527    let linked = |m: &crate::ApiMember| m.ty.as_deref().and_then(link);
528    if members
529        .iter()
530        .any(|m| m.doc.is_some() || linked(m).is_some())
531    {
532        let label = match t.kind {
533            ApiTypeKind::Record(_) => "Fields",
534            ApiTypeKind::Flags(_) => "Flags",
535            _ => "Cases",
536        };
537        out.push_str(&format!("**{label}**\n\n"));
538        for m in members {
539            let ty = match (m.ty.as_deref(), linked(m)) {
540                (Some(ty), Some(href)) => format!(": [`{ty}`]({href})"),
541                (Some(ty), None) => format!(": `{ty}`"),
542                (None, _) => String::new(),
543            };
544            out.push_str(&format!("- `{}`{ty}", m.name));
545            match &m.doc {
546                Some(doc) => {
547                    out.push_str(" — ");
548                    out.push_str(&indent_continuation(doc.trim_end(), "  "));
549                    out.push('\n');
550                }
551                None => out.push('\n'),
552            }
553        }
554        out.push('\n');
555    }
556}
557
558/// The type written back as a WIT declaration, members without docs — the
559/// shape at a glance, with the prose below it.
560pub fn wit_definition(t: &ApiType) -> String {
561    let kw = t.kind.keyword();
562    match &t.kind {
563        ApiTypeKind::Alias(target) => format!("type {} = {target};", t.name),
564        ApiTypeKind::Resource => format!("resource {};", t.name),
565        ApiTypeKind::Record(ms)
566        | ApiTypeKind::Variant(ms)
567        | ApiTypeKind::Enum(ms)
568        | ApiTypeKind::Flags(ms) => {
569            let is_record = matches!(t.kind, ApiTypeKind::Record(_));
570            let mut s = format!("{kw} {} {{\n", t.name);
571            for m in ms {
572                match (&m.ty, is_record) {
573                    (Some(ty), true) => s.push_str(&format!("    {}: {ty},\n", m.name)),
574                    (Some(ty), false) => s.push_str(&format!("    {}({ty}),\n", m.name)),
575                    (None, _) => s.push_str(&format!("    {},\n", m.name)),
576                }
577            }
578            s.push('}');
579            s
580        }
581    }
582}
583
584/// Is `f` a method, static or constructor of the resource `resource`?
585fn belongs_to(f: &ApiFunction, resource: &str) -> bool {
586    match &f.kind {
587        ApiFunctionKind::Freestanding => false,
588        ApiFunctionKind::Method(r)
589        | ApiFunctionKind::Static(r)
590        | ApiFunctionKind::Constructor(r) => r == resource,
591    }
592}
593
594/// The worlds that import or export `iface`, as `` `world` (exports) ``.
595fn worlds_of(cat: &PluginApiCatalog, iface: &str) -> Vec<String> {
596    cat.worlds
597        .iter()
598        .filter_map(|w| {
599            let exports = w.exports.iter().any(|e| e == iface);
600            let imports = w.imports.iter().any(|i| i == iface);
601            match (exports, imports) {
602                (true, _) => Some(format!("`{}` (exports)", w.name)),
603                (false, true) => Some(format!("`{}` (imports)", w.name)),
604                (false, false) => None,
605            }
606        })
607        .collect()
608}
609
610/// Push every ATX heading in `doc` down so its top level is `min_level`,
611/// leaving fenced code alone. A WIT doc that uses `##` for its own sections
612/// must not become a sibling of the seam it describes.
613fn demote_headings(doc: &str, min_level: usize) -> String {
614    let mut in_fence = false;
615    doc.trim_end()
616        .lines()
617        .map(|line| {
618            if line.trim_start().starts_with("```") {
619                in_fence = !in_fence;
620                return line.to_string();
621            }
622            if in_fence {
623                return line.to_string();
624            }
625            let hashes = line.chars().take_while(|&c| c == '#').count();
626            if hashes > 0 && line[hashes..].starts_with(' ') {
627                let level = (hashes + min_level - 1).min(6);
628                format!("{}{}", "#".repeat(level), &line[hashes..])
629            } else {
630                line.to_string()
631            }
632        })
633        .collect::<Vec<_>>()
634        .join("\n")
635}
636
637/// Indent every line after the first by `pad`, so a multi-line doc stays
638/// inside the list item it is attached to.
639fn indent_continuation(doc: &str, pad: &str) -> String {
640    let mut lines = doc.lines();
641    let mut out = lines.next().unwrap_or("").to_string();
642    for line in lines {
643        out.push('\n');
644        if !line.is_empty() {
645            out.push_str(pad);
646            out.push_str(line);
647        }
648    }
649    out
650}