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                            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    // Section hashes: heading line start through the next phase (or EOF).
101    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
124/// A level-2 `Format…` heading that documents `depends on`, as the Explicit
125/// Task Dependencies preset writes it.
126fn 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
197/// Parses `- [ ] T014 [P] [US1] Title` (the checkbox has already been seen by
198/// the Markdown parser; here it is only skipped).
199fn 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/// Task IDs named after `depends on`, `depend on` or `after` (any case):
253/// lists separated by whitespace, `,`, `and`, `&`, and ranges `T012-T015` /
254/// `T012–T015`. Nothing else in the text creates a dependency.
255#[must_use]
256pub fn explicit_deps(title: &str) -> Vec<String> {
257    // Inline code is quoted text (`depends on T099` as an example), not a reference.
258    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
272/// `s` with every inline code span (`` `…` ``) replaced by spaces of the same
273/// byte length, so offsets and case-folding stay aligned.
274fn 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
290/// Reads a list of IDs from the start of `s`.
291fn 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
332/// `T012…` → (`T012`, rest) when followed by a non-alphanumeric boundary.
333fn 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
344/// `[US12] …` → `US12`.
345fn 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
369/// Byte offsets of line starts, for offset → line and line → text.
370struct 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    /// 0-based line containing `offset`.
382    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    /// The line's text without its line ending (`\n` or `\r\n`).
393    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}