Skip to main content

krabka_docgen/
snippets.rs

1//! Sync fenced code blocks in website markdown from anchored regions of source
2//! files.
3//!
4//! The published docs thus contain exactly the tested example code.
5
6use std::{fs, path::Path};
7
8/// Extract the lines between `// docs:begin <anchor>` and `// docs:end <anchor>`
9/// in `source`.
10///
11/// The function removes the markers and trims the common leading indentation.
12///
13/// # Errors
14/// Returns an error string if a marker is missing, repeated, or out of order.
15pub fn extract(source: &str, anchor: &str) -> Result<String, String> {
16    let begin = format!("// docs:begin {anchor}");
17    let end = format!("// docs:end {anchor}");
18    let mut lines = Vec::new();
19    let mut inside = false;
20    let mut completed = false;
21    for line in source.lines() {
22        if line.trim() == begin {
23            if inside || completed {
24                return Err(format!("anchor {anchor}: repeated begin marker"));
25            }
26            inside = true;
27            continue;
28        }
29        if line.trim() == end {
30            if !inside {
31                return Err(format!("anchor {anchor}: end before begin"));
32            }
33            inside = false;
34            completed = true;
35            continue;
36        }
37        if inside {
38            lines.push(line.to_owned());
39        }
40    }
41    if !completed || inside {
42        return Err(format!("anchor {anchor}: markers not found in source"));
43    }
44    let indent = lines
45        .iter()
46        .filter(|line| !line.trim().is_empty())
47        .map(|line| line.len() - line.trim_start_matches([' ', '\t']).len())
48        .min()
49        .unwrap_or(0);
50    Ok(lines
51        .iter()
52        .map(|line| line.get(indent..).unwrap_or(line))
53        .collect::<Vec<_>>()
54        .join("\n"))
55}
56
57/// Rewrite every `<!-- snippet: <relpath>#<anchor> --> ... <!-- /snippet -->`
58/// block in `markdown` with the current code from `crates_dir/<relpath>`.
59///
60/// The function returns the new markdown. It is idempotent.
61///
62/// # Errors
63/// Returns an error if a referenced source file or anchor cannot be read.
64pub fn sync_markdown(markdown: &str, crates_dir: &Path) -> Result<String, String> {
65    let mut out = String::new();
66    let mut rest = markdown;
67    while let Some(start) = rest.find("<!-- snippet:") {
68        let (head, after) = rest.split_at(start);
69        out.push_str(head);
70        let (directive, body_start) = after
71            .split_once("-->")
72            .ok_or("unterminated snippet directive")?;
73        let spec = directive.trim_start_matches("<!-- snippet:").trim();
74        let (relpath, anchor) = spec
75            .split_once('#')
76            .ok_or_else(|| format!("snippet directive missing '#': {spec}"))?;
77        let end_marker = "<!-- /snippet -->";
78        let (_, after_body) = body_start
79            .split_once(end_marker)
80            .ok_or("missing <!-- /snippet -->")?;
81        let source = fs::read_to_string(crates_dir.join(relpath.trim()))
82            .map_err(|e| format!("read {relpath}: {e}"))?;
83        let code = extract(&source, anchor.trim())?;
84        out.push_str(directive);
85        out.push_str("-->\n");
86        out.push_str("```rust\n");
87        out.push_str(&code);
88        out.push_str("\n```\n");
89        out.push_str(end_marker);
90        rest = after_body;
91    }
92    out.push_str(rest);
93    Ok(out)
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn extract_trims_indent_and_markers() {
102        let src = "fn main() {\n    // docs:begin foo\n    let x = 1;\n    // docs:end foo\n}\n";
103        assert2::assert!(extract(src, "foo").unwrap() == "let x = 1;");
104    }
105
106    #[test]
107    fn extract_uses_the_unique_exact_comment_marker_pair() {
108        let source = "// docs:begin foobar\nwrong();\n// docs:end foobar\n\
109                      // docs:begin foo\nright();\n// docs:end foo\n";
110        assert2::assert!(extract(source, "foo").unwrap() == "right();");
111    }
112
113    #[yare::parameterized(
114        wrong_marker_name = { "// docs:begin foobar\nwrong();\n// docs:end foobar\n" },
115        marker_text_in_strings = {
116            "let s = \"// docs:begin foo\";\nwrong();\nlet e = \"// docs:end foo\";\n",
117        },
118        duplicate_begin = { "// docs:begin foo\n// docs:begin foo\n// docs:end foo\n" },
119        duplicate_pairs = {
120            "// docs:begin foo\none();\n// docs:end foo\n// docs:begin foo\ntwo();\n// docs:end foo\n",
121        },
122        reversed_markers = { "// docs:end foo\n// docs:begin foo\n" },
123    )]
124    fn extract_requires_an_exact_comment_marker_and_unique_pair(invalid: &str) {
125        assert2::assert!(extract(invalid, "foo").is_err());
126    }
127
128    #[test]
129    fn sync_is_idempotent() {
130        let dir = tempfile::tempdir().unwrap();
131        fs::create_dir_all(dir.path().join("c/examples")).unwrap();
132        fs::write(
133            dir.path().join("c/examples/e.rs"),
134            "// docs:begin a\nlet y = 2;\n// docs:end a\n",
135        )
136        .unwrap();
137        let md = "intro\n<!-- snippet: c/examples/e.rs#a -->\nOLD\n<!-- /snippet -->\nend\n";
138        let once = sync_markdown(md, dir.path()).unwrap();
139        let twice = sync_markdown(&once, dir.path()).unwrap();
140        assert2::assert!(once == twice);
141        assert2::assert!(once.contains("```rust\nlet y = 2;\n```"));
142        assert2::assert!(!once.contains("OLD"));
143    }
144}