1use std::collections::BTreeMap;
19use std::fmt;
20
21pub const MAX_DIRECTIVE_DEPTH: usize = 8;
23
24pub const MAX_DOCUMENT_BYTES: usize = 4 * 1024 * 1024;
27
28const DIRECTIVE_PREFIX: &str = "submilli:";
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum Condition {
33 All(Vec<String>),
35 Any(Vec<String>),
37}
38
39impl Condition {
40 pub fn capabilities(&self) -> &[String] {
42 match self {
43 Condition::All(names) | Condition::Any(names) => names,
44 }
45 }
46
47 pub fn holds(&self, available: impl Fn(&str) -> bool) -> bool {
49 match self {
50 Condition::All(names) => names.iter().all(|name| available(name)),
51 Condition::Any(names) => names.iter().any(|name| available(name)),
52 }
53 }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct DirectiveError {
59 pub line: usize,
60 pub message: String,
61}
62
63impl fmt::Display for DirectiveError {
64 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65 write!(f, "line {}: {}", self.line, self.message)
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Default)]
71pub struct DocMarkdown {
72 blocks: Vec<Block>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
76enum Block {
77 Text(String),
78 Region {
79 condition: Condition,
80 line: usize,
81 blocks: Vec<Block>,
82 },
83}
84
85pub fn parse_doc_directives(markdown: &str) -> Result<DocMarkdown, Vec<DirectiveError>> {
87 if markdown.len() > MAX_DOCUMENT_BYTES {
88 return Err(vec![DirectiveError {
89 line: 1,
90 message: format!(
91 "the document is {} bytes; documentation is limited to {MAX_DOCUMENT_BYTES}",
92 markdown.len()
93 ),
94 }]);
95 }
96 let mut parser = Parser::default();
97 for (index, line) in markdown.split_inclusive('\n').enumerate() {
98 parser.line(index.saturating_add(1), line);
99 }
100 parser.finish()
101}
102
103impl DocMarkdown {
104 pub fn regions(&self) -> Vec<(usize, &Condition)> {
106 let mut out = Vec::new();
107 collect_regions(&self.blocks, &mut out);
108 out
109 }
110
111 pub fn render(&self, keep: &dyn Fn(&Condition) -> bool) -> String {
115 let mut out = Output::default();
116 render_blocks(&self.blocks, keep, &mut out);
117 out.text
118 }
119}
120
121fn collect_regions<'a>(blocks: &'a [Block], out: &mut Vec<(usize, &'a Condition)>) {
122 for block in blocks {
123 if let Block::Region {
124 condition,
125 line,
126 blocks,
127 } = block
128 {
129 out.push((*line, condition));
130 collect_regions(blocks, out);
132 }
133 }
134}
135
136fn render_blocks(blocks: &[Block], keep: &dyn Fn(&Condition) -> bool, out: &mut Output) {
137 for block in blocks {
138 match block {
139 Block::Text(text) => out.push(text),
140 Block::Region {
141 condition, blocks, ..
142 } => {
143 out.seam();
144 if keep(condition) {
145 render_blocks(blocks, keep, out);
147 out.seam();
148 }
149 }
150 }
151 }
152}
153
154#[derive(Default)]
157struct Output {
158 text: String,
159 at_seam: bool,
160}
161
162impl Output {
163 fn seam(&mut self) {
164 self.at_seam = true;
165 }
166
167 fn push(&mut self, text: &str) {
168 let mut text = text;
169 if std::mem::take(&mut self.at_seam) && (self.text.is_empty() || self.ends_blank()) {
170 while let Some(rest) = strip_blank_line(text) {
171 text = rest;
172 }
173 }
174 self.text.push_str(text);
175 }
176
177 fn ends_blank(&self) -> bool {
179 let Some(body) = self.text.strip_suffix('\n') else {
180 return false;
181 };
182 body.rsplit_once('\n')
183 .is_some_and(|(_, last)| last.trim().is_empty())
184 }
185}
186
187fn strip_blank_line(text: &str) -> Option<&str> {
188 let (line, rest) = text.split_once('\n')?;
189 line.trim().is_empty().then_some(rest)
190}
191
192#[derive(Default)]
193struct Parser {
194 open: Vec<OpenRegion>,
196 blocks: Vec<Block>,
197 errors: Vec<DirectiveError>,
198 fence: Option<Fence>,
199}
200
201struct Fence {
202 marker: u8,
203 len: usize,
204 line: usize,
206 directive_line: Option<usize>,
208}
209
210struct OpenRegion {
212 condition: Condition,
213 line: usize,
214 blocks: Vec<Block>,
215}
216
217impl Parser {
218 fn line(&mut self, number: usize, line: &str) {
219 let content = line.trim_end_matches(['\n', '\r']);
220 if let Some(fence) = &mut self.fence {
221 if closes_fence(content, fence) {
222 self.fence = None;
223 } else if fence.directive_line.is_none() && is_directive_like(content.trim()) {
224 fence.directive_line = Some(number);
225 }
226 self.text(line);
227 return;
228 }
229 if indentation(content) >= 4 && is_directive_like(content.trim_start()) {
233 self.error(
234 number,
235 "a directive must not be indented by four or more spaces".to_string(),
236 );
237 self.text(line);
238 return;
239 }
240 if let Some(fence) = opens_fence(content, number) {
241 self.fence = Some(fence);
242 self.text(line);
243 return;
244 }
245 let trimmed = content.trim();
246 if is_directive_like(trimmed) {
247 self.directive(number, trimmed);
248 return;
249 }
250 if contains_directive(content) {
251 self.error(number, "a directive must stand on its own line".to_string());
252 }
253 self.text(line);
254 }
255
256 fn text(&mut self, line: &str) {
257 let blocks = match self.open.last_mut() {
258 Some(region) => &mut region.blocks,
259 None => &mut self.blocks,
260 };
261 match blocks.last_mut() {
262 Some(Block::Text(text)) => text.push_str(line),
263 _ => blocks.push(Block::Text(line.to_string())),
264 }
265 }
266
267 fn directive(&mut self, number: usize, trimmed: &str) {
268 let inner = match trimmed
269 .strip_prefix("<!--")
270 .and_then(|rest| rest.strip_suffix("-->"))
271 {
272 Some(inner) => inner.trim(),
273 None if trimmed.contains("-->") => {
274 return self.error(number, "a directive must stand on its own line".to_string());
275 }
276 None => {
277 return self.error(
278 number,
279 "a directive must close with `-->` on the same line".to_string(),
280 );
281 }
282 };
283 let Some(directive) = inner.strip_prefix(DIRECTIVE_PREFIX) else {
284 return self.error(
285 number,
286 format!("a directive starts with `{DIRECTIVE_PREFIX}`, in lowercase"),
287 );
288 };
289 let (verb, rest) = directive
290 .split_once(char::is_whitespace)
291 .unwrap_or((directive, ""));
292 match verb {
293 "if" => {
294 let condition = parse_condition(rest.trim()).unwrap_or_else(|message| {
297 self.error(number, message);
298 Condition::All(Vec::new())
299 });
300 self.open_region(number, condition);
301 }
302 "endif" if rest.trim().is_empty() => self.close_region(number),
303 "endif" => self.error(number, "`submilli:endif` takes no attributes".to_string()),
304 _ => self.error(
305 number,
306 format!(
307 "unknown directive `submilli:{verb}`; use `submilli:if` or `submilli:endif`"
308 ),
309 ),
310 }
311 }
312
313 fn open_region(&mut self, number: usize, condition: Condition) {
314 if self.open.len() >= MAX_DIRECTIVE_DEPTH {
315 return self.error(
316 number,
317 format!("regions nest at most {MAX_DIRECTIVE_DEPTH} deep"),
318 );
319 }
320 self.open.push(OpenRegion {
321 condition,
322 line: number,
323 blocks: Vec::new(),
324 });
325 }
326
327 fn close_region(&mut self, number: usize) {
328 let Some(OpenRegion {
329 condition,
330 line,
331 blocks,
332 }) = self.open.pop()
333 else {
334 return self.error(
335 number,
336 "`submilli:endif` has no matching `submilli:if`".to_string(),
337 );
338 };
339 let region = Block::Region {
340 condition,
341 line,
342 blocks,
343 };
344 match self.open.last_mut() {
345 Some(parent) => parent.blocks.push(region),
346 None => self.blocks.push(region),
347 }
348 }
349
350 fn error(&mut self, line: usize, message: String) {
351 self.errors.push(DirectiveError { line, message });
352 }
353
354 fn finish(mut self) -> Result<DocMarkdown, Vec<DirectiveError>> {
355 for region in std::mem::take(&mut self.open) {
356 self.error(
357 region.line,
358 "`submilli:if` has no matching `submilli:endif`".to_string(),
359 );
360 }
361 if let Some(Fence {
364 line,
365 directive_line: Some(directive_line),
366 ..
367 }) = self.fence.take()
368 {
369 self.error(
370 line,
371 format!(
372 "this code fence is never closed, so the directive on line {directive_line} is shown as text"
373 ),
374 );
375 }
376 if self.errors.is_empty() {
377 Ok(DocMarkdown {
378 blocks: self.blocks,
379 })
380 } else {
381 self.errors.sort_by_key(|error| error.line);
382 Err(self.errors)
383 }
384 }
385}
386
387fn parse_condition(attributes: &str) -> Result<Condition, String> {
389 let Some((name, rest)) = attributes.split_once('=') else {
390 return Err("`submilli:if` needs one attribute: `capability`, `all` or `any`".to_string());
391 };
392 let name = name.trim();
393 let rest = rest.trim_start();
394 let Some(value) = rest.strip_prefix('"') else {
395 return Err(format!("the `{name}` value must be in double quotes"));
396 };
397 let Some((value, after)) = value.split_once('"') else {
398 return Err(format!("the `{name}` value has no closing quote"));
399 };
400 if !after.trim().is_empty() {
401 return Err("`submilli:if` takes exactly one attribute".to_string());
402 }
403 let names = capability_names(name, value)?;
404 match name {
405 "capability" if names.len() == 1 => Ok(Condition::All(names)),
406 "capability" => {
407 Err("`capability` names one capability; use `all` or `any` for several".to_string())
408 }
409 "all" => Ok(Condition::All(names)),
410 "any" => Ok(Condition::Any(names)),
411 _ => Err(format!(
412 "unknown attribute `{name}`; use `capability`, `all` or `any`"
413 )),
414 }
415}
416
417fn capability_names(attribute: &str, value: &str) -> Result<Vec<String>, String> {
418 let mut names: Vec<String> = Vec::new();
419 for name in value.split_whitespace() {
420 if !is_capability_name(name) {
421 return Err(format!("`{name}` is not a capability name"));
422 }
423 if names.iter().any(|seen| seen == name) {
424 return Err(format!("`{name}` appears twice in `{attribute}`"));
425 }
426 names.push(name.to_string());
427 }
428 if names.is_empty() {
429 return Err(format!("`{attribute}` names no capability"));
430 }
431 Ok(names)
432}
433
434fn is_capability_name(name: &str) -> bool {
435 name.bytes().all(|byte| {
436 byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'/' | b'-' | b'_' | b':' | b'@')
437 })
438}
439
440fn is_directive_like(trimmed: &str) -> bool {
441 trimmed
442 .strip_prefix("<!--")
443 .is_some_and(|rest| starts_like_directive(rest.trim_start()))
444}
445
446fn starts_like_directive(text: &str) -> bool {
449 text.get(..DIRECTIVE_PREFIX.len())
450 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(DIRECTIVE_PREFIX))
451}
452
453fn contains_directive(line: &str) -> bool {
455 let prose = without_code_spans(line);
456 prose.match_indices("<!--").any(|(at, _)| {
457 prose
458 .get(at.saturating_add(4)..)
459 .is_some_and(|rest| starts_like_directive(rest.trim_start()))
460 })
461}
462
463fn without_code_spans(line: &str) -> String {
467 let mut runs: Vec<(usize, usize)> = Vec::new();
469 let bytes = line.as_bytes();
470 let mut index = 0;
471 while let Some(&byte) = bytes.get(index) {
472 if byte != b'`' {
473 index = index.saturating_add(1);
474 continue;
475 }
476 let start = index;
477 while bytes.get(index) == Some(&b'`') {
478 index = index.saturating_add(1);
479 }
480 runs.push((start, index));
481 }
482 let mut next_same: Vec<Option<usize>> = vec![None; runs.len()];
484 let mut last_by_length: BTreeMap<usize, usize> = BTreeMap::new();
485 for (position, (start, end)) in runs.iter().enumerate().rev() {
486 let length = end.saturating_sub(*start);
487 if let Some(slot) = next_same.get_mut(position) {
488 *slot = last_by_length.insert(length, position);
489 }
490 }
491 let mut out = String::with_capacity(line.len());
492 let mut copied = 0;
493 let mut position = 0;
494 while let Some(&(start, end)) = runs.get(position) {
495 if let Some(closing) = next_same.get(position).copied().flatten() {
496 out.push_str(line.get(copied..start).unwrap_or(""));
497 copied = runs.get(closing).map_or(line.len(), |(_, end)| *end);
498 position = closing.saturating_add(1);
499 } else {
500 out.push_str(line.get(copied..end).unwrap_or(""));
501 copied = end;
502 position = position.saturating_add(1);
503 }
504 }
505 out.push_str(line.get(copied..).unwrap_or(""));
506 out
507}
508
509fn indentation(line: &str) -> usize {
511 let mut columns: usize = 0;
512 for byte in line.bytes() {
513 match byte {
514 b' ' => columns = columns.saturating_add(1),
515 b'\t' => columns = columns.saturating_add(4),
516 _ => break,
517 }
518 }
519 columns
520}
521
522fn opens_fence(content: &str, line: usize) -> Option<Fence> {
523 if indentation(content) >= 4 {
524 return None;
525 }
526 let trimmed = content.trim_start();
527 let marker = *trimmed.as_bytes().first()?;
528 if marker != b'`' && marker != b'~' {
529 return None;
530 }
531 let len = trimmed.bytes().take_while(|byte| *byte == marker).count();
532 if len < 3 {
533 return None;
534 }
535 let info = trimmed.get(len..).unwrap_or("");
537 if marker == b'`' && info.contains('`') {
538 return None;
539 }
540 Some(Fence {
541 marker,
542 len,
543 line,
544 directive_line: None,
545 })
546}
547
548fn closes_fence(line: &str, fence: &Fence) -> bool {
549 if indentation(line) >= 4 {
550 return false;
551 }
552 let trimmed = line.trim();
553 let len = trimmed
554 .bytes()
555 .take_while(|byte| *byte == fence.marker)
556 .count();
557 len >= fence.len && trimmed.len() == len
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563
564 fn render_with(markdown: &str, available: &[&str]) -> String {
565 let doc = parse_doc_directives(markdown).expect("valid directives");
566 doc.render(&|condition| condition.holds(|name| available.contains(&name)))
567 }
568
569 fn errors(markdown: &str) -> Vec<String> {
570 parse_doc_directives(markdown)
571 .expect_err("invalid directives")
572 .iter()
573 .map(ToString::to_string)
574 .collect()
575 }
576
577 const DOC: &str = "# Linear\n\nRead issues.\n\n<!-- submilli:if capability=\"linear.app/createIssue\" -->\n\n## Creating issues\n\nCall `createIssue`.\n\n<!-- submilli:endif -->\n\n## Teams\n";
578
579 #[test]
580 fn a_region_is_removed_when_its_capability_is_unavailable() {
581 assert_eq!(
582 render_with(DOC, &[]),
583 "# Linear\n\nRead issues.\n\n## Teams\n"
584 );
585 }
586
587 #[test]
588 fn a_kept_region_loses_its_markers() {
589 assert_eq!(
590 render_with(DOC, &["linear.app/createIssue"]),
591 "# Linear\n\nRead issues.\n\n## Creating issues\n\nCall `createIssue`.\n\n## Teams\n"
592 );
593 let doc = parse_doc_directives(DOC).unwrap();
594 assert_eq!(
595 doc.render(&|_| true),
596 render_with(DOC, &["linear.app/createIssue"])
597 );
598 }
599
600 #[test]
601 fn all_any_and_nesting() {
602 let doc = "<!-- submilli:if all=\"a b\" -->\nboth\n<!-- submilli:endif -->\n<!-- submilli:if any=\"a b\" -->\neither\n<!-- submilli:if capability=\"c\" -->\nand c\n<!-- submilli:endif -->\n<!-- submilli:endif -->\n";
603 assert_eq!(render_with(doc, &["a", "b"]), "both\neither\n");
604 assert_eq!(render_with(doc, &["b"]), "either\n");
605 assert_eq!(render_with(doc, &["b", "c"]), "either\nand c\n");
606 assert_eq!(render_with(doc, &["c"]), "");
607 let parsed = parse_doc_directives(doc).unwrap();
608 let named = parsed
609 .regions()
610 .into_iter()
611 .flat_map(|(_, condition)| condition.capabilities())
612 .map(String::as_str)
613 .collect::<Vec<_>>();
614 assert_eq!(named, ["a", "b", "a", "b", "c"]);
615 }
616
617 #[test]
618 fn directives_in_code_stay_literal() {
619 let doc = "```markdown\n<!-- submilli:if capability=\"x\" -->\n```\n\n~~~~\n<!-- submilli:endif -->\n```\n~~~~\n";
620 assert_eq!(render_with(doc, &[]), doc);
621 }
622
623 #[test]
624 fn an_indented_directive_is_refused_even_after_a_blank_line() {
625 assert_eq!(
628 errors(
629 "- item\n\n <!-- submilli:if capability=\"x\" -->\n text\n <!-- submilli:endif -->\n"
630 ),
631 [
632 "line 3: a directive must not be indented by four or more spaces",
633 "line 5: a directive must not be indented by four or more spaces",
634 ]
635 );
636 }
637
638 #[test]
639 fn slips_that_would_show_a_region_to_everyone_are_reported() {
640 assert_eq!(
641 errors(
642 "```\ncode\n\n<!-- submilli:if capability=\"x\" -->\nhidden\n<!-- submilli:endif -->\n"
643 ),
644 [
645 "line 1: this code fence is never closed, so the directive on line 4 is shown as text"
646 ]
647 );
648 assert!(parse_doc_directives("```\ncode\n").is_ok());
650 assert_eq!(
651 errors("<!-- Submilli:if capability=\"x\" -->\n"),
652 ["line 1: a directive starts with `submilli:`, in lowercase"]
653 );
654 assert_eq!(
655 errors("<!-- submilli:endif --> trailing\n"),
656 ["line 1: a directive must stand on its own line"]
657 );
658 }
659
660 #[test]
661 fn crlf_seams_collapse_like_lf() {
662 let doc = "a\r\n\r\n<!-- submilli:if capability=\"x\" -->\r\n\r\nhidden\r\n\r\n<!-- submilli:endif -->\r\n\r\nb\r\n";
663 assert_eq!(render_with(doc, &[]), "a\r\n\r\nb\r\n");
664 }
665
666 #[test]
667 fn code_spans_close_on_a_run_of_the_same_length() {
668 assert_eq!(without_code_spans("a ``x ` y`` b"), "a b");
669 assert_eq!(without_code_spans("a ``` b ` c"), "a ``` b ` c");
670 assert_eq!(without_code_spans("`one` and `two`"), " and ");
671 let many = "`".repeat(3000) + &" `".repeat(10);
672 assert!(!without_code_spans(&many).is_empty());
673 }
674
675 #[test]
676 fn inline_code_may_mention_a_directive() {
677 let doc = "Wrap it in `<!-- submilli:if capability=\"x\" -->`.\n";
678 assert_eq!(render_with(doc, &[]), doc);
679 }
680
681 #[test]
682 fn malformed_directives_report_their_lines() {
683 assert_eq!(
684 errors("a\n<!-- submilli:if capability=\"x\" -->\nb\n"),
685 ["line 2: `submilli:if` has no matching `submilli:endif`"]
686 );
687 assert_eq!(
688 errors("<!-- submilli:endif -->\n"),
689 ["line 1: `submilli:endif` has no matching `submilli:if`"]
690 );
691 assert_eq!(
692 errors("<!-- submilli:if role=\"x\" -->\n<!-- submilli:endif -->\n"),
693 ["line 1: unknown attribute `role`; use `capability`, `all` or `any`"]
694 );
695 assert_eq!(
696 errors("<!-- submilli:if capability='x' -->\n<!-- submilli:endif -->\n"),
697 ["line 1: the `capability` value must be in double quotes"]
698 );
699 assert_eq!(
700 errors("<!-- submilli:if all=\"a a\" -->\n<!-- submilli:endif -->\n"),
701 ["line 1: `a` appears twice in `all`"]
702 );
703 assert_eq!(
704 errors("<!-- submilli:if capability=\"a b\" -->\n<!-- submilli:endif -->\n"),
705 ["line 1: `capability` names one capability; use `all` or `any` for several"]
706 );
707 assert_eq!(
708 errors("<!-- submilli:if any=\"\" -->\n<!-- submilli:endif -->\n"),
709 ["line 1: `any` names no capability"]
710 );
711 assert_eq!(
712 errors("<!-- submilli:else -->\n"),
713 ["line 1: unknown directive `submilli:else`; use `submilli:if` or `submilli:endif`"]
714 );
715 assert_eq!(
716 errors("text <!-- submilli:endif -->\n"),
717 ["line 1: a directive must stand on its own line"]
718 );
719 assert_eq!(
720 errors("<!-- submilli:if capability=\"x\"\n"),
721 ["line 1: a directive must close with `-->` on the same line"]
722 );
723 assert_eq!(
724 errors("para\n <!-- submilli:endif -->\n"),
725 ["line 2: a directive must not be indented by four or more spaces"]
726 );
727 }
728
729 #[test]
730 fn every_error_is_reported() {
731 assert_eq!(
732 errors("<!-- submilli:endif -->\n<!-- submilli:if x=\"y\" -->\n"),
733 [
734 "line 1: `submilli:endif` has no matching `submilli:if`",
735 "line 2: unknown attribute `x`; use `capability`, `all` or `any`",
736 "line 2: `submilli:if` has no matching `submilli:endif`",
737 ]
738 );
739 }
740
741 #[test]
742 fn nesting_is_bounded() {
743 let mut doc = String::new();
744 for _ in 0..=MAX_DIRECTIVE_DEPTH {
745 doc.push_str("<!-- submilli:if capability=\"x\" -->\n");
746 }
747 for _ in 0..=MAX_DIRECTIVE_DEPTH {
748 doc.push_str("<!-- submilli:endif -->\n");
749 }
750 let errors = errors(&doc);
751 assert!(
752 errors.contains(&format!(
753 "line {}: regions nest at most {MAX_DIRECTIVE_DEPTH} deep",
754 MAX_DIRECTIVE_DEPTH + 1
755 )),
756 "{errors:?}"
757 );
758 }
759
760 #[test]
761 fn a_document_without_directives_is_unchanged() {
762 let doc = "# Title\n\n\n\nText with <!-- a comment -->.\n";
763 assert_eq!(render_with(doc, &[]), doc);
764 }
765}