Skip to main content

speccoder_speckit/
tasks.rs

1//! Tolerant parser for Spec Kit's `tasks.md`.
2//!
3//! A task is a Markdown task-list item (`- [ ]`, `- [x]`, `- [X]`) whose text
4//! starts with an ID `T` + three or more digits. Items inside code blocks and
5//! HTML comments are not list items to a CommonMark parser, so they are never
6//! tasks. Level-2 headings are phases, level-3 headings subsections. No
7//! dependencies are inferred here.
8
9use 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/// Parses `text` (the contents of the workspace-relative `path`).
20#[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(); // with section start offset
25    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    // Spec Kit's tasks.md opens with YAML front matter; without metadata-block
31    // support its closing `---` would turn the line above it into a heading.
32    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    // Section hashes: heading line start through the next phase (or EOF).
102    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
125/// A level-2 `Format…` heading that documents `depends on`, as the Explicit
126/// Task Dependencies preset writes it.
127fn 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
199/// Parses `- [ ] T014 [P] [US1] [YOU] Title` (the checkbox has already been seen by
200/// the Markdown parser; here it is only skipped).
201fn 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/// Task IDs named after `depends on`, `depend on` or `after` (any case):
260/// lists separated by whitespace, `,`, `and`, `&`, and ranges `T012-T015` /
261/// `T012–T015`. Nothing else in the text creates a dependency.
262#[must_use]
263pub fn explicit_deps(title: &str) -> Vec<String> {
264    // Inline code is quoted text (`depends on T099` as an example), not a reference.
265    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
279/// `s` with every inline code span (`` `…` ``) replaced by spaces of the same
280/// byte length, so offsets and case-folding stay aligned.
281fn 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
297/// Reads a list of IDs from the start of `s`.
298fn 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
339/// `T012…` → (`T012`, rest) when followed by a non-alphanumeric boundary.
340fn 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
351/// `[US12] …` → `US12`.
352fn 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
376/// Byte offsets of line starts, for offset → line and line → text.
377struct 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    /// 0-based line containing `offset`.
389    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    /// The line's text without its line ending (`\n` or `\r\n`).
400    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}