Skip to main content

lattice_plugin_api/
examples.rs

1//! AD.3: examples for the plugin-API reference, extracted from guests CI
2//! already builds.
3//!
4//! An example is a region of a real guest's source, marked in place:
5//!
6//! ```text
7//! // @example grammar-callbacks.apply-operator: Toggle comments over a range
8//! fn apply_operator(...) -> Result<Vec<Effect>, String> { ... }
9//! // @end-example
10//! ```
11//!
12//! The target is `<interface>`, `<interface>.<function>` (a method is
13//! `<interface>.<resource>.<method>`, as the reference names it) or
14//! `<interface>.<type>`. Regions are scanned from `plugins/*/src` and
15//! `crates/lattice-plugin-host/tests/fixtures/*/src` — both compiled against the
16//! current `wit/` by `lattice-plugin-host`'s build script, which fails the
17//! build when a guest does not compile and the wasm target is installed (as
18//! it is in CI). So an example here compiles, and the fixture ones also run
19//! under real guest↔host tests.
20//!
21//! **Why scanned at test time, not in `build.rs` with the catalog.** The
22//! catalog is compiled into `lattice-plugin-api`, which `lattice-host` links.
23//! Declaring every guest source as a build input would make any edit to a
24//! plugin or fixture rebuild this crate and so relink the whole host — the
25//! exact cost `lattice-plugin-host/build.rs` documents paying once already.
26//! The price of scanning here instead: the editor's in-app
27//! `:export-plugin-api` renders the reference without examples; the published
28//! pages, the JSON and the agent bundle carry them.
29
30use std::fs;
31use std::path::{Path, PathBuf};
32
33/// One extracted example.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct ApiExample {
36    /// `<guest>:<target>`, e.g. `comment:grammar-callbacks.apply-operator`.
37    /// Unique; guides quote an example by this id.
38    pub id: String,
39    /// What it illustrates: `<interface>` or `<interface>.<item>`.
40    pub target: String,
41    /// One line saying what the example does.
42    pub caption: String,
43    /// Repo-relative source file, `/`-separated.
44    pub source: String,
45    /// The region's code, dedented, without the marker lines.
46    pub code: String,
47}
48
49impl ApiExample {
50    /// The interface the target names.
51    pub fn interface(&self) -> &str {
52        self.target.split('.').next().unwrap_or(&self.target)
53    }
54
55    /// The item within the interface, or `None` for an interface-level
56    /// example.
57    pub fn item(&self) -> Option<&str> {
58        self.target.split_once('.').map(|(_, item)| item)
59    }
60}
61
62/// Every example found, plus every malformed region — reported together so
63/// one test run names all of them.
64#[derive(Debug, Default)]
65pub struct Examples {
66    /// The well-formed examples, sorted by id.
67    pub items: Vec<ApiExample>,
68    /// One line per malformed region (unterminated, nested, empty, duplicate).
69    pub problems: Vec<String>,
70}
71
72impl Examples {
73    /// The examples whose target is exactly `target`.
74    pub fn for_target(&self, target: &str) -> Vec<&ApiExample> {
75        self.items.iter().filter(|e| e.target == target).collect()
76    }
77
78    /// The example with this id.
79    pub fn by_id(&self, id: &str) -> Option<&ApiExample> {
80        self.items.iter().find(|e| e.id == id)
81    }
82}
83
84const START: &str = "// @example ";
85const END: &str = "// @end-example";
86
87/// The directories whose immediate subdirectories are guest crates, relative
88/// to the repository root.
89pub const GUEST_ROOTS: &[&str] = &["plugins", "crates/lattice-plugin-host/tests/fixtures"];
90
91/// Scan every guest under [`GUEST_ROOTS`] in the repository at `repo_root`.
92pub fn scan(repo_root: &Path) -> Examples {
93    let mut out = Examples::default();
94    for root in GUEST_ROOTS {
95        let Ok(entries) = fs::read_dir(repo_root.join(root)) else {
96            continue;
97        };
98        let mut guests: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
99        guests.sort();
100        for guest in guests {
101            let src = guest.join("src");
102            if !src.is_dir() {
103                continue;
104            }
105            let name = guest
106                .file_name()
107                .and_then(|n| n.to_str())
108                .unwrap_or("?")
109                .to_string();
110            let mut files = Vec::new();
111            rust_files(&src, &mut files);
112            files.sort();
113            for file in files {
114                let rel = file
115                    .strip_prefix(repo_root)
116                    .unwrap_or(&file)
117                    .to_string_lossy()
118                    .replace('\\', "/");
119                if let Ok(text) = fs::read_to_string(&file) {
120                    extract(&name, &rel, &text, &mut out);
121                }
122            }
123        }
124    }
125    out.items.sort_by(|a, b| a.id.cmp(&b.id));
126    let mut seen = std::collections::BTreeSet::new();
127    for e in &out.items {
128        if !seen.insert(e.id.clone()) {
129            out.problems.push(format!(
130                "{}: a second `{}` example in guest `{}` — give each target one example per guest",
131                e.source,
132                e.target,
133                e.id.split(':').next().unwrap_or("?")
134            ));
135        }
136    }
137    out
138}
139
140fn rust_files(dir: &Path, out: &mut Vec<PathBuf>) {
141    let Ok(entries) = fs::read_dir(dir) else {
142        return;
143    };
144    for entry in entries.flatten() {
145        let path = entry.path();
146        if path.is_dir() {
147            rust_files(&path, out);
148        } else if path.extension().is_some_and(|e| e == "rs") {
149            out.push(path);
150        }
151    }
152}
153
154/// Extract every region in one file.
155fn extract(guest: &str, source: &str, text: &str, out: &mut Examples) {
156    // (target, caption, first line number, lines)
157    let mut open: Option<(String, String, usize, Vec<&str>)> = None;
158    for (n, line) in text.lines().enumerate() {
159        let lineno = n + 1;
160        let trimmed = line.trim();
161        if let Some(rest) = trimmed.strip_prefix(START) {
162            if let Some((target, _, start, _)) = &open {
163                out.problems.push(format!(
164                    "{source}:{lineno}: `@example` inside the `{target}` region opened at \
165                     line {start} — regions do not nest"
166                ));
167            }
168            let (target, caption) = match rest.split_once(':') {
169                Some((t, c)) => (t.trim().to_string(), c.trim().to_string()),
170                None => (rest.trim().to_string(), String::new()),
171            };
172            if caption.is_empty() {
173                out.problems.push(format!(
174                    "{source}:{lineno}: `@example {target}` has no caption — write \
175                     `// @example {target}: <what it shows>`"
176                ));
177            }
178            open = Some((target, caption, lineno, Vec::new()));
179        } else if trimmed == END {
180            match open.take() {
181                Some((target, caption, start, lines)) => {
182                    let code = dedent(&lines);
183                    if code.trim().is_empty() {
184                        out.problems
185                            .push(format!("{source}:{start}: the `{target}` region is empty"));
186                        continue;
187                    }
188                    out.items.push(ApiExample {
189                        id: format!("{guest}:{target}"),
190                        target,
191                        caption,
192                        source: source.to_string(),
193                        code,
194                    });
195                }
196                None => out.problems.push(format!(
197                    "{source}:{lineno}: `@end-example` with no open region"
198                )),
199            }
200        } else if let Some((_, _, _, lines)) = &mut open {
201            lines.push(line);
202        }
203    }
204    if let Some((target, _, start, _)) = open {
205        out.problems.push(format!(
206            "{source}:{start}: the `{target}` region is never closed with `{END}`"
207        ));
208    }
209}
210
211/// Remove the indentation every non-blank line shares, and leading/trailing
212/// blank lines. The example reads as if it were written at the left margin.
213fn dedent(lines: &[&str]) -> String {
214    let indent = lines
215        .iter()
216        .filter(|l| !l.trim().is_empty())
217        .map(|l| l.len() - l.trim_start().len())
218        .min()
219        .unwrap_or(0);
220    let mut out: Vec<&str> = lines
221        .iter()
222        .map(|l| {
223            if l.len() >= indent {
224                &l[indent..]
225            } else {
226                l.trim_start()
227            }
228        })
229        .collect();
230    while out.last().is_some_and(|l| l.trim().is_empty()) {
231        out.pop();
232    }
233    while out.first().is_some_and(|l| l.trim().is_empty()) {
234        out.remove(0);
235    }
236    out.join("\n")
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    fn run(text: &str) -> Examples {
244        let mut out = Examples::default();
245        extract("g", "g/src/lib.rs", text, &mut out);
246        out
247    }
248
249    #[test]
250    fn a_region_is_extracted_dedented_with_its_target_and_caption() {
251        let ex = run(
252            "fn f() {\n    // @example buffer.document.line: Read a line\n        \
253             let l = doc.line(0);\n        drop(l);\n    // @end-example\n}\n",
254        );
255        assert!(ex.problems.is_empty(), "{:?}", ex.problems);
256        let e = &ex.items[0];
257        assert_eq!(e.id, "g:buffer.document.line");
258        assert_eq!(e.interface(), "buffer");
259        assert_eq!(e.item(), Some("document.line"));
260        assert_eq!(e.caption, "Read a line");
261        assert_eq!(e.code, "let l = doc.line(0);\ndrop(l);");
262    }
263
264    #[test]
265    fn malformed_regions_are_problems_not_panics() {
266        let unterminated = run("// @example a.b: x\nlet x = 1;\n");
267        assert!(unterminated.problems[0].contains("never closed"));
268        let nested = run("// @example a.b: x\n// @example a.c: y\n1;\n// @end-example\n");
269        assert!(nested.problems[0].contains("do not nest"));
270        let empty = run("// @example a.b: x\n\n// @end-example\n");
271        assert!(empty.problems[0].contains("empty"));
272        let stray = run("// @end-example\n");
273        assert!(stray.problems[0].contains("no open region"));
274        let uncaptioned = run("// @example a.b\n1;\n// @end-example\n");
275        assert!(uncaptioned.problems[0].contains("no caption"));
276    }
277}