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::types::{AttrValue, InlineExt};
8
9/// Scan `text` for inline extensions (single-colon prefix).
10///
11/// Returns a vec of `(start_byte, end_byte, InlineExt)` tuples.
12/// Double-colon prefixes (`::evidence[...]`) are block directives and are
13/// intentionally skipped.
14pub fn scan_inline_extensions(text: &str) -> Vec<(usize, usize, InlineExt)> {
15    let mut results = Vec::new();
16    let bytes = text.as_bytes();
17    let len = bytes.len();
18    let mut pos = 0;
19
20    while pos < len {
21        // Look for ':' that is NOT preceded by another ':' and NOT followed by another ':'.
22        if bytes[pos] == b':' {
23            // Skip if preceded by ':'  (double-colon = block directive).
24            if pos > 0 && bytes[pos - 1] == b':' {
25                pos += 1;
26                continue;
27            }
28            // Skip if followed by ':'  (double-colon = block directive).
29            if pos + 1 < len && bytes[pos + 1] == b':' {
30                pos += 2;
31                continue;
32            }
33
34            // Try to match a known extension name starting right after the colon.
35            if let Some(ext) = try_parse_extension(text, pos) {
36                let end = ext.1;
37                results.push(ext);
38                // Advance past this extension.
39                pos = end;
40                continue;
41            }
42        }
43        pos += 1;
44    }
45
46    results
47}
48
49/// Try to parse an inline extension at position `colon_pos` (the `:` character).
50///
51/// Returns `Some((start, end, InlineExt))` if a valid extension is found.
52fn try_parse_extension(text: &str, colon_pos: usize) -> Option<(usize, usize, InlineExt)> {
53    let rest = &text[colon_pos + 1..];
54
55    let (name, after_name) = if let Some(stripped) = rest.strip_prefix("evidence[") {
56        ("evidence", stripped)
57    } else if let Some(stripped) = rest.strip_prefix("status[") {
58        ("status", stripped)
59    } else {
60        return None;
61    };
62
63    // Find the closing bracket.
64    let bracket_close = after_name.find(']')?;
65    let attr_str = &after_name[..bracket_close];
66
67    // The full extent: from colon through closing bracket.
68    // colon_pos + 1 (colon) + name.len() + 1 ([) + bracket_close + 1 (])
69    let end_pos = colon_pos + 1 + name.len() + 1 + bracket_close + 1;
70
71    // Parse the bracketed content as attributes.
72    let attrs = parse_attrs(attr_str).ok()?;
73
74    match name {
75        "evidence" => {
76            let tier = attrs.get("tier").and_then(|v| match v {
77                AttrValue::Number(n) => Some(*n as u8),
78                AttrValue::String(s) => s.parse::<u8>().ok(),
79                _ => None,
80            });
81            let source = attrs.get("source").and_then(|v| match v {
82                AttrValue::String(s) => Some(s.clone()),
83                _ => None,
84            });
85            // Use the whole attr string as the text representation.
86            Some((
87                colon_pos,
88                end_pos,
89                InlineExt::Evidence {
90                    tier,
91                    source,
92                    text: attr_str.trim().to_string(),
93                },
94            ))
95        }
96        "status" => {
97            let value = attrs
98                .get("value")
99                .and_then(|v| match v {
100                    AttrValue::String(s) => Some(s.clone()),
101                    AttrValue::Bool(b) => Some(b.to_string()),
102                    AttrValue::Number(n) => Some(n.to_string()),
103                    AttrValue::Null => None,
104                })
105                .unwrap_or_default();
106            Some((colon_pos, end_pos, InlineExt::Status { value }))
107        }
108        _ => None,
109    }
110}
111
112// ------------------------------------------------------------------
113// Tests
114// ------------------------------------------------------------------
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use pretty_assertions::assert_eq;
120
121    #[test]
122    fn scan_evidence_basic() {
123        let text = r#"Some text :evidence[tier=1 source="Gartner"] more text"#;
124        let results = scan_inline_extensions(text);
125        assert_eq!(results.len(), 1);
126        match &results[0].2 {
127            InlineExt::Evidence { tier, source, .. } => {
128                assert_eq!(*tier, Some(1));
129                assert_eq!(source.as_deref(), Some("Gartner"));
130            }
131            other => panic!("Expected Evidence, got {other:?}"),
132        }
133    }
134
135    #[test]
136    fn scan_status_basic() {
137        let text = ":status[value=shipped] and done";
138        let results = scan_inline_extensions(text);
139        assert_eq!(results.len(), 1);
140        match &results[0].2 {
141            InlineExt::Status { value } => {
142                assert_eq!(value, "shipped");
143            }
144            other => panic!("Expected Status, got {other:?}"),
145        }
146    }
147
148    #[test]
149    fn scan_multiple_inline() {
150        let text = r#":status[value=done] and :evidence[tier=2 source="IEEE"] end"#;
151        let results = scan_inline_extensions(text);
152        assert_eq!(results.len(), 2);
153        assert!(matches!(&results[0].2, InlineExt::Status { .. }));
154        assert!(matches!(&results[1].2, InlineExt::Evidence { .. }));
155    }
156
157    #[test]
158    fn scan_no_extensions() {
159        let text = "Just plain text with no extensions.";
160        let results = scan_inline_extensions(text);
161        assert!(results.is_empty());
162    }
163
164    #[test]
165    fn scan_double_colon_ignored() {
166        let text = "::evidence[tier=1] should not match as inline";
167        let results = scan_inline_extensions(text);
168        assert!(results.is_empty(), "Double-colon should not be matched: {results:?}");
169    }
170}