Skip to main content

okf_core/
footnotes.rs

1//! Markdown footnotes, the carrier for per-claim attribution.
2//!
3//! v0.2 retires the v0.1 body `# Citations` list. To attribute a specific claim
4//! to a source, a producer writes a footnote whose **label is a
5//! `sources[].id`**:
6//!
7//! ```markdown
8//! The `events_` table is sharded daily as `events_YYYYMMDD`.[^ga4-schema]
9//!
10//! [^ga4-schema]: GA4 BigQuery Export schema
11//! ```
12//!
13//! The label is the join key into `sources`; consumers resolve attribution
14//! through the matching entry, not by parsing the footnote prose. Labels are
15//! keyed rather than positional precisely because agents reorder these lists.
16//!
17//! This module only finds the footnotes. Joining them to `sources` is
18//! [`provenance::attributions`](crate::provenance::attributions).
19
20use crate::markdown::code_free_lines;
21
22/// A `[^label]` reference in the body prose.
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct FootnoteRef {
25    /// The label between `[^` and `]`.
26    pub label: String,
27    /// 1-based line number within the body.
28    pub line: usize,
29}
30
31/// A `[^label]: text` definition line.
32#[derive(Clone, Debug, PartialEq, Eq)]
33pub struct FootnoteDef {
34    /// The label between `[^` and `]:`.
35    pub label: String,
36    /// The prose after the colon.
37    pub text: String,
38    /// 1-based line number within the body.
39    pub line: usize,
40}
41
42/// Extracts every `[^label]` reference from the body prose.
43///
44/// Definition lines are not counted as references to themselves, and footnotes
45/// inside fenced code blocks or inline code spans are ignored, since those
46/// are content, not attribution.
47#[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        // A definition line's own `[^label]:` marker is not a reference, but
52        // anything after it may be.
53        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/// Extracts every `[^label]: text` definition from the body.
68#[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
83/// If `line` is a footnote definition, returns its label and the byte offset of
84/// the text after `]:`.
85fn split_definition(line: &str) -> Option<(String, usize)> {
86    let indent = line.len() - line.trim_start().len();
87    // Definitions may be indented up to three spaces, like other block markers.
88    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
103/// Collects the labels of every `[^label]` occurrence in a single line.
104fn 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}