mdbook_plotly/
docs_parser.rs1pub 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#[derive(Debug, Clone, Default)]
17pub struct UsageDoc {
18 pub schema_version: u32,
20 pub schema_supported: bool,
22 pub plots: Vec<PlotEntry>,
24 pub warnings: Vec<String>,
26}
27
28impl UsageDoc {
29 pub fn schema_supported(&self) -> bool {
31 self.schema_supported
32 }
33
34 pub fn declared_schema_version(&self) -> Option<u32> {
36 (self.schema_version != 0).then_some(self.schema_version)
37 }
38
39 pub fn get(&self, id: &str) -> Option<&PlotEntry> {
41 self.plots.iter().find(|p| p.id == id)
42 }
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct PlotEntry {
48 pub id: String,
50 pub title: String,
52 pub tags: Vec<String>,
54 pub description: String,
56 pub code: String,
58 pub begin_line: usize,
60 pub end_line: usize,
62}
63
64impl PlotEntry {
65 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
75pub 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
99fn 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
138fn 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 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
233fn 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
240fn 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
248fn 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
261fn 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
284fn 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
304fn 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}