1use crate::links::code_free_lines;
21
22#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct FootnoteRef {
25 pub label: String,
27 pub line: usize,
29}
30
31#[derive(Clone, Debug, PartialEq, Eq)]
33pub struct FootnoteDef {
34 pub label: String,
36 pub text: String,
38 pub line: usize,
40}
41
42#[must_use]
48pub fn extract_refs(body: &str) -> Vec<FootnoteRef> {
49 let mut out = Vec::new();
50 for (line_no, line) in code_free_lines(body) {
51 let scan = match split_definition(&line) {
54 Some((_, rest_offset)) => &line[rest_offset..],
55 None => line.as_str(),
56 };
57 for label in scan_labels(scan) {
58 out.push(FootnoteRef {
59 label,
60 line: line_no,
61 });
62 }
63 }
64 out
65}
66
67#[must_use]
69pub fn extract_definitions(body: &str) -> Vec<FootnoteDef> {
70 let mut out = Vec::new();
71 for (line_no, line) in code_free_lines(body) {
72 if let Some((label, rest_offset)) = split_definition(&line) {
73 out.push(FootnoteDef {
74 label,
75 text: line[rest_offset..].trim().to_string(),
76 line: line_no,
77 });
78 }
79 }
80 out
81}
82
83fn split_definition(line: &str) -> Option<(String, usize)> {
86 let indent = line.len() - line.trim_start().len();
87 if indent > 3 {
89 return None;
90 }
91 let rest = &line[indent..];
92 let inner = rest.strip_prefix("[^")?;
93 let close = inner.find(']')?;
94 let label = inner[..close].trim();
95 if label.is_empty() {
96 return None;
97 }
98 let after = &inner[close + 1..];
99 let text = after.strip_prefix(':')?;
100 Some((label.to_string(), line.len() - text.len()))
101}
102
103fn scan_labels(line: &str) -> Vec<String> {
105 let mut out = Vec::new();
106 let bytes = line.as_bytes();
107 let mut i = 0;
108 while i + 2 < bytes.len() {
109 if bytes[i] == b'['
110 && bytes[i + 1] == b'^'
111 && let Some(close) = line[i + 2..].find(']')
112 {
113 let label = line[i + 2..i + 2 + close].trim();
114 if !label.is_empty() && !label.contains('[') {
115 out.push(label.to_string());
116 i += 2 + close + 1;
117 continue;
118 }
119 }
120 i += 1;
121 }
122 out
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 const BODY: &str = "\
130# Computation
131
132 SELECT SUM(amount) FROM t WHERE year = @year
133
134Recognized revenue per the recognition policy,[^rev-policy] corroborated by
135the executive revenue dashboard.[^exec-rev-dash]
136
137[^rev-policy]: Revenue recognition policy
138[^exec-rev-dash]: Executive revenue dashboard
139";
140
141 #[test]
142 fn refs_and_definitions_are_separated() {
143 let refs: Vec<String> = extract_refs(BODY).into_iter().map(|r| r.label).collect();
144 assert_eq!(refs, vec!["rev-policy", "exec-rev-dash"]);
145
146 let defs = extract_definitions(BODY);
147 assert_eq!(defs.len(), 2);
148 assert_eq!(defs[0].label, "rev-policy");
149 assert_eq!(defs[0].text, "Revenue recognition policy");
150 assert_eq!(defs[1].label, "exec-rev-dash");
151 }
152
153 #[test]
154 fn footnotes_in_code_are_ignored() {
155 let body = "Real.[^a]\n\n```\nCode [^b] here.\n```\n\nInline `[^c]` too.\n";
156 let refs: Vec<String> = extract_refs(body).into_iter().map(|r| r.label).collect();
157 assert_eq!(refs, vec!["a"]);
158 }
159
160 #[test]
161 fn multiple_refs_on_one_line() {
162 let refs = extract_refs("Both [^a] and [^b] apply.\n");
163 assert_eq!(refs.len(), 2);
164 assert_eq!(refs[0].line, 1);
165 assert_eq!(refs[1].label, "b");
166 }
167
168 #[test]
169 fn markdown_links_are_not_footnotes() {
170 assert!(extract_refs("See [customers](/tables/customers.md).").is_empty());
171 assert!(extract_definitions("[label]: https://example.com").is_empty());
172 }
173}