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// sdd: permanent the classifier is one state machine, and splitting it splits the state
124#[allow(clippy::too_many_lines, clippy::option_if_let_else)]
125pub fn classify(text: &str) -> Vec<LineKind> {
126    let mut out = Vec::new();
127    let mut fence: Option<Fence> = None;
128    let mut in_front_matter = false;
129    let mut in_comment = false;
130    let mut in_toc = false;
131
132    for (index, raw) in text.lines().enumerate() {
133        let number = index + 1;
134        let (quotes, indent, content) = container(raw);
135
136        if index == 0 && raw == "---" {
137            in_front_matter = true;
138            out.push(LineKind::FrontMatter);
139            continue;
140        }
141        if in_front_matter {
142            if raw == "---" || raw == "..." {
143                in_front_matter = false;
144            }
145            out.push(LineKind::FrontMatter);
146            continue;
147        }
148        if in_comment {
149            out.push(LineKind::Comment);
150            if content.contains("-->") {
151                in_comment = false;
152            }
153            continue;
154        }
155
156        if let Some(open) = &fence
157            && !content.is_empty()
158            && (quotes < open.quotes || indent < open.indent)
159        {
160            fence = None;
161        }
162        if let Some((delimiter, length)) = fence_delimiter(content) {
163            match &fence {
164                None => {
165                    fence = Some(Fence {
166                        delimiter,
167                        length,
168                        quotes,
169                        indent,
170                    });
171                }
172                Some(open)
173                    if delimiter == open.delimiter
174                        && length >= open.length
175                        && content.trim_start_matches(delimiter).trim_end().is_empty() =>
176                {
177                    fence = None;
178                }
179                Some(_) => {}
180            }
181            out.push(LineKind::Fence);
182            continue;
183        }
184        if fence.is_some() {
185            out.push(LineKind::Fence);
186            continue;
187        }
188        // The generated table of contents is not authored prose. Its entries
189        // mirror the requirement heading titles, dashes and all, so measuring
190        // it would flag the generator's output. Skip the marked region.
191        if content.trim() == "<!--TOC-->" {
192            in_toc = !in_toc;
193            out.push(LineKind::Comment);
194            continue;
195        }
196        if in_toc {
197            out.push(LineKind::Blank);
198            continue;
199        }
200        if content.starts_with("<!--") && !content.contains("-->") {
201            in_comment = true;
202            out.push(LineKind::Comment);
203            continue;
204        }
205        if content.starts_with("<!--") {
206            out.push(LineKind::Comment);
207            continue;
208        }
209        if content.is_empty() || is_thematic_break(content) || is_definition(content) {
210            out.push(LineKind::Blank);
211            continue;
212        }
213        if content.starts_with('#') {
214            out.push(LineKind::Heading);
215            continue;
216        }
217        if content.starts_with('|') || content.starts_with('<') {
218            out.push(LineKind::Table);
219            continue;
220        }
221
222        let (ordered_item, marker) = if let Some(width) = ordered_marker(content) {
223            (true, width)
224        } else if let Some(width) = bullet_marker(content) {
225            (false, width)
226        } else {
227            (false, 0)
228        };
229        let stripped = content[marker..].trim_start().to_string();
230        out.push(LineKind::Prose(Prose {
231            number,
232            content: stripped,
233            ordered_item,
234        }));
235    }
236    out
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn skips_fences_headings_tables_and_front_matter() {
245        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";
246        let kinds = classify(text);
247        let prose: Vec<&Prose> = kinds
248            .iter()
249            .filter_map(|k| {
250                if let LineKind::Prose(p) = k {
251                    Some(p)
252                } else {
253                    None
254                }
255            })
256            .collect();
257        assert_eq!(prose.len(), 3);
258        assert_eq!(prose[0].content, "A plain paragraph.");
259        assert_eq!(prose[1].content, "a list item");
260        assert!(prose[2].ordered_item);
261        assert_eq!(prose[2].content, "a numbered item");
262    }
263
264    #[test]
265    fn a_multi_line_comment_is_not_prose() {
266        let text = "<!-- a note\nover two lines -->\ntext after.\n";
267        let prose: Vec<String> = classify(text)
268            .into_iter()
269            .filter_map(|k| {
270                if let LineKind::Prose(p) = k {
271                    Some(p.content)
272                } else {
273                    None
274                }
275            })
276            .collect();
277        assert_eq!(prose, vec!["text after.".to_string()]);
278    }
279}