Skip to main content

aam_core/aaml/
parsing.rs

1//! Parsing helpers: comment stripping, assignment parsing, multi-line block accumulation.
2
3use crate::error::{AamlError, ErrorDiagnostics};
4
5/// Strips an inline `#` comment from a raw source line, respecting quoted strings.
6///
7/// A `#` is a comment start only when it is preceded by whitespace (or at line start),
8/// so unquoted color values like `tint = #ff6600` are stored correctly.
9#[must_use]
10pub fn strip_comment(line: &str) -> &str {
11    let mut quote_state: Option<char> = None;
12    let bytes = line.as_bytes();
13
14    for (idx, c) in line.char_indices() {
15        match (quote_state, c) {
16            (None, '#') => {
17                let preceded_by_space =
18                    idx == 0 || bytes.get(idx - 1).is_some_and(u8::is_ascii_whitespace);
19                let followed_by_space = bytes.get(idx + 1).is_none_or(u8::is_ascii_whitespace);
20                if preceded_by_space && followed_by_space {
21                    return &line[..idx];
22                }
23            }
24            (None, '"' | '\'') => quote_state = Some(c),
25            (Some(q), c) if c == q => quote_state = None,
26            _ => {}
27        }
28    }
29    line
30}
31
32/// Parses a `key = value` assignment and returns trimmed (key, value) slices.
33///
34/// The split point is the **first `=`** that appears outside any
35/// `{ ... }` or `[ ... ]` nesting.  This allows values like
36/// `pos = { x = 1.0, y = 2.0 }` or `tags = [a, b, c]` to be parsed
37/// correctly.  Surrounding quotes are stripped from the value via
38/// [`unwrap_quotes`], but `{...}` and `[...]` literals are returned as-is.
39#[cfg(feature = "legacy")]
40pub(crate) fn parse_assignment(line: &str) -> Result<(&str, &str), AamlError> {
41    // Find the first '=' outside nesting
42    let mut depth: i32 = 0;
43    let mut eq_pos: Option<usize> = None;
44    for (i, ch) in line.char_indices() {
45        match ch {
46            '{' | '[' => depth += 1,
47            '}' | ']' => depth -= 1,
48            '=' if depth == 0 => {
49                eq_pos = Some(i);
50                break;
51            }
52            _ => {}
53        }
54    }
55
56    let pos = eq_pos.ok_or_else(|| AamlError::MalformedLiteral {
57        literal_type: "assignment".to_string(),
58        content: line.to_string(),
59        diagnostics: Some(Box::new(ErrorDiagnostics::new(
60            "Missing assignment operator",
61            format!("Line '{line}' does not contain '=' separator"),
62            "Use format: key = value",
63        ))),
64    })?;
65    let key = line[..pos].trim();
66    let raw_val = line[pos + 1..].trim();
67
68    if key.is_empty() {
69        return Err(AamlError::InvalidValue {
70            details: "Key is empty".to_string(),
71            expected: "non-empty key name".to_string(),
72            diagnostics: Some(Box::new(ErrorDiagnostics::new(
73                "Empty key in assignment",
74                format!("Line '{line}' has no key before '='"),
75                "Provide a valid key name before the '=' operator",
76            ))),
77        });
78    }
79
80    validate_balanced_delimiters(raw_val, line)?;
81
82    // Do NOT unwrap quotes when the value is an inline object or list literal
83    let val = if raw_val.starts_with('{') || raw_val.starts_with('[') {
84        raw_val
85    } else {
86        unwrap_quotes(raw_val)
87    };
88
89    Ok((key, val))
90}
91
92/// Ensures `{}` and `[]` are balanced in `value`, ignoring bracket-like chars in quotes.
93#[cfg(feature = "legacy")]
94fn validate_balanced_delimiters(value: &str, line: &str) -> Result<(), AamlError> {
95    let mut stack: Vec<char> = Vec::new();
96    let mut quote_state: Option<char> = None;
97    let mut escaped = false;
98
99    for ch in value.chars() {
100        if let Some(q) = quote_state {
101            if escaped {
102                escaped = false;
103                continue;
104            }
105            if ch == '\\' {
106                escaped = true;
107                continue;
108            }
109            if ch == q {
110                quote_state = None;
111            }
112            continue;
113        }
114
115        match ch {
116            '"' | '\'' => quote_state = Some(ch),
117            '{' | '[' => stack.push(ch),
118            '}' if stack.pop() != Some('{') => {
119                return Err(AamlError::MalformedLiteral {
120                    literal_type: "assignment".to_string(),
121                    content: line.to_string(),
122                    diagnostics: Some(Box::new(ErrorDiagnostics::new(
123                        "Mismatched delimiters",
124                        format!("Assignment '{line}' has an unmatched '}}'"),
125                        "Ensure inline objects and lists use balanced braces/brackets",
126                    ))),
127                });
128            }
129            ']' if stack.pop() != Some('[') => {
130                return Err(AamlError::MalformedLiteral {
131                    literal_type: "assignment".to_string(),
132                    content: line.to_string(),
133                    diagnostics: Some(Box::new(ErrorDiagnostics::new(
134                        "Mismatched delimiters",
135                        format!("Assignment '{line}' has an unmatched ']'"),
136                        "Ensure inline objects and lists use balanced braces/brackets",
137                    ))),
138                });
139            }
140            _ => {}
141        }
142    }
143
144    if quote_state.is_some() {
145        return Err(AamlError::MalformedLiteral {
146            literal_type: "assignment".to_string(),
147            content: line.to_string(),
148            diagnostics: Some(Box::new(ErrorDiagnostics::new(
149                "Unterminated quote",
150                format!("Assignment '{line}' contains an unterminated quoted value"),
151                "Close the opening quote in the assignment value",
152            ))),
153        });
154    }
155
156    if !stack.is_empty() {
157        return Err(AamlError::MalformedLiteral {
158            literal_type: "assignment".to_string(),
159            content: line.to_string(),
160            diagnostics: Some(Box::new(ErrorDiagnostics::new(
161                "Unclosed delimiters",
162                format!("Assignment '{line}' has unclosed '{{' or '['"),
163                "Ensure inline objects and lists use balanced braces/brackets",
164            ))),
165        });
166    }
167
168    Ok(())
169}
170
171/// Strips a matching pair of surrounding `"…"` or `'…'` quotes from `s`.
172///
173/// Returns `s` unchanged (trimmed) if it is not quoted.
174#[must_use]
175pub fn unwrap_quotes(s: &str) -> &str {
176    let s = s.trim();
177    if s.len() >= 2 {
178        if s.starts_with('"') && s.ends_with('"') {
179            return &s[1..s.len() - 1];
180        }
181        if s.starts_with('\'') && s.ends_with('\'') {
182            return &s[1..s.len() - 1];
183        }
184    }
185    s
186}
187
188/// Returns `true` when `text` is a directive that opens a `{` block that is
189/// not yet closed on the same line — i.e. it needs multi-line accumulation.
190#[cfg(feature = "legacy")]
191pub(super) fn needs_accumulation(text: &str) -> bool {
192    if !text.starts_with('@') {
193        return false;
194    }
195    let opens = text.chars().filter(|&c| c == '{').count();
196    let closes = text.chars().filter(|&c| c == '}').count();
197    opens > closes
198}
199
200/// Returns `true` when the accumulated buffer has at least as many `}` as `{`.
201#[cfg(feature = "legacy")]
202pub(super) fn block_is_complete(buf: &str) -> bool {
203    let opens = buf.chars().filter(|&c| c == '{').count();
204    let closes = buf.chars().filter(|&c| c == '}').count();
205    closes >= opens
206}
207
208/// Returns `true` when `value` is an inline object literal `{ ... }`.
209#[must_use]
210pub fn is_inline_object(value: &str) -> bool {
211    let v = value.trim();
212    v.starts_with('{') && v.ends_with('}')
213}
214
215/// Parses an inline object `{ key = val, key2 = val2, ... }` into `(key, value)` pairs.
216///
217/// Field separators are commas respecting `{}` / `[]` nesting, so values like
218/// `{ base = { x = 1, y = 2 }, z = 3 }` are parsed correctly.
219pub fn parse_inline_object(value: &str) -> Result<Vec<(String, String)>, AamlError> {
220    let inner = value
221        .trim()
222        .strip_prefix('{')
223        .and_then(|s| s.strip_suffix('}'))
224        .ok_or_else(|| AamlError::MalformedLiteral {
225            literal_type: "inline object".to_string(),
226            content: value.to_string(),
227            diagnostics: Some(Box::new(ErrorDiagnostics::new(
228                "Malformed inline object",
229                format!("Inline object must be wrapped in '{{}}', got: '{value}'"),
230                "Wrap your object with curly braces: { key = value }",
231            ))),
232        })?;
233
234    split_top_level_fields(inner)
235        .into_iter()
236        .map(str::trim)
237        .filter(|s| !s.is_empty())
238        .map(|entry| {
239            let (k, v) = split_field_pair(entry)?;
240            let k = k.trim();
241
242            if k.is_empty() {
243                return Err(AamlError::InvalidValue {
244                    details: format!("Empty key in field '{entry}'"),
245                    expected: "non-empty field name".to_string(),
246                    diagnostics: Some(Box::new(ErrorDiagnostics::new(
247                        "Empty field name in inline object",
248                        format!("Field entry '{entry}' has no valid key"),
249                        "Provide a valid key name before '=' or ':'",
250                    ))),
251                });
252            }
253
254            let v = v.trim();
255            let final_v = match v.chars().next() {
256                Some('{' | '[') => v,
257                _ => unwrap_quotes(v),
258            };
259
260            Ok((k.to_string(), final_v.to_string()))
261        })
262        .collect()
263}
264
265/// Splits `s` on commas that are not inside `{}`, `[]`, or quotes.
266fn split_top_level_fields(s: &str) -> Vec<&str> {
267    let mut items = Vec::new();
268    let mut depth: i32 = 0;
269    let mut start = 0;
270    let mut in_quote: Option<char> = None;
271
272    for (i, ch) in s.char_indices() {
273        match ch {
274            '"' | '\'' => match in_quote {
275                Some(q) if q == ch => in_quote = None,
276                None => in_quote = Some(ch),
277                _ => {}
278            },
279            '{' | '[' if in_quote.is_none() => depth += 1,
280            '}' | ']' if in_quote.is_none() => depth -= 1,
281            ',' if depth == 0 && in_quote.is_none() => {
282                items.push(&s[start..i]);
283                start = i + 1;
284            }
285            _ => {}
286        }
287    }
288    if start <= s.len() {
289        items.push(&s[start..]);
290    }
291
292    items
293}
294
295/// Splits `"key = val"` or `"key: val"` on the first `=` or `:` at depth 0.
296fn split_field_pair(entry: &str) -> Result<(&str, &str), AamlError> {
297    let mut depth: i32 = 0;
298    for (i, ch) in entry.char_indices() {
299        match ch {
300            '{' | '[' => depth += 1,
301            '}' | ']' => depth -= 1,
302            '=' | ':' if depth == 0 => return Ok((&entry[..i], &entry[i + 1..])),
303            _ => {}
304        }
305    }
306    Err(AamlError::MalformedLiteral {
307        literal_type: "field pair".to_string(),
308        content: entry.to_string(),
309        diagnostics: Some(Box::new(ErrorDiagnostics::new(
310            "Missing field separator",
311            format!("Field entry '{entry}' has no '=' or ':' separator"),
312            "Use format: key = value or key: value",
313        ))),
314    })
315}