kimun_notes/ask/
citations.rs1pub struct CitationSpan {
6 pub range: std::ops::Range<usize>,
8 pub index: usize,
11}
12
13pub 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 if j > i + 1 && j < bytes.len() && bytes[j] == b']' {
27 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 i = j + 2;
47 continue;
48 }
49 }
50 }
51 i += 1;
52 }
53 spans
54}
55
56pub fn strip(text: &str) -> String {
58 rewrite(text, |_| String::new())
59}
60
61pub 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
72fn 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 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 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}