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