Skip to main content

opys_engine/
body.rs

1//! Parsing of the markdown body: title, sections, and checklist items.
2
3use std::ops::Range;
4use std::sync::LazyLock;
5
6use regex::Regex;
7
8static TITLE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?m)^# (.+)$").unwrap());
9static CHECKED_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)^- \[x\] ").unwrap());
10static UNCHECKED_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^- \[ \] ").unwrap());
11static SECTION_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?m)^## +(.*?)\s*$").unwrap());
12
13/// First `# Heading` line, or `""`.
14pub fn title(body: &str) -> String {
15    TITLE_RE
16        .captures(body)
17        .map(|c| c[1].trim().to_string())
18        .unwrap_or_default()
19}
20
21/// Text of the `## <header>` section, up to the next `## ` heading (or EOF).
22pub fn section(body: &str, header: &str) -> String {
23    let re = Regex::new(&format!(r"(?m)^## {}\s*$", regex::escape(header))).unwrap();
24    let Some(m) = re.find(body) else {
25        return String::new();
26    };
27    let rest = &body[m.end()..];
28    let next = Regex::new(r"(?m)^## ").unwrap();
29    match next.find(rest) {
30        Some(n) => rest[..n.start()].to_string(),
31        None => rest.to_string(),
32    }
33}
34
35#[derive(Debug, Clone)]
36pub struct ChecklistItem {
37    pub checked: bool,
38    pub line: String,
39}
40
41/// Top-level checkbox items under the named `## <header>` section. Used for a
42/// feature's `## Test plan` and a work item's `## Tasks`.
43pub fn checklist_items(body: &str, header: &str) -> Vec<ChecklistItem> {
44    let mut out = Vec::new();
45    for line in section(body, header).lines() {
46        if CHECKED_RE.is_match(line) {
47            out.push(ChecklistItem {
48                checked: true,
49                line: line.to_string(),
50            });
51        } else if UNCHECKED_RE.is_match(line) {
52            out.push(ChecklistItem {
53                checked: false,
54                line: line.to_string(),
55            });
56        }
57    }
58    out
59}
60
61/// Whether the body contains a `## <header>` section heading.
62pub fn has_section(body: &str, header: &str) -> bool {
63    Regex::new(&format!(r"(?m)^## {}\s*$", regex::escape(header)))
64        .unwrap()
65        .is_match(body)
66}
67
68/// Split the body into `## ` sections in document order as `(heading, content)`
69/// pairs — `content` is the text under each heading up to the next `## `. The
70/// first pair has an empty heading and holds the preamble (title + intro) before
71/// the first `## `. Follows [`section`]'s `## `-delimited convention.
72pub fn sections(body: &str) -> Vec<(String, String)> {
73    section_spans(body)
74        .into_iter()
75        .map(|(h, r)| (h, body[r].trim().to_string()))
76        .collect()
77}
78
79/// The byte range of each `## ` section's *content* (the text under the heading,
80/// up to the next `## `), paired with its heading, in document order — the
81/// preamble (empty heading) first unless it is blank. The `blocks` projection's
82/// `seq` indexes into this, so it is the addressing scheme for section edits.
83pub fn section_spans(body: &str) -> Vec<(String, Range<usize>)> {
84    let mut raw: Vec<(String, Range<usize>)> = Vec::new();
85    let mut content_start = 0;
86    let mut heading = String::new();
87    for m in SECTION_RE.captures_iter(body) {
88        let whole = m.get(0).unwrap();
89        raw.push((std::mem::take(&mut heading), content_start..whole.start()));
90        heading = m[1].trim().to_string();
91        content_start = whole.end();
92    }
93    raw.push((heading, content_start..body.len()));
94    raw.into_iter()
95        .filter(|(h, r)| !(h.is_empty() && body[r.clone()].trim().is_empty()))
96        .collect()
97}
98
99/// Splice `new_text` into sections addressed by index (from [`section_spans`]),
100/// replacing each section's *trimmed content* while preserving the heading line
101/// and the surrounding blank lines byte-for-byte. Edits apply back-to-front so
102/// byte offsets stay valid across multiple edits to one body.
103pub fn apply_section_edits(
104    body: &str,
105    spans: &[(String, Range<usize>)],
106    edits: &[(usize, String)],
107) -> String {
108    let mut edits: Vec<&(usize, String)> = edits.iter().filter(|(i, _)| *i < spans.len()).collect();
109    edits.sort_by(|a, b| spans[b.0].1.start.cmp(&spans[a.0].1.start));
110    let mut body = body.to_string();
111    for (i, new_text) in edits {
112        let range = spans[*i].1.clone();
113        let content = &body[range.clone()];
114        let trimmed = content.trim();
115        if trimmed.is_empty() {
116            body.replace_range(range, &format!("\n{new_text}\n"));
117        } else {
118            let lead = content.find(trimmed).unwrap_or(0);
119            let ts = range.start + lead;
120            let te = ts + trimmed.len();
121            body.replace_range(ts..te, new_text);
122        }
123    }
124    body
125}
126
127#[cfg(test)]
128mod tests {
129
130    #[test]
131    fn section_edit_splices_byte_accurately() {
132        let body = "# T\n\nintro\n\n## A\nold a\n\n## B\nold b\n";
133        let spans = section_spans(body);
134        let a = spans.iter().position(|(h, _)| h == "A").unwrap();
135        let out = apply_section_edits(body, &spans, &[(a, "new a".to_string())]);
136        // only section A content changes; heading lines, blank lines, B untouched
137        assert_eq!(out, "# T\n\nintro\n\n## A\nnew a\n\n## B\nold b\n");
138    }
139    use super::*;
140
141    const BODY: &str = "# Tab title\n\n## Test plan\n- [x] valid UTF-8 — `tab::osc_title`\n- [ ] invalid UTF-8 — uncovered\n\n## Manual verification\n- Legible at scaling — *manual: rendering*\n  - Setup: external monitor at 150%\n  - Steps:\n    1. Open a tab\n    2. printf escape\n  - Expect: crisp glyphs\n";
142
143    #[test]
144    fn finds_title() {
145        assert_eq!(title(BODY), "Tab title");
146    }
147
148    #[test]
149    fn parses_test_plan() {
150        let items = checklist_items(BODY, "Test plan");
151        assert_eq!(items.len(), 2);
152        assert!(items[0].checked);
153        assert!(!items[1].checked);
154        assert!(items[0].line.contains("tab::osc_title"));
155    }
156}