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