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 title,
286 depends_on,
287 phase,
288 subsection: None,
289 line: u32::try_from(no + 1).unwrap_or(u32::MAX),
290 byte_range: [start, start + line.len()],
291 line_hash: sha256_hex(line.as_bytes()),
292 },
293 indent: cb.indent,
294 group,
295 });
296 }
297
298 for (i, r) in raws.iter().enumerate() {
300 if raws[..i].iter().any(|e| e.task.id == r.task.id) {
301 diags.push(
302 Diagnostic::new(
303 Severity::Error,
304 "duplicate_id",
305 format!("task {} appears more than once", r.task.id),
306 )
307 .at(path)
308 .line(r.task.line)
309 .hint("give each task its own number"),
310 );
311 }
312 }
313 if raws.is_empty() {
314 diags.push(
315 Diagnostic::new(Severity::Error, "no_tasks", "tasks.md has no tasks")
316 .at(path)
317 .hint("add `- [ ] 1.1 …` lines under `## 1. …`"),
318 );
319 }
320
321 let mut tasks = Vec::with_capacity(raws.len());
323 let mut stack: Vec<Raw> = Vec::new();
324 for r in raws {
325 while stack
326 .last()
327 .is_some_and(|top| top.group != r.group || top.indent >= r.indent)
328 {
329 tasks.extend(stack.pop().map(|t| t.task));
330 }
331 stack.push(r);
332 }
333 while let Some(t) = stack.pop() {
334 tasks.push(t.task);
335 }
336
337 let starts: Vec<usize> = phases.iter().map(|(_, s, _)| *s).collect();
338 let phases = phases
339 .into_iter()
340 .enumerate()
341 .map(|(i, (mut p, start, _))| {
342 let end = starts.get(i + 1).copied().unwrap_or(text.len());
343 p.section_hash = sha256_hex(&text.as_bytes()[start..end]);
344 p
345 })
346 .collect();
347 let done = tasks.iter().filter(|t| t.done).count();
348 let totals = Totals {
349 total: tasks.len(),
350 done,
351 open: tasks.len() - done,
352 };
353 (
354 ParsedTasks {
355 path: path.to_owned(),
356 phases,
357 tasks,
358 totals,
359 dependency_mode: DependencyMode::Chain,
360 },
361 diags,
362 )
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368
369 #[test]
370 fn checkboxes_match_openspecs_pattern() {
371 for (line, inner, text) in [
372 ("- [ ] 1.1 a", " ", "1.1 a"),
373 (" * [x] b", "x", "b"),
374 ("1) [X] c", "X", "c"),
375 ("- [~] d", "~", "d"),
376 ("- [] e", "", "e"),
377 ("- [ ]", " ", ""),
378 ("+[ ]f", " ", "f"),
379 ] {
380 let cb = checkbox(line).unwrap_or_else(|| panic!("{line}"));
381 assert_eq!((cb.inner, cb.text), (inner, text), "{line}");
382 assert_eq!(
383 &line[cb.inner_start..cb.inner_start + cb.inner.len()],
384 inner
385 );
386 }
387 for line in [
388 "[ ] no marker",
389 "- [ab] two",
390 "- [x](link)",
391 "text",
392 "1234567890. [ ] x",
393 ] {
394 assert!(checkbox(line).is_none(), "{line}");
395 }
396 assert!(checkbox("- [ ](x)").is_some());
397 }
398
399 #[test]
400 fn ids_and_references() {
401 assert_eq!(leading_id("1.2 x"), Some("1.2"));
402 assert_eq!(leading_id("2.3a y"), Some("2.3a"));
403 assert_eq!(leading_id("1.4.1"), Some("1.4.1"));
404 assert_eq!(leading_id("1 x"), None);
405 assert_eq!(leading_id("1.2x3"), None);
406 assert_eq!(leading_id("Read 1.2"), None);
407 assert_eq!(references("do it, depends on 2.2"), ["2.2"]);
408 assert_eq!(references("After 9.9, which"), ["9.9"]);
409 assert!(references("hereafter 1.2").is_empty());
410 }
411}