Skip to main content

mdbook_plotly/
docs_parser.rs

1//! Lenient parser for the machine-readable plot-block schema in
2//! `docs/USAGE.md`. See `docs/USAGE-SCHEMA.md` for the contract.
3//!
4//! This module is intentionally dependency-free so it compiles and is
5//! tested in every feature combination (including the slim build).
6
7/// The highest schema version this binary understands.
8pub const USAGE_SCHEMA_VERSION: u32 = 1;
9
10const SCHEMA_MARKER_PREFIX: &str = "<!-- usage-schema: ";
11const PLOT_BEGIN_PREFIX: &str = "<!-- plot:begin";
12const PLOT_END: &str = "<!-- plot:end -->";
13
14/// A parsed usage document. Parsing never fails; problems are recorded
15/// as warnings and the rest of the document is still parsed.
16#[derive(Debug, Clone, Default)]
17pub struct UsageDoc {
18    /// Schema version declared in the document (0 when absent).
19    pub schema_version: u32,
20    /// False when the document declares a newer schema than we support.
21    pub schema_supported: bool,
22    /// Successfully parsed plot blocks, in document order.
23    pub plots: Vec<PlotEntry>,
24    /// Non-fatal problems encountered while parsing.
25    pub warnings: Vec<String>,
26}
27
28impl UsageDoc {
29    /// True when the document's schema version is understood by this binary.
30    pub fn schema_supported(&self) -> bool {
31        self.schema_supported
32    }
33
34    /// The declared schema version, or `None` when the document has none.
35    pub fn declared_schema_version(&self) -> Option<u32> {
36        (self.schema_version != 0).then_some(self.schema_version)
37    }
38
39    /// Look up a plot block by its stable `id`.
40    pub fn get(&self, id: &str) -> Option<&PlotEntry> {
41        self.plots.iter().find(|p| p.id == id)
42    }
43}
44
45/// A single `plot:begin`/`plot:end` block.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct PlotEntry {
48    /// Stable identifier (required attribute).
49    pub id: String,
50    /// Human-readable title (defaults to `id`).
51    pub title: String,
52    /// Comma-separated search tags.
53    pub tags: Vec<String>,
54    /// Markdown description between the begin sentinel and the code fence.
55    pub description: String,
56    /// Content of the `plotly`/`plot` fenced code block.
57    pub code: String,
58    /// 1-based line of the begin sentinel.
59    pub begin_line: usize,
60    /// 1-based line of the end sentinel.
61    pub end_line: usize,
62}
63
64impl PlotEntry {
65    /// True when `needle` appears in the id, title, tags, or code.
66    pub fn matches(&self, needle: &str) -> bool {
67        let needle = needle.to_lowercase();
68        self.id.to_lowercase().contains(&needle)
69            || self.title.to_lowercase().contains(&needle)
70            || self.tags.iter().any(|t| t.to_lowercase().contains(&needle))
71            || self.code.to_lowercase().contains(&needle)
72    }
73}
74
75/// Parse a usage document. Never fails; tolerant of malformed input.
76pub fn parse_doc(source: &str) -> UsageDoc {
77    let mut doc = UsageDoc {
78        schema_supported: true,
79        ..UsageDoc::default()
80    };
81
82    let lines: Vec<&str> = source.lines().collect();
83    parse_schema_marker(&lines, &mut doc);
84
85    let mut i = 0usize;
86    while i < lines.len() {
87        if lines[i].trim().starts_with(PLOT_BEGIN_PREFIX) {
88            match parse_block(&lines, i, &mut doc) {
89                Some(next) => i = next,
90                None => break,
91            }
92        } else {
93            i += 1;
94        }
95    }
96    doc
97}
98
99/// Parse the schema version marker within the first few lines.
100fn parse_schema_marker(lines: &[&str], doc: &mut UsageDoc) {
101    for (idx, line) in lines.iter().take(50).enumerate() {
102        let trimmed = line.trim();
103        let Some(rest) = trimmed.strip_prefix(SCHEMA_MARKER_PREFIX) else {
104            continue;
105        };
106        let Some(version_part) = rest.strip_suffix(" -->") else {
107            doc.warnings.push(format!(
108                "Malformed usage-schema marker on line {}: '{}'",
109                idx + 1,
110                trimmed
111            ));
112            continue;
113        };
114        match version_part.trim().parse::<u32>() {
115            Ok(version) => {
116                doc.schema_version = version;
117                if version > USAGE_SCHEMA_VERSION {
118                    doc.schema_supported = false;
119                    doc.warnings.push(format!(
120                        "docs/USAGE.md declares schema version {}, but this binary \
121                         supports up to {}. Parsing on a best-effort basis; please \
122                         upgrade mdbook-plotly.",
123                        version, USAGE_SCHEMA_VERSION
124                    ));
125                }
126            }
127            Err(_) => {
128                doc.warnings.push(format!(
129                    "Invalid usage-schema version '{}' on line {}.",
130                    version_part.trim(),
131                    idx + 1
132                ));
133            }
134        }
135    }
136}
137
138/// Parse one block starting at `start` (the begin sentinel line).
139/// Returns the index of the next line to resume scanning from.
140fn parse_block(lines: &[&str], start: usize, doc: &mut UsageDoc) -> Option<usize> {
141    let begin_line = start + 1;
142    let attrs = parse_begin_marker(lines[start].trim());
143    if attrs.is_none() {
144        doc.warnings.push(format!(
145            "Skipping malformed plot block at line {}: unreadable begin marker.",
146            begin_line
147        ));
148        return Some(start + 1);
149    }
150    let attrs = attrs.unwrap();
151
152    let id = match get_attr(&attrs, "id") {
153        Some(id) if !id.trim().is_empty() => id.trim().to_string(),
154        _ => {
155            doc.warnings.push(format!(
156                "Skipping plot block at line {}: missing required 'id' attribute.",
157                begin_line
158            ));
159            return skip_to_block_end(lines, start + 1);
160        }
161    };
162
163    // Find the matching end sentinel, tolerating a nested begin sentinel
164    // (which implies the current block is unclosed).
165    let mut end = None;
166    let mut j = start + 1;
167    while j < lines.len() {
168        let trimmed = lines[j].trim();
169        if trimmed == PLOT_END {
170            end = Some(j);
171            break;
172        }
173        if trimmed.starts_with(PLOT_BEGIN_PREFIX) {
174            break;
175        }
176        j += 1;
177    }
178    let end = match end {
179        Some(e) => e,
180        None => {
181            doc.warnings.push(format!(
182                "Skipping plot block '{}' starting at line {}: missing '{}' sentinel.",
183                id, begin_line, PLOT_END
184            ));
185            return Some(j);
186        }
187    };
188
189    let (code, description) = match extract_code_fence(&lines[start + 1..end]) {
190        Some(found) => found,
191        None => {
192            doc.warnings.push(format!(
193                "Skipping plot block '{}' at line {}: no plotly/plot code fence found.",
194                id, begin_line
195            ));
196            return Some(end + 1);
197        }
198    };
199
200    let title = get_attr(&attrs, "title")
201        .map(|t| t.trim().to_string())
202        .filter(|t| !t.is_empty())
203        .unwrap_or_else(|| id.clone());
204    let tags: Vec<String> = get_attr(&attrs, "tags")
205        .map(|t| {
206            t.split(',')
207                .map(|tag| tag.trim().to_string())
208                .filter(|tag| !tag.is_empty())
209                .collect()
210        })
211        .unwrap_or_default();
212
213    if let Some(prev) = doc.get(&id) {
214        doc.warnings.push(format!(
215            "Duplicate plot id '{}' (first at line {}, later at line {}); the later block wins.",
216            id, prev.begin_line, begin_line
217        ));
218    }
219
220    doc.plots.push(PlotEntry {
221        id,
222        title,
223        tags,
224        description,
225        code,
226        begin_line,
227        end_line: end + 1,
228    });
229
230    Some(end + 1)
231}
232
233/// Parse the attributes out of a `<!-- plot:begin ... -->` marker.
234fn parse_begin_marker(trimmed: &str) -> Option<Vec<(String, String)>> {
235    let rest = trimmed.strip_prefix(PLOT_BEGIN_PREFIX)?;
236    let inner = rest.strip_suffix(" -->")?;
237    Some(parse_attrs(inner))
238}
239
240/// Look up an attribute by key (first occurrence wins).
241fn get_attr<'a>(attrs: &'a [(String, String)], key: &str) -> Option<&'a str> {
242    attrs
243        .iter()
244        .find(|(k, _)| k == key)
245        .map(|(_, v)| v.as_str())
246}
247
248/// Scan forward until the next begin sentinel or EOF. Used after skipping
249/// a malformed block so parsing can resume cleanly.
250fn skip_to_block_end(lines: &[&str], from: usize) -> Option<usize> {
251    let mut j = from;
252    while j < lines.len() {
253        if lines[j].trim().starts_with(PLOT_BEGIN_PREFIX) {
254            return Some(j);
255        }
256        j += 1;
257    }
258    None
259}
260
261/// Find the first `plotly`/`plot` fenced code block inside the slice.
262/// Returns `(code, description)`.
263fn extract_code_fence(lines: &[&str]) -> Option<(String, String)> {
264    let mut i = 0usize;
265    while i < lines.len() {
266        let trimmed = lines[i].trim();
267        if let Some("plotly" | "plot") = fence_info(trimmed) {
268            let description = lines[..i].join("\n").trim().to_string();
269            let mut code_lines = Vec::new();
270            i += 1;
271            while i < lines.len() && fence_info(lines[i].trim()).is_none() {
272                code_lines.push(lines[i]);
273                i += 1;
274            }
275            let code = code_lines.join("\n");
276            let code = code.trim_matches('\n').to_string();
277            return Some((code, description));
278        }
279        i += 1;
280    }
281    None
282}
283
284/// For a trimmed line, return the fence info string when it opens/closes a
285/// backtick-fenced code block, otherwise `None`. A closing fence has an
286/// empty info string.
287fn fence_info(trimmed: &str) -> Option<&str> {
288    let ticks = trimmed.as_bytes();
289    if ticks.first() != Some(&b'`') {
290        return None;
291    }
292    let run = trimmed.bytes().take_while(|&b| b == b'`').count();
293    if run < 3 {
294        return None;
295    }
296    let info = &trimmed[run..];
297    if info.trim().is_empty() {
298        Some("")
299    } else {
300        Some(info.trim())
301    }
302}
303
304/// Parse `key=value` / `key="value with spaces"` / `key='value'` pairs.
305/// Unknown keys and malformed tokens are ignored.
306fn parse_attrs(inner: &str) -> Vec<(String, String)> {
307    let bytes = inner.as_bytes();
308    let mut i = 0usize;
309    let n = bytes.len();
310    let mut attrs = Vec::new();
311
312    while i < n {
313        while i < n && bytes[i].is_ascii_whitespace() {
314            i += 1;
315        }
316        if i >= n {
317            break;
318        }
319        let key_start = i;
320        while i < n && bytes[i] != b'=' && !bytes[i].is_ascii_whitespace() {
321            i += 1;
322        }
323        let key = &inner[key_start..i];
324        while i < n && bytes[i].is_ascii_whitespace() {
325            i += 1;
326        }
327        if i >= n || bytes[i] != b'=' {
328            continue;
329        }
330        i += 1;
331        while i < n && bytes[i].is_ascii_whitespace() {
332            i += 1;
333        }
334        if i >= n {
335            break;
336        }
337        let value = match bytes[i] {
338            b'"' | b'\'' => {
339                let quote = bytes[i];
340                i += 1;
341                let vstart = i;
342                while i < n && bytes[i] != quote {
343                    i += 1;
344                }
345                let value = inner[vstart..i].to_string();
346                if i < n {
347                    i += 1;
348                }
349                value
350            }
351            _ => {
352                let vstart = i;
353                while i < n && !bytes[i].is_ascii_whitespace() {
354                    i += 1;
355                }
356                inner[vstart..i].to_string()
357            }
358        };
359        attrs.push((key.to_string(), value));
360    }
361    attrs
362}