1use std::fmt::Write as _;
13use std::path::Path;
14
15pub mod write;
16
17pub const CATEGORY_FILE: &str = "_category.md";
19pub const MAX_DEPTH: usize = 8;
21pub const MAX_FILES: usize = 2000;
23pub const MAX_FILE_BYTES: u64 = 1024 * 1024;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Source {
29 BuiltIn,
31 User,
33}
34
35impl Source {
36 pub fn as_str(self) -> &'static str {
38 match self {
39 Self::BuiltIn => "built-in",
40 Self::User => "yours",
41 }
42 }
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct Doc {
48 pub path: String,
50 pub title: String,
51 pub description: String,
52 pub category: String,
54 pub order: Option<i64>,
55 pub tags: Vec<String>,
56 pub source: Source,
57 pub text: String,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct Category {
64 pub path: String,
66 pub title: String,
67 pub description: String,
68 pub overview: String,
70 pub text: Option<String>,
72 pub source: Source,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct Node {
78 pub path: String,
79 pub title: String,
80 pub description: String,
81 pub docs: usize,
83 pub total: usize,
85 pub children: Vec<Node>,
86}
87
88#[derive(Debug, Clone, Default, PartialEq, Eq)]
90pub struct Library {
91 docs: Vec<Doc>,
92 categories: Vec<Category>,
93}
94
95impl Library {
96 pub fn new() -> Self {
97 Self::default()
98 }
99
100 pub fn from_files<'a>(files: impl IntoIterator<Item = (&'a str, &'a str)>) -> Self {
102 let mut lib = Self::new();
103 for (path, text) in files {
104 lib.insert(path, text, Source::BuiltIn);
105 }
106 lib
107 }
108
109 pub fn insert(&mut self, path: &str, text: &str, source: Source) {
111 let path = normalize(path);
112 let (dir, name) = split(&path);
113 let front = Frontmatter::parse(text);
114 if name == CATEGORY_FILE {
115 let cat = Category {
116 path: dir.to_string(),
117 title: front.title.clone().unwrap_or_else(|| default_title(dir)),
118 description: front.description.clone().unwrap_or_default(),
119 overview: front.body.trim().to_string(),
120 text: Some(text.to_string()),
121 source,
122 };
123 self.categories.retain(|c| c.path != cat.path);
124 self.categories.push(cat);
125 self.categories.sort_by(|a, b| a.path.cmp(&b.path));
126 return;
127 }
128 let stem = name.strip_suffix(".md").unwrap_or(name);
129 let doc = Doc {
130 title: front.title.clone().unwrap_or_else(|| first_heading(front.body).unwrap_or_else(|| humanize(stem))),
131 description: front.description.clone().unwrap_or_default(),
132 category: dir.to_string(),
133 order: front.order,
134 tags: front.tags.clone(),
135 source,
136 text: text.to_string(),
137 path,
138 };
139 self.docs.retain(|d| d.path != doc.path);
140 self.docs.push(doc);
141 self.docs.sort_by(|a, b| a.path.cmp(&b.path));
142 }
143
144 pub fn overlay_dir(mut self, dir: &Path) -> Self {
149 let mut files = Vec::new();
150 walk(dir, "", 0, &mut files);
151 for (rel, file) in files.into_iter().take(MAX_FILES) {
152 let small = std::fs::metadata(&file).is_ok_and(|m| m.len() <= MAX_FILE_BYTES);
153 if let (true, Ok(text)) = (small, std::fs::read_to_string(&file)) {
154 self.insert(&rel, &text, Source::User);
155 }
156 }
157 self
158 }
159
160 pub fn built_in_only(&self) -> Self {
162 Self {
163 docs: self.docs.iter().filter(|d| d.source == Source::BuiltIn).cloned().collect(),
164 categories: self.categories.iter().filter(|c| c.source == Source::BuiltIn).cloned().collect(),
165 }
166 }
167
168 pub fn is_empty(&self) -> bool {
169 self.docs.is_empty() && self.categories.is_empty()
170 }
171
172 pub fn docs(&self) -> &[Doc] {
174 &self.docs
175 }
176
177 pub fn described(&self) -> &[Category] {
179 &self.categories
180 }
181
182 pub fn overview(&self) -> Option<&str> {
184 self.categories.iter().find(|c| c.path.is_empty()).map(|c| c.overview.as_str()).filter(|o| !o.is_empty())
185 }
186
187 pub fn category(&self, path: &str) -> Option<Category> {
190 let path = normalize(path);
191 if let Some(c) = self.categories.iter().find(|c| c.path == path) {
192 return Some(c.clone());
193 }
194 let known = path.is_empty() || self.all_category_paths().contains(&path);
195 known.then(|| Category {
196 title: default_title(&path),
197 path,
198 description: String::new(),
199 overview: String::new(),
200 text: None,
201 source: Source::BuiltIn,
202 })
203 }
204
205 pub fn tree(&self, path: &str) -> Option<Node> {
207 let cat = self.category(path)?;
208 Some(self.node(cat))
209 }
210
211 pub fn list(&self, category: Option<&str>, query: Option<&str>, recursive: bool) -> Vec<&Doc> {
215 let cat = category.map(normalize);
216 let q = query.map(str::to_lowercase).filter(|q| !q.is_empty());
217 let mut out: Vec<&Doc> = self
218 .docs
219 .iter()
220 .filter(|d| match &cat {
221 None => true,
222 Some(c) if c.is_empty() => recursive || d.category.is_empty(),
223 Some(c) => d.category == *c || (recursive && d.category.starts_with(&format!("{c}/"))),
224 })
225 .filter(|d| q.as_deref().is_none_or(|q| d.matches(q)))
226 .collect();
227 out.sort_by(|a, b| a.category.cmp(&b.category).then(a.order.unwrap_or(i64::MAX).cmp(&b.order.unwrap_or(i64::MAX))).then(a.path.cmp(&b.path)));
228 out
229 }
230
231 pub fn read(&self, path: &str) -> Result<&str, String> {
234 let p = normalize(path.trim().trim_start_matches("docs://"));
235 if let Some(d) = self.docs.iter().find(|d| d.path == p) {
236 return Ok(&d.text);
237 }
238 let (dir, name) = split(&p);
239 if name == CATEGORY_FILE
240 && let Some(text) = self.categories.iter().find(|c| c.path == dir).and_then(|c| c.text.as_deref())
241 {
242 return Ok(text);
243 }
244 if self.category(&p).is_some() {
245 return Err(format!("`{p}` is a category, not a document: list it to see its documents"));
246 }
247 let close = self.close_matches(&p);
248 if close.is_empty() {
249 Err(format!("no document `{p}`"))
250 } else {
251 Err(format!("no document `{p}`; did you mean {}?", close.iter().map(|c| format!("`{c}`")).collect::<Vec<_>>().join(", ")))
252 }
253 }
254
255 pub fn close_matches(&self, path: &str) -> Vec<&str> {
257 let (_, name) = split(path);
258 let stem = name.strip_suffix(".md").unwrap_or(name).to_lowercase();
259 if stem.is_empty() {
260 return Vec::new();
261 }
262 let same: Vec<&str> = self.docs.iter().filter(|d| split(&d.path).1.eq_ignore_ascii_case(name)).map(|d| d.path.as_str()).collect();
263 let near = self.docs.iter().filter(|d| d.path.to_lowercase().contains(&stem)).map(|d| d.path.as_str());
264 let mut out = same;
265 for p in near {
266 if !out.contains(&p) {
267 out.push(p);
268 }
269 }
270 out.truncate(5);
271 out
272 }
273
274 fn all_category_paths(&self) -> Vec<String> {
275 let mut paths: Vec<String> = Vec::new();
276 let dirs = self.docs.iter().map(|d| d.category.as_str()).chain(self.categories.iter().map(|c| c.path.as_str()));
277 for dir in dirs {
278 let mut acc = String::new();
279 for part in dir.split('/').filter(|p| !p.is_empty()) {
280 if !acc.is_empty() {
281 acc.push('/');
282 }
283 acc.push_str(part);
284 if !paths.contains(&acc) {
285 paths.push(acc.clone());
286 }
287 }
288 }
289 paths.sort();
290 paths
291 }
292
293 fn node(&self, cat: Category) -> Node {
294 let prefix = if cat.path.is_empty() { String::new() } else { format!("{}/", cat.path) };
295 let children: Vec<Node> = self
296 .all_category_paths()
297 .into_iter()
298 .filter(|p| p.strip_prefix(&prefix).is_some_and(|rest| !rest.is_empty() && !rest.contains('/')))
299 .filter_map(|p| self.category(&p))
300 .map(|c| self.node(c))
301 .collect();
302 let docs = self.docs.iter().filter(|d| d.category == cat.path).count();
303 let total = docs + children.iter().map(|c| c.total).sum::<usize>();
304 Node { path: cat.path, title: cat.title, description: cat.description, docs, total, children }
305 }
306}
307
308impl Doc {
309 fn matches(&self, q: &str) -> bool {
310 [&self.path, &self.title, &self.description, &self.text].iter().any(|s| s.to_lowercase().contains(q))
311 || self.tags.iter().any(|t| t.to_lowercase().contains(q))
312 }
313}
314
315#[derive(Debug, Default)]
317pub struct Frontmatter<'a> {
318 pub title: Option<String>,
319 pub description: Option<String>,
320 pub order: Option<i64>,
321 pub tags: Vec<String>,
322 pub body: &'a str,
323}
324
325impl<'a> Frontmatter<'a> {
326 pub fn parse(text: &'a str) -> Self {
329 let mut out = Self { body: text, ..Self::default() };
330 let rest = text.strip_prefix('\u{feff}').unwrap_or(text);
331 let Some(after_open) = rest.strip_prefix("---\n").or_else(|| rest.strip_prefix("---\r\n")) else { return out };
332 let mut offset = 0;
333 let mut closed = None;
334 for line in after_open.split_inclusive('\n') {
335 if line.trim_end() == "---" || line.trim_end() == "..." {
336 closed = Some(offset + line.len());
337 break;
338 }
339 if let Some((key, value)) = line.split_once(':') {
340 let value = unquote(value.trim());
341 match key.trim() {
342 "title" => out.title = Some(value).filter(|v| !v.is_empty()),
343 "description" => out.description = Some(value).filter(|v| !v.is_empty()),
344 "order" => out.order = value.parse().ok(),
345 "tags" => {
346 out.tags = value
347 .trim_start_matches('[')
348 .trim_end_matches(']')
349 .split(',')
350 .map(|t| unquote(t.trim()))
351 .filter(|t| !t.is_empty())
352 .collect()
353 }
354 _ => {}
355 }
356 }
357 offset += line.len();
358 }
359 if let Some(end) = closed {
360 out.body = after_open.get(end..).unwrap_or("");
361 } else {
362 out = Self { body: text, ..Self::default() };
363 }
364 out
365 }
366}
367
368pub fn embed(dir: &Path, out_file: &Path) -> std::io::Result<usize> {
373 let mut files = Vec::new();
374 walk(dir, "", 0, &mut files);
375 files.sort();
376 println!("cargo:rerun-if-changed={}", dir.display());
377 let mut dirs = Vec::new();
378 walk_dirs(dir, 0, &mut dirs);
379 for d in &dirs {
380 println!("cargo:rerun-if-changed={}", d.display());
381 }
382 let mut src =
383 String::from("/// Every documentation file, `(path from the docs root, text)`, sorted by path.\npub static FILES: &[(&str, &str)] = &[\n");
384 for (rel, file) in &files {
385 println!("cargo:rerun-if-changed={}", file.display());
386 let abs = std::fs::canonicalize(file)?;
387 let _ = writeln!(src, " ({rel:?}, include_str!({:?})),", abs.display().to_string());
388 }
389 src.push_str("];\n");
390 std::fs::write(out_file, src)?;
391 Ok(files.len())
392}
393
394fn walk(dir: &Path, rel: &str, depth: usize, out: &mut Vec<(String, std::path::PathBuf)>) {
397 if depth > MAX_DEPTH || out.len() >= MAX_FILES {
398 return;
399 }
400 let Ok(entries) = std::fs::read_dir(dir) else { return };
401 let mut entries: Vec<_> = entries.flatten().collect();
402 entries.sort_by_key(|e| e.file_name());
403 for entry in entries {
404 let name = entry.file_name().to_string_lossy().into_owned();
405 let Ok(kind) = entry.file_type() else { continue };
407 if name.starts_with('.') || kind.is_symlink() {
408 continue;
409 }
410 let child = if rel.is_empty() { name.clone() } else { format!("{rel}/{name}") };
411 if kind.is_dir() {
412 walk(&entry.path(), &child, depth + 1, out);
413 } else if kind.is_file() && name.ends_with(".md") {
414 out.push((child, entry.path()));
415 }
416 }
417}
418
419fn walk_dirs(dir: &Path, depth: usize, out: &mut Vec<std::path::PathBuf>) {
420 if depth > MAX_DEPTH {
421 return;
422 }
423 let Ok(entries) = std::fs::read_dir(dir) else { return };
424 for entry in entries.flatten() {
425 let hidden = entry.file_name().to_string_lossy().starts_with('.');
426 if !hidden && entry.file_type().is_ok_and(|k| k.is_dir()) {
427 out.push(entry.path());
428 walk_dirs(&entry.path(), depth + 1, out);
429 }
430 }
431}
432
433fn normalize(path: &str) -> String {
435 path.replace('\\', "/").split('/').filter(|p| !p.is_empty() && *p != ".").collect::<Vec<_>>().join("/")
436}
437
438fn split(path: &str) -> (&str, &str) {
440 path.rsplit_once('/').unwrap_or(("", path))
441}
442
443fn unquote(v: &str) -> String {
444 let inner = v.strip_prefix('"').and_then(|s| s.strip_suffix('"')).or_else(|| v.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')));
445 match inner {
446 Some(s) if v.starts_with('"') => s.replace("\\\"", "\"").replace("\\\\", "\\"),
447 Some(s) => s.replace("''", "'"),
448 None => v.to_string(),
449 }
450}
451
452fn first_heading(body: &str) -> Option<String> {
453 body.lines().find_map(|l| l.strip_prefix("# ")).map(|t| t.trim().to_string()).filter(|t| !t.is_empty())
454}
455
456fn default_title(dir: &str) -> String {
457 if dir.is_empty() { "Documentation".to_string() } else { humanize(split(dir).1) }
458}
459
460fn humanize(name: &str) -> String {
462 let words: Vec<&str> = name.split(['-', '_']).filter(|w| !w.is_empty()).collect();
463 let joined = words.join(" ");
464 let mut chars = joined.chars();
465 match chars.next() {
466 Some(first) => first.to_uppercase().chain(chars).collect(),
467 None => name.to_string(),
468 }
469}