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                            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    // Section hashes: heading line start through the next phase (or EOF).
103    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
126/// A level-2 `Format…` heading that documents `depends on`, as the Explicit
127/// Task Dependencies preset writes it.
128fn 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
200/// Parses `- [ ] T014 [P] [US1] [YOU] Title` (the checkbox has already been seen by
201/// the Markdown parser; here it is only skipped).
202fn 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/// Task IDs named after `depends on`, `depend on` or `after` (any case):
261/// lists separated by whitespace, `,`, `and`, `&`, and ranges `T012-T015` /
262/// `T012–T015`. Nothing else in the text creates a dependency.
263#[must_use]
264pub fn explicit_deps(title: &str) -> Vec<String> {
265    // Inline code is quoted text (`depends on T099` as an example), not a reference.
266    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
280/// `s` with every inline code span (`` `…` ``) replaced by spaces of the same
281/// byte length, so offsets and case-folding stay aligned.
282fn 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
298/// Reads a list of IDs from the start of `s`.
299fn 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
340/// `T012…` → (`T012`, rest) when followed by a non-alphanumeric boundary.
341fn 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
352/// `[US12] …` → `US12`.
353fn 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
377/// Byte offsets of line starts, for offset → line and line → text.
378struct 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    /// 0-based line containing `offset`.
390    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    /// The line's text without its line ending (`\n` or `\r\n`).
401    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}