Skip to main content

submilli_engine/
doc_directives.rs

1//! Author-controlled regions in agent-facing Markdown, such as a package's
2//! `docs/readme.md`. A region is shown only when the capabilities it names are
3//! available to the reader:
4//!
5//! ```markdown
6//! <!-- submilli:if capability="linear.app/createIssue" -->
7//! ## Creating issues
8//! <!-- submilli:endif -->
9//! ```
10//!
11//! `all="a b"` needs every name, `any="a b"` at least one, and a nested region
12//! needs its parents' conditions too. A directive is a whole line, indented by
13//! at most three spaces, outside fenced code, so an example in a fence that
14//! shows the syntax stays literal. Parsing collects every malformed directive
15//! with its line rather than stopping at the first, and rendering drops the
16//! directive lines.
17
18use std::collections::BTreeMap;
19use std::fmt;
20
21/// Regions nest at most this deep.
22pub const MAX_DIRECTIVE_DEPTH: usize = 8;
23
24/// Documents are parsed only up to this size, so a malformed artifact cannot
25/// make parsing unbounded.
26pub const MAX_DOCUMENT_BYTES: usize = 4 * 1024 * 1024;
27
28const DIRECTIVE_PREFIX: &str = "submilli:";
29
30/// When a region is shown.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum Condition {
33    /// Every capability is available; `capability="x"` is `All` of one.
34    All(Vec<String>),
35    /// At least one capability is available.
36    Any(Vec<String>),
37}
38
39impl Condition {
40    /// The capabilities the condition names.
41    pub fn capabilities(&self) -> &[String] {
42        match self {
43            Condition::All(names) | Condition::Any(names) => names,
44        }
45    }
46
47    /// Whether the condition holds, given which capabilities are available.
48    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/// A malformed directive, by its one-based line.
57#[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/// A parsed document: text and the regions around parts of it.
70#[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
85/// Parse `markdown`, or report every malformed directive in it.
86pub 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    /// Each region's condition with the line it opens on, in document order.
105    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    /// The document with each region whose condition `keep` rejects removed,
112    /// and every directive line dropped. A region inside a removed one is
113    /// removed with it.
114    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            // Bounded by `MAX_DIRECTIVE_DEPTH`, which parsing enforces.
131            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                    // Bounded by `MAX_DIRECTIVE_DEPTH`, which parsing enforces.
146                    render_blocks(blocks, keep, out);
147                    out.seam();
148                }
149            }
150        }
151    }
152}
153
154/// Rendered text. A dropped directive line leaves a seam, where blank lines
155/// on both sides would otherwise pile up.
156#[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    /// The text ends with a blank line, whatever its line endings.
178    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 regions, innermost last, each with the blocks gathered inside it.
195    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    /// Where the fence opens.
205    line: usize,
206    /// The first directive-shaped line inside it.
207    directive_line: Option<usize>,
208}
209
210/// A region whose `endif` is still to come.
211struct 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        // Indented code is not tracked: inside a list item the same
230        // indentation continues the item, so an indented directive is refused
231        // rather than guessed at. Examples of directives belong in a fence.
232        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                // A malformed `if` still opens its region, so its `endif` is
295                // not reported as unmatched too.
296                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        // An unclosed fence runs to the end, so a directive after it would be
362        // shown to every reader as text.
363        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
387/// `capability="x"`, `all="a b"` or `any="a b"`.
388fn 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
446/// `text` starts with the directive prefix in any case, so a miscased
447/// directive is reported rather than shown as a comment.
448fn 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
453/// A directive-shaped comment anywhere in the line, outside inline code.
454fn 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
463/// `line` with each inline code span removed: text from a backtick run to
464/// the next run of the same length. One pass, so a line of many unmatched
465/// runs stays linear.
466fn without_code_spans(line: &str) -> String {
467    // Each backtick run as (start, end) byte offsets.
468    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    // For each run, the next run of the same length.
483    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
509/// Leading indentation in columns, a tab counting as four.
510fn 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    // A backtick fence's info string cannot hold a backtick.
536    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        // In a list item this indentation continues the item, so it is not
626        // taken for code that would leave the directive in place.
627        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        // An unclosed fence with no directive after it is ordinary Markdown.
649        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}