Skip to main content

relon_parser/
lib.rs

1//! Relon parser.
2//!
3//! Two public entry points cover the full surface:
4//!
5//! * [`parse_document`] — strict-parse. Returns
6//!   `Result<Node, ParseDocumentError>` and rejects any input that
7//!   doesn't form a complete document. Use it from the analyzer's
8//!   main entry, the evaluator, the formatter, and the CLI — they
9//!   all want a hard fail on broken input.
10//! * [`parse_document_recovering`] — IDE-facing partial-AST entry.
11//!   Returns a [`ParsedDocument`] (partial AST + diagnostics) that
12//!   is always populated, even on completely broken input. Use it
13//!   from completion / hover / goto-def callers that must keep
14//!   offering features while the user is mid-edit (`#`, `&`, `@`,
15//!   `{a:`, ...).
16//!
17//! Internals:
18//!
19//! * [`cst`] / [`syntax`] — rowan CST, the single source of truth
20//!   for what input the parser accepts.
21//! * [`ast`] — typed wrappers over the CST nodes.
22//! * `lower` — CST → legacy [`Node`] / [`Expr`] / [`TokenKey`]
23//!   tree. The legacy tree is still public because the analyzer and
24//!   evaluator depend on its semantic shape; new consumers should
25//!   prefer the [`ast`] wrappers (cheap, ranged, error-tolerant).
26
27#![forbid(unsafe_code)]
28
29pub mod ast;
30pub mod cst;
31pub mod directive;
32pub mod fast_path;
33pub mod lex;
34// `lower` is an implementation detail: it owns the CST → legacy `Node`
35// translation that backs `parse_document` / `parse_document_recovering`.
36// Downstream callers should only depend on those entry points (and the
37// resulting `Node` / `Expr` tree); the lowering walker, partial-recovery
38// scope guard, and offset-translation helpers are subject to change as
39// the rowan rewrite continues and are deliberately not part of the
40// public API surface.
41pub(crate) mod lower;
42pub mod rewrite;
43pub mod syntax;
44pub mod token;
45
46pub use fast_path::parse_document_fast;
47
48pub use token::*;
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum ParseDocumentError {
52    Parse { offset: usize, message: String },
53    TrailingInput { offset: usize, remaining: String },
54}
55
56impl std::fmt::Display for ParseDocumentError {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        match self {
59            Self::Parse { message, .. } => write!(f, "parse error: {message}"),
60            Self::TrailingInput { offset, remaining } => {
61                write!(f, "trailing input at byte {offset}: {remaining:?}")
62            }
63        }
64    }
65}
66
67impl std::error::Error for ParseDocumentError {}
68
69impl ParseDocumentError {
70    pub fn source_span(&self) -> Option<miette::SourceSpan> {
71        match self {
72            Self::Parse { offset, .. } => Some((*offset, 1).into()),
73            Self::TrailingInput { offset, remaining } => {
74                Some((*offset, remaining.len().max(1)).into())
75            }
76        }
77    }
78}
79
80/// Parse a Relon document into the legacy [`Node`] tree.
81///
82/// The entry point routes every call through the rowan CST
83/// ([`cst::parse_cst`]) first, then hands off to
84/// `lower::lower_document` for the typed-tree construction. The
85/// CST is the single source of truth for what input the parser
86/// accepts; downstream consumers (analyzer / evaluator / fmt / wasm
87/// / lsp / cli) keep seeing the same `Node` / `Expr` shape they did
88/// pre-rowan-rewrite. See the `lower` module for the migration design note.
89///
90/// This is the strict-parsing entry point — any CST error or
91/// lowering failure surfaces as a typed [`ParseDocumentError`]. Use
92/// it from the analyzer's main entry, the evaluator, fmt, and the
93/// CLI where the caller wants a hard fail on broken input. For IDE
94/// features (completion, hover, goto-def) that must tolerate
95/// in-progress edits, prefer [`parse_document_recovering`] — it
96/// never returns `Err` and yields a partial [`ParsedDocument`] +
97/// diagnostics.
98pub fn parse_document(source: &str) -> Result<Node, ParseDocumentError> {
99    let parse = cst::parse_cst(source);
100    lower::lower_document(&parse, source)
101}
102
103/// One span-bearing diagnostic from a partial parse. Emitted by
104/// [`parse_document_recovering`] for every CST recovery point plus
105/// any sub-tree the lowering walker could not turn into a [`Node`].
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct ParseDiagnostic {
108    pub message: String,
109    pub range: TokenRange,
110}
111
112/// Result of [`parse_document_recovering`]. Always populated — even
113/// completely unrecoverable input yields an empty `nodes` + a
114/// non-empty `diagnostics` list.
115#[derive(Debug, Clone)]
116pub struct ParsedDocument {
117    /// Top-level nodes successfully lowered from the CST. Empty if
118    /// the CST root is unrecoverable; otherwise contains as many
119    /// partial nodes as the lowering could produce. A clean Relon
120    /// document yields exactly one element — the legacy single-root
121    /// `Node` — but the API shape is `Vec<_>` for forward
122    /// compatibility with future multi-top-level forms.
123    pub nodes: Vec<Node>,
124    /// Span-bearing diagnostics describing why parsing was
125    /// incomplete. Empty iff the source parsed cleanly.
126    pub diagnostics: Vec<ParseDiagnostic>,
127}
128
129/// Parse `source` into a partial AST + diagnostics. Never returns
130/// `Err` for any byte input — even completely broken sources
131/// produce an empty `nodes` vec + a populated `diagnostics` list.
132///
133/// This is the IDE entry point: use it from completion / hover /
134/// goto-def call sites that need to keep offering features while
135/// the user is mid-edit (`#`, `&`, `@`, `{`, `{a:`, `{ ?`, ...).
136/// The strict counterpart [`parse_document`] still surfaces a hard
137/// `Err` for callers that require well-formed input (evaluator,
138/// fmt, CLI, analyzer main entry).
139///
140/// Implementation: routes through [`cst::parse_cst`] (which never
141/// panics) and then walks the resulting CST, lowering every
142/// top-level expression child via `lower::lower_document_node_v2`.
143/// CST `ERROR` spans + lowering misses are collected as
144/// [`ParseDiagnostic`]s with byte-accurate ranges.
145pub fn parse_document_recovering(source: &str) -> ParsedDocument {
146    let _scope = lower::RecoveringScope::enter();
147    let parse = cst::parse_cst(source);
148    let mut nodes: Vec<Node> = Vec::new();
149    let mut diagnostics: Vec<ParseDiagnostic> = Vec::new();
150
151    // Surface every CST parser error as a diagnostic. Each one
152    // carries a byte offset into the original source; we widen the
153    // range to a 1-byte span so the IDE has something to anchor to.
154    for err in &parse.errors {
155        let end = (err.offset + 1).min(source.len().max(err.offset));
156        diagnostics.push(ParseDiagnostic {
157            message: err.message.clone(),
158            range: lower::range_from_offsets(source, err.offset, end),
159        });
160    }
161
162    if let Some(doc) = ast::document_of(parse.syntax()) {
163        // Lowering yields a single root Node when it succeeds. The
164        // partial-tolerant lowering already substitutes placeholders
165        // for individual sub-tree failures, so the only remaining
166        // `None` case here is when the CST has no recognizable root
167        // expression at all (e.g. a lone `@` or `#` with nothing
168        // attached). When that happens we still synthesize an empty
169        // top-level Node so IDE callers can attach completion to
170        // whatever cursor context they have.
171        if doc.root_expr().is_some() {
172            if let Some(node) = lower::lower_document_node_v2(&doc, source) {
173                nodes.push(node);
174            } else {
175                // Defensive: with partial-tolerant lowering this should
176                // never trigger, but keep a synthesized empty root and
177                // a diagnostic on the document range so the IDE has
178                // something to attach to.
179                let end_offset = source.len();
180                nodes.push(Node {
181                    id: NodeId::alloc(),
182                    expr: std::sync::Arc::new(Expr::Missing),
183                    decorators: Vec::new(),
184                    directives: Vec::new(),
185                    type_hint: None,
186                    range: lower::range_from_offsets(source, 0, end_offset),
187                    doc_comment: None,
188                });
189                if parse.errors.is_empty() {
190                    diagnostics.push(ParseDiagnostic {
191                        message: "could not lower CST to legacy Node".to_string(),
192                        range: lower::range_from_offsets(source, 0, end_offset),
193                    });
194                }
195            }
196        } else {
197            // No root expression slot — e.g. a lone `@`, `#`, or
198            // similar prefix the user is still typing. Synthesize an
199            // empty placeholder root so completion still has a
200            // navigable AST hook to dispatch off; the diagnostics list
201            // already explains the missing piece.
202            let end_offset = source.len();
203            nodes.push(Node {
204                id: NodeId::alloc(),
205                expr: std::sync::Arc::new(Expr::Missing),
206                decorators: Vec::new(),
207                directives: Vec::new(),
208                type_hint: None,
209                range: lower::range_from_offsets(source, 0, end_offset),
210                doc_comment: None,
211            });
212            if parse.errors.is_empty() {
213                diagnostics.push(ParseDiagnostic {
214                    message: "empty document".to_string(),
215                    range: lower::range_from_offsets(source, 0, 0),
216                });
217            }
218        }
219    }
220
221    ParsedDocument { nodes, diagnostics }
222}
223
224/// Extract leading comments as a single doc string. Walks the byte
225/// prefix of `source` consuming whitespace + `//` line / `/* */`
226/// block comments until the first non-trivia byte. Returns the
227/// joined doc-comment text (if any) plus the number of bytes
228/// consumed — callers use the count to advance their cursor.
229pub fn parse_leading_comments(source: &str) -> (Option<String>, usize) {
230    let bytes = source.as_bytes();
231    let mut i = 0;
232    let mut comments: Vec<String> = Vec::new();
233    loop {
234        // Skip whitespace.
235        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
236            i += 1;
237        }
238        // Try a `//` line comment.
239        if i + 2 <= bytes.len() && &bytes[i..i + 2] == b"//" {
240            let start = i + 2;
241            let mut end = start;
242            while end < bytes.len() && bytes[end] != b'\n' && bytes[end] != b'\r' {
243                end += 1;
244            }
245            comments.push(source[start..end].trim().to_string());
246            i = end;
247            continue;
248        }
249        // Try a `/* ... */` block comment.
250        if i + 2 <= bytes.len() && &bytes[i..i + 2] == b"/*" {
251            let start = i + 2;
252            let mut end = start;
253            while end + 1 < bytes.len() && !(bytes[end] == b'*' && bytes[end + 1] == b'/') {
254                end += 1;
255            }
256            comments.push(source[start..end].trim().to_string());
257            if end + 1 < bytes.len() {
258                i = end + 2;
259            } else {
260                i = bytes.len();
261            }
262            continue;
263        }
264        break;
265    }
266    let joined = if comments.is_empty() {
267        None
268    } else {
269        Some(comments.join("\n"))
270    };
271    (joined, i)
272}
273
274/// Combine two `TokenRange`s — start from `start.start`, end from
275/// `end.end`. Used by binary-expression lowering to compute the
276/// operand-bounded range.
277pub fn combine_ranges(start: TokenRange, end: TokenRange) -> TokenRange {
278    TokenRange {
279        start: start.start,
280        end: end.end,
281    }
282}
283
284pub(crate) fn position_at_source(source: &str, offset: usize) -> TokenPosition {
285    let offset = offset.min(source.len());
286    let end = if source.is_char_boundary(offset) {
287        offset
288    } else {
289        let mut boundary = offset;
290        while boundary > 0 && !source.is_char_boundary(boundary) {
291            boundary -= 1;
292        }
293        boundary
294    };
295
296    let mut line = 1u32;
297    let mut column = 1usize;
298    let mut chars = source[..end].chars().peekable();
299    while let Some(ch) = chars.next() {
300        match ch {
301            '\r' => {
302                if chars.peek() == Some(&'\n') {
303                    chars.next();
304                }
305                line += 1;
306                column = 1;
307            }
308            '\n' => {
309                line += 1;
310                column = 1;
311            }
312            _ => column += 1,
313        }
314    }
315
316    TokenPosition {
317        line,
318        column,
319        offset,
320    }
321}
322
323/// Yield the expression-shaped child nodes of `node` for AST walkers
324/// (analyzer passes, LSP enclosing-scope lookups, ...). Decorators,
325/// directives, and type hints are intentionally *not* included — those
326/// have their own dedicated walkers that need different semantics.
327pub fn child_nodes(node: &Node) -> Vec<&Node> {
328    let mut out = Vec::new();
329    match &*node.expr {
330        Expr::Dict(pairs) => {
331            for (_, value) in pairs {
332                out.push(value);
333            }
334        }
335        Expr::List(items) => out.extend(items.iter()),
336        Expr::Tuple(items) => out.extend(items.iter()),
337        Expr::Spread(inner) => out.push(inner),
338        Expr::Comprehension {
339            element,
340            iterable,
341            condition,
342            ..
343        } => {
344            out.push(element);
345            out.push(iterable);
346            if let Some(cond) = condition {
347                out.push(cond);
348            }
349        }
350        Expr::Binary(_, l, r) => {
351            out.push(l);
352            out.push(r);
353        }
354        Expr::Unary(_, inner) => out.push(inner),
355        Expr::Ternary { cond, then, els } => {
356            out.push(cond);
357            out.push(then);
358            out.push(els);
359        }
360        Expr::FnCall { args, .. } => {
361            for arg in args {
362                out.push(&arg.value);
363            }
364        }
365        Expr::FString(parts) => {
366            for part in parts {
367                if let crate::FStringPart::Interpolation(n) = part {
368                    out.push(n);
369                }
370            }
371        }
372        Expr::Where { expr, bindings } => {
373            out.push(expr);
374            out.push(bindings);
375        }
376        Expr::Match { expr, arms } => {
377            out.push(expr);
378            for (pat, body) in arms {
379                out.push(pat);
380                out.push(body);
381            }
382        }
383        Expr::Closure { body, .. } => out.push(body),
384        Expr::VariantCtor { body, .. } => out.push(body),
385        Expr::VariantPattern { .. } => {}
386        Expr::Reference { .. }
387        | Expr::Variable(_)
388        | Expr::Type(_)
389        | Expr::Wildcard
390        | Expr::Missing
391        | Expr::Bool(_)
392        | Expr::Int(_)
393        | Expr::Float(_)
394        | Expr::String(_) => {}
395    }
396    out
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    #[test]
404    fn test_comments() {
405        let src = r##"/* hello world */
406// this is a test file
407{}"##;
408        let node = parse_document(src).unwrap();
409        assert!(matches!(*node.expr, Expr::Dict(_)));
410    }
411
412    #[test]
413    fn test_parse_document_accepts_trailing_trivia() {
414        assert!(parse_document("{ a: 1 } // trailing\n /* ok */").is_ok());
415    }
416
417    #[test]
418    fn test_parse_document_rejects_trailing_tokens() {
419        let err = parse_document("{ a: 1 } true").unwrap_err();
420        assert!(matches!(
421            err,
422            ParseDocumentError::TrailingInput {
423                offset: 9,
424                ref remaining
425            } if remaining == "true"
426        ));
427        assert_eq!(err.source_span(), Some((9, 4).into()));
428    }
429
430    #[test]
431    fn test_parse_document_reports_parse_error_span() {
432        let err = parse_document("{ a: }").unwrap_err();
433        assert!(matches!(err, ParseDocumentError::Parse { .. }));
434        assert!(err.source_span().is_some());
435    }
436
437    #[test]
438    fn test_token_range_has_line_and_column() {
439        let node = parse_document("// leading\n{\n  answer: 42\n}\n").unwrap();
440        assert_eq!(node.range.start.line, 2);
441        assert_eq!(node.range.start.column, 1);
442        assert_eq!(node.range.end.line, 4);
443        assert_eq!(node.range.end.column, 2);
444
445        if let Expr::Dict(pairs) = &*node.expr {
446            let TokenKey::String(_, key_range, _) = &pairs[0].0 else {
447                panic!("Expected string key")
448            };
449            assert_eq!(key_range.start.line, 3);
450            assert_eq!(key_range.start.column, 3);
451            assert_eq!(pairs[0].1.range.start.line, 3);
452            assert_eq!(pairs[0].1.range.start.column, 11);
453        } else {
454            panic!("Expected dict")
455        }
456    }
457
458    #[test]
459    fn test_simple_root() {
460        let node = parse_document(r#"{ "a": 1 }"#).unwrap();
461        if let Expr::Dict(pairs) = &*node.expr {
462            assert_eq!(pairs.len(), 1);
463        } else {
464            panic!()
465        }
466
467        let node = parse_document("// comment \n {foo: 1, bar: 2,}").unwrap();
468        if let Expr::Dict(pairs) = &*node.expr {
469            assert_eq!(pairs.len(), 2);
470        } else {
471            panic!()
472        }
473    }
474
475    #[test]
476    fn test_expr_integration() {
477        let node = parse_document(r#"{ "a": 1 != 2 }"#).unwrap();
478        if let Expr::Dict(pairs) = &*node.expr {
479            assert!(matches!(*pairs[0].1.expr, Expr::Binary(Operator::Ne, _, _)));
480        } else {
481            panic!()
482        }
483    }
484
485    #[test]
486    fn test_comment_decorator_integration() {
487        let node = parse_document(
488            r###"
489                // foo decorator
490                @foo
491                { "a": 1 }"###,
492        )
493        .unwrap();
494        assert_eq!(node.decorators.len(), 1);
495        assert_eq!(node.decorators[0].path[0].to_string_key(), "foo");
496    }
497
498    #[test]
499    fn test_tuple_two_element() {
500        // `(a, b)` is a 2-tuple, distinct from a list.
501        let node = parse_document("(1, \"a\")").unwrap();
502        match &*node.expr {
503            Expr::Tuple(items) => {
504                assert_eq!(items.len(), 2);
505                assert!(matches!(*items[0].expr, Expr::Int(1)));
506                assert!(matches!(*items[1].expr, Expr::String(_)));
507            }
508            other => panic!("expected Tuple, got {other:?}"),
509        }
510    }
511
512    #[test]
513    fn test_tuple_one_element_trailing_comma() {
514        // `(a,)` — the trailing comma makes a 1-tuple.
515        let node = parse_document("(42,)").unwrap();
516        match &*node.expr {
517            Expr::Tuple(items) => {
518                assert_eq!(items.len(), 1);
519                assert!(matches!(*items[0].expr, Expr::Int(42)));
520            }
521            other => panic!("expected 1-tuple, got {other:?}"),
522        }
523    }
524
525    #[test]
526    fn test_unit_tuple() {
527        // `()` — the zero-tuple / unit.
528        let node = parse_document("()").unwrap();
529        match &*node.expr {
530            Expr::Tuple(items) => assert!(items.is_empty()),
531            other => panic!("expected unit tuple, got {other:?}"),
532        }
533    }
534
535    #[test]
536    fn test_paren_grouping_is_not_tuple() {
537        // `(a)` stays a grouping — it lowers to the inner expression,
538        // never a Tuple.
539        let node = parse_document("(1 + 2)").unwrap();
540        assert!(
541            matches!(&*node.expr, Expr::Binary(Operator::Add, _, _)),
542            "grouping must not produce a Tuple: {:?}",
543            node.expr
544        );
545    }
546
547    #[test]
548    fn test_nested_tuple() {
549        // `((1, 2), 3)` — nested tuples.
550        let node = parse_document("((1, 2), 3)").unwrap();
551        match &*node.expr {
552            Expr::Tuple(items) => {
553                assert_eq!(items.len(), 2);
554                assert!(matches!(*items[0].expr, Expr::Tuple(_)));
555                assert!(matches!(*items[1].expr, Expr::Int(3)));
556            }
557            other => panic!("expected nested tuple, got {other:?}"),
558        }
559    }
560
561    #[test]
562    fn test_list_integration() {
563        let node = parse_document(r#"[1, 2, 3]"#).unwrap();
564        if let Expr::List(elements) = &*node.expr {
565            assert_eq!(elements.len(), 3);
566        } else {
567            panic!()
568        }
569    }
570
571    #[test]
572    fn test_ref_dict() {
573        let node = parse_document(r#"{ "a": &sibling.b, "b": 2 }"#).unwrap();
574        if let Expr::Dict(pairs) = &*node.expr {
575            assert_eq!(pairs.len(), 2);
576            assert!(matches!(
577                *pairs[0].1.expr,
578                Expr::Reference {
579                    base: RefBase::Sibling,
580                    ..
581                }
582            ));
583        } else {
584            panic!()
585        }
586    }
587
588    #[test]
589    fn test_ref_list() {
590        let node = parse_document(r#"[&sibling.b[1], 2]"#).unwrap();
591        if let Expr::List(elements) = &*node.expr {
592            assert_eq!(elements.len(), 2);
593        } else {
594            panic!()
595        }
596    }
597
598    #[test]
599    fn test_var_list() {
600        let node = parse_document(r#"[a, 2]"#).unwrap();
601        if let Expr::List(elements) = &*node.expr {
602            assert!(matches!(*elements[0].expr, Expr::Variable(_)));
603        } else {
604            panic!()
605        }
606    }
607
608    #[test]
609    fn test_fn_call_list() {
610        let node = parse_document(r#"[f({a: 1}), 2]"#).unwrap();
611        if let Expr::List(elements) = &*node.expr {
612            assert!(matches!(*elements[0].expr, Expr::FnCall { .. }));
613        } else {
614            panic!()
615        }
616    }
617
618    #[test]
619    fn test_fmt_string_list() {
620        let node = parse_document(r#"[f"a ${ &sibling.b[1] }", "b"]"#).unwrap();
621        if let Expr::List(elements) = &*node.expr {
622            assert!(matches!(*elements[0].expr, Expr::FString(_)));
623        } else {
624            panic!()
625        }
626    }
627
628    #[test]
629    fn test_root_ref_in_fmt_string_dict() {
630        assert!(parse_document(r#"{ "a": f"a ${ &root.b[0] }", "b": [0, 1] }"#).is_ok());
631    }
632
633    #[test]
634    fn test_doc_comment_extraction() {
635        let src = r#"{
636            // line 1
637            // line 2
638            a: 1,
639            /* block */
640            b: 2
641        }"#;
642        let node = parse_document(src).unwrap();
643        if let Expr::Dict(pairs) = &*node.expr {
644            assert_eq!(pairs[0].1.doc_comment.as_deref(), Some("line 1\nline 2"));
645            assert_eq!(pairs[1].1.doc_comment.as_deref(), Some("block"));
646        } else {
647            panic!()
648        }
649    }
650
651    /// v1.2: root may be any expression, not just `dict` / `list`
652    /// literals — the parser accepts atomic / variant / arithmetic /
653    /// fn-call roots as well.
654    #[test]
655    fn test_root_accepts_atomic_literals() {
656        let node = parse_document("42").unwrap();
657        assert!(matches!(*node.expr, Expr::Int(42)));
658
659        let node = parse_document(r#""hello""#).unwrap();
660        assert!(matches!(*node.expr, Expr::String(_)));
661
662        let node = parse_document("true").unwrap();
663        assert!(matches!(*node.expr, Expr::Bool(true)));
664
665        let node = parse_document("null").unwrap();
666        assert!(matches!(*node.expr, Expr::Missing));
667    }
668
669    #[test]
670    fn test_root_accepts_binary_expression() {
671        let node = parse_document("1 + 2").unwrap();
672        assert!(matches!(*node.expr, Expr::Binary(Operator::Add, _, _)));
673    }
674
675    #[test]
676    fn test_root_accepts_variant_constructor() {
677        let node = parse_document("Result.Ok { value: 1 }").unwrap();
678        assert!(matches!(*node.expr, Expr::VariantCtor { .. }));
679    }
680
681    #[test]
682    fn test_root_accepts_fn_call() {
683        let node = parse_document("range(0, 10)").unwrap();
684        assert!(matches!(*node.expr, Expr::FnCall { .. }));
685    }
686
687    /// Pre-v1.2 root forms (dict / list literals) must keep parsing
688    /// — v1.2 is a strict superset.
689    #[test]
690    fn test_root_dict_and_list_still_work() {
691        let node = parse_document("{ a: 1 }").unwrap();
692        assert!(matches!(*node.expr, Expr::Dict(_)));
693
694        let node = parse_document("[1, 2, 3]").unwrap();
695        assert!(matches!(*node.expr, Expr::List(_)));
696    }
697
698    /// Truly invalid input is still rejected — v1.2 widens the root
699    /// shape but does not weaken parser strictness elsewhere.
700    #[test]
701    fn test_root_rejects_garbage() {
702        assert!(parse_document("").is_err());
703        assert!(parse_document("   \n\t  ").is_err());
704        assert!(parse_document("{ bad syntax").is_err());
705    }
706
707    // -----------------------------------------------------------------
708    // parse_document_recovering — IDE-facing partial-AST entry point.
709    // -----------------------------------------------------------------
710
711    #[test]
712    fn recovering_clean_input_yields_one_node_no_diagnostics() {
713        let result = parse_document_recovering("{ a: 1, b: 2 }");
714        assert_eq!(result.nodes.len(), 1);
715        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
716        if let Expr::Dict(pairs) = &*result.nodes[0].expr {
717            assert_eq!(pairs.len(), 2);
718        } else {
719            panic!("expected Dict root");
720        }
721    }
722
723    #[test]
724    fn recovering_never_errs_on_partial_inputs() {
725        // Every one of these inputs would force `parse_document` to
726        // surface an `Err`; the recovering API must absorb each one.
727        for src in &[
728            "#", "&", "@", "{", "{a:", "{ ?", "}", "[", "(", "f\"hi ${", "", "   ", "\n\t",
729        ] {
730            let result = parse_document_recovering(src);
731            // We only assert: never panics, never crashes. Diagnostics
732            // may or may not be populated — empty input is its own
733            // edge case.
734            let _ = result.nodes;
735            let _ = result.diagnostics;
736        }
737    }
738
739    #[test]
740    fn recovering_reports_diagnostic_for_unterminated_dict() {
741        let result = parse_document_recovering("{ a: ");
742        assert!(
743            !result.diagnostics.is_empty(),
744            "expected at least one diagnostic for unterminated dict"
745        );
746        // The span should fall within the source.
747        for diag in &result.diagnostics {
748            assert!(
749                diag.range.start.offset <= 5,
750                "diagnostic offset out of range: {:?}",
751                diag
752            );
753        }
754    }
755
756    #[test]
757    fn recovering_includes_empty_document_diagnostic() {
758        let result = parse_document_recovering("");
759        // Partial-tolerant contract: every input yields at least one
760        // navigable root Node — empty source gets a Missing placeholder.
761        assert_eq!(result.nodes.len(), 1);
762        assert!(matches!(&*result.nodes[0].expr, Expr::Missing));
763        // Either a CST error or our own "empty document" — but
764        // diagnostics MUST be non-empty (the caller has nothing to
765        // attach a "you must write something" hint to otherwise).
766        assert!(!result.diagnostics.is_empty());
767    }
768
769    #[test]
770    fn recovering_completes_partial_for_lone_hash() {
771        // The IDE feeds us `#` to look up directive completions.
772        // We must yield a diagnostic + leave the byte-offset usable.
773        let result = parse_document_recovering("#");
774        assert!(!result.diagnostics.is_empty());
775    }
776
777    #[test]
778    fn recovering_completes_partial_for_lone_amp() {
779        let result = parse_document_recovering("&");
780        assert!(!result.diagnostics.is_empty());
781    }
782
783    #[test]
784    fn recovering_always_yields_at_least_one_node() {
785        // Every input the IDE can hand us — including completely
786        // broken prefixes — must produce a non-empty `nodes` vec so
787        // downstream completion has an AST root to dispatch off.
788        for src in [
789            "@",
790            "#",
791            "&",
792            "{",
793            "{ @",
794            "{ x: 1, @ }",
795            "[",
796            "}",
797            "{ a:",
798            "{ ?",
799            "f\"hi ${",
800            "(",
801            "",
802        ] {
803            let r = parse_document_recovering(src);
804            assert!(
805                !r.nodes.is_empty(),
806                "expected at least one partial node for src {:?}, got 0",
807                src
808            );
809        }
810    }
811
812    #[test]
813    fn recovering_at_decorator_keeps_sibling_fields() {
814        // The smoking-gun case the user filed: a stray `@` between
815        // dict fields should NOT erase the entire dict; the well-
816        // formed siblings must survive so completion can walk them
817        // for decorator suggestions.
818        let r = parse_document_recovering("{ fmt: (v) => v + 1, @ y: 2 }");
819        assert_eq!(r.nodes.len(), 1, "expected partial Dict root");
820        match &*r.nodes[0].expr {
821            Expr::Dict(fields) => {
822                let has_fmt = fields.iter().any(|(k, _)| {
823                    matches!(
824                        k,
825                        TokenKey::String(s, _, _) if s == "fmt"
826                    )
827                });
828                assert!(
829                    has_fmt,
830                    "expected the `fmt` sibling to survive partial lowering, got {:?}",
831                    fields
832                );
833            }
834            other => panic!("expected Dict root, got {:?}", other),
835        }
836    }
837}