1use crate::examples::{ApiExample, Examples};
15use crate::{
16 ApiFunction, ApiFunctionKind, ApiInterface, ApiType, ApiTypeKind, Capability, Direction,
17 PluginApiCatalog,
18};
19
20pub 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
31pub 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
42pub 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
52pub 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
63pub 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
88pub 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
96pub 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
132fn 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
238fn 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
291pub 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
310pub fn seam(cat: &PluginApiCatalog, iface: &ApiInterface, level: usize) -> String {
318 seam_with(cat, iface, level, Links::None, None)
319}
320
321#[derive(Clone, Copy, PartialEq, Eq)]
325enum Links {
326 None,
327 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 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
450pub fn type_anchor(t: &ApiType) -> String {
455 format!("{}-{}", t.kind.keyword(), t.name)
456}
457
458fn 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
482fn 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
499fn 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
509fn 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 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
558pub 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
584fn 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
594fn 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
610fn 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
637fn 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}