Skip to main content

kimun_notes/ask/
citations.rs

1//! The ONE home of citation-marker (`[n]`) logic (CONTEXT.md: **Citation**).
2//! Scanning, stripping (copy, history), and wikilink conversion (saved
3//! answers) all live here; no other module may parse `[n]`.
4
5pub struct CitationSpan {
6    /// Byte range of the whole marker, e.g. `[12]`, brackets included.
7    pub range: std::ops::Range<usize>,
8    /// The marker's 1-based citation number (the `n` in `[n]`). Resolved to a
9    /// source by ordinal via `Turn::source_for_citation`, never by vec position.
10    pub index: usize,
11}
12
13/// Scan text for all `[digits]` citation markers, returning spans with byte ranges and 1-based indices.
14pub fn scan(text: &str) -> Vec<CitationSpan> {
15    let bytes = text.as_bytes();
16    let mut spans = Vec::new();
17    let mut i = 0;
18    while i < bytes.len() {
19        if bytes[i] == b'[' {
20            let start = i;
21            let mut j = i + 1;
22            while j < bytes.len() && bytes[j].is_ascii_digit() {
23                j += 1;
24            }
25            // at least one digit, closed by ']', not part of '[[…' or '…]]'
26            if j > i + 1 && j < bytes.len() && bytes[j] == b']' {
27                // Heuristic, not exact pair matching: this only checks the one
28                // byte on each side, so it can't tell a real `[[wikilink]]`
29                // from an accidental `[[42]` / `[42]]` byte sequence — good
30                // enough in practice since citation markers and wikilinks
31                // don't otherwise collide.
32                let is_bracket_adjacent = (start > 0 && bytes[start - 1] == b'[')
33                    || (j + 1 < bytes.len() && bytes[j + 1] == b']');
34                if !is_bracket_adjacent {
35                    let index: usize = text[i + 1..j].parse().unwrap_or(0);
36                    if index > 0 {
37                        spans.push(CitationSpan {
38                            range: start..j + 1,
39                            index,
40                        });
41                    }
42                    i = j + 1;
43                    continue;
44                } else {
45                    // Skip past both brackets if they form a wikilink
46                    i = j + 2;
47                    continue;
48                }
49            }
50        }
51        i += 1;
52    }
53    spans
54}
55
56/// Remove all `[n]` citation markers from text, tidying whitespace only where markers are removed.
57pub fn strip(text: &str) -> String {
58    rewrite(text, |_| String::new())
59}
60
61/// Convert `[n]` markers to `[[source_name]]` using a names vec addressed by
62/// citation number (`source_names[n - 1]`). A marker whose slot is out of range
63/// OR an empty-string sentinel (a citation number with no backing source — a
64/// gap) is left untouched, so a stray `[n]` never becomes a broken wikilink.
65pub fn link_sources(text: &str, source_names: &[String]) -> String {
66    rewrite(text, |span| match source_names.get(span.index - 1) {
67        Some(name) if !name.is_empty() => format!("[[{name}]]"),
68        _ => text[span.range.clone()].to_string(),
69    })
70}
71
72/// Shared splice loop: replace each scanned span via `f`, tidying locally only when a marker is removed.
73fn rewrite(text: &str, f: impl Fn(&CitationSpan) -> String) -> String {
74    let mut out = String::with_capacity(text.len());
75    let mut last = 0;
76    for span in scan(text) {
77        out.push_str(&text[last..span.range.start]);
78        let replacement = f(&span);
79        let mut end = span.range.end;
80        if replacement.is_empty() {
81            let next = text[end..].chars().next();
82            let follows_break = matches!(
83                next,
84                None | Some(' ' | '.' | ',' | ';' | ':' | '!' | '?' | '\n')
85            );
86            if follows_break && out.ends_with(' ') {
87                out.pop();
88            } else if next == Some(' ') && (out.is_empty() || out.ends_with('\n')) {
89                // The marker opens the text or a line, so there's no
90                // preceding space to pop — drop the following space instead,
91                // otherwise the result would start with a stray space.
92                end += 1;
93            }
94        } else {
95            out.push_str(&replacement);
96        }
97        last = end;
98    }
99    out.push_str(&text[last..]);
100    out
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn scan_finds_markers_with_ranges_and_indices() {
109        let t = "Alpha [1] beta [12].";
110        let spans = scan(t);
111        assert_eq!(spans.len(), 2);
112        assert_eq!(&t[spans[0].range.clone()], "[1]");
113        assert_eq!(spans[0].index, 1);
114        assert_eq!(spans[1].index, 12);
115    }
116
117    #[test]
118    fn scan_ignores_non_numeric_brackets() {
119        assert!(scan("a [[wikilink]] and [tag] and [1a]").is_empty());
120    }
121
122    #[test]
123    fn strip_removes_markers_and_tidies_double_spaces() {
124        assert_eq!(strip("Fact [1] stands. Next [2]."), "Fact stands. Next.");
125    }
126
127    #[test]
128    fn link_sources_rewrites_in_range_and_keeps_out_of_range() {
129        let names = vec!["alpha".to_string()];
130        assert_eq!(
131            link_sources("See [1] not [7].", &names),
132            "See [[alpha]] not [7]."
133        );
134    }
135
136    #[test]
137    fn scan_ignores_numeric_wikilinks() {
138        assert!(scan("see [[1]] and [[42]]").is_empty());
139    }
140
141    #[test]
142    fn strip_preserves_text_without_markers() {
143        let t = "code:\n    indented  twice .";
144        assert_eq!(strip(t), t);
145    }
146
147    #[test]
148    fn strip_tidies_only_around_removed_markers() {
149        assert_eq!(strip("a [1] b"), "a b");
150        assert_eq!(strip("end [2]."), "end.");
151        assert_eq!(strip("tail [3]"), "tail");
152    }
153
154    #[test]
155    fn strip_drops_the_following_space_when_the_marker_opens_the_text() {
156        // No preceding space to pop (the marker is at byte 0), so the fix
157        // must skip the *following* space instead of leaving it behind.
158        assert_eq!(strip("[1] Hello"), "Hello");
159    }
160
161    #[test]
162    fn strip_drops_the_following_space_when_the_marker_opens_a_line() {
163        assert_eq!(strip("a\n[1] b"), "a\nb");
164    }
165}