Skip to main content

specfold_core/markdown/
sections.rs

1//! Extracting one section of a document, so a reader (or an agent) pays only for what it reads.
2
3/// The heading line whose text or anchor matches `name` (case-insensitive), and everything below
4/// it up to the next heading of the same or higher level.
5pub fn extract(body: &str, name: &str) -> Option<String> {
6    let lines: Vec<&str> = body.lines().collect();
7    span(body, name).map(|(start, end)| lines[start..end].join("\n"))
8}
9
10/// Line range `[heading, end)` of the section `extract` would return, so writers can splice it.
11/// Headings inside fenced code are not headings.
12pub fn span(body: &str, name: &str) -> Option<(usize, usize)> {
13    let want = name.trim().trim_start_matches('#').to_lowercase();
14    let lines: Vec<&str> = body.lines().collect();
15    let mut fence = false;
16    let levels: Vec<Option<usize>> = lines
17        .iter()
18        .map(|l| {
19            let t = l.trim_start();
20            if t.starts_with("```") || t.starts_with("~~~") {
21                fence = !fence;
22                return None;
23            }
24            let n = t.chars().take_while(|c| *c == '#').count();
25            (!fence && n > 0 && t[n..].starts_with(' ')).then_some(n)
26        })
27        .collect();
28    let start = (0..lines.len()).find(|&i| {
29        levels[i].is_some() && {
30            let text = lines[i].trim_start().trim_start_matches('#').trim().to_lowercase();
31            text == want || text.starts_with(&format!("{want} ")) || text.contains(&format!("{{#{want}}}"))
32        }
33    })?;
34    let lv = levels[start]?;
35    let end = (start + 1..lines.len()).find(|&i| levels[i].is_some_and(|n| n <= lv)).unwrap_or(lines.len());
36    Some((start, end))
37}
38
39/// Heading outline: `(level, text)` for every heading.
40pub fn outline(body: &str) -> Vec<(usize, String)> {
41    let mut fence = false;
42    body.lines()
43        .filter_map(|l| {
44            let t = l.trim_start();
45            if t.starts_with("```") || t.starts_with("~~~") {
46                fence = !fence;
47            }
48            if fence {
49                return None;
50            }
51            let n = t.chars().take_while(|c| *c == '#').count();
52            (n > 0 && t[n..].starts_with(' ')).then(|| (n, t[n..].trim().to_string()))
53        })
54        .collect()
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60
61    #[test]
62    fn extracts_by_heading_or_anchor() {
63        let b = "# T\n## Rule\nr1\n### Detail {#R-1}\nd\n## Why\nw\n";
64        assert_eq!(extract(b, "rule").unwrap(), "## Rule\nr1\n### Detail {#R-1}\nd");
65        assert_eq!(extract(b, "R-1").unwrap(), "### Detail {#R-1}\nd");
66        assert!(extract(b, "nope").is_none());
67        assert_eq!(outline(b).len(), 4);
68        assert_eq!(span(b, "why"), Some((5, 7)));
69        assert_eq!(span("## A\n```\n## not\n```\n## B\n", "a"), Some((0, 4)));
70    }
71}