Skip to main content

wisp/screens/plan_review/
document.rs

1use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub struct PlanDocument {
5    pub path: String,
6    pub lines: Vec<PlanSourceLine>,
7    pub outline: Vec<PlanSection>,
8}
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct PlanSourceLine {
12    pub line_no: usize,
13    pub text: String,
14    pub section_index: Option<usize>,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct PlanSection {
19    pub title: String,
20    pub level: u8,
21    pub first_line_no: usize,
22}
23
24impl PlanDocument {
25    pub fn parse(path: impl Into<String>, markdown: &str) -> Self {
26        let outline = parse_headings(markdown);
27        let mut lines = markdown
28            .split('\n')
29            .enumerate()
30            .map(|(index, raw_line)| PlanSourceLine {
31                line_no: index + 1,
32                text: raw_line.trim_end_matches('\r').to_string(),
33                section_index: None,
34            })
35            .collect::<Vec<_>>();
36
37        assign_section_indices(&mut lines, &outline);
38
39        Self { path: path.into(), lines, outline }
40    }
41
42    pub fn section_title_for(&self, line: &PlanSourceLine) -> Option<&str> {
43        line.section_index.and_then(|index| self.outline.get(index)).map(|section| section.title.as_str())
44    }
45
46    pub fn markdown_text(&self) -> String {
47        self.lines.iter().map(|line| line.text.as_str()).collect::<Vec<_>>().join("\n")
48    }
49
50    pub fn line_count(&self) -> usize {
51        self.lines.len()
52    }
53
54    pub fn line_by_no(&self, line_no: usize) -> Option<&PlanSourceLine> {
55        line_no.checked_sub(1).and_then(|index| self.lines.get(index))
56    }
57}
58
59fn assign_section_indices(lines: &mut [PlanSourceLine], outline: &[PlanSection]) {
60    let mut outline_index = 0;
61    let mut current_section: Option<usize> = None;
62
63    for line in lines {
64        while let Some(section) = outline.get(outline_index)
65            && section.first_line_no <= line.line_no
66        {
67            current_section = Some(outline_index);
68            outline_index += 1;
69        }
70
71        line.section_index = current_section;
72    }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
76struct MarkdownHeading {
77    title: String,
78    level: u8,
79    source_line_no: usize,
80}
81
82fn parse_headings(text: &str) -> Vec<PlanSection> {
83    let mut headings: Vec<MarkdownHeading> = Vec::new();
84    let mut line_starts = vec![0usize];
85    for (index, byte) in text.bytes().enumerate() {
86        if byte == b'\n' {
87            line_starts.push(index + 1);
88        }
89    }
90
91    let options = Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES;
92    let parser = Parser::new_ext(text, options).into_offset_iter();
93
94    let mut active: Option<(u8, usize, String)> = None;
95    for (event, range) in parser {
96        match event {
97            Event::Start(Tag::Heading { level, .. }) => {
98                let line_no = line_starts.partition_point(|ls| *ls <= range.start).max(1);
99                active = Some((level as u8, line_no, String::new()));
100            }
101            Event::End(TagEnd::Heading(_)) => {
102                if let Some((level, line_no, title)) = active.take() {
103                    let title = title.trim().to_string();
104                    if !title.is_empty() {
105                        headings.push(MarkdownHeading { title, level, source_line_no: line_no });
106                    }
107                }
108            }
109            Event::Text(text) | Event::Code(text) => {
110                if let Some((_, _, title)) = active.as_mut() {
111                    title.push_str(&text);
112                }
113            }
114            _ => {}
115        }
116    }
117
118    headings
119        .into_iter()
120        .map(|h| PlanSection { title: h.title, level: h.level, first_line_no: h.source_line_no })
121        .collect()
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn parse_preserves_source_line_numbers() {
130        let document = PlanDocument::parse("plan.md", "# Title\n\n- item\nparagraph");
131
132        let line_numbers: Vec<_> = document.lines.iter().map(|line| line.line_no).collect();
133        assert_eq!(line_numbers, vec![1, 2, 3, 4]);
134    }
135
136    #[test]
137    fn parse_builds_outline_from_headings() {
138        let document = PlanDocument::parse("plan.md", "# Top\n## Child\ntext");
139
140        assert_eq!(document.outline.len(), 2);
141        assert_eq!(document.outline[0].title, "Top");
142        assert_eq!(document.outline[0].first_line_no, 1);
143        assert_eq!(document.outline[1].title, "Child");
144        assert_eq!(document.outline[1].first_line_no, 2);
145    }
146
147    #[test]
148    fn parse_preserves_raw_source_lines_for_feedback() {
149        let document = PlanDocument::parse("plan.md", "# Intro\n`inline` and **bold**\n```rust");
150
151        assert_eq!(document.lines[1].text, "`inline` and **bold**");
152        assert_eq!(document.lines[2].text, "```rust");
153        assert_eq!(document.markdown_text(), "# Intro\n`inline` and **bold**\n```rust");
154    }
155
156    #[test]
157    fn parse_tracks_active_section_title_for_lines() {
158        let document = PlanDocument::parse("plan.md", "# Intro\nline\n## Details\nmore");
159
160        assert_eq!(document.section_title_for(&document.lines[0]), Some("Intro"));
161        assert_eq!(document.section_title_for(&document.lines[1]), Some("Intro"));
162        assert_eq!(document.section_title_for(&document.lines[2]), Some("Details"));
163        assert_eq!(document.section_title_for(&document.lines[3]), Some("Details"));
164    }
165
166    #[test]
167    fn line_by_no_returns_line_when_present() {
168        let document = PlanDocument::parse("plan.md", "first\nsecond");
169        let line = document.line_by_no(2).expect("line exists");
170        assert_eq!(line.text, "second");
171    }
172}