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