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(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 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}