1pub 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
10pub 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
38pub 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
54pub 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
70pub fn names(body: &str) -> Vec<String> {
72 outline(body).into_iter().filter(|(n, _)| *n > 1).map(|(_, t)| t).collect()
73}
74
75pub 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
88fn 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}