Skip to main content

forgedb_parser/parser/
core.rs

1use crate::ast::{
2    ComponentProtocol, ComponentReference, CompositeIndex, Constraint, ConstraintParam, EnumDef,
3    Field, FieldType, Model, Projection, RelationInclusion, RelationType, Schema, Struct,
4};
5use crate::lexer::{Lexer, Token, TokenWithPos};
6use forgedb_validation::{Position, ValidationError};
7
8/// The result of a resilient parse ([`Parser::parse_recover`]): a best-effort
9/// (possibly partial) [`Schema`] plus every diagnostic collected along the way —
10/// syntax errors recovered from during parsing *and* the semantic diagnostics
11/// from [`crate::validate::validate_schema`] — each positioned and sorted by
12/// source location. This is the shape the LSP consumes to report diagnostics and
13/// offer symbols on a buffer that does not fully parse.
14#[derive(Debug, Clone)]
15pub struct ParsedSchema {
16    pub schema: Schema,
17    pub diagnostics: Vec<ValidationError>,
18}
19
20pub struct Parser {
21    tokens: Vec<Token>,
22    tokens_with_pos: Vec<TokenWithPos>,
23    position: usize,
24    use_validation: bool,
25    /// When set, the field/member loops record a diagnostic and skip to the next
26    /// boundary instead of aborting the parse (see [`Parser::parse_recover`]).
27    /// The fail-fast [`Parser::parse`]/[`Parser::parse_unvalidated`] paths leave
28    /// this `false`, so their behavior is unchanged.
29    recovering: bool,
30    /// Syntax diagnostics accumulated during a recovering parse.
31    recovery_diagnostics: Vec<ValidationError>,
32}
33
34impl Parser {
35    pub fn new(input: &str) -> Result<Self, String> {
36        Self::new_with_validation(input, true)
37    }
38
39    pub fn new_with_validation(input: &str, use_validation: bool) -> Result<Self, String> {
40        let mut lexer = Lexer::new(input);
41        let tokens = lexer.tokenize()?;
42
43        let mut lexer = Lexer::new(input);
44        let tokens_with_pos = lexer.tokenize_with_pos()?;
45
46        Ok(Parser {
47            tokens,
48            tokens_with_pos,
49            position: 0,
50            use_validation,
51            recovering: false,
52            recovery_diagnostics: Vec::new(),
53        })
54    }
55
56    fn get_current_position(&self) -> Option<Position> {
57        if self.position < self.tokens_with_pos.len() {
58            Some(self.tokens_with_pos[self.position].position)
59        } else {
60            None
61        }
62    }
63
64    /// Position of the token at `idx` (used to anchor a recovered diagnostic at
65    /// the start of the member/declaration it came from, not wherever the cursor
66    /// happened to stop).
67    fn position_at(&self, idx: usize) -> Option<Position> {
68        self.tokens_with_pos.get(idx).map(|t| t.position)
69    }
70
71    /// Build a positioned diagnostic from a parse-error message.
72    fn diag(message: String, position: Option<Position>) -> ValidationError {
73        let err = ValidationError::new(message);
74        match position {
75            Some(p) => err.with_position(p),
76            None => err,
77        }
78    }
79
80    /// Recover from a bad member (field or model directive) by skipping to the
81    /// next member boundary. A member is a single line — it never spans a newline
82    /// or contains braces — so the next `Newline`/`}`/EOF is a safe resync point.
83    /// The closing `}`/EOF is left unconsumed so the enclosing loop can see it.
84    fn recover_to_member_boundary(&mut self) {
85        while !matches!(
86            self.current_token(),
87            Token::Newline | Token::RBrace | Token::Eof
88        ) {
89            self.advance();
90        }
91        self.skip_newlines();
92    }
93
94    /// Is the next significant token at or after `idx` (skipping newlines) an
95    /// opening brace? Used to spot a bare model header (`Name {`) — the one
96    /// top-level construct with no leading keyword — during recovery.
97    fn next_significant_is_lbrace(&self, idx: usize) -> bool {
98        let mut i = idx;
99        while matches!(self.tokens.get(i), Some(Token::Newline)) {
100            i += 1;
101        }
102        matches!(self.tokens.get(i), Some(Token::LBrace))
103    }
104
105    /// Recover from a bad *declaration* by skipping the whole broken block. From
106    /// the declaration's start token, scan forward: if this declaration has an
107    /// opening `{`, consume through its balanced closing `}` (to EOF if
108    /// unterminated); otherwise — a header with no brace — stop at the next clear
109    /// declaration boundary (a `struct`/`enum` keyword, or a bare `Name {` model
110    /// header) so a following valid declaration is not swallowed. Always advances
111    /// past `start`, guaranteeing top-level progress.
112    fn synchronize_from(&mut self, start: usize) {
113        let n = self.tokens.len();
114        let mut i = start;
115
116        while i < n {
117            match &self.tokens[i] {
118                Token::LBrace => {
119                    // This declaration's block: balance from here through its match.
120                    let mut depth = 0i32;
121                    let mut j = i;
122                    while j < n {
123                        match self.tokens[j] {
124                            Token::LBrace => depth += 1,
125                            Token::RBrace => {
126                                depth -= 1;
127                                if depth == 0 {
128                                    self.position = j + 1;
129                                    return;
130                                }
131                            }
132                            Token::Eof => {
133                                self.position = j;
134                                return;
135                            }
136                            _ => {}
137                        }
138                        j += 1;
139                    }
140                    self.position = n; // unterminated — consume to the end
141                    return;
142                }
143                Token::Eof => {
144                    self.position = i;
145                    return;
146                }
147                // A clear next-declaration boundary (past our own start token):
148                // stop here rather than scanning into it for a brace.
149                Token::KwStruct | Token::KwEnum if i > start => {
150                    self.position = i;
151                    return;
152                }
153                Token::Ident(_) if i > start && self.next_significant_is_lbrace(i + 1) => {
154                    self.position = i;
155                    return;
156                }
157                _ => i += 1,
158            }
159        }
160        self.position = n;
161    }
162
163    /// Record a recovered syntax diagnostic anchored at token `at`.
164    fn recover_diag(&mut self, message: String, at: usize) {
165        let pos = self.position_at(at);
166        self.recovery_diagnostics.push(Self::diag(message, pos));
167    }
168
169    fn current_token(&self) -> &Token {
170        if self.position < self.tokens.len() {
171            &self.tokens[self.position]
172        } else {
173            &Token::Eof
174        }
175    }
176
177    fn advance(&mut self) {
178        if self.position < self.tokens.len() {
179            self.position += 1;
180        }
181    }
182
183    fn skip_newlines(&mut self) {
184        while matches!(self.current_token(), Token::Newline) {
185            self.advance();
186        }
187    }
188
189    fn read_component_path(&mut self) -> Result<String, String> {
190        // Read a path like: components/user/card
191        // This is a sequence of identifiers separated by slashes
192        let mut path = String::new();
193
194        loop {
195            match self.current_token() {
196                Token::Ident(name) => {
197                    path.push_str(name);
198                    self.advance();
199
200                    // Check if there's a slash for more path segments
201                    if matches!(self.current_token(), Token::Slash) {
202                        path.push('/');
203                        self.advance();
204                        // Continue to read next segment
205                    } else {
206                        // End of path
207                        break;
208                    }
209                }
210                _ => {
211                    return Err(format!(
212                        "Expected path component (identifier), found {:?}",
213                        self.current_token()
214                    ))
215                }
216            }
217        }
218
219        if path.is_empty() {
220            return Err("Component path cannot be empty".to_string());
221        }
222
223        Ok(path)
224    }
225
226    fn expect(&mut self, expected: Token) -> Result<(), String> {
227        if self.current_token() == &expected {
228            self.advance();
229            Ok(())
230        } else {
231            Err(format!(
232                "Expected {:?}, found {:?}",
233                expected,
234                self.current_token()
235            ))
236        }
237    }
238
239    fn parse_constraint(&mut self) -> Result<Constraint, String> {
240        // Expect @
241        self.expect(Token::At)?;
242
243        // Parse constraint name
244        let name = match self.current_token() {
245            Token::Ident(s) => s.clone(),
246            _ => {
247                return Err(format!(
248                    "Expected constraint name after '@', found {:?}",
249                    self.current_token()
250                ))
251            }
252        };
253        self.advance();
254
255        let mut constraint = Constraint::new(name);
256
257        // Check for parameters
258        if matches!(self.current_token(), Token::LParen) {
259            self.advance();
260
261            // Parse parameters
262            loop {
263                match self.current_token() {
264                    Token::Number(n) => {
265                        constraint = constraint.with_param(ConstraintParam::Number(*n));
266                        self.advance();
267                    }
268                    Token::Ident(s) => {
269                        constraint = constraint.with_param(ConstraintParam::String(s.clone()));
270                        self.advance();
271                    }
272                    Token::Str(s) => {
273                        constraint = constraint.with_param(ConstraintParam::String(s.clone()));
274                        self.advance();
275                    }
276                    Token::Asterisk => {
277                        // Special case for @relations(*) syntax
278                        constraint =
279                            constraint.with_param(ConstraintParam::String("*".to_string()));
280                        self.advance();
281                    }
282                    _ => {
283                        return Err(format!(
284                            "Expected constraint parameter, found {:?}",
285                            self.current_token()
286                        ))
287                    }
288                }
289
290                // Check for comma (more params) or closing paren
291                match self.current_token() {
292                    Token::Comma => {
293                        self.advance();
294                        continue;
295                    }
296                    Token::RParen => {
297                        self.advance();
298                        break;
299                    }
300                    _ => {
301                        return Err(format!(
302                            "Expected ',' or ')' in constraint parameters, found {:?}",
303                            self.current_token()
304                        ))
305                    }
306                }
307            }
308        }
309
310        Ok(constraint)
311    }
312
313    fn parse_type(&mut self) -> Result<FieldType, String> {
314        // Check for relation types first
315        match self.current_token() {
316            // Fixed array or One-to-many: [type; count] or [Post]
317            Token::LBracket => {
318                self.advance();
319
320                // Check if this is a fixed array [type; count] or one-to-many [Model]
321                let first_token = self.current_token().clone();
322
323                match first_token {
324                    Token::Ident(name) => {
325                        self.advance();
326
327                        // Check next token to distinguish [Model] vs [type; count]
328                        match self.current_token() {
329                            Token::Semicolon => {
330                                // This is [Ident; count] - but Ident should be a type or struct
331                                self.advance();
332                                let count = match self.current_token() {
333                                    Token::Number(n) => *n as usize,
334                                    _ => {
335                                        return Err(format!(
336                                            "Expected array count after ';', found {:?}",
337                                            self.current_token()
338                                        ))
339                                    }
340                                };
341                                self.advance();
342                                self.expect(Token::RBracket)?;
343                                // The Ident could be a struct type
344                                return Ok(FieldType::FixedArray(
345                                    Box::new(FieldType::StructType(name)),
346                                    count,
347                                ));
348                            }
349                            Token::RBracket => {
350                                // This is [Model] - one-to-many relation
351                                self.advance();
352                                return Ok(FieldType::Relation(RelationType::OneToMany(name)));
353                            }
354                            _ => {
355                                return Err(format!(
356                                    "Expected ';' or ']' after type name, found {:?}",
357                                    self.current_token()
358                                ))
359                            }
360                        }
361                    }
362                    _ => {
363                        // Parse base type for fixed array
364                        let inner_type = self.parse_primitive_type()?;
365                        self.expect(Token::Semicolon)?;
366                        let count = match self.current_token() {
367                            Token::Number(n) => *n as usize,
368                            _ => {
369                                return Err(format!(
370                                    "Expected array count, found {:?}",
371                                    self.current_token()
372                                ))
373                            }
374                        };
375                        self.advance();
376                        self.expect(Token::RBracket)?;
377                        return Ok(FieldType::FixedArray(Box::new(inner_type), count));
378                    }
379                }
380            }
381            // Required reference: *User
382            Token::Asterisk => {
383                self.advance();
384                let model_name = match self.current_token() {
385                    Token::Ident(name) => name.clone(),
386                    _ => {
387                        return Err(format!(
388                            "Expected model name after '*', found {:?}",
389                            self.current_token()
390                        ))
391                    }
392                };
393                self.advance();
394                return Ok(FieldType::Relation(RelationType::RequiredReference(
395                    model_name,
396                )));
397            }
398            // Optional reference or nullable primitive: ?User  or  ?i32
399            Token::Question => {
400                self.advance();
401                match self.current_token().clone() {
402                    Token::Ident(name) => {
403                        self.advance();
404                        return Ok(FieldType::Relation(RelationType::OptionalReference(name)));
405                    }
406                    // Nullable primitive types: ?i32, ?string, ?bool, etc.
407                    Token::TypeU32
408                    | Token::TypeU64
409                    | Token::TypeI32
410                    | Token::TypeI64
411                    | Token::TypeF64
412                    | Token::TypeBool
413                    | Token::TypeString
414                    | Token::TypeUuid
415                    | Token::TypeTimestamp
416                    | Token::TypeJson
417                    | Token::TypeDecimal
418                    | Token::TypeChar => {
419                        let inner = self.parse_primitive_type()?;
420                        return Ok(FieldType::Nullable(Box::new(inner)));
421                    }
422                    _ => {
423                        return Err(format!(
424                            "Expected model name or primitive type after '?', found {:?}",
425                            self.current_token()
426                        ))
427                    }
428                }
429            }
430            // Struct types, component references, or primitive identifiers
431            Token::Ident(name) => {
432                let type_name = name.clone();
433                self.advance();
434
435                // Check if this is a component protocol (tsx://, jsx://, api://)
436                if matches!(type_name.as_str(), "tsx" | "jsx" | "api")
437                    && matches!(self.current_token(), Token::Colon)
438                {
439                    // Parse component reference: tsx://path
440                    self.advance(); // skip :
441                    self.skip_newlines(); // Skip any newlines after colon
442
443                    // Expect two slashes
444                    self.expect(Token::Slash)?;
445                    self.expect(Token::Slash)?;
446
447                    // Read the component path
448                    let path = self.read_component_path()?;
449
450                    // Determine protocol
451                    let protocol = match type_name.as_str() {
452                        "tsx" => ComponentProtocol::Tsx,
453                        "jsx" => ComponentProtocol::Jsx,
454                        "api" => ComponentProtocol::Api,
455                        _ => unreachable!(),
456                    };
457
458                    return Ok(FieldType::Component(ComponentReference {
459                        protocol,
460                        path,
461                        relations: RelationInclusion::None,
462                    }));
463                }
464
465                // This could be a struct type or struct? for optional
466                return Ok(FieldType::StructType(type_name));
467            }
468            _ => {}
469        }
470
471        // Primitive types
472        self.parse_primitive_type()
473    }
474
475    fn parse_primitive_type(&mut self) -> Result<FieldType, String> {
476        let field_type = match self.current_token() {
477            Token::TypeU32 => FieldType::U32,
478            Token::TypeU64 => FieldType::U64,
479            Token::TypeI32 => FieldType::I32,
480            Token::TypeI64 => FieldType::I64,
481            Token::TypeF64 => FieldType::F64,
482            Token::TypeBool => FieldType::Bool,
483            Token::TypeString => FieldType::String,
484            Token::TypeJson => FieldType::Json,
485            Token::TypeDecimal => FieldType::Decimal,
486            Token::TypeUuid => FieldType::Uuid,
487            Token::TypeTimestamp => FieldType::Timestamp,
488            Token::TypeChar => {
489                // char(N) - expect (N)
490                self.advance();
491                self.expect(Token::LParen)?;
492                let size = match self.current_token() {
493                    Token::Number(n) => *n as usize,
494                    _ => {
495                        return Err(format!(
496                            "Expected size after 'char(', found {:?}",
497                            self.current_token()
498                        ))
499                    }
500                };
501                self.advance();
502                self.expect(Token::RParen)?;
503                return Ok(FieldType::Char(size));
504            }
505            _ => return Err(format!("Expected type, found {:?}", self.current_token())),
506        };
507        self.advance();
508        Ok(field_type)
509    }
510
511    fn parse_directive(&mut self) -> Result<CompositeIndex, String> {
512        // Expect @
513        self.expect(Token::At)?;
514
515        // Get directive name
516        let directive_name = match self.current_token() {
517            Token::Ident(s) => s.clone(),
518            _ => {
519                return Err(format!(
520                    "Expected directive name after '@', found {:?}",
521                    self.current_token()
522                ))
523            }
524        };
525        self.advance();
526
527        match directive_name.as_str() {
528            "index" => self.parse_index_directive(),
529            _ => Err(format!("Unknown directive: @{}", directive_name)),
530        }
531    }
532
533    fn parse_index_directive(&mut self) -> Result<CompositeIndex, String> {
534        // Expect (
535        self.expect(Token::LParen)?;
536
537        // Parse field list
538        let mut fields = Vec::new();
539        loop {
540            // Parse field name
541            let field_name = match self.current_token() {
542                Token::Ident(s) => s.clone(),
543                _ => {
544                    return Err(format!(
545                        "Expected field name in @index directive, found {:?}",
546                        self.current_token()
547                    ))
548                }
549            };
550            self.advance();
551            fields.push(field_name);
552
553            // Check for comma or closing paren
554            match self.current_token() {
555                Token::Comma => {
556                    self.advance();
557                    // Continue loop to parse next field
558                }
559                Token::RParen => {
560                    self.advance();
561                    break;
562                }
563                _ => {
564                    return Err(format!(
565                        "Expected ',' or ')' in @index directive, found {:?}",
566                        self.current_token()
567                    ))
568                }
569            }
570        }
571
572        if fields.len() < 2 {
573            return Err("Composite index must include at least 2 fields".to_string());
574        }
575
576        Ok(CompositeIndex { fields })
577    }
578
579    /// Parse a `@projection(<name>: <field>, ...)` directive body (#113).  The
580    /// caller has already consumed `@projection`; the current token is `(`.
581    fn parse_projection_directive(&mut self) -> Result<Projection, String> {
582        self.expect(Token::LParen)?;
583
584        // Projection name.
585        let name = match self.current_token() {
586            Token::Ident(s) => s.clone(),
587            _ => {
588                return Err(format!(
589                    "Expected projection name in @projection directive, found {:?}",
590                    self.current_token()
591                ))
592            }
593        };
594        self.advance();
595
596        // Name/field-list separator.
597        self.expect(Token::Colon)?;
598
599        // Field list (same shape as @index).
600        let mut fields = Vec::new();
601        loop {
602            let field_name = match self.current_token() {
603                Token::Ident(s) => s.clone(),
604                _ => {
605                    return Err(format!(
606                        "Expected field name in @projection directive, found {:?}",
607                        self.current_token()
608                    ))
609                }
610            };
611            self.advance();
612            fields.push(field_name);
613
614            match self.current_token() {
615                Token::Comma => {
616                    self.advance();
617                }
618                Token::RParen => {
619                    self.advance();
620                    break;
621                }
622                _ => {
623                    return Err(format!(
624                        "Expected ',' or ')' in @projection directive, found {:?}",
625                        self.current_token()
626                    ))
627                }
628            }
629        }
630
631        if fields.is_empty() {
632            return Err(format!(
633                "@projection '{}' must name at least one field",
634                name
635            ));
636        }
637
638        Ok(Projection { name, fields })
639    }
640
641    fn parse_field(&mut self) -> Result<Field, String> {
642        self.skip_newlines();
643
644        // Parse field name
645        let field_pos = self.get_current_position();
646        let name = match self.current_token() {
647            Token::Ident(s) => s.clone(),
648            _ => {
649                return Err(format!(
650                    "Expected field name, found {:?}",
651                    self.current_token()
652                ))
653            }
654        };
655        self.advance();
656
657        // Naming, duplicate, and reference checks are deferred to
658        // `crate::validate` (the single positioned authority), run after the
659        // whole schema is assembled. Only structural/syntactic errors are fatal
660        // during parsing.
661
662        // Expect colon
663        self.expect(Token::Colon)?;
664
665        // Check for symbols (+, &, and ^)
666        let mut auto_generate = false;
667        let mut unique = false;
668        let mut indexed = false;
669
670        loop {
671            match self.current_token() {
672                Token::Plus => {
673                    auto_generate = true;
674                    self.advance();
675                }
676                Token::Ampersand => {
677                    unique = true;
678                    self.advance();
679                }
680                Token::Caret => {
681                    indexed = true;
682                    self.advance();
683                }
684                _ => break,
685            }
686        }
687
688        // Parse type
689        let mut field_type = self.parse_type()?;
690
691        // Check for postfix nullable marker (Type?)
692        if matches!(self.current_token(), Token::Question) {
693            match field_type {
694                FieldType::StructType(ref name) => {
695                    let name = name.clone();
696                    self.advance();
697                    field_type = FieldType::OptionalStructType(name);
698                }
699                // Nullable primitives with postfix `?`: e.g. `age: i32?`
700                FieldType::U32
701                | FieldType::U64
702                | FieldType::I32
703                | FieldType::I64
704                | FieldType::F64
705                | FieldType::Bool
706                | FieldType::String
707                | FieldType::Json
708                | FieldType::Decimal
709                | FieldType::Uuid
710                | FieldType::Timestamp
711                | FieldType::Char(_)
712                | FieldType::FixedArray(_, _) => {
713                    let inner = field_type.clone();
714                    self.advance();
715                    field_type = FieldType::Nullable(Box::new(inner));
716                }
717                _ => {} // Don't consume `?` for relations or other types
718            }
719        }
720
721        // Validate auto-generate is compatible with type
722        if auto_generate && !field_type.is_auto_generatable() {
723            return Err(format!(
724                "Auto-generate symbol '+' cannot be used with type {:?}. Only u32, u64, uuid, and timestamp support auto-generation",
725                field_type
726            ));
727        }
728
729        // Parse constraints (@directives)
730        let mut constraints = Vec::new();
731        let mut is_computed = false;
732        let mut fulltext_indexed = false;
733        let mut is_materialized = false;
734        while matches!(self.current_token(), Token::At) {
735            let constraint = self.parse_constraint()?;
736            // Check if this is the @relations directive (Sprint 17 - for components)
737            if constraint.name == "relations" {
738                if let FieldType::Component(ref mut comp_ref) = field_type {
739                    // Parse the @relations parameters
740                    if constraint.params.is_empty() {
741                        return Err("@relations directive requires parameters".to_string());
742                    }
743
744                    // Check if it's @relations(*)
745                    if constraint.params.len() == 1 {
746                        if let ConstraintParam::String(s) = &constraint.params[0] {
747                            if s == "*" {
748                                comp_ref.relations = RelationInclusion::All;
749                            } else {
750                                // Single relation field
751                                comp_ref.relations = RelationInclusion::Specific(vec![s.clone()]);
752                            }
753                        } else {
754                            return Err(
755                                "@relations parameter must be a field name or *".to_string()
756                            );
757                        }
758                    } else {
759                        // Multiple specific relations
760                        let mut fields = Vec::new();
761                        for param in &constraint.params {
762                            if let ConstraintParam::String(field_name) = param {
763                                fields.push(field_name.clone());
764                            } else {
765                                return Err("@relations parameters must be field names".to_string());
766                            }
767                        }
768                        comp_ref.relations = RelationInclusion::Specific(fields);
769                    }
770                    // Don't add @relations to constraints list since we've handled it
771                    continue;
772                } else {
773                    return Err(
774                        "@relations directive can only be used with component fields".to_string(),
775                    );
776                }
777            }
778            // Check if this is the @computed directive
779            if constraint.name == "computed" {
780                is_computed = true;
781            }
782            // Check if this is the @fulltext directive (Sprint 18)
783            if constraint.name == "fulltext" {
784                fulltext_indexed = true;
785            }
786            // Check if this is the @materialized directive (Sprint 19)
787            if constraint.name == "materialized" {
788                is_materialized = true;
789            }
790            constraints.push(constraint);
791        }
792
793        // Determine index type based on field type
794        let index_type = field_type.default_index_type();
795
796        Ok(Field {
797            name,
798            field_type,
799            auto_generate,
800            unique,
801            indexed,
802            constraints,
803            index_type,
804            is_computed,
805            fulltext_indexed,
806            is_materialized,
807            position: field_pos,
808        })
809    }
810
811    fn parse_struct(&mut self) -> Result<Struct, String> {
812        self.skip_newlines();
813
814        // Expect 'struct' keyword
815        self.expect(Token::KwStruct)?;
816
817        // Parse struct name
818        let struct_pos = self.get_current_position();
819        let name = match self.current_token() {
820            Token::Ident(s) => s.clone(),
821            _ => {
822                return Err(format!(
823                    "Expected struct name, found {:?}",
824                    self.current_token()
825                ))
826            }
827        };
828        self.advance();
829
830        // Name/duplicate/reference validation is deferred to `crate::validate`.
831
832        // Expect opening brace
833        self.skip_newlines();
834        self.expect(Token::LBrace)?;
835
836        // Parse fields
837        let mut fields = Vec::new();
838        self.skip_newlines();
839
840        while !matches!(self.current_token(), Token::RBrace | Token::Eof) {
841            let member_start = self.position;
842            match self.parse_field() {
843                Ok(field) => {
844                    fields.push(field);
845                    self.skip_newlines();
846                }
847                Err(e) if self.recovering => {
848                    self.recover_diag(e, member_start);
849                    self.recover_to_member_boundary();
850                    if self.position == member_start {
851                        self.advance();
852                    }
853                }
854                Err(e) => return Err(e),
855            }
856        }
857
858        // Expect closing brace.
859        if let Err(e) = self.expect(Token::RBrace) {
860            if self.recovering {
861                self.recover_diag(e, self.position);
862            } else {
863                return Err(e);
864            }
865        }
866
867        if fields.is_empty() {
868            let e = format!("Struct '{}' has no fields", name);
869            if self.recovering {
870                self.recovery_diagnostics.push(Self::diag(e, struct_pos));
871            } else {
872                return Err(e);
873            }
874        }
875
876        Ok(Struct {
877            name,
878            fields,
879            position: struct_pos,
880        })
881    }
882
883    /// Parse a top-level `enum Name { V1, V2, ... }` (#enum).  A sibling of
884    /// `struct`/model; variants are a comma/newline-separated PascalCase list
885    /// (trailing comma optional, matching the struct/model brace style).
886    fn parse_enum(&mut self) -> Result<EnumDef, String> {
887        self.skip_newlines();
888
889        // Expect 'enum' keyword
890        self.expect(Token::KwEnum)?;
891
892        // Parse enum name (PascalCase — validated like a model/struct name).
893        let enum_pos = self.get_current_position();
894        let name = match self.current_token() {
895            Token::Ident(s) => s.clone(),
896            _ => {
897                return Err(format!(
898                    "Expected enum name, found {:?}",
899                    self.current_token()
900                ))
901            }
902        };
903        self.advance();
904
905        // Name/variant/duplicate validation is deferred to `crate::validate`.
906
907        // Expect opening brace
908        self.skip_newlines();
909        self.expect(Token::LBrace)?;
910        self.skip_newlines();
911
912        // Parse variant list.
913        let mut variants = Vec::new();
914        while !matches!(self.current_token(), Token::RBrace | Token::Eof) {
915            let variant = match self.current_token() {
916                Token::Ident(s) => s.clone(),
917                _ => {
918                    return Err(format!(
919                        "Expected enum variant name in enum '{}', found {:?}",
920                        name,
921                        self.current_token()
922                    ))
923                }
924            };
925            self.advance();
926
927            // Variant PascalCase + uniqueness are checked by `crate::validate`.
928            variants.push(variant);
929
930            // Separator: comma or newline; trailing comma optional.
931            match self.current_token() {
932                Token::Comma => {
933                    self.advance();
934                    self.skip_newlines();
935                }
936                _ => {
937                    self.skip_newlines();
938                }
939            }
940        }
941
942        self.expect(Token::RBrace)?;
943
944        if variants.is_empty() {
945            return Err(format!("Enum '{}' has no variants", name));
946        }
947
948        Ok(EnumDef {
949            name,
950            variants,
951            position: enum_pos,
952        })
953    }
954
955    fn parse_model(&mut self) -> Result<Model, String> {
956        self.skip_newlines();
957
958        // Parse model name
959        let model_pos = self.get_current_position();
960        let name = match self.current_token() {
961            Token::Ident(s) => s.clone(),
962            _ => {
963                return Err(format!(
964                    "Expected model name, found {:?}",
965                    self.current_token()
966                ))
967            }
968        };
969        self.advance();
970
971        // Name/duplicate/reference validation is deferred to `crate::validate`.
972
973        // Expect opening brace
974        self.skip_newlines();
975        self.expect(Token::LBrace)?;
976
977        // Parse fields and directives
978        let mut fields = Vec::new();
979        let mut composite_indexes = Vec::new();
980        let mut projections = Vec::new();
981        let mut soft_delete = false;
982        self.skip_newlines();
983
984        while !matches!(self.current_token(), Token::RBrace | Token::Eof) {
985            let member_start = self.position;
986            match self.parse_model_member(
987                &mut fields,
988                &mut composite_indexes,
989                &mut projections,
990                &mut soft_delete,
991            ) {
992                Ok(()) => {}
993                Err(e) if self.recovering => {
994                    // Record the bad member, skip to the next line, keep going —
995                    // one malformed field/directive should not blank out the rest
996                    // of the model for the LSP.
997                    self.recover_diag(e, member_start);
998                    self.recover_to_member_boundary();
999                    if self.position == member_start {
1000                        self.advance();
1001                    }
1002                }
1003                Err(e) => return Err(e),
1004            }
1005        }
1006
1007        // Expect closing brace.
1008        if let Err(e) = self.expect(Token::RBrace) {
1009            if self.recovering {
1010                self.recover_diag(e, self.position);
1011            } else {
1012                return Err(e);
1013            }
1014        }
1015
1016        if fields.is_empty() {
1017            let e = format!("Model '{}' has no fields", name);
1018            if self.recovering {
1019                // Keep the (empty) model so its symbol still exists for the LSP.
1020                self.recovery_diagnostics
1021                    .push(Self::diag(e, model_pos));
1022            } else {
1023                return Err(e);
1024            }
1025        }
1026
1027        // Duplicate field names, composite-index field references, and
1028        // projection name/field checks are deferred to `crate::validate` (the
1029        // single positioned authority), run once the whole schema is assembled.
1030
1031        Ok(Model {
1032            name,
1033            fields,
1034            composite_indexes,
1035            projections,
1036            soft_delete,
1037            position: model_pos,
1038        })
1039    }
1040
1041    /// Parse a single member of a model body — a field, or a model-level
1042    /// directive (`@index(...)`, `@projection(...)`, `@soft_delete`) — appending
1043    /// it to the relevant accumulator. Extracted from the `parse_model` loop so a
1044    /// recovering parse can catch a member's error and skip to the next line
1045    /// without duplicating the body logic.
1046    fn parse_model_member(
1047        &mut self,
1048        fields: &mut Vec<Field>,
1049        composite_indexes: &mut Vec<CompositeIndex>,
1050        projections: &mut Vec<Projection>,
1051        soft_delete: &mut bool,
1052    ) -> Result<(), String> {
1053        if matches!(self.current_token(), Token::At) {
1054            // Try to parse as a composite index first.
1055            let start_pos = self.position;
1056            match self.parse_directive() {
1057                Ok(composite_index) => {
1058                    composite_indexes.push(composite_index);
1059                    self.skip_newlines();
1060                }
1061                Err(_) => {
1062                    // Reset and try to parse as a model-level directive.
1063                    self.position = start_pos;
1064                    self.advance(); // skip @
1065                    let directive_name = match self.current_token() {
1066                        Token::Ident(s) => s.clone(),
1067                        _ => {
1068                            return Err(format!(
1069                                "Expected directive name after '@', found {:?}",
1070                                self.current_token()
1071                            ))
1072                        }
1073                    };
1074                    self.advance();
1075
1076                    match directive_name.as_str() {
1077                        "soft_delete" => {
1078                            *soft_delete = true;
1079                            self.skip_newlines();
1080                        }
1081                        "projection" => {
1082                            let projection = self.parse_projection_directive()?;
1083                            projections.push(projection);
1084                            self.skip_newlines();
1085                        }
1086                        _ => {
1087                            return Err(format!("Unknown model directive: @{}", directive_name));
1088                        }
1089                    }
1090                }
1091            }
1092        } else {
1093            let field = self.parse_field()?;
1094            fields.push(field);
1095            self.skip_newlines();
1096        }
1097        Ok(())
1098    }
1099
1100    /// Parse the input into a [`Schema`], running the full positioned semantic
1101    /// validation ([`crate::validate::validate_schema`]) and failing fast on the
1102    /// first diagnostic to preserve the historical `Result<Schema, String>`
1103    /// contract. Naming diagnostics are gated by the parser's `use_validation`
1104    /// flag (see [`Self::new_with_validation`]); structural/reference diagnostics
1105    /// always run.
1106    pub fn parse(&mut self) -> Result<Schema, String> {
1107        let schema = self.parse_unvalidated()?;
1108
1109        let mut errors = Vec::new();
1110        if self.use_validation {
1111            crate::validate::collect_naming_errors(&schema, &mut errors);
1112        }
1113        crate::validate::collect_structure_errors(&schema, &mut errors);
1114
1115        if let Some(first) = errors.first() {
1116            return Err(first.to_string());
1117        }
1118
1119        Ok(schema)
1120    }
1121
1122    /// Parse the input into a [`Schema`] performing only *structural* parsing:
1123    /// tokens are assembled into the AST and enum field-type references are
1124    /// resolved, but no schema-level semantic validation is run. Syntactic
1125    /// errors (unexpected tokens, malformed directives, empty models/structs,
1126    /// composite-index arity) are still fatal.
1127    ///
1128    /// The returned schema may therefore contain semantic defects (duplicate
1129    /// names, dangling relations, bad casing). Callers that want those reported —
1130    /// the CLI `forgedb validate` command and the LSP — run
1131    /// [`crate::validate::validate_schema`] on the result to collect **all**
1132    /// positioned diagnostics instead of only the first. (Error recovery for
1133    /// mid-keystroke buffers is #173 WS2c.)
1134    pub fn parse_unvalidated(&mut self) -> Result<Schema, String> {
1135        let mut structs = Vec::new();
1136        let mut enums = Vec::new();
1137        let mut models = Vec::new();
1138        // Names of declared enums, used only to resolve bare-identifier field
1139        // types below (duplicate detection is deferred to `crate::validate`).
1140        let mut enum_names = std::collections::HashSet::new();
1141        self.skip_newlines();
1142
1143        while !matches!(self.current_token(), Token::Eof) {
1144            // Dispatch on the leading keyword: struct / enum / (bare) model.
1145            if matches!(self.current_token(), Token::KwStruct) {
1146                structs.push(self.parse_struct()?);
1147            } else if matches!(self.current_token(), Token::KwEnum) {
1148                let enum_def = self.parse_enum()?;
1149                enum_names.insert(enum_def.name.clone());
1150                enums.push(enum_def);
1151            } else {
1152                models.push(self.parse_model()?);
1153            }
1154
1155            self.skip_newlines();
1156        }
1157
1158        if models.is_empty() && structs.is_empty() && enums.is_empty() {
1159            return Err("Schema is empty".to_string());
1160        }
1161
1162        // Resolve bare-identifier field types (parsed as `StructType`/
1163        // `OptionalStructType`) that name a declared enum into `FieldType::Enum`
1164        // (#enum).  A bare PascalCase identifier is either an enum (resolved here)
1165        // or a struct (left as `StructType`, validated by struct-reference checks).
1166        // This runs AFTER all declarations are collected so an enum may be declared
1167        // after the model that references it.
1168        for model in &mut models {
1169            for field in &mut model.fields {
1170                Self::resolve_enum_field_type(&mut field.field_type, &enum_names);
1171            }
1172        }
1173
1174        Ok(Schema {
1175            structs,
1176            enums,
1177            models,
1178        })
1179    }
1180
1181    /// Resilient parse (#173 WS2c): parse the input into a best-effort
1182    /// [`ParsedSchema`] — a partial [`Schema`] plus **all** diagnostics — instead
1183    /// of aborting on the first error. This is the entry point for the LSP, where
1184    /// a buffer is usually mid-edit and blanking every diagnostic/symbol on a
1185    /// single typo is unacceptable.
1186    ///
1187    /// Recovery is two-tier: a malformed field or model directive is recorded and
1188    /// skipped to the next line (the rest of its model still parses); a malformed
1189    /// *declaration* is recorded and skipped as a whole balanced block (the rest
1190    /// of the file still parses). After the partial AST is assembled it is run
1191    /// through [`crate::validate::validate_schema`], so the returned diagnostics
1192    /// contain both recovered syntax errors and every semantic error, positioned
1193    /// and sorted by source location.
1194    ///
1195    /// Unlike [`Self::parse`], this always succeeds — an empty or unparseable
1196    /// buffer yields an empty schema and (possibly) diagnostics rather than an
1197    /// error. (Lexer errors are still fatal at [`Self::new`]; the LSP surfaces
1198    /// those as a single diagnostic.)
1199    pub fn parse_recover(&mut self) -> ParsedSchema {
1200        enum Decl {
1201            Struct(Struct),
1202            Enum(EnumDef),
1203            Model(Model),
1204        }
1205
1206        let prev_recovering = self.recovering;
1207        self.recovering = true;
1208        self.recovery_diagnostics.clear();
1209
1210        let mut structs = Vec::new();
1211        let mut enums = Vec::new();
1212        let mut models = Vec::new();
1213        let mut enum_names = std::collections::HashSet::new();
1214
1215        self.skip_newlines();
1216        while !matches!(self.current_token(), Token::Eof) {
1217            let decl_start = self.position;
1218            let result = if matches!(self.current_token(), Token::KwStruct) {
1219                self.parse_struct().map(Decl::Struct)
1220            } else if matches!(self.current_token(), Token::KwEnum) {
1221                self.parse_enum().map(Decl::Enum)
1222            } else {
1223                self.parse_model().map(Decl::Model)
1224            };
1225
1226            match result {
1227                Ok(Decl::Struct(s)) => structs.push(s),
1228                Ok(Decl::Enum(e)) => {
1229                    enum_names.insert(e.name.clone());
1230                    enums.push(e);
1231                }
1232                Ok(Decl::Model(m)) => models.push(m),
1233                Err(e) => {
1234                    self.recover_diag(e, decl_start);
1235                    self.synchronize_from(decl_start);
1236                }
1237            }
1238
1239            // Guarantee forward progress even if a parse returned without
1240            // consuming and recovery could not advance.
1241            if self.position == decl_start {
1242                self.advance();
1243            }
1244            self.skip_newlines();
1245        }
1246
1247        // Resolve enum field-type references over whatever models we recovered.
1248        for model in &mut models {
1249            for field in &mut model.fields {
1250                Self::resolve_enum_field_type(&mut field.field_type, &enum_names);
1251            }
1252        }
1253
1254        let schema = Schema {
1255            structs,
1256            enums,
1257            models,
1258        };
1259
1260        let mut diagnostics = std::mem::take(&mut self.recovery_diagnostics);
1261        diagnostics.extend(crate::validate::validate_schema(&schema));
1262        // Present diagnostics in source order (unpositioned ones last).
1263        diagnostics.sort_by_key(|d| {
1264            d.position
1265                .map(|p| (p.line, p.column))
1266                .unwrap_or((usize::MAX, usize::MAX))
1267        });
1268
1269        self.recovering = prev_recovering;
1270        ParsedSchema { schema, diagnostics }
1271    }
1272
1273    /// Rewrite `StructType(name)`/`OptionalStructType(name)` → `Enum(name)`/
1274    /// `Nullable(Enum(name))` when `name` is a declared enum (#enum).  Everything
1275    /// else is left untouched (a real struct reference, or a non-named type).
1276    fn resolve_enum_field_type(
1277        field_type: &mut FieldType,
1278        enum_names: &std::collections::HashSet<String>,
1279    ) {
1280        match field_type {
1281            FieldType::StructType(name) if enum_names.contains(name) => {
1282                *field_type = FieldType::Enum(name.clone());
1283            }
1284            FieldType::OptionalStructType(name) if enum_names.contains(name) => {
1285                *field_type = FieldType::Nullable(Box::new(FieldType::Enum(name.clone())));
1286            }
1287            _ => {}
1288        }
1289    }
1290}
1291
1292#[cfg(test)]
1293mod tests {
1294    use super::*;
1295
1296    fn field_constraints<'a>(schema: &'a Schema, model: &str, field: &str) -> &'a [Constraint] {
1297        let m = schema
1298            .models
1299            .iter()
1300            .find(|m| m.name == model)
1301            .expect("model not found");
1302        let f = m
1303            .fields
1304            .iter()
1305            .find(|f| f.name == field)
1306            .expect("field not found");
1307        &f.constraints
1308    }
1309
1310    /// 2a (epic #173): parsed AST nodes carry the source position of their name,
1311    /// so the LSP and unified validation can map diagnostics to editor ranges.
1312    /// Positions are 1-based line/column.
1313    #[test]
1314    fn parsed_nodes_carry_source_positions() {
1315        // Line-numbered so the expected positions are unambiguous:
1316        // 1: User {          2:   id: +uuid       3:   email: &string   4: }
1317        // 6: struct Point {  7:   x: i32          8: }
1318        // 10: enum Status {  11:   Active         12:   Inactive         13: }
1319        let src = "User {\n  id: +uuid\n  email: &string\n}\n\nstruct Point {\n  x: i32\n}\n\nenum Status {\n  Active\n  Inactive\n}\n";
1320        let schema = Parser::new(src).unwrap().parse().unwrap();
1321
1322        let user = schema.find_model("User").unwrap();
1323        let upos = user.position.expect("model carries a position");
1324        assert_eq!((upos.line, upos.column), (1, 1), "User model name");
1325
1326        let id = user.fields.iter().find(|f| f.name == "id").unwrap();
1327        let idpos = id.position.expect("field carries a position");
1328        assert_eq!((idpos.line, idpos.column), (2, 3), "id field name");
1329
1330        let email = user.fields.iter().find(|f| f.name == "email").unwrap();
1331        assert_eq!(email.position.unwrap().line, 3, "email field line");
1332
1333        let point = schema.find_struct("Point").unwrap();
1334        assert_eq!(point.position.unwrap().line, 6, "Point struct line");
1335
1336        let status = schema.find_enum("Status").unwrap();
1337        assert_eq!(status.position.unwrap().line, 10, "Status enum line");
1338    }
1339
1340    #[test]
1341    fn parses_projection_directive() {
1342        let src = r#"
1343            Post {
1344                id: +uuid
1345                title: string
1346                slug: ^string
1347                content: string
1348
1349                @projection(card: title, slug)
1350                @projection(list_row: title)
1351            }
1352        "#;
1353        let schema = Parser::new(src).unwrap().parse().unwrap();
1354        let post = schema.models.iter().find(|m| m.name == "Post").unwrap();
1355        assert_eq!(post.projections.len(), 2);
1356        assert_eq!(post.projections[0].name, "card");
1357        assert_eq!(post.projections[0].fields, vec!["title", "slug"]);
1358        assert_eq!(post.projections[1].name, "list_row");
1359        assert_eq!(post.projections[1].fields, vec!["title"]);
1360    }
1361
1362    #[test]
1363    fn projection_rejects_unknown_field() {
1364        let src = r#"
1365            Post {
1366                id: +uuid
1367                title: string
1368                @projection(card: title, nope)
1369            }
1370        "#;
1371        let err = Parser::new(src).unwrap().parse().unwrap_err();
1372        assert!(err.contains("undefined field 'nope'"), "got: {err}");
1373    }
1374
1375    #[test]
1376    fn projection_rejects_duplicate_name() {
1377        let src = r#"
1378            Post {
1379                id: +uuid
1380                title: string
1381                slug: string
1382                @projection(card: title)
1383                @projection(card: slug)
1384            }
1385        "#;
1386        let err = Parser::new(src).unwrap().parse().unwrap_err();
1387        assert!(err.contains("Duplicate @projection name 'card'"), "got: {err}");
1388    }
1389
1390    #[test]
1391    fn parses_json_and_nullable_json_types() {
1392        let src = r#"
1393            Event {
1394                id: +uuid
1395                payload: json
1396                meta: json?
1397            }
1398        "#;
1399        let schema = Parser::new(src).unwrap().parse().unwrap();
1400        let m = schema.models.iter().find(|m| m.name == "Event").unwrap();
1401        let payload = m.fields.iter().find(|f| f.name == "payload").unwrap();
1402        let meta = m.fields.iter().find(|f| f.name == "meta").unwrap();
1403        assert_eq!(payload.field_type, FieldType::Json);
1404        assert_eq!(
1405            meta.field_type,
1406            FieldType::Nullable(Box::new(FieldType::Json))
1407        );
1408        // json is variable-length (rides the string column path), not fixed-size.
1409        assert!(!payload.field_type.is_fixed_size());
1410        assert!(!meta.field_type.is_fixed_size());
1411    }
1412
1413    #[test]
1414    fn parses_decimal_and_nullable_decimal_types() {
1415        let src = r#"
1416            Product {
1417                id: +uuid
1418                price: decimal
1419                discount: decimal?
1420            }
1421        "#;
1422        let schema = Parser::new(src).unwrap().parse().unwrap();
1423        let m = schema.models.iter().find(|m| m.name == "Product").unwrap();
1424        let price = m.fields.iter().find(|f| f.name == "price").unwrap();
1425        let discount = m.fields.iter().find(|f| f.name == "discount").unwrap();
1426        assert_eq!(price.field_type, FieldType::Decimal);
1427        assert_eq!(
1428            discount.field_type,
1429            FieldType::Nullable(Box::new(FieldType::Decimal))
1430        );
1431        // decimal is a fixed 16-byte column (like Uuid), NOT variable-length.
1432        assert!(price.field_type.is_fixed_size());
1433        assert!(discount.field_type.is_fixed_size());
1434        assert_eq!(price.field_type.to_rust_type(), "rust_decimal::Decimal");
1435    }
1436
1437    #[test]
1438    fn parses_string_literal_directive_arg() {
1439        let src = r#"
1440            User {
1441                id: +uuid
1442                phone: string @pattern("^[0-9]+$")
1443            }
1444        "#;
1445        let schema = Parser::new(src).unwrap().parse().unwrap();
1446        let cons = field_constraints(&schema, "User", "phone");
1447        assert_eq!(cons.len(), 1);
1448        assert_eq!(cons[0].name, "pattern");
1449        assert_eq!(
1450            cons[0].params,
1451            vec![ConstraintParam::String("^[0-9]+$".to_string())]
1452        );
1453    }
1454
1455    #[test]
1456    fn parses_string_default_on_nullable_string() {
1457        let src = r#"
1458            Ticket {
1459                id: +uuid
1460                status: string? @default("pending")
1461            }
1462        "#;
1463        let schema = Parser::new(src).unwrap().parse().unwrap();
1464        let cons = field_constraints(&schema, "Ticket", "status");
1465        assert_eq!(cons[0].name, "default");
1466        assert_eq!(
1467            cons[0].params,
1468            vec![ConstraintParam::String("pending".to_string())]
1469        );
1470    }
1471
1472    #[test]
1473    fn bare_identifier_default_still_parses() {
1474        // The prior workaround must keep working — no regression.
1475        let src = r#"
1476            Ticket {
1477                id: +uuid
1478                status: string @default(pending)
1479            }
1480        "#;
1481        let schema = Parser::new(src).unwrap().parse().unwrap();
1482        let cons = field_constraints(&schema, "Ticket", "status");
1483        assert_eq!(
1484            cons[0].params,
1485            vec![ConstraintParam::String("pending".to_string())]
1486        );
1487    }
1488
1489    #[test]
1490    fn string_and_number_args_mix() {
1491        let src = r#"
1492            Thing {
1493                id: +uuid
1494                code: string @pattern("[A-Z]{3}") @length(3, 3)
1495            }
1496        "#;
1497        let schema = Parser::new(src).unwrap().parse().unwrap();
1498        let cons = field_constraints(&schema, "Thing", "code");
1499        assert_eq!(cons.len(), 2);
1500        assert_eq!(cons[0].name, "pattern");
1501        assert_eq!(
1502            cons[0].params,
1503            vec![ConstraintParam::String("[A-Z]{3}".to_string())]
1504        );
1505        assert_eq!(cons[1].name, "length");
1506        assert_eq!(
1507            cons[1].params,
1508            vec![ConstraintParam::Number(3), ConstraintParam::Number(3)]
1509        );
1510    }
1511
1512    #[test]
1513    fn parses_enum_and_enum_typed_fields() {
1514        // A top-level enum declaration, a field referencing it by bare name (the
1515        // field is resolved to `FieldType::Enum`), a nullable enum field, and an
1516        // enum declared AFTER the model that uses it (forward reference).
1517        let src = r#"
1518            Order {
1519                id: +uuid
1520                status: Status
1521                prev_status: Status?
1522            }
1523            enum Status { Active, Inactive, Pending }
1524        "#;
1525        let schema = Parser::new(src).unwrap().parse().unwrap();
1526        assert_eq!(schema.enums.len(), 1);
1527        let status_enum = schema.find_enum("Status").unwrap();
1528        assert_eq!(status_enum.variants, vec!["Active", "Inactive", "Pending"]);
1529
1530        let order = schema.models.iter().find(|m| m.name == "Order").unwrap();
1531        let status = order.fields.iter().find(|f| f.name == "status").unwrap();
1532        assert_eq!(status.field_type, FieldType::Enum("Status".to_string()));
1533        let prev = order.fields.iter().find(|f| f.name == "prev_status").unwrap();
1534        assert_eq!(
1535            prev.field_type,
1536            FieldType::Nullable(Box::new(FieldType::Enum("Status".to_string())))
1537        );
1538        // enum is a fixed 1-byte column (not variable-length).
1539        assert!(status.field_type.is_fixed_size());
1540    }
1541
1542    #[test]
1543    fn enum_trailing_comma_optional() {
1544        // Both trailing-comma and no-trailing-comma variant lists parse.
1545        let with = Parser::new("enum A { X, Y, }\nM { id: +uuid }")
1546            .unwrap()
1547            .parse()
1548            .unwrap();
1549        assert_eq!(with.find_enum("A").unwrap().variants, vec!["X", "Y"]);
1550        let without = Parser::new("enum A { X, Y }\nM { id: +uuid }")
1551            .unwrap()
1552            .parse()
1553            .unwrap();
1554        assert_eq!(without.find_enum("A").unwrap().variants, vec!["X", "Y"]);
1555    }
1556
1557    #[test]
1558    fn enum_rejects_duplicate_variant() {
1559        let err = Parser::new("enum Status { Active, Active }\nM { id: +uuid }")
1560            .unwrap()
1561            .parse()
1562            .unwrap_err();
1563        assert!(err.contains("Duplicate variant 'Active'"), "got: {err}");
1564    }
1565
1566    #[test]
1567    fn enum_rejects_lowercase_variant() {
1568        // Variants must be PascalCase.
1569        let err = Parser::new("enum Status { active }\nM { id: +uuid }")
1570            .unwrap()
1571            .parse()
1572            .unwrap_err();
1573        assert!(err.contains("PascalCase"), "got: {err}");
1574    }
1575
1576    #[test]
1577    fn enum_rejects_empty() {
1578        let err = Parser::new("enum Status { }\nM { id: +uuid }")
1579            .unwrap()
1580            .parse()
1581            .unwrap_err();
1582        assert!(err.contains("no variants"), "got: {err}");
1583    }
1584
1585    #[test]
1586    fn field_referencing_undefined_named_type_errors() {
1587        // A bare PascalCase identifier that is neither a declared enum nor struct
1588        // is an unknown named type.
1589        let err = Parser::new("Order { id: +uuid\n status: Nope }")
1590            .unwrap()
1591            .parse()
1592            .unwrap_err();
1593        assert!(err.contains("unknown type 'Nope'"), "got: {err}");
1594    }
1595
1596    #[test]
1597    fn enum_rejects_duplicate_declaration() {
1598        let err = Parser::new("enum S { A }\nenum S { B }\nM { id: +uuid }")
1599            .unwrap()
1600            .parse()
1601            .unwrap_err();
1602        assert!(err.contains("Duplicate enum name 'S'"), "got: {err}");
1603    }
1604
1605    // ---- #173 WS2c: resilient parse (`parse_recover`) --------------------------
1606
1607    fn recover(input: &str) -> ParsedSchema {
1608        Parser::new(input).unwrap().parse_recover()
1609    }
1610
1611    /// A valid schema parses identically under recovery, with no diagnostics —
1612    /// parity with the fatal path.
1613    #[test]
1614    fn recover_valid_schema_matches_parse() {
1615        let input = "User {\n  id: +uuid\n  email: string\n}\nPost {\n  id: +uuid\n}\n";
1616        let recovered = recover(input);
1617        assert!(recovered.diagnostics.is_empty(), "no diagnostics: {:?}", recovered.diagnostics);
1618        assert_eq!(recovered.schema, Parser::new(input).unwrap().parse().unwrap());
1619    }
1620
1621    /// Empty / whitespace-only input is not an error under recovery (unlike the
1622    /// fatal path, which rejects an empty schema).
1623    #[test]
1624    fn recover_empty_input_is_not_an_error() {
1625        let recovered = recover("\n  \n");
1626        assert!(recovered.diagnostics.is_empty());
1627        assert!(recovered.schema.models.is_empty());
1628    }
1629
1630    /// Declaration-level recovery: a header with no opening brace is reported, and
1631    /// the valid declarations on either side of it both survive.
1632    #[test]
1633    fn recover_skips_a_broken_declaration_and_keeps_the_rest() {
1634        let input = "\
1635User {
1636  id: +uuid
1637}
1638
1639Broken
1640  id: +uuid
1641
1642Post {
1643  id: +uuid
1644}
1645";
1646        let recovered = recover(input);
1647        let names: Vec<&str> = recovered.schema.models.iter().map(|m| m.name.as_str()).collect();
1648        assert!(names.contains(&"User"), "User survived: {names:?}");
1649        assert!(names.contains(&"Post"), "Post survived after recovery: {names:?}");
1650        assert!(
1651            recovered.diagnostics.iter().any(|d| d.position.is_some()),
1652            "the broken declaration produced a positioned diagnostic"
1653        );
1654    }
1655
1656    /// Field-level recovery: one malformed field does not blank out its model —
1657    /// the surrounding good fields still parse, and the model is still produced.
1658    #[test]
1659    fn recover_skips_a_broken_field_and_keeps_the_model() {
1660        let input = "User {\n  id: +uuid\n  @@@ : broken\n  email: string\n}\n";
1661        let recovered = recover(input);
1662        let user = recovered
1663            .schema
1664            .find_model("User")
1665            .expect("User model still produced despite a bad field");
1666        let fields: Vec<&str> = user.fields.iter().map(|f| f.name.as_str()).collect();
1667        assert!(fields.contains(&"id") && fields.contains(&"email"), "good fields survived: {fields:?}");
1668        assert!(!recovered.diagnostics.is_empty(), "the bad field was reported");
1669    }
1670
1671    /// Recovered partial AST is still run through `validate_schema`, so semantic
1672    /// diagnostics (here a dangling relation) are merged with syntax diagnostics.
1673    #[test]
1674    fn recover_merges_semantic_diagnostics() {
1675        let input = "User {\n  id: +uuid\n  pet: *Ghost\n}\n";
1676        let recovered = recover(input);
1677        assert!(
1678            recovered
1679                .diagnostics
1680                .iter()
1681                .any(|d| d.message.contains("references undefined model 'Ghost'")),
1682            "semantic diagnostics included: {:?}",
1683            recovered.diagnostics
1684        );
1685    }
1686
1687    /// Diagnostics come back ordered by source position.
1688    #[test]
1689    fn recover_diagnostics_are_sorted_by_position() {
1690        // Two dangling relations on different lines + a snake_case violation.
1691        let input = "User {\n  id: +uuid\n  BadName: string\n  a: *Nope\n}\n";
1692        let recovered = recover(input);
1693        let lines: Vec<usize> = recovered
1694            .diagnostics
1695            .iter()
1696            .filter_map(|d| d.position.map(|p| p.line))
1697            .collect();
1698        let mut sorted = lines.clone();
1699        sorted.sort();
1700        assert_eq!(lines, sorted, "diagnostics sorted by line: {lines:?}");
1701    }
1702
1703    /// A mid-keystroke buffer with an unterminated block terminates (no infinite
1704    /// loop) and yields a diagnostic rather than hanging or panicking.
1705    #[test]
1706    fn recover_handles_unterminated_block() {
1707        let recovered = recover("User {\n  id: +uuid\n  email:");
1708        assert!(!recovered.diagnostics.is_empty(), "unterminated buffer is diagnosed");
1709    }
1710
1711    /// Multiple independent broken declarations each produce a diagnostic and the
1712    /// good one between them survives.
1713    #[test]
1714    fn recover_reports_multiple_broken_declarations() {
1715        let input = "\
1716A
1717  x: u32
1718
1719Good {
1720  id: +uuid
1721}
1722
1723B
1724  y: u32
1725";
1726        let recovered = recover(input);
1727        assert!(recovered.schema.find_model("Good").is_some(), "middle valid model survived");
1728        assert!(
1729            recovered.diagnostics.len() >= 2,
1730            "both broken declarations reported: {:?}",
1731            recovered.diagnostics
1732        );
1733    }
1734}