Skip to main content

surf_parse/
inline.rs

1//! Inline extension scanner.
2//!
3//! Detects `:evidence[...]` and `:status[...]` patterns in text content and
4//! returns their byte ranges and parsed `InlineExt` values.
5
6use crate::attrs::parse_attrs;
7use crate::citation::{CiteItem, CiteRef};
8use crate::types::{AttrValue, InlineExt};
9
10/// Scan `text` for inline citations: `[@key]`, `[@key, p. 12]`, `[@key1; @key2]`.
11///
12/// Returns `(start_byte, end_byte, CiteRef)` tuples in document order. A bracket
13/// group qualifies only when its first non-space character is `@`. Within a
14/// group, items are separated by `;`; each item is `@key` optionally followed by
15/// `, <locator>`. Keys are runs of `[A-Za-z0-9_:.-]`.
16pub 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
42/// Parse the inside of a `[...]` citation group into a [`CiteRef`].
43fn 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
72/// Scan `text` for inline extensions (single-colon prefix).
73///
74/// Returns a vec of `(start_byte, end_byte, InlineExt)` tuples.
75/// Double-colon prefixes (`::evidence[...]`) are block directives and are
76/// intentionally skipped.
77pub 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        // Look for ':' that is NOT preceded by another ':' and NOT followed by another ':'.
85        if bytes[pos] == b':' {
86            // Skip if preceded by ':'  (double-colon = block directive).
87            if pos > 0 && bytes[pos - 1] == b':' {
88                pos += 1;
89                continue;
90            }
91            // Skip if followed by ':'  (double-colon = block directive).
92            if pos + 1 < len && bytes[pos + 1] == b':' {
93                pos += 2;
94                continue;
95            }
96
97            // Try to match a known extension name starting right after the colon.
98            if let Some(ext) = try_parse_extension(text, pos) {
99                let end = ext.1;
100                results.push(ext);
101                // Advance past this extension.
102                pos = end;
103                continue;
104            }
105        }
106        pos += 1;
107    }
108
109    results
110}
111
112/// Try to parse an inline extension at position `colon_pos` (the `:` character).
113///
114/// Returns `Some((start, end, InlineExt))` if a valid extension is found.
115fn 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    // Find the closing bracket.
127    let bracket_close = after_name.find(']')?;
128    let attr_str = &after_name[..bracket_close];
129
130    // The full extent: from colon through closing bracket.
131    // colon_pos + 1 (colon) + name.len() + 1 ([) + bracket_close + 1 (])
132    let end_pos = colon_pos + 1 + name.len() + 1 + bracket_close + 1;
133
134    // Parse the bracketed content as attributes.
135    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            // Use the whole attr string as the text representation.
149            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// ------------------------------------------------------------------
176// Tests
177// ------------------------------------------------------------------
178
179#[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    // ── inline citations ────────────────────────────────────────────────────
235
236    #[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}