1use std::collections::HashMap;
10use std::ops::Range;
11
12use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag};
13use speccoder_core::snapshot::{
14 DependencyMode, Diagnostic, ParsedTasks, Phase, Severity, Task, Totals,
15};
16
17use crate::hash::sha256_hex;
18
19#[must_use]
21pub fn parse(path: &str, text: &str) -> (ParsedTasks, Vec<Diagnostic>) {
22 let lines = LineIndex::new(text);
23 let mut diags = Vec::new();
24 let mut phases: Vec<(Phase, usize)> = Vec::new(); let mut tasks = Vec::new();
26 let mut subsection: Option<String> = None;
27 let mut pending_item: Option<Range<usize>> = None;
28 let mut dependency_mode = DependencyMode::Structural;
29
30 let options = Options::ENABLE_TASKLISTS | Options::ENABLE_YAML_STYLE_METADATA_BLOCKS;
33 for (event, range) in Parser::new_ext(text, options).into_offset_iter() {
34 match event {
35 Event::Start(Tag::Heading { level, .. }) => {
36 pending_item = None;
37 let line_no = lines.line_of(range.start);
38 let title = heading_title(lines.text(text, line_no));
39 if level == HeadingLevel::H2 && declares_dependencies(&title) {
40 dependency_mode = DependencyMode::Declared;
41 }
42 match level {
43 HeadingLevel::H2 => {
44 phases.push((
45 Phase {
46 title,
47 line: to_u32(line_no + 1),
48 section_hash: String::new(),
49 },
50 lines.start(line_no),
51 ));
52 subsection = None;
53 }
54 HeadingLevel::H3 => subsection = Some(title),
55 _ => {}
56 }
57 }
58 Event::Start(Tag::Item) => pending_item = Some(range),
59 Event::Start(Tag::Paragraph) => {}
60 Event::TaskListMarker(checked) => {
61 if let Some(item) = pending_item.take() {
62 let line_no = lines.line_of(item.start);
63 let line = lines.text(text, line_no);
64 let display_line = to_u32(line_no + 1);
65 match parse_task_line(line) {
66 Some(parsed) => tasks.push(Task {
67 id: parsed.id,
68 done: checked,
69 parallel: parsed.parallel,
70 story: parsed.story,
71 depends_on: explicit_deps(&parsed.title),
72 title: parsed.title,
73 phase: phases.last().map(|(p, _)| p.title.clone()),
74 subsection: if phases.is_empty() {
75 None
76 } else {
77 subsection.clone()
78 },
79 line: display_line,
80 byte_range: [item.start, item.end],
81 line_hash: sha256_hex(line.as_bytes()),
82 }),
83 None => diags.push(
84 Diagnostic::new(
85 Severity::Warning,
86 "tasks.missing-id",
87 "checklist item without a task ID; not treated as a task",
88 )
89 .at(path)
90 .line(display_line)
91 .hint("start the item with an ID such as T001"),
92 ),
93 }
94 }
95 }
96 _ => pending_item = None,
97 }
98 }
99
100 let starts: Vec<usize> = phases.iter().map(|(_, s)| *s).collect();
102 for (i, (phase, start)) in phases.iter_mut().enumerate() {
103 let end = starts.get(i + 1).copied().unwrap_or(text.len());
104 phase.section_hash = sha256_hex(&text.as_bytes()[*start..end]);
105 }
106
107 check(path, &tasks, &mut diags);
108 let done = tasks.iter().filter(|t| t.done).count();
109 let totals = Totals {
110 total: tasks.len(),
111 done,
112 open: tasks.len() - done,
113 };
114 let parsed = ParsedTasks {
115 path: path.to_owned(),
116 phases: phases.into_iter().map(|(p, _)| p).collect(),
117 tasks,
118 totals,
119 dependency_mode,
120 };
121 (parsed, diags)
122}
123
124fn declares_dependencies(title: &str) -> bool {
127 let lower = title.to_lowercase();
128 lower.starts_with("format") && lower.contains("depends on")
129}
130
131fn check(path: &str, tasks: &[Task], diags: &mut Vec<Diagnostic>) {
132 if tasks.is_empty() {
133 diags.push(
134 Diagnostic::new(
135 Severity::Warning,
136 "tasks.empty",
137 "tasks.md contains no tasks",
138 )
139 .at(path)
140 .hint("speccoder spec tasks generates them (/speckit-tasks in your editor)"),
141 );
142 return;
143 }
144 let mut first_line: HashMap<&str, u32> = HashMap::new();
145 let mut previous: Option<(u64, &str)> = None;
146 for t in tasks {
147 if let Some(first) = first_line.get(t.id.as_str()) {
148 diags.push(
149 Diagnostic::new(
150 Severity::Error,
151 "tasks.duplicate-id",
152 format!("{} appears on lines {first} and {}", t.id, t.line),
153 )
154 .at(path)
155 .line(t.line)
156 .hint("give every task a unique ID"),
157 );
158 } else {
159 first_line.insert(&t.id, t.line);
160 }
161 let n = id_number(&t.id);
162 if let Some((prev_n, prev_id)) = previous
163 && n < prev_n
164 {
165 diags.push(
166 Diagnostic::new(
167 Severity::Warning,
168 "tasks.out-of-order",
169 format!("{} follows {prev_id}", t.id),
170 )
171 .at(path)
172 .line(t.line),
173 );
174 }
175 previous = Some((n, &t.id));
176 if t.phase.is_none() {
177 diags.push(
178 Diagnostic::new(
179 Severity::Warning,
180 "tasks.before-phase",
181 format!("{} comes before the first phase heading", t.id),
182 )
183 .at(path)
184 .line(t.line),
185 );
186 }
187 }
188}
189
190struct TaskLine {
191 id: String,
192 parallel: bool,
193 story: Option<String>,
194 title: String,
195}
196
197fn parse_task_line(line: &str) -> Option<TaskLine> {
200 let rest = line.trim_start();
201 let rest = rest
202 .strip_prefix(['-', '*', '+'])
203 .or_else(|| {
204 let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
205 (digits > 0)
206 .then(|| rest[digits..].strip_prefix(['.', ')']))
207 .flatten()
208 })?
209 .trim_start();
210 let rest = rest
211 .get(3..)
212 .filter(|_| rest.starts_with('['))?
213 .trim_start();
214
215 let digits = rest
216 .strip_prefix('T')?
217 .bytes()
218 .take_while(u8::is_ascii_digit)
219 .count();
220 let id_end = 1 + digits;
221 let boundary_ok = rest[id_end..]
222 .chars()
223 .next()
224 .is_none_or(|c| !c.is_alphanumeric());
225 if digits < 3 || !boundary_ok {
226 return None;
227 }
228 let id = rest[..id_end].to_owned();
229
230 let mut rest = rest[id_end..].trim_start();
231 let mut parallel = false;
232 let mut story = None;
233 loop {
234 if let Some(r) = rest.strip_prefix("[P]") {
235 parallel = true;
236 rest = r.trim_start();
237 } else if let Some(label) = story_label(rest) {
238 rest = rest[label.len() + 2..].trim_start();
239 story = Some(label.to_owned());
240 } else {
241 break;
242 }
243 }
244 Some(TaskLine {
245 id,
246 parallel,
247 story,
248 title: rest.trim_end().to_owned(),
249 })
250}
251
252#[must_use]
256pub fn explicit_deps(title: &str) -> Vec<String> {
257 let title = &without_code_spans(title);
259 let lower = title.to_lowercase();
260 let mut out: Vec<String> = Vec::new();
261 for phrase in ["depends on", "depend on", "after"] {
262 for (i, _) in lower.match_indices(phrase) {
263 let word_start = i == 0 || !lower[..i].ends_with(|c: char| c.is_alphanumeric());
264 if word_start {
265 collect_ids(&title[i + phrase.len()..], &mut out);
266 }
267 }
268 }
269 out
270}
271
272fn without_code_spans(s: &str) -> String {
275 let mut out = String::with_capacity(s.len());
276 let mut in_code = false;
277 for c in s.chars() {
278 if c == '`' {
279 in_code = !in_code;
280 out.push(' ');
281 } else if in_code {
282 out.extend(std::iter::repeat_n(' ', c.len_utf8()));
283 } else {
284 out.push(c);
285 }
286 }
287 out
288}
289
290fn collect_ids(mut s: &str, out: &mut Vec<String>) {
292 let mut push = |id: String| {
293 if !out.contains(&id) {
294 out.push(id);
295 }
296 };
297 loop {
298 s = s.trim_start_matches(|c: char| c.is_whitespace() || c == ',' || c == '&');
299 if let Some(rest) = s.strip_prefix("and ") {
300 s = rest;
301 continue;
302 }
303 let Some((id, rest)) = take_id(s) else {
304 return;
305 };
306 let range_end = rest
307 .strip_prefix('-')
308 .or_else(|| rest.strip_prefix('–'))
309 .and_then(take_id);
310 match range_end {
311 Some((end, after)) => {
312 let width = id.len() - 1;
313 let (a, b) = (id_number(&id), id_number(&end));
314 if a <= b && b - a <= 1000 {
315 for n in a..=b {
316 push(format!("T{n:0width$}"));
317 }
318 } else {
319 push(id);
320 push(end);
321 }
322 s = after;
323 }
324 None => {
325 push(id);
326 s = rest;
327 }
328 }
329 }
330}
331
332fn take_id(s: &str) -> Option<(String, &str)> {
334 let digits = s
335 .strip_prefix('T')?
336 .bytes()
337 .take_while(u8::is_ascii_digit)
338 .count();
339 let end = 1 + digits;
340 let boundary = s[end..].chars().next().is_none_or(|c| !c.is_alphanumeric());
341 (digits >= 3 && boundary).then(|| (s[..end].to_owned(), &s[end..]))
342}
343
344fn story_label(s: &str) -> Option<&str> {
346 let inner = s.strip_prefix('[')?;
347 let end = inner.find(']')?;
348 let label = &inner[..end];
349 let n = label.strip_prefix("US")?;
350 (!n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())).then_some(label)
351}
352
353fn id_number(id: &str) -> u64 {
354 id[1..].parse().unwrap_or(u64::MAX)
355}
356
357fn heading_title(line: &str) -> String {
358 line.trim()
359 .trim_start_matches('#')
360 .trim_end_matches('#')
361 .trim()
362 .to_owned()
363}
364
365fn to_u32(n: usize) -> u32 {
366 u32::try_from(n).unwrap_or(u32::MAX)
367}
368
369struct LineIndex {
371 starts: Vec<usize>,
372}
373
374impl LineIndex {
375 fn new(text: &str) -> Self {
376 let mut starts = vec![0];
377 starts.extend(text.match_indices('\n').map(|(i, _)| i + 1));
378 Self { starts }
379 }
380
381 fn line_of(&self, offset: usize) -> usize {
383 self.starts
384 .partition_point(|&s| s <= offset)
385 .saturating_sub(1)
386 }
387
388 fn start(&self, line: usize) -> usize {
389 self.starts[line]
390 }
391
392 fn text<'t>(&self, text: &'t str, line: usize) -> &'t str {
394 let start = self.starts[line];
395 let end = self.starts.get(line + 1).map_or(text.len(), |&n| n - 1);
396 let s = &text[start..end.max(start)];
397 s.strip_suffix('\r').unwrap_or(s)
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404
405 #[test]
406 fn parses_markers_and_title() {
407 let t =
408 parse_task_line("- [ ] T014 [P] [US1] Implement Service in src/x.py (depends on T012)")
409 .unwrap();
410 assert_eq!(t.id, "T014");
411 assert!(t.parallel);
412 assert_eq!(t.story.as_deref(), Some("US1"));
413 assert_eq!(t.title, "Implement Service in src/x.py (depends on T012)");
414 }
415
416 #[test]
417 fn rejects_items_without_a_proper_id() {
418 assert!(parse_task_line("- [ ] Create model").is_none());
419 assert!(parse_task_line("- [ ] T01 too short").is_none());
420 assert!(parse_task_line("- [ ] T001x glued").is_none());
421 assert!(parse_task_line("- [x] T001").is_some());
422 }
423
424 #[test]
425 fn ignores_code_blocks_and_html_comments() {
426 let text = "## Phase 1\n<!--\n- [ ] T900 in a comment\n-->\n```\n- [ ] T901 in a fence\n```\n- [ ] T001 real\n";
427 let (parsed, diags) = parse("tasks.md", text);
428 let ids: Vec<_> = parsed.tasks.iter().map(|t| t.id.as_str()).collect();
429 assert_eq!(ids, ["T001"]);
430 assert!(diags.is_empty(), "{diags:?}");
431 }
432
433 #[test]
434 fn extracts_explicit_dependencies() {
435 assert_eq!(
436 explicit_deps("Implement X (depends on T012, T013)"),
437 ["T012", "T013"]
438 );
439 assert_eq!(
440 explicit_deps("Do Y after T005 and T006 & T007"),
441 ["T005", "T006", "T007"]
442 );
443 assert_eq!(
444 explicit_deps("Depends On T010-T012"),
445 ["T010", "T011", "T012"]
446 );
447 assert_eq!(explicit_deps("depend on T008–T009 first"), ["T008", "T009"]);
448 assert!(explicit_deps("Run after deployment, see T004").is_empty());
449 assert!(explicit_deps("Hereafter T004 matters").is_empty());
450 assert!(explicit_deps("Write T001 docs").is_empty());
451 assert!(explicit_deps("Fixture with a dangling `depends on T099` line").is_empty());
452 assert_eq!(explicit_deps("Ä `x` after T002"), ["T002"]);
453 }
454
455 #[test]
456 fn format_line_sets_declared_mode() {
457 let declared = "## Format: `[ID] [P?] [Story?] Description [(depends on ...)]`\n## Phase 1\n- [ ] T001 a\n";
458 assert_eq!(
459 parse("t", declared).0.dependency_mode,
460 DependencyMode::Declared
461 );
462 let plain = "## Format: `[ID] [P?] [Story] Description`\n## Phase 1\n- [ ] T001 a\n";
463 assert_eq!(
464 parse("t", plain).0.dependency_mode,
465 DependencyMode::Structural
466 );
467 let fenced = "```\n## Format: (depends on ...)\n```\n## Phase 1\n- [ ] T001 a\n";
468 assert_eq!(
469 parse("t", fenced).0.dependency_mode,
470 DependencyMode::Structural
471 );
472 }
473
474 #[test]
475 fn front_matter_is_not_a_phase() {
476 let text = "---\ndescription: \"Task list\"\n---\n\n# Tasks\n\n## Phase 1\n- [ ] T001 a\n";
477 let (parsed, _) = parse("t", text);
478 let phases: Vec<_> = parsed.phases.iter().map(|p| p.title.as_str()).collect();
479 assert_eq!(phases, ["Phase 1"]);
480 }
481
482 #[test]
483 fn crlf_line_hash_excludes_line_ending() {
484 let (lf, _) = parse("t", "## P\n- [ ] T001 a\n");
485 let (crlf, _) = parse("t", "## P\r\n- [ ] T001 a\r\n");
486 assert_eq!(lf.tasks[0].line_hash, crlf.tasks[0].line_hash);
487 assert_eq!(crlf.tasks[0].line, 2);
488 let r = crlf.tasks[0].byte_range;
489 assert!("## P\r\n- [ ] T001 a\r\n"[r[0]..r[1]].starts_with("- [ ] T001 a"));
490 }
491}