Skip to main content

spec_driven_docs/gates/
markdown_prose.rs

1//! Shared markdown structure for the prose gates.
2//!
3//! One scan classifies every source line of a markdown document: front
4//! matter, a fenced code block, an HTML comment, a heading, a table row, a
5//! thematic break, a blockquote, a list item, or plain prose. A prose line
6//! also carries its container prefix stripped and its ordered-list depth, so
7//! a gate can measure the prose alone and leave every structural line exact.
8//! What a gate does with a prose line is the gate's business.
9
10/// What one source line is, for a prose gate.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum LineKind {
13    /// Inside the leading `---` front matter, or its fence lines.
14    FrontMatter,
15    /// A fenced code block, opening and closing fences included.
16    Fence,
17    /// An HTML comment line (a directive is classified separately by the gate).
18    Comment,
19    /// A heading line.
20    Heading,
21    /// A table row or delimiter row.
22    Table,
23    /// A blank line, a thematic break, or a link reference definition.
24    Blank,
25    /// Prose the gate measures. Carries the container-stripped content, the
26    /// 1-based line number, and whether it opens a numbered list item.
27    Prose(Prose),
28}
29
30/// A prose line, stripped of its blockquote and list-marker prefix.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Prose {
33    /// The 1-based source line number.
34    pub number: usize,
35    /// The content after any blockquote markers and one list marker.
36    pub content: String,
37    /// True where the line opens an ordered (numbered) list item.
38    pub ordered_item: bool,
39}
40
41struct Fence {
42    delimiter: char,
43    length: usize,
44    quotes: usize,
45    indent: usize,
46}
47
48fn container(line: &str) -> (usize, usize, &str) {
49    let mut rest = line;
50    let mut quotes = 0;
51    loop {
52        let trimmed = rest.trim_start_matches([' ', '\t']);
53        if let Some(after) = trimmed.strip_prefix('>') {
54            quotes += 1;
55            rest = after.strip_prefix([' ', '\t']).unwrap_or(after);
56        } else {
57            break;
58        }
59    }
60    let content = rest.trim_start_matches([' ', '\t']);
61    let indent = rest.len() - content.len();
62    (quotes, indent, content)
63}
64
65fn fence_delimiter(content: &str) -> Option<(char, usize)> {
66    let first = content.chars().next()?;
67    if first != '`' && first != '~' {
68        return None;
69    }
70    let length = content.chars().take_while(|&c| c == first).count();
71    (length >= 3).then_some((first, length))
72}
73
74fn is_thematic_break(content: &str) -> bool {
75    let mut marker = None;
76    let mut count = 0;
77    for ch in content.chars() {
78        match ch {
79            ' ' | '\t' => {}
80            '-' | '=' | '*' | '_' => {
81                if *marker.get_or_insert(ch) != ch {
82                    return false;
83                }
84                count += 1;
85            }
86            _ => return false,
87        }
88    }
89    match marker {
90        Some('=') => count >= 1,
91        Some('-') => count >= 2,
92        Some(_) => count >= 3,
93        None => false,
94    }
95}
96
97fn ordered_marker(content: &str) -> Option<usize> {
98    let digits = content.chars().take_while(char::is_ascii_digit).count();
99    if digits == 0 || digits > 9 {
100        return None;
101    }
102    let rest = &content[digits..];
103    let ok = matches!(rest.chars().next(), Some('.' | ')'))
104        && matches!(rest.chars().nth(1), None | Some(' ' | '\t'));
105    ok.then_some(digits + 1)
106}
107
108fn bullet_marker(content: &str) -> Option<usize> {
109    let ok = matches!(content.chars().next(), Some('-' | '*' | '+'))
110        && matches!(content.chars().nth(1), None | Some(' ' | '\t'));
111    ok.then_some(1)
112}
113
114fn is_definition(content: &str) -> bool {
115    content
116        .strip_prefix('[')
117        .and_then(|rest| rest.split_once(']'))
118        .is_some_and(|(_, after)| after.starts_with(':'))
119}
120
121/// Classify every line of a markdown document.
122#[must_use]
123#[allow(
124    clippy::too_many_lines,
125    clippy::option_if_let_else,
126    reason = "the classifier is one state machine, and splitting it splits the state"
127)]
128pub fn classify(text: &str) -> Vec<LineKind> {
129    let mut out = Vec::new();
130    let mut fence: Option<Fence> = None;
131    let mut in_front_matter = false;
132    let mut in_comment = false;
133    let mut in_toc = false;
134
135    for (index, raw) in text.lines().enumerate() {
136        let number = index + 1;
137        let (quotes, indent, content) = container(raw);
138
139        if index == 0 && raw == "---" {
140            in_front_matter = true;
141            out.push(LineKind::FrontMatter);
142            continue;
143        }
144        if in_front_matter {
145            if raw == "---" || raw == "..." {
146                in_front_matter = false;
147            }
148            out.push(LineKind::FrontMatter);
149            continue;
150        }
151        if in_comment {
152            out.push(LineKind::Comment);
153            if content.contains("-->") {
154                in_comment = false;
155            }
156            continue;
157        }
158
159        if let Some(open) = &fence
160            && !content.is_empty()
161            && (quotes < open.quotes || indent < open.indent)
162        {
163            fence = None;
164        }
165        if let Some((delimiter, length)) = fence_delimiter(content) {
166            match &fence {
167                None => {
168                    fence = Some(Fence {
169                        delimiter,
170                        length,
171                        quotes,
172                        indent,
173                    });
174                }
175                Some(open)
176                    if delimiter == open.delimiter
177                        && length >= open.length
178                        && content.trim_start_matches(delimiter).trim_end().is_empty() =>
179                {
180                    fence = None;
181                }
182                Some(_) => {}
183            }
184            out.push(LineKind::Fence);
185            continue;
186        }
187        if fence.is_some() {
188            out.push(LineKind::Fence);
189            continue;
190        }
191        // The generated table of contents is not authored prose. Its entries
192        // mirror the requirement heading titles, dashes and all, so measuring
193        // it would flag the generator's output. Skip the marked region.
194        if content.trim() == "<!--TOC-->" {
195            in_toc = !in_toc;
196            out.push(LineKind::Comment);
197            continue;
198        }
199        if in_toc {
200            out.push(LineKind::Blank);
201            continue;
202        }
203        if content.starts_with("<!--") && !content.contains("-->") {
204            in_comment = true;
205            out.push(LineKind::Comment);
206            continue;
207        }
208        if content.starts_with("<!--") {
209            out.push(LineKind::Comment);
210            continue;
211        }
212        if content.is_empty() || is_thematic_break(content) || is_definition(content) {
213            out.push(LineKind::Blank);
214            continue;
215        }
216        if content.starts_with('#') {
217            out.push(LineKind::Heading);
218            continue;
219        }
220        if content.starts_with('|') || content.starts_with('<') {
221            out.push(LineKind::Table);
222            continue;
223        }
224
225        let (ordered_item, marker) = if let Some(width) = ordered_marker(content) {
226            (true, width)
227        } else if let Some(width) = bullet_marker(content) {
228            (false, width)
229        } else {
230            (false, 0)
231        };
232        let stripped = content[marker..].trim_start().to_string();
233        out.push(LineKind::Prose(Prose {
234            number,
235            content: stripped,
236            ordered_item,
237        }));
238    }
239    out
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn skips_fences_headings_tables_and_front_matter() {
248        let text = "---\ntitle: x\n---\n# Heading\n\nA plain paragraph.\n\n```text\ncode\n```\n\n| a | b |\n| - | - |\n- a list item\n1. a numbered item\n";
249        let kinds = classify(text);
250        let prose: Vec<&Prose> = kinds
251            .iter()
252            .filter_map(|k| {
253                if let LineKind::Prose(p) = k {
254                    Some(p)
255                } else {
256                    None
257                }
258            })
259            .collect();
260        assert_eq!(prose.len(), 3);
261        assert_eq!(prose[0].content, "A plain paragraph.");
262        assert_eq!(prose[1].content, "a list item");
263        assert!(prose[2].ordered_item);
264        assert_eq!(prose[2].content, "a numbered item");
265    }
266
267    #[test]
268    fn a_multi_line_comment_is_not_prose() {
269        let text = "<!-- a note\nover two lines -->\ntext after.\n";
270        let prose: Vec<String> = classify(text)
271            .into_iter()
272            .filter_map(|k| {
273                if let LineKind::Prose(p) = k {
274                    Some(p.content)
275                } else {
276                    None
277                }
278            })
279            .collect();
280        assert_eq!(prose, vec!["text after.".to_string()]);
281    }
282}