lattice_plugin_api/
examples.rs1use std::fs;
31use std::path::{Path, PathBuf};
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct ApiExample {
36 pub id: String,
39 pub target: String,
41 pub caption: String,
43 pub source: String,
45 pub code: String,
47}
48
49impl ApiExample {
50 pub fn interface(&self) -> &str {
52 self.target.split('.').next().unwrap_or(&self.target)
53 }
54
55 pub fn item(&self) -> Option<&str> {
58 self.target.split_once('.').map(|(_, item)| item)
59 }
60}
61
62#[derive(Debug, Default)]
65pub struct Examples {
66 pub items: Vec<ApiExample>,
68 pub problems: Vec<String>,
70}
71
72impl Examples {
73 pub fn for_target(&self, target: &str) -> Vec<&ApiExample> {
75 self.items.iter().filter(|e| e.target == target).collect()
76 }
77
78 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
87pub const GUEST_ROOTS: &[&str] = &["plugins", "crates/lattice-plugin-host/tests/fixtures"];
90
91pub 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
154fn extract(guest: &str, source: &str, text: &str, out: &mut Examples) {
156 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
211fn 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}