Skip to main content

nodedb_sql/parser/object_literal/
scan.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! The recursive-descent scanner behind the object-literal entry points:
4//! whitespace, identifiers, strings, numbers, nested objects, and arrays.
5
6use std::collections::HashMap;
7
8use nodedb_types::Value;
9
10use crate::error::SqlError;
11
12pub(super) fn skip_ws(chars: &[char], pos: &mut usize) {
13    while *pos < chars.len() && chars[*pos].is_ascii_whitespace() {
14        *pos += 1;
15    }
16}
17
18fn parse_ident(chars: &[char], pos: &mut usize) -> String {
19    let mut s = String::new();
20    while *pos < chars.len() {
21        let c = chars[*pos];
22        if c.is_ascii_alphanumeric() || c == '_' || c == '.' {
23            s.push(c);
24            *pos += 1;
25        } else {
26            break;
27        }
28    }
29    s
30}
31
32fn parse_string(chars: &[char], pos: &mut usize) -> Result<String, SqlError> {
33    // Expect opening single-quote
34    if *pos >= chars.len() || chars[*pos] != '\'' {
35        return Err(SqlError::Parse {
36            detail: format!(
37                "expected single quote at position {}, found {:?}",
38                pos,
39                chars.get(*pos)
40            ),
41        });
42    }
43    *pos += 1; // consume opening quote
44    let mut s = String::new();
45    loop {
46        if *pos >= chars.len() {
47            return Err(SqlError::Parse {
48                detail: "unterminated string literal".to_string(),
49            });
50        }
51        if chars[*pos] == '\'' {
52            *pos += 1; // consume quote
53            // SQL escaped quote: '' → '
54            if *pos < chars.len() && chars[*pos] == '\'' {
55                s.push('\'');
56                *pos += 1;
57            } else {
58                break; // end of string
59            }
60        } else {
61            s.push(chars[*pos]);
62            *pos += 1;
63        }
64    }
65    Ok(s)
66}
67
68fn parse_double_quoted_string(chars: &[char], pos: &mut usize) -> Result<String, SqlError> {
69    if *pos >= chars.len() || chars[*pos] != '"' {
70        return Err(SqlError::Parse {
71            detail: format!(
72                "expected double quote at position {}, found {:?}",
73                pos,
74                chars.get(*pos)
75            ),
76        });
77    }
78    *pos += 1;
79    let mut s = String::new();
80    loop {
81        if *pos >= chars.len() {
82            return Err(SqlError::Parse {
83                detail: "unterminated double-quoted string literal".to_string(),
84            });
85        }
86        match chars[*pos] {
87            '"' => {
88                *pos += 1;
89                if *pos < chars.len() && chars[*pos] == '"' {
90                    s.push('"');
91                    *pos += 1;
92                } else {
93                    break;
94                }
95            }
96            c => {
97                s.push(c);
98                *pos += 1;
99            }
100        }
101    }
102    Ok(s)
103}
104
105fn parse_number(chars: &[char], pos: &mut usize) -> Result<Value, SqlError> {
106    let start = *pos;
107    if *pos < chars.len() && chars[*pos] == '-' {
108        *pos += 1;
109    }
110    while *pos < chars.len() && chars[*pos].is_ascii_digit() {
111        *pos += 1;
112    }
113    let is_float = *pos < chars.len() && chars[*pos] == '.';
114    if is_float {
115        *pos += 1; // consume '.'
116        while *pos < chars.len() && chars[*pos].is_ascii_digit() {
117            *pos += 1;
118        }
119    }
120    let raw: String = chars[start..*pos].iter().collect();
121    if is_float {
122        raw.parse::<f64>()
123            .map(Value::Float)
124            .map_err(|_| SqlError::Parse {
125                detail: format!("invalid float: {raw}"),
126            })
127    } else {
128        raw.parse::<i64>()
129            .map(Value::Integer)
130            .map_err(|_| SqlError::Parse {
131                detail: format!("invalid integer: {raw}"),
132            })
133    }
134}
135
136fn parse_array(chars: &[char], pos: &mut usize) -> Result<Vec<Value>, SqlError> {
137    // Expect '['
138    if *pos >= chars.len() || chars[*pos] != '[' {
139        return Err(SqlError::Parse {
140            detail: format!(
141                "expected '[' at position {pos}, found {:?}",
142                chars.get(*pos)
143            ),
144        });
145    }
146    *pos += 1; // consume '['
147    let mut items = Vec::new();
148    loop {
149        skip_ws(chars, pos);
150        if *pos >= chars.len() {
151            return Err(SqlError::Parse {
152                detail: "unterminated array literal".to_string(),
153            });
154        }
155        if chars[*pos] == ']' {
156            *pos += 1; // consume ']'
157            break;
158        }
159        // trailing comma already consumed; skip it
160        if chars[*pos] == ',' {
161            *pos += 1;
162            continue;
163        }
164        let val = parse_value(chars, pos)?;
165        items.push(val);
166        skip_ws(chars, pos);
167        if *pos < chars.len() && chars[*pos] == ',' {
168            *pos += 1; // consume ','
169        }
170    }
171    Ok(items)
172}
173
174pub(super) fn parse_object(
175    chars: &[char],
176    pos: &mut usize,
177) -> Result<HashMap<String, Value>, SqlError> {
178    // Expect '{'
179    if *pos >= chars.len() || chars[*pos] != '{' {
180        return Err(SqlError::Parse {
181            detail: format!(
182                "expected '{{' at position {pos}, found {:?}",
183                chars.get(*pos)
184            ),
185        });
186    }
187    *pos += 1; // consume '{'
188    let mut map = HashMap::new();
189    loop {
190        skip_ws(chars, pos);
191        if *pos >= chars.len() {
192            return Err(SqlError::Parse {
193                detail: "unterminated object literal".to_string(),
194            });
195        }
196        if chars[*pos] == '}' {
197            *pos += 1; // consume '}'
198            break;
199        }
200        // Trailing comma: skip and re-check for '}'
201        if chars[*pos] == ',' {
202            *pos += 1;
203            continue;
204        }
205
206        // Parse key (identifier or JSON-style quoted key).
207        skip_ws(chars, pos);
208        if *pos >= chars.len() {
209            return Err(SqlError::Parse {
210                detail: "expected key, reached end of input".to_string(),
211            });
212        }
213        let key = if chars[*pos] == '"' {
214            parse_double_quoted_string(chars, pos)?
215        } else {
216            let first = chars[*pos];
217            if !(first.is_ascii_alphabetic() || first == '_') {
218                return Err(SqlError::Parse {
219                    detail: format!("expected identifier key at position {pos}, found '{first}'"),
220                });
221            }
222            parse_ident(chars, pos)
223        };
224        if key.is_empty() {
225            return Err(SqlError::Parse {
226                detail: format!("expected non-empty key at position {pos}"),
227            });
228        }
229
230        // Expect ':'
231        skip_ws(chars, pos);
232        if *pos >= chars.len() || chars[*pos] != ':' {
233            return Err(SqlError::Parse {
234                detail: format!(
235                    "expected ':' after key '{key}' at position {pos}, found {:?}",
236                    chars.get(*pos)
237                ),
238            });
239        }
240        *pos += 1; // consume ':'
241
242        // Parse value
243        skip_ws(chars, pos);
244        if *pos >= chars.len() {
245            return Err(SqlError::Parse {
246                detail: format!("expected value for key '{key}', reached end of input"),
247            });
248        }
249        if chars[*pos] == '}' || chars[*pos] == ',' {
250            return Err(SqlError::Parse {
251                detail: format!("expected value for key '{key}', found '{}'", chars[*pos]),
252            });
253        }
254        let val = parse_value(chars, pos)?;
255        map.insert(key, val);
256
257        // Optional comma
258        skip_ws(chars, pos);
259        if *pos < chars.len() && chars[*pos] == ',' {
260            *pos += 1;
261        }
262    }
263    Ok(map)
264}
265
266fn parse_value(chars: &[char], pos: &mut usize) -> Result<Value, SqlError> {
267    skip_ws(chars, pos);
268    if *pos >= chars.len() {
269        return Err(SqlError::Parse {
270            detail: "unexpected end of input while parsing value".to_string(),
271        });
272    }
273    match chars[*pos] {
274        '\'' => parse_string(chars, pos).map(Value::String),
275        '{' => parse_object(chars, pos).map(Value::Object),
276        '[' => parse_array(chars, pos).map(Value::Array),
277        '-' | '0'..='9' => parse_number(chars, pos),
278        _ => {
279            // bare word: true / false / null / identifier
280            let word = parse_ident(chars, pos);
281            match word.to_lowercase().as_str() {
282                "true" => Ok(Value::Bool(true)),
283                "false" => Ok(Value::Bool(false)),
284                "null" => Ok(Value::Null),
285                _ if word.is_empty() => Err(SqlError::Parse {
286                    detail: format!("unexpected character '{}' at position {pos}", chars[*pos]),
287                }),
288                _ => Err(SqlError::Parse {
289                    detail: format!("unknown bare word: '{word}'"),
290                }),
291            }
292        }
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use crate::parser::object_literal::{
300        parse_object_literal, parse_object_literal_array, parse_object_literal_array_complete,
301        parse_object_literal_complete,
302    };
303
304    fn parse(s: &str) -> HashMap<String, Value> {
305        parse_object_literal(s).unwrap().unwrap()
306    }
307
308    /// The prefix parser tolerates trailing input BY DESIGN — the
309    /// function-argument rewriter depends on it — so the tolerance is pinned
310    /// here rather than left as an accident of the implementation.
311    #[test]
312    fn the_prefix_parser_stops_at_the_matching_brace() {
313        let fields = parse("{ a: 1 } and then some");
314        assert_eq!(fields.len(), 1);
315    }
316
317    /// …and the strict form reports what the prefix parser would have thrown
318    /// away, naming it so the author can see which part was rejected.
319    #[test]
320    fn the_strict_parser_reports_trailing_input() {
321        let error = parse_object_literal_complete("{ a: 1 } RETURNING *")
322            .expect("input starts with a brace")
323            .expect_err("trailing input must not be accepted");
324        let detail = error.to_string();
325        assert!(
326            detail.contains("RETURNING *"),
327            "the error must name the leftover, got: {detail}"
328        );
329    }
330
331    /// A statement terminator is not content, so it does not trip the check.
332    #[test]
333    fn the_strict_parser_accepts_a_trailing_semicolon() {
334        assert!(
335            parse_object_literal_complete("{ a: 1 };")
336                .expect("input starts with a brace")
337                .is_ok()
338        );
339    }
340
341    /// A `}` inside a quoted value belongs to the value, so it must not be
342    /// mistaken for the end of the literal.
343    #[test]
344    fn the_strict_parser_ignores_a_brace_inside_a_string() {
345        let fields = parse_object_literal_complete("{ note: '} not the end' }")
346            .expect("input starts with a brace")
347            .expect("a brace inside a string is part of the value");
348        assert_eq!(fields.len(), 1);
349    }
350
351    /// The array form gets the same contract, measured against the real
352    /// closing bracket rather than the last one in the input.
353    #[test]
354    fn the_strict_array_parser_reports_trailing_input() {
355        assert!(
356            parse_object_literal_array_complete("[{ a: 1 }] RETURNING *")
357                .expect("input starts with a bracket")
358                .is_err()
359        );
360        assert!(
361            parse_object_literal_array_complete("[{ note: 'x]y' }]")
362                .expect("input starts with a bracket")
363                .is_ok(),
364            "a bracket inside a string must not be read as the array's end"
365        );
366    }
367
368    #[test]
369    fn simple_string_and_int() {
370        let m = parse("{ name: 'Alice', age: 30 }");
371        assert_eq!(m["name"], Value::String("Alice".to_string()));
372        assert_eq!(m["age"], Value::Integer(30));
373    }
374
375    #[test]
376    fn nested_object() {
377        let m = parse("{ addr: { city: 'NYC' } }");
378        let inner = match &m["addr"] {
379            Value::Object(o) => o,
380            _ => panic!("expected Object"),
381        };
382        assert_eq!(inner["city"], Value::String("NYC".to_string()));
383    }
384
385    #[test]
386    fn array_value() {
387        let m = parse("{ tags: ['a', 'b'] }");
388        assert_eq!(
389            m["tags"],
390            Value::Array(vec![
391                Value::String("a".to_string()),
392                Value::String("b".to_string()),
393            ])
394        );
395    }
396
397    #[test]
398    fn mixed_types() {
399        let m = parse("{ a: 'str', b: 42, c: 2.78, d: true, e: false, f: null }");
400        assert_eq!(m["a"], Value::String("str".to_string()));
401        assert_eq!(m["b"], Value::Integer(42));
402        assert_eq!(m["c"], Value::Float(2.78));
403        assert_eq!(m["d"], Value::Bool(true));
404        assert_eq!(m["e"], Value::Bool(false));
405        assert_eq!(m["f"], Value::Null);
406    }
407
408    #[test]
409    fn escaped_quotes() {
410        let m = parse("{ name: 'O''Brien' }");
411        assert_eq!(m["name"], Value::String("O'Brien".to_string()));
412    }
413
414    #[test]
415    fn empty_object() {
416        let m = parse("{ }");
417        assert!(m.is_empty());
418    }
419
420    #[test]
421    fn trailing_comma() {
422        let m = parse("{ name: 'Alice', }");
423        assert_eq!(m["name"], Value::String("Alice".to_string()));
424    }
425
426    #[test]
427    fn not_an_object_returns_none() {
428        assert!(parse_object_literal("not an object").is_none());
429    }
430
431    #[test]
432    fn missing_value_returns_err() {
433        let result = parse_object_literal("{ name: }");
434        assert!(matches!(result, Some(Err(_))));
435    }
436
437    #[test]
438    fn missing_key_returns_err() {
439        let result = parse_object_literal("{ : 'val' }");
440        assert!(matches!(result, Some(Err(_))));
441    }
442
443    #[test]
444    fn negative_numbers() {
445        let m = parse("{ x: -42, y: -2.78 }");
446        assert_eq!(m["x"], Value::Integer(-42));
447        assert_eq!(m["y"], Value::Float(-2.78));
448    }
449
450    #[test]
451    fn nested_array_in_object() {
452        let m = parse("{ data: { items: [1, 2, 3] } }");
453        let inner = match &m["data"] {
454            Value::Object(o) => o,
455            _ => panic!("expected Object"),
456        };
457        assert_eq!(
458            inner["items"],
459            Value::Array(vec![
460                Value::Integer(1),
461                Value::Integer(2),
462                Value::Integer(3),
463            ])
464        );
465    }
466
467    #[test]
468    fn dotted_key() {
469        let m = parse("{ metadata.source: 'web' }");
470        assert_eq!(m["metadata.source"], Value::String("web".to_string()));
471    }
472
473    #[test]
474    fn parse_array_of_objects() {
475        let result = parse_object_literal_array("[{ name: 'Alice' }, { name: 'Bob' }]")
476            .unwrap()
477            .unwrap();
478        assert_eq!(result.len(), 2);
479        assert_eq!(result[0]["name"], Value::String("Alice".to_string()));
480        assert_eq!(result[1]["name"], Value::String("Bob".to_string()));
481    }
482
483    #[test]
484    fn parse_array_empty() {
485        let result = parse_object_literal_array("[]").unwrap().unwrap();
486        assert!(result.is_empty());
487    }
488
489    #[test]
490    fn parse_array_not_array_returns_none() {
491        assert!(parse_object_literal_array("{ name: 'Alice' }").is_none());
492    }
493
494    #[test]
495    fn parse_array_non_object_element_returns_err() {
496        let result = parse_object_literal_array("[42]");
497        assert!(matches!(result, Some(Err(_))));
498    }
499
500    #[test]
501    fn parse_array_trailing_comma() {
502        let result = parse_object_literal_array("[{ a: 1 }, { b: 2 },]")
503            .unwrap()
504            .unwrap();
505        assert_eq!(result.len(), 2);
506    }
507}