Skip to main content

kernel/spine/
grammar.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Table grammar: locating the table and reading its rows.
7//! The part `crates/kernel/spec/Spine.lean` specifies this module with the
8//! rest of `kernel::spine`.
9
10use crate::locator::Locator;
11use crate::node_id::NodeId;
12
13use super::row::{
14    EvidenceCell, ROADMAP_COLUMNS, ROADMAP_STATUS_SPELLINGS, RoadmapRow, RoadmapShape,
15    RoadmapStatus,
16};
17
18fn parse_status(raw: &str) -> Option<RoadmapStatus> {
19    ROADMAP_STATUS_SPELLINGS
20        .iter()
21        .find(|(_, spelling)| spelling.eq_ignore_ascii_case(raw))
22        .map(|(status, _)| *status)
23}
24
25pub(crate) fn split_table_row(line: &str) -> Option<Vec<String>> {
26    let trimmed = line.trim();
27    if !trimmed.starts_with('|') {
28        return None;
29    }
30    let inner = trimmed.strip_prefix('|')?.strip_suffix('|')?;
31    Some(
32        inner
33            .split('|')
34            .map(|cell| cell.trim().to_owned())
35            .collect(),
36    )
37}
38
39pub(crate) fn is_separator(cells: &[String]) -> bool {
40    !cells.is_empty()
41        && cells
42            .iter()
43            .all(|c| !c.is_empty() && c.chars().all(|ch| ch == '-' || ch == ':'))
44}
45
46/// Whether a line is a body row of the roadmap: a table row that is
47/// neither the header nor the separator. The one test, shared by the
48/// parser and both writers, so "which lines are the plan" has a single
49/// answer.
50pub(crate) fn body_row(cells: &[String]) -> bool {
51    cells.len() == ROADMAP_COLUMNS && !is_separator(cells)
52}
53
54/// The lines of the first table, as an inclusive range over
55/// `text.split('\n')`. `None` when the text carries no table.
56pub(crate) fn locate_table(text: &str) -> Option<(usize, usize)> {
57    let mut first = None;
58    let mut last = 0;
59    for (n, line) in text.split('\n').enumerate() {
60        if split_table_row(line).is_some() {
61            if first.is_none() {
62                first = Some(n);
63            }
64            last = n;
65        } else if first.is_some() {
66            break;
67        }
68    }
69    first.map(|start| (start, last))
70}
71
72/// Finds the first pipe table and checks the six-column contract. Pure
73/// string work, no I/O; `crate::plan` builds the tree from what comes
74/// back.
75#[must_use]
76pub fn check_roadmap_shape(text: &str) -> RoadmapShape {
77    let mut rows = Vec::new();
78    let mut problems = Vec::new();
79    let mut in_table = false;
80    let mut header_seen = false;
81    for (n, line) in text.lines().enumerate() {
82        let line_no = n.saturating_add(1);
83        let Some(cells) = split_table_row(line) else {
84            if in_table {
85                break; // first table ended
86            }
87            continue;
88        };
89        in_table = true;
90        if cells.len() != ROADMAP_COLUMNS {
91            problems.push(format!(
92                "line {line_no}: {} columns, the roadmap table has exactly {ROADMAP_COLUMNS}",
93                cells.len()
94            ));
95            continue;
96        }
97        if !header_seen {
98            header_seen = true;
99            continue; // header row
100        }
101        if is_separator(&cells) {
102            continue;
103        }
104        match read_row(&cells) {
105            Ok(row) => rows.push(row),
106            Err(why) => problems.push(format!("line {line_no}: {why}")),
107        }
108    }
109    if !header_seen {
110        problems.push(format!("no {ROADMAP_COLUMNS}-column table found"));
111    }
112    if problems.is_empty() {
113        RoadmapShape::WellFormed { rows }
114    } else {
115        RoadmapShape::Malformed { problems }
116    }
117}
118
119/// Reads one body row, or says in one clause what is wrong with it.
120pub(crate) fn read_row(cells: &[String]) -> Result<RoadmapRow, String> {
121    let (
122        Some(index_cell),
123        Some(item_cell),
124        Some(weight_cell),
125        Some(needs_cell),
126        Some(status_cell),
127        Some(evidence_cell),
128    ) = (
129        cells.first(),
130        cells.get(1),
131        cells.get(2),
132        cells.get(3),
133        cells.get(4),
134        cells.get(5),
135    )
136    else {
137        return Err("the row is short of cells".to_owned());
138    };
139    let id = NodeId::parse(index_cell).map_err(|err| err.subject().to_owned())?;
140    let weight = if weight_cell.is_empty() {
141        1
142    } else {
143        weight_cell
144            .parse::<u32>()
145            .map_err(|_| format!("weight `{weight_cell}` is not a number"))?
146    };
147    let mut needs = Vec::new();
148    for part in needs_cell.split(',') {
149        let trimmed = part.trim();
150        if trimmed.is_empty() {
151            continue;
152        }
153        needs.push(NodeId::parse(trimmed).map_err(|err| err.subject().to_owned())?);
154    }
155    let status = parse_status(status_cell)
156        .ok_or_else(|| format!("status `{status_cell}` outside the five-value enum"))?;
157    let evidence = if evidence_cell.is_empty() {
158        EvidenceCell::Empty
159    } else {
160        match Locator::parse(evidence_cell) {
161            Ok(locator) => EvidenceCell::Present(locator),
162            Err(_) => EvidenceCell::Invalid {
163                raw: evidence_cell.clone(),
164            },
165        }
166    };
167    Ok(RoadmapRow {
168        id,
169        item: item_cell.clone(),
170        weight,
171        needs,
172        status,
173        evidence,
174    })
175}
176
177#[cfg(test)]
178#[allow(
179    clippy::unwrap_used,
180    clippy::expect_used,
181    clippy::panic,
182    clippy::indexing_slicing,
183    clippy::arithmetic_side_effects,
184    reason = "test code"
185)]
186mod tests {
187    use super::super::rewrite::set_roadmap_status;
188    use super::*;
189    use crate::error::AxCode;
190    use crate::locator::Locator;
191    use crate::node_id::NodeId;
192    fn node(raw: &str) -> NodeId {
193        NodeId::parse(raw).unwrap()
194    }
195    fn locator() -> Locator {
196        Locator::parse(&format!("cas:b3-{}", "ab".repeat(32))).unwrap()
197    }
198    fn rows_of(text: &str) -> Vec<RoadmapRow> {
199        match check_roadmap_shape(text) {
200            RoadmapShape::WellFormed { rows } => rows,
201            RoadmapShape::Malformed { problems } => panic!("expected well-formed: {problems:?}"),
202        }
203    }
204
205    const GOOD: &str = "# Roadmap
206
207| # | Item | Weight | Needs | Status | Evidence |
208|---|------|--------|-------|--------|----------|
209| 1 | chain verification | 1 |  | Done | cas:b3-abababababababababababababababababababababababababababababababab |
210| 2 | range retrieval | 3 | 1 | In progress |  |
211| 3 | fixtures | 1 |  | Awaiting approval |  |
212| 4 | claimed without evidence | 1 |  | Done |  |
213| 5 | evidence that does not parse | 1 |  | done | not-a-locator |
214";
215    #[test]
216    fn a_good_table_parses_every_column() {
217        let rows = rows_of(GOOD);
218        assert_eq!(rows.len(), 5);
219        assert_eq!(rows[0].status, RoadmapStatus::Done);
220        assert!(matches!(rows[0].evidence, EvidenceCell::Present(_)));
221        assert_eq!(rows[1].weight, 3);
222        assert_eq!(rows[1].needs, vec![node("1")]);
223        assert_eq!(rows[2].weight, 1, "an empty weight cell reads as one");
224        assert!(rows[2].needs.is_empty());
225        assert!(matches!(rows[3].evidence, EvidenceCell::Empty));
226        assert!(matches!(rows[4].evidence, EvidenceCell::Invalid { .. }));
227        assert_eq!(rows[4].status, RoadmapStatus::Done, "case is not contract");
228    }
229
230    #[test]
231    fn done_without_evidence_is_refused_where_it_is_written() {
232        let refusal = set_roadmap_status(GOOD, &node("2"), RoadmapStatus::Done, None).unwrap_err();
233        assert_eq!(refusal.code(), &AxCode::EvidenceMissing);
234        assert!(refusal.recovery().contains("cas:"));
235        let text =
236            set_roadmap_status(GOOD, &node("2"), RoadmapStatus::Done, Some(&locator())).unwrap();
237        assert!(text.contains("| 2 | range retrieval | 3 | 1 | Done | cas:b3-"));
238    }
239
240    #[test]
241    fn an_index_no_row_carries_is_refused_by_number() {
242        let refusal =
243            set_roadmap_status(GOOD, &node("99"), RoadmapStatus::InProgress, None).unwrap_err();
244        assert_eq!(refusal.code(), &AxCode::InvalidArgs);
245        assert!(refusal.subject().contains("99"));
246    }
247
248    #[test]
249    fn a_second_table_below_the_roadmap_is_not_edited() {
250        let text = format!(
251            "{GOOD}
252## Notes
253
254| # | Item | Weight | Needs | Status | Evidence |
255|---|---|---|---|---|---|
256| 2 | other table | 1 |  | Not started |  |
257"
258        );
259        let edited = set_roadmap_status(&text, &node("2"), RoadmapStatus::Blocked, None).unwrap();
260        assert!(
261            edited.contains("| 2 | other table | 1 |  | Not started |  |"),
262            "only the first table is the roadmap"
263        );
264        assert!(edited.contains("| 2 | range retrieval | 3 | 1 | Blocked |  |"));
265    }
266
267    #[test]
268    fn wrong_column_count_and_alien_status_are_named_problems() {
269        let text = "\
270| # | Item | Status |
271|---|------|--------|
272| 1 | x | nearly there |
273";
274        let RoadmapShape::Malformed { problems } = check_roadmap_shape(text) else {
275            panic!("expected malformed");
276        };
277        assert!(problems.iter().any(|p| p.contains("3 columns")));
278    }
279
280    #[test]
281    fn free_text_status_is_rejected_by_the_enum() {
282        let text = "\
283| # | Item | Weight | Needs | Status | Evidence |
284|---|------|--------|-------|--------|----------|
285| 1 | x | 1 |  | nearly there |  |
286";
287        let RoadmapShape::Malformed { problems } = check_roadmap_shape(text) else {
288            panic!("expected malformed");
289        };
290        assert!(problems.iter().any(|p| p.contains("nearly there")));
291    }
292
293    /// The four-column table is the old grammar, and a plan written in
294    /// it has no weights and no dependencies. It is reported rather than
295    /// half-read: reading four columns as six would put the status word
296    /// in the weight cell.
297    /// The four-column table is the old grammar, and a plan written in
298    /// it has no weights and no dependencies. It is reported rather than
299    /// half-read: reading four columns as six would put the status word
300    /// in the weight cell.
301    #[test]
302    fn the_old_four_column_table_is_reported_rather_than_half_read() {
303        let old = "\
304| # | Item | Status | Evidence |
305|---|------|--------|----------|
306| 1 | x | Not started | |
307";
308        let RoadmapShape::Malformed { problems } = check_roadmap_shape(old) else {
309            panic!("expected malformed");
310        };
311        assert!(problems.iter().any(|p| p.contains("4 columns")));
312    }
313
314    #[test]
315    fn a_bad_index_and_a_bad_weight_are_named_by_line() {
316        let text = "\
317| # | Item | Weight | Needs | Status | Evidence |
318|---|------|--------|-------|--------|----------|
319| one | x | 1 |  | Not started |  |
320| 2 | y | heavy |  | Not started |  |
321| 3 | z | 1 | nine | Not started |  |
322";
323        let RoadmapShape::Malformed { problems } = check_roadmap_shape(text) else {
324            panic!("expected malformed");
325        };
326        assert_eq!(problems.len(), 3);
327        assert!(problems[0].contains("line 3"));
328        assert!(problems[1].contains("heavy"));
329        assert!(problems[2].contains("line 5"));
330    }
331
332    #[test]
333    fn no_table_at_all_is_malformed() {
334        assert!(matches!(
335            check_roadmap_shape("just prose"),
336            RoadmapShape::Malformed { .. }
337        ));
338    }
339}