1use speccoder_core::snapshot::{
10 DependencyMode, Diagnostic, ParsedTasks, Phase, Severity, Task, Totals,
11};
12
13use crate::hash::sha256_hex;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Checkbox<'a> {
18 pub indent: usize,
20 pub inner_start: usize,
22 pub inner: &'a str,
24 pub text: &'a str,
26}
27
28impl Checkbox<'_> {
29 #[must_use]
31 pub fn done(&self) -> bool {
32 matches!(self.inner.trim(), "x" | "X")
33 }
34
35 #[must_use]
37 pub fn tickable(&self) -> bool {
38 self.inner == " "
39 }
40}
41
42#[must_use]
45pub fn checkbox(line: &str) -> Option<Checkbox<'_>> {
46 let indent = line.len() - line.trim_start().len();
47 let rest = &line[indent..];
48 let after_marker = if let Some(r) = rest.strip_prefix(['-', '*', '+']) {
49 r
50 } else {
51 let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
52 if !(1..=9).contains(&digits) {
53 return None;
54 }
55 rest[digits..].strip_prefix(['.', ')'])?
56 };
57 let open = after_marker.trim_start();
58 let body = open.strip_prefix('[')?;
59 let close = body.find(']')?;
60 let inner = &body[..close];
61 let after = &body[close + 1..];
62 let blank = inner.chars().all(char::is_whitespace);
63 let one_char = inner.trim().chars().count() == 1;
64 if !(blank || one_char) {
65 return None;
66 }
67 if after.starts_with(['(', '[']) && (inner.is_empty() || !blank) {
69 return None;
70 }
71 let inner_start = line.len() - body.len();
72 Some(Checkbox {
73 indent,
74 inner_start,
75 inner,
76 text: after.trim(),
77 })
78}
79
80#[must_use]
82pub fn leading_id(text: &str) -> Option<&str> {
83 let mut end = 0;
84 let bytes = text.as_bytes();
85 let mut parts = 0;
86 loop {
87 let digits = bytes[end..]
88 .iter()
89 .take_while(|b| b.is_ascii_digit())
90 .count();
91 if digits == 0 {
92 break;
93 }
94 end += digits;
95 parts += 1;
96 if bytes.get(end) == Some(&b'.') && bytes.get(end + 1).is_some_and(u8::is_ascii_digit) {
97 end += 1;
98 } else {
99 break;
100 }
101 }
102 if parts < 2 {
103 return None;
104 }
105 end += bytes[end..]
106 .iter()
107 .take_while(|b| b.is_ascii_alphabetic())
108 .count();
109 let next = text[end..].chars().next();
110 (next.is_none_or(char::is_whitespace)).then_some(&text[..end])
111}
112
113fn group_number(title: &str) -> Option<u32> {
115 let digits: String = title.chars().take_while(char::is_ascii_digit).collect();
116 if digits.is_empty() || !title[digits.len()..].starts_with('.') {
117 return None;
118 }
119 digits.parse().ok()
120}
121
122fn references(text: &str) -> Vec<String> {
124 let lower = text.to_lowercase();
125 let mut out = Vec::new();
126 for key in ["depends on ", "after "] {
127 for (i, _) in lower.match_indices(key) {
128 if i > 0 && lower.as_bytes()[i - 1].is_ascii_alphanumeric() {
129 continue;
130 }
131 let word: String = text[i + key.len()..]
132 .trim_start()
133 .chars()
134 .take_while(|c| c.is_ascii_alphanumeric() || *c == '.')
135 .collect();
136 let word = word.trim_end_matches('.');
137 if leading_id(word) == Some(word) && !out.iter().any(|o| o == word) {
138 out.push(word.to_owned());
139 }
140 }
141 }
142 out
143}
144
145struct Raw {
146 task: Task,
147 indent: usize,
148 group: usize,
149}
150
151#[must_use]
153pub fn parse(path: &str, text: &str) -> (ParsedTasks, Vec<Diagnostic>) {
154 let mut diags = Vec::new();
155 let diag = |severity, code: &str, line: usize, message: String, hint: &str| {
156 let d = Diagnostic::new(severity, code, message)
157 .at(path)
158 .line(u32::try_from(line + 1).unwrap_or(u32::MAX));
159 if hint.is_empty() { d } else { d.hint(hint) }
160 };
161 let mut phases: Vec<(Phase, usize, Option<u32>)> = Vec::new();
162 let mut raws: Vec<Raw> = Vec::new();
163 let mut fence: Option<&str> = None;
164 let mut unnumbered_warned: Option<usize> = None;
165 let mut offset = 0;
166 let mut ordinal = 0;
167 for (no, full) in text.split_inclusive('\n').enumerate() {
168 let start = offset;
169 offset += full.len();
170 let line = full.trim_end_matches(['\n', '\r']);
171 let trimmed = line.trim_start();
172 if let Some(f) = fence {
173 if trimmed.starts_with(f) {
174 fence = None;
175 }
176 } else if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
177 fence = Some(&trimmed[..3]);
178 continue;
179 } else if let Some(title) = line.strip_prefix("## ") {
180 let title = title.trim().to_owned();
181 let number = group_number(&title);
182 phases.push((
183 Phase {
184 title,
185 line: u32::try_from(no + 1).unwrap_or(u32::MAX),
186 section_hash: String::new(),
187 },
188 start,
189 number,
190 ));
191 continue;
192 }
193 let Some(cb) = checkbox(line) else { continue };
194 ordinal += 1;
195 if fence.is_some() {
196 diags.push(diag(
197 Severity::Warning,
198 "in_fence",
199 no,
200 "a checkbox inside a code block: OpenSpec counts it as a task".to_owned(),
201 "move it out of the code block, or remove its brackets",
202 ));
203 }
204 if cb.text.is_empty() {
205 diags.push(diag(
206 Severity::Error,
207 "textless",
208 no,
209 "a checkbox without text".to_owned(),
210 "describe the task, or remove the line",
211 ));
212 continue;
213 }
214 if cb.inner.is_empty() {
215 diags.push(diag(
216 Severity::Warning,
217 "empty_brackets",
218 no,
219 "`[]` has no space: speccoder cannot tick it".to_owned(),
220 "write `[ ]`",
221 ));
222 } else if !cb.done() && !cb.tickable() {
223 diags.push(diag(
224 Severity::Warning,
225 "marker_not_space",
226 no,
227 format!(
228 "`[{}]` is neither open nor done: speccoder cannot tick it",
229 cb.inner
230 ),
231 "write `[ ]` (open) or `[x]` (done)",
232 ));
233 }
234 let (id, title) = match leading_id(cb.text) {
235 Some(id) => (id.to_owned(), cb.text[id.len()..].trim().to_owned()),
236 None => (format!("#{ordinal}"), cb.text.to_owned()),
237 };
238 let group = phases.len();
239 let (phase, number) = match phases.last() {
240 Some((p, _, n)) => (Some(p.title.clone()), *n),
241 None => (None, None),
242 };
243 if let (Some(n), Some(first)) = (number, id.split('.').next()) {
244 if first.parse::<u32>().ok().is_some_and(|f| f != n) && !id.starts_with('#') {
245 diags.push(diag(
246 Severity::Warning,
247 "misplaced_id",
248 no,
249 format!("{id} is under group {n}"),
250 "renumber the task or move it to its group",
251 ));
252 }
253 } else if phase.is_some() && number.is_none() && unnumbered_warned != Some(group) {
254 unnumbered_warned = Some(group);
255 diags.push(diag(
256 Severity::Warning,
257 "unnumbered_section",
258 no,
259 format!(
260 "`## {}` is not a numbered group; its tasks run after everything before them",
261 phase.as_deref().unwrap_or_default()
262 ),
263 "number the heading (`## 3. …`)",
264 ));
265 }
266 let depends_on = references(&title);
267 if !depends_on.is_empty() {
268 diags.push(diag(
269 Severity::Warning,
270 "extension_dependency",
271 no,
272 format!(
273 "{id} names its dependencies ({}): a speccoder extension OpenSpec ignores",
274 depends_on.join(", ")
275 ),
276 "",
277 ));
278 }
279 raws.push(Raw {
280 task: Task {
281 id,
282 done: cb.done(),
283 parallel: false,
284 story: None,
285 yours: false,
287 title,
288 depends_on,
289 phase,
290 subsection: None,
291 line: u32::try_from(no + 1).unwrap_or(u32::MAX),
292 byte_range: [start, start + line.len()],
293 line_hash: sha256_hex(line.as_bytes()),
294 },
295 indent: cb.indent,
296 group,
297 });
298 }
299
300 for (i, r) in raws.iter().enumerate() {
302 if raws[..i].iter().any(|e| e.task.id == r.task.id) {
303 diags.push(
304 Diagnostic::new(
305 Severity::Error,
306 "duplicate_id",
307 format!("task {} appears more than once", r.task.id),
308 )
309 .at(path)
310 .line(r.task.line)
311 .hint("give each task its own number"),
312 );
313 }
314 }
315 if raws.is_empty() {
316 diags.push(
317 Diagnostic::new(Severity::Error, "no_tasks", "tasks.md has no tasks")
318 .at(path)
319 .hint("add `- [ ] 1.1 …` lines under `## 1. …`"),
320 );
321 }
322
323 let mut tasks = Vec::with_capacity(raws.len());
325 let mut stack: Vec<Raw> = Vec::new();
326 for r in raws {
327 while stack
328 .last()
329 .is_some_and(|top| top.group != r.group || top.indent >= r.indent)
330 {
331 tasks.extend(stack.pop().map(|t| t.task));
332 }
333 stack.push(r);
334 }
335 while let Some(t) = stack.pop() {
336 tasks.push(t.task);
337 }
338
339 let starts: Vec<usize> = phases.iter().map(|(_, s, _)| *s).collect();
340 let phases = phases
341 .into_iter()
342 .enumerate()
343 .map(|(i, (mut p, start, _))| {
344 let end = starts.get(i + 1).copied().unwrap_or(text.len());
345 p.section_hash = sha256_hex(&text.as_bytes()[start..end]);
346 p
347 })
348 .collect();
349 let done = tasks.iter().filter(|t| t.done).count();
350 let totals = Totals {
351 total: tasks.len(),
352 done,
353 open: tasks.len() - done,
354 };
355 (
356 ParsedTasks {
357 path: path.to_owned(),
358 phases,
359 tasks,
360 totals,
361 dependency_mode: DependencyMode::Chain,
362 },
363 diags,
364 )
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370
371 #[test]
372 fn checkboxes_match_openspecs_pattern() {
373 for (line, inner, text) in [
374 ("- [ ] 1.1 a", " ", "1.1 a"),
375 (" * [x] b", "x", "b"),
376 ("1) [X] c", "X", "c"),
377 ("- [~] d", "~", "d"),
378 ("- [] e", "", "e"),
379 ("- [ ]", " ", ""),
380 ("+[ ]f", " ", "f"),
381 ] {
382 let cb = checkbox(line).unwrap_or_else(|| panic!("{line}"));
383 assert_eq!((cb.inner, cb.text), (inner, text), "{line}");
384 assert_eq!(
385 &line[cb.inner_start..cb.inner_start + cb.inner.len()],
386 inner
387 );
388 }
389 for line in [
390 "[ ] no marker",
391 "- [ab] two",
392 "- [x](link)",
393 "text",
394 "1234567890. [ ] x",
395 ] {
396 assert!(checkbox(line).is_none(), "{line}");
397 }
398 assert!(checkbox("- [ ](x)").is_some());
399 }
400
401 #[test]
402 fn ids_and_references() {
403 assert_eq!(leading_id("1.2 x"), Some("1.2"));
404 assert_eq!(leading_id("2.3a y"), Some("2.3a"));
405 assert_eq!(leading_id("1.4.1"), Some("1.4.1"));
406 assert_eq!(leading_id("1 x"), None);
407 assert_eq!(leading_id("1.2x3"), None);
408 assert_eq!(leading_id("Read 1.2"), None);
409 assert_eq!(references("do it, depends on 2.2"), ["2.2"]);
410 assert_eq!(references("After 9.9, which"), ["9.9"]);
411 assert!(references("hereafter 1.2").is_empty());
412 }
413}