Skip to main content

surf_parse/
attrs.rs

1use crate::types::{AttrValue, Attrs, Span};
2use crate::error::ParseError;
3
4/// Parse a SurfDoc attribute string into an ordered map.
5///
6/// Accepted formats:
7///   - `[key=value key2="quoted with spaces" flag num=42]`
8///   - `key=value key2="quoted"` (without brackets)
9///
10/// Boolean flags (bare keys without `=`) are stored as `AttrValue::Bool(true)`.
11/// Numeric values are stored as `AttrValue::Number`. Everything else is
12/// `AttrValue::String`.
13pub fn parse_attrs(input: &str) -> Result<Attrs, ParseError> {
14    let trimmed = input.trim();
15
16    // Strip surrounding brackets if present.
17    let inner = if trimmed.starts_with('[') && trimmed.ends_with(']') {
18        &trimmed[1..trimmed.len() - 1]
19    } else {
20        trimmed
21    };
22
23    let chars: Vec<char> = inner.chars().collect();
24    let len = chars.len();
25    let mut pos = 0;
26    let mut attrs = Attrs::new();
27
28    while pos < len {
29        // Skip whitespace.
30        while pos < len && chars[pos].is_whitespace() {
31            pos += 1;
32        }
33        if pos >= len {
34            break;
35        }
36
37        // Scan key: alphanumeric, hyphen, underscore.
38        let key_start = pos;
39        while pos < len && (chars[pos].is_alphanumeric() || chars[pos] == '-' || chars[pos] == '_')
40        {
41            pos += 1;
42        }
43
44        if pos == key_start {
45            return Err(ParseError::InvalidAttrs {
46                message: format!("unexpected character '{}' at position {}", chars[pos], pos),
47                span: Span {
48                    start_line: 0,
49                    end_line: 0,
50                    start_offset: pos,
51                    end_offset: pos + 1,
52                },
53            });
54        }
55
56        let key: String = chars[key_start..pos].iter().collect();
57
58        // Check for `=`.
59        if pos < len && chars[pos] == '=' {
60            pos += 1; // consume `=`
61
62            if pos >= len {
63                return Err(ParseError::InvalidAttrs {
64                    message: format!("missing value after '=' for key '{key}'"),
65                    span: Span {
66                        start_line: 0,
67                        end_line: 0,
68                        start_offset: pos,
69                        end_offset: pos,
70                    },
71                });
72            }
73
74            if chars[pos] == '"' {
75                // Quoted value.
76                pos += 1; // consume opening quote
77                let mut value = String::new();
78                while pos < len && chars[pos] != '"' {
79                    if chars[pos] == '\\' && pos + 1 < len {
80                        let next = chars[pos + 1];
81                        match next {
82                            '"' | '\\' => {
83                                value.push(next);
84                                pos += 2;
85                            }
86                            _ => {
87                                value.push(chars[pos]);
88                                pos += 1;
89                            }
90                        }
91                    } else {
92                        value.push(chars[pos]);
93                        pos += 1;
94                    }
95                }
96                if pos < len && chars[pos] == '"' {
97                    pos += 1; // consume closing quote
98                } else {
99                    return Err(ParseError::InvalidAttrs {
100                        message: format!("unterminated quoted value for key '{key}'"),
101                        span: Span {
102                            start_line: 0,
103                            end_line: 0,
104                            start_offset: key_start,
105                            end_offset: pos,
106                        },
107                    });
108                }
109                attrs.insert(key, AttrValue::String(value));
110            } else {
111                // Unquoted value: read until whitespace or `]`.
112                let val_start = pos;
113                while pos < len && !chars[pos].is_whitespace() && chars[pos] != ']' {
114                    pos += 1;
115                }
116                let raw: String = chars[val_start..pos].iter().collect();
117                attrs.insert(key, coerce_value(&raw));
118            }
119        } else {
120            // No `=` — boolean flag.
121            attrs.insert(key, AttrValue::Bool(true));
122        }
123    }
124
125    Ok(attrs)
126}
127
128/// Coerce an unquoted value string into the most specific `AttrValue`:
129/// `true`/`false` -> Bool, valid f64 -> Number, otherwise String.
130fn coerce_value(raw: &str) -> AttrValue {
131    match raw {
132        "true" => AttrValue::Bool(true),
133        "false" => AttrValue::Bool(false),
134        "null" => AttrValue::Null,
135        _ => {
136            if let Ok(n) = raw.parse::<f64>() {
137                // Avoid coercing things like `v1.2` (parse would fail anyway).
138                AttrValue::Number(n)
139            } else {
140                AttrValue::String(raw.to_string())
141            }
142        }
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use pretty_assertions::assert_eq;
150
151    #[test]
152    fn parse_empty_attrs() {
153        let attrs = parse_attrs("[]").unwrap();
154        assert!(attrs.is_empty());
155    }
156
157    #[test]
158    fn parse_single_unquoted() {
159        let attrs = parse_attrs("[key=value]").unwrap();
160        assert_eq!(attrs.len(), 1);
161        assert_eq!(attrs["key"], AttrValue::String("value".into()));
162    }
163
164    #[test]
165    fn parse_quoted_value() {
166        let attrs = parse_attrs(r#"[key="hello world"]"#).unwrap();
167        assert_eq!(attrs["key"], AttrValue::String("hello world".into()));
168    }
169
170    #[test]
171    fn parse_boolean_flag() {
172        let attrs = parse_attrs("[sortable]").unwrap();
173        assert_eq!(attrs["sortable"], AttrValue::Bool(true));
174    }
175
176    #[test]
177    fn parse_numeric() {
178        let attrs = parse_attrs("[count=42]").unwrap();
179        assert_eq!(attrs["count"], AttrValue::Number(42.0));
180    }
181
182    #[test]
183    fn parse_multiple() {
184        let attrs = parse_attrs(r#"[id=x sortable key="val"]"#).unwrap();
185        assert_eq!(attrs.len(), 3);
186        assert_eq!(attrs["id"], AttrValue::String("x".into()));
187        assert_eq!(attrs["sortable"], AttrValue::Bool(true));
188        assert_eq!(attrs["key"], AttrValue::String("val".into()));
189    }
190
191    #[test]
192    fn parse_escaped_quote() {
193        let attrs = parse_attrs(r#"[key="say \"hi\""]"#).unwrap();
194        assert_eq!(attrs["key"], AttrValue::String(r#"say "hi""#.into()));
195    }
196
197    #[test]
198    fn parse_no_brackets() {
199        let attrs = parse_attrs("key=value").unwrap();
200        assert_eq!(attrs.len(), 1);
201        assert_eq!(attrs["key"], AttrValue::String("value".into()));
202    }
203
204    #[test]
205    fn parse_bool_values() {
206        let attrs = parse_attrs("[enabled=true disabled=false]").unwrap();
207        assert_eq!(attrs["enabled"], AttrValue::Bool(true));
208        assert_eq!(attrs["disabled"], AttrValue::Bool(false));
209    }
210
211    #[test]
212    fn parse_null_value() {
213        let attrs = parse_attrs("[val=null]").unwrap();
214        assert_eq!(attrs["val"], AttrValue::Null);
215    }
216
217    #[test]
218    fn parse_float_number() {
219        let attrs = parse_attrs("[ratio=3.14]").unwrap();
220        assert_eq!(attrs["ratio"], AttrValue::Number(3.14));
221    }
222}