spec_driven_docs/gates/
markdown_prose.rs1#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum LineKind {
13 FrontMatter,
15 Fence,
17 Comment,
19 Heading,
21 Table,
23 Blank,
25 Prose(Prose),
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Prose {
33 pub number: usize,
35 pub content: String,
37 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#[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 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}