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 (lines, levels) = heading_levels(body);
14    let start = match matches(body, name).as_slice() {
15        [one] => *one,
16        _ => return None,
17    };
18    let lv = levels[start]?;
19    let end = (start + 1..lines.len()).find(|&i| levels[i].is_some_and(|n| n <= lv)).unwrap_or(lines.len());
20    Some((start, end))
21}
22
23fn heading_levels(body: &str) -> (Vec<&str>, Vec<Option<usize>>) {
24    let lines: Vec<&str> = body.lines().collect();
25    let mut fence = crate::markdown::Fence::default();
26    let levels = lines
27        .iter()
28        .map(|l| {
29            if fence.step(l) || fence.inside() {
30                return None;
31            }
32            crate::markdown::atx(l).map(|(n, _)| n)
33        })
34        .collect();
35    (lines, levels)
36}
37
38/// C198: the heading lines `name` picks. An exact heading text or `{#anchor}` wins over a heading
39/// that only starts with the name; a prefix match counts only when there is no exact one. More
40/// than one result means the name is ambiguous, and [`span`] refuses it.
41pub fn matches(body: &str, name: &str) -> Vec<usize> {
42    let want = name.trim().trim_start_matches('#').to_lowercase();
43    let (lines, levels) = heading_levels(body);
44    let text = |i: usize| head_text(lines[i]).to_lowercase();
45    let heads: Vec<usize> = (0..lines.len()).filter(|&i| levels[i].is_some()).collect();
46    let exact: Vec<usize> =
47        heads.iter().copied().filter(|&i| text(i) == want || text(i).contains(&format!("{{#{want}}}"))).collect();
48    if !exact.is_empty() {
49        return exact;
50    }
51    heads.into_iter().filter(|&i| text(i).starts_with(&format!("{want} "))).collect()
52}
53
54/// The error for a name [`span`] does not resolve: several candidates, or none. `first` is the
55/// file line of the body's first line, so the lines named are file lines (C226).
56pub fn not_found(body: &str, first: usize, name: &str) -> String {
57    let lines: Vec<&str> = body.lines().collect();
58    let hits = matches(body, name);
59    if hits.len() > 1 {
60        let list: Vec<String> = hits.iter().map(|&i| format!("line {} '{}'", i + first, head_text(lines[i]))).collect();
61        return format!(
62            "section '{name}' matches {} headings ({}); name one exactly, or give it an {{#anchor}}",
63            hits.len(),
64            list.join(" | ")
65        );
66    }
67    format!("no section '{name}' (sections: {})", names(body).join(" | "))
68}
69
70/// The section names an error lists when a name is not found: every heading but the H1 title.
71pub fn names(body: &str) -> Vec<String> {
72    outline(body).into_iter().filter(|(n, _)| *n > 1).map(|(_, t)| t).collect()
73}
74
75/// Heading outline: `(level, text)` for every heading.
76pub fn outline(body: &str) -> Vec<(usize, String)> {
77    let mut fence = crate::markdown::Fence::default();
78    body.lines()
79        .filter_map(|l| {
80            if fence.step(l) || fence.inside() {
81                return None;
82            }
83            crate::markdown::atx(l).map(|(n, t)| (n, t.to_string()))
84        })
85        .collect()
86}
87
88/// The text of a heading line (already known to be one).
89fn head_text(line: &str) -> &str {
90    crate::markdown::atx(line).map_or("", |(_, t)| t)
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn extracts_by_heading_or_anchor() {
99        let b = "# T\n## Rule\nr1\n### Detail {#R-1}\nd\n## Why\nw\n";
100        assert_eq!(extract(b, "rule").unwrap(), "## Rule\nr1\n### Detail {#R-1}\nd");
101        assert_eq!(extract(b, "R-1").unwrap(), "### Detail {#R-1}\nd");
102        assert!(extract(b, "nope").is_none());
103        assert_eq!(outline(b).len(), 4);
104        assert_eq!(names(b), ["Rule", "Detail {#R-1}", "Why"], "the H1 title is not a section");
105        assert_eq!(span(b, "why"), Some((5, 7)));
106        assert_eq!(span("## A\n```\n## not\n```\n## B\n", "a"), Some((0, 4)));
107    }
108
109    #[test]
110    fn c198_exact_beats_prefix_and_ambiguity_refuses() {
111        let b = "## Steps to roll back\nr\n## Steps\ns\n";
112        assert_eq!(span(b, "steps"), Some((2, 4)));
113        assert_eq!(span(b, "steps to"), Some((0, 2)), "one prefix match still resolves");
114        let p = "## Steps one\n## Steps two\n";
115        assert_eq!(span(p, "steps"), None);
116        assert!(not_found(p, 1, "steps").contains("matches 2 headings"), "{}", not_found(p, 1, "steps"));
117        let d = "## Notes\na\n## Notes\nb\n";
118        assert_eq!(span(d, "notes"), None, "duplicate titles are ambiguous");
119        assert!(not_found(d, 5, "notes").contains("line 7 'Notes'"));
120        assert!(not_found(d, 1, "zz").starts_with("no section"));
121    }
122
123    #[test]
124    fn c238_indented_code_and_seven_hashes_are_not_headings() {
125        let b = "## Decision\nRun this:\n\n    # install the tool\n    cargo install x\n\n####### seven\nThen use Postgres.\n## Next\n";
126        assert_eq!(outline(b), [(2, "Decision".to_string()), (2, "Next".to_string())]);
127        assert_eq!(span(b, "decision"), Some((0, 8)));
128        assert_eq!(span(b, "seven"), None);
129        assert_eq!(outline("   ### three spaces\n#\n"), [(3, "three spaces".to_string()), (1, String::new())]);
130    }
131
132    #[test]
133    fn c240_closing_hashes_are_not_heading_text() {
134        let b = "## Context ##\n## Decision #\n## C# ##\n## Tag#\n### ###\n";
135        let t: Vec<String> = outline(b).into_iter().map(|(_, t)| t).collect();
136        assert_eq!(t, ["Context", "Decision", "C#", "Tag#", ""]);
137        assert_eq!(span(b, "context"), Some((0, 1)));
138    }
139}