1use crate::attrs::parse_attrs;
7use crate::citation::{CiteItem, CiteRef};
8use crate::types::{AttrValue, InlineExt};
9
10pub fn find_inline_cites(text: &str) -> Vec<(usize, usize, CiteRef)> {
17 let bytes = text.as_bytes();
18 let len = bytes.len();
19 let mut pos = 0;
20 let mut out = Vec::new();
21
22 while pos < len {
23 if bytes[pos] == b'[' {
24 if let Some(rel) = text[pos + 1..].find(']') {
25 let inner = &text[pos + 1..pos + 1 + rel];
26 if inner.trim_start().starts_with('@') {
27 if let Some(cr) = parse_cite_group(inner) {
28 let end = pos + 1 + rel + 1;
29 out.push((pos, end, cr));
30 pos = end;
31 continue;
32 }
33 }
34 }
35 }
36 pos += 1;
37 }
38
39 out
40}
41
42fn parse_cite_group(inner: &str) -> Option<CiteRef> {
44 let mut items = Vec::new();
45 for part in inner.split(';') {
46 let p = part.trim();
47 let p = p.strip_prefix('@')?;
48 let key_end = p
49 .find(|c: char| !(c.is_alphanumeric() || matches!(c, '_' | '-' | ':' | '.')))
50 .unwrap_or(p.len());
51 let key = &p[..key_end];
52 if key.is_empty() {
53 return None;
54 }
55 let rest = p[key_end..].trim_start();
56 let locator = rest
57 .strip_prefix(',')
58 .map(|l| l.trim().to_string())
59 .filter(|s| !s.is_empty());
60 items.push(CiteItem {
61 key: key.to_string(),
62 locator,
63 });
64 }
65 if items.is_empty() {
66 None
67 } else {
68 Some(CiteRef { items })
69 }
70}
71
72pub fn scan_inline_extensions(text: &str) -> Vec<(usize, usize, InlineExt)> {
78 let mut results = Vec::new();
79 let bytes = text.as_bytes();
80 let len = bytes.len();
81 let mut pos = 0;
82
83 while pos < len {
84 if bytes[pos] == b':' {
86 if pos > 0 && bytes[pos - 1] == b':' {
88 pos += 1;
89 continue;
90 }
91 if pos + 1 < len && bytes[pos + 1] == b':' {
93 pos += 2;
94 continue;
95 }
96
97 if let Some(ext) = try_parse_extension(text, pos) {
99 let end = ext.1;
100 results.push(ext);
101 pos = end;
103 continue;
104 }
105 }
106 pos += 1;
107 }
108
109 results
110}
111
112fn try_parse_extension(text: &str, colon_pos: usize) -> Option<(usize, usize, InlineExt)> {
116 let rest = &text[colon_pos + 1..];
117
118 let (name, after_name) = if let Some(stripped) = rest.strip_prefix("evidence[") {
119 ("evidence", stripped)
120 } else if let Some(stripped) = rest.strip_prefix("status[") {
121 ("status", stripped)
122 } else {
123 return None;
124 };
125
126 let bracket_close = after_name.find(']')?;
128 let attr_str = &after_name[..bracket_close];
129
130 let end_pos = colon_pos + 1 + name.len() + 1 + bracket_close + 1;
133
134 let attrs = parse_attrs(attr_str).ok()?;
136
137 match name {
138 "evidence" => {
139 let tier = attrs.get("tier").and_then(|v| match v {
140 AttrValue::Number(n) => Some(*n as u8),
141 AttrValue::String(s) => s.parse::<u8>().ok(),
142 _ => None,
143 });
144 let source = attrs.get("source").and_then(|v| match v {
145 AttrValue::String(s) => Some(s.clone()),
146 _ => None,
147 });
148 Some((
150 colon_pos,
151 end_pos,
152 InlineExt::Evidence {
153 tier,
154 source,
155 text: attr_str.trim().to_string(),
156 },
157 ))
158 }
159 "status" => {
160 let value = attrs
161 .get("value")
162 .and_then(|v| match v {
163 AttrValue::String(s) => Some(s.clone()),
164 AttrValue::Bool(b) => Some(b.to_string()),
165 AttrValue::Number(n) => Some(n.to_string()),
166 AttrValue::Null => None,
167 })
168 .unwrap_or_default();
169 Some((colon_pos, end_pos, InlineExt::Status { value }))
170 }
171 _ => None,
172 }
173}
174
175#[cfg(test)]
180mod tests {
181 use super::*;
182 use pretty_assertions::assert_eq;
183
184 #[test]
185 fn scan_evidence_basic() {
186 let text = r#"Some text :evidence[tier=1 source="Gartner"] more text"#;
187 let results = scan_inline_extensions(text);
188 assert_eq!(results.len(), 1);
189 match &results[0].2 {
190 InlineExt::Evidence { tier, source, .. } => {
191 assert_eq!(*tier, Some(1));
192 assert_eq!(source.as_deref(), Some("Gartner"));
193 }
194 other => panic!("Expected Evidence, got {other:?}"),
195 }
196 }
197
198 #[test]
199 fn scan_status_basic() {
200 let text = ":status[value=shipped] and done";
201 let results = scan_inline_extensions(text);
202 assert_eq!(results.len(), 1);
203 match &results[0].2 {
204 InlineExt::Status { value } => {
205 assert_eq!(value, "shipped");
206 }
207 other => panic!("Expected Status, got {other:?}"),
208 }
209 }
210
211 #[test]
212 fn scan_multiple_inline() {
213 let text = r#":status[value=done] and :evidence[tier=2 source="IEEE"] end"#;
214 let results = scan_inline_extensions(text);
215 assert_eq!(results.len(), 2);
216 assert!(matches!(&results[0].2, InlineExt::Status { .. }));
217 assert!(matches!(&results[1].2, InlineExt::Evidence { .. }));
218 }
219
220 #[test]
221 fn scan_no_extensions() {
222 let text = "Just plain text with no extensions.";
223 let results = scan_inline_extensions(text);
224 assert!(results.is_empty());
225 }
226
227 #[test]
228 fn scan_double_colon_ignored() {
229 let text = "::evidence[tier=1] should not match as inline";
230 let results = scan_inline_extensions(text);
231 assert!(results.is_empty(), "Double-colon should not be matched: {results:?}");
232 }
233
234 #[test]
237 fn find_single_cite() {
238 let cites = find_inline_cites("As shown [@smith2020] the model works.");
239 assert_eq!(cites.len(), 1);
240 assert_eq!(cites[0].2.items.len(), 1);
241 assert_eq!(cites[0].2.items[0].key, "smith2020");
242 assert_eq!(cites[0].2.items[0].locator, None);
243 }
244
245 #[test]
246 fn find_cite_with_locator() {
247 let cites = find_inline_cites("See [@smith2020, p. 12].");
248 assert_eq!(cites.len(), 1);
249 assert_eq!(cites[0].2.items[0].key, "smith2020");
250 assert_eq!(cites[0].2.items[0].locator.as_deref(), Some("p. 12"));
251 }
252
253 #[test]
254 fn find_multi_key_cite() {
255 let cites = find_inline_cites("Prior work [@smith2020; @jones2019] agrees.");
256 assert_eq!(cites.len(), 1);
257 let keys: Vec<&str> = cites[0].2.items.iter().map(|i| i.key.as_str()).collect();
258 assert_eq!(keys, vec!["smith2020", "jones2019"]);
259 }
260
261 #[test]
262 fn find_multiple_cites_in_order() {
263 let cites = find_inline_cites("[@a] then [@b] and [@c]");
264 let keys: Vec<&str> = cites.iter().map(|c| c.2.items[0].key.as_str()).collect();
265 assert_eq!(keys, vec!["a", "b", "c"]);
266 }
267
268 #[test]
269 fn non_cite_brackets_ignored() {
270 assert!(find_inline_cites("a [link](url) and [ref][1] and [ ] box").is_empty());
271 }
272}