Skip to main content

forgedb_parser/parser/
core.rs

1use crate::ast::{
2    ComponentProtocol, ComponentReference, CompositeIndex, Constraint, ConstraintParam, Field,
3    FieldType, Model, RelationInclusion, RelationType, Schema, Struct,
4};
5use crate::lexer::{Lexer, Token, TokenWithPos};
6use forgedb_validation::{validate_field_name, validate_model_name, Position};
7
8pub struct Parser {
9    tokens: Vec<Token>,
10    tokens_with_pos: Vec<TokenWithPos>,
11    position: usize,
12    use_validation: bool,
13}
14
15impl Parser {
16    pub fn new(input: &str) -> Result<Self, String> {
17        Self::new_with_validation(input, true)
18    }
19
20    pub fn new_with_validation(input: &str, use_validation: bool) -> Result<Self, String> {
21        let mut lexer = Lexer::new(input);
22        let tokens = lexer.tokenize()?;
23
24        let mut lexer = Lexer::new(input);
25        let tokens_with_pos = lexer.tokenize_with_pos()?;
26
27        Ok(Parser {
28            tokens,
29            tokens_with_pos,
30            position: 0,
31            use_validation,
32        })
33    }
34
35    fn get_current_position(&self) -> Option<Position> {
36        if self.position < self.tokens_with_pos.len() {
37            Some(self.tokens_with_pos[self.position].position)
38        } else {
39            None
40        }
41    }
42
43    fn current_token(&self) -> &Token {
44        if self.position < self.tokens.len() {
45            &self.tokens[self.position]
46        } else {
47            &Token::Eof
48        }
49    }
50
51    fn advance(&mut self) {
52        if self.position < self.tokens.len() {
53            self.position += 1;
54        }
55    }
56
57    fn skip_newlines(&mut self) {
58        while matches!(self.current_token(), Token::Newline) {
59            self.advance();
60        }
61    }
62
63    fn read_component_path(&mut self) -> Result<String, String> {
64        // Read a path like: components/user/card
65        // This is a sequence of identifiers separated by slashes
66        let mut path = String::new();
67
68        loop {
69            match self.current_token() {
70                Token::Ident(name) => {
71                    path.push_str(name);
72                    self.advance();
73
74                    // Check if there's a slash for more path segments
75                    if matches!(self.current_token(), Token::Slash) {
76                        path.push('/');
77                        self.advance();
78                        // Continue to read next segment
79                    } else {
80                        // End of path
81                        break;
82                    }
83                }
84                _ => {
85                    return Err(format!(
86                        "Expected path component (identifier), found {:?}",
87                        self.current_token()
88                    ))
89                }
90            }
91        }
92
93        if path.is_empty() {
94            return Err("Component path cannot be empty".to_string());
95        }
96
97        Ok(path)
98    }
99
100    fn expect(&mut self, expected: Token) -> Result<(), String> {
101        if self.current_token() == &expected {
102            self.advance();
103            Ok(())
104        } else {
105            Err(format!(
106                "Expected {:?}, found {:?}",
107                expected,
108                self.current_token()
109            ))
110        }
111    }
112
113    fn parse_constraint(&mut self) -> Result<Constraint, String> {
114        // Expect @
115        self.expect(Token::At)?;
116
117        // Parse constraint name
118        let name = match self.current_token() {
119            Token::Ident(s) => s.clone(),
120            _ => {
121                return Err(format!(
122                    "Expected constraint name after '@', found {:?}",
123                    self.current_token()
124                ))
125            }
126        };
127        self.advance();
128
129        let mut constraint = Constraint::new(name);
130
131        // Check for parameters
132        if matches!(self.current_token(), Token::LParen) {
133            self.advance();
134
135            // Parse parameters
136            loop {
137                match self.current_token() {
138                    Token::Number(n) => {
139                        constraint = constraint.with_param(ConstraintParam::Number(*n));
140                        self.advance();
141                    }
142                    Token::Ident(s) => {
143                        constraint = constraint.with_param(ConstraintParam::String(s.clone()));
144                        self.advance();
145                    }
146                    Token::Str(s) => {
147                        constraint = constraint.with_param(ConstraintParam::String(s.clone()));
148                        self.advance();
149                    }
150                    Token::Asterisk => {
151                        // Special case for @relations(*) syntax
152                        constraint =
153                            constraint.with_param(ConstraintParam::String("*".to_string()));
154                        self.advance();
155                    }
156                    _ => {
157                        return Err(format!(
158                            "Expected constraint parameter, found {:?}",
159                            self.current_token()
160                        ))
161                    }
162                }
163
164                // Check for comma (more params) or closing paren
165                match self.current_token() {
166                    Token::Comma => {
167                        self.advance();
168                        continue;
169                    }
170                    Token::RParen => {
171                        self.advance();
172                        break;
173                    }
174                    _ => {
175                        return Err(format!(
176                            "Expected ',' or ')' in constraint parameters, found {:?}",
177                            self.current_token()
178                        ))
179                    }
180                }
181            }
182        }
183
184        Ok(constraint)
185    }
186
187    fn parse_type(&mut self) -> Result<FieldType, String> {
188        // Check for relation types first
189        match self.current_token() {
190            // Fixed array or One-to-many: [type; count] or [Post]
191            Token::LBracket => {
192                self.advance();
193
194                // Check if this is a fixed array [type; count] or one-to-many [Model]
195                let first_token = self.current_token().clone();
196
197                match first_token {
198                    Token::Ident(name) => {
199                        self.advance();
200
201                        // Check next token to distinguish [Model] vs [type; count]
202                        match self.current_token() {
203                            Token::Semicolon => {
204                                // This is [Ident; count] - but Ident should be a type or struct
205                                self.advance();
206                                let count = match self.current_token() {
207                                    Token::Number(n) => *n as usize,
208                                    _ => {
209                                        return Err(format!(
210                                            "Expected array count after ';', found {:?}",
211                                            self.current_token()
212                                        ))
213                                    }
214                                };
215                                self.advance();
216                                self.expect(Token::RBracket)?;
217                                // The Ident could be a struct type
218                                return Ok(FieldType::FixedArray(
219                                    Box::new(FieldType::StructType(name)),
220                                    count,
221                                ));
222                            }
223                            Token::RBracket => {
224                                // This is [Model] - one-to-many relation
225                                self.advance();
226                                return Ok(FieldType::Relation(RelationType::OneToMany(name)));
227                            }
228                            _ => {
229                                return Err(format!(
230                                    "Expected ';' or ']' after type name, found {:?}",
231                                    self.current_token()
232                                ))
233                            }
234                        }
235                    }
236                    _ => {
237                        // Parse base type for fixed array
238                        let inner_type = self.parse_primitive_type()?;
239                        self.expect(Token::Semicolon)?;
240                        let count = match self.current_token() {
241                            Token::Number(n) => *n as usize,
242                            _ => {
243                                return Err(format!(
244                                    "Expected array count, found {:?}",
245                                    self.current_token()
246                                ))
247                            }
248                        };
249                        self.advance();
250                        self.expect(Token::RBracket)?;
251                        return Ok(FieldType::FixedArray(Box::new(inner_type), count));
252                    }
253                }
254            }
255            // Required reference: *User
256            Token::Asterisk => {
257                self.advance();
258                let model_name = match self.current_token() {
259                    Token::Ident(name) => name.clone(),
260                    _ => {
261                        return Err(format!(
262                            "Expected model name after '*', found {:?}",
263                            self.current_token()
264                        ))
265                    }
266                };
267                self.advance();
268                return Ok(FieldType::Relation(RelationType::RequiredReference(
269                    model_name,
270                )));
271            }
272            // Optional reference or nullable primitive: ?User  or  ?i32
273            Token::Question => {
274                self.advance();
275                match self.current_token().clone() {
276                    Token::Ident(name) => {
277                        self.advance();
278                        return Ok(FieldType::Relation(RelationType::OptionalReference(name)));
279                    }
280                    // Nullable primitive types: ?i32, ?string, ?bool, etc.
281                    Token::TypeU32
282                    | Token::TypeU64
283                    | Token::TypeI32
284                    | Token::TypeI64
285                    | Token::TypeF64
286                    | Token::TypeBool
287                    | Token::TypeString
288                    | Token::TypeUuid
289                    | Token::TypeTimestamp
290                    | Token::TypeChar => {
291                        let inner = self.parse_primitive_type()?;
292                        return Ok(FieldType::Nullable(Box::new(inner)));
293                    }
294                    _ => {
295                        return Err(format!(
296                            "Expected model name or primitive type after '?', found {:?}",
297                            self.current_token()
298                        ))
299                    }
300                }
301            }
302            // Struct types, component references, or primitive identifiers
303            Token::Ident(name) => {
304                let type_name = name.clone();
305                self.advance();
306
307                // Check if this is a component protocol (tsx://, jsx://, api://)
308                if matches!(type_name.as_str(), "tsx" | "jsx" | "api")
309                    && matches!(self.current_token(), Token::Colon)
310                {
311                    // Parse component reference: tsx://path
312                    self.advance(); // skip :
313                    self.skip_newlines(); // Skip any newlines after colon
314
315                    // Expect two slashes
316                    self.expect(Token::Slash)?;
317                    self.expect(Token::Slash)?;
318
319                    // Read the component path
320                    let path = self.read_component_path()?;
321
322                    // Determine protocol
323                    let protocol = match type_name.as_str() {
324                        "tsx" => ComponentProtocol::Tsx,
325                        "jsx" => ComponentProtocol::Jsx,
326                        "api" => ComponentProtocol::Api,
327                        _ => unreachable!(),
328                    };
329
330                    return Ok(FieldType::Component(ComponentReference {
331                        protocol,
332                        path,
333                        relations: RelationInclusion::None,
334                    }));
335                }
336
337                // This could be a struct type or struct? for optional
338                return Ok(FieldType::StructType(type_name));
339            }
340            _ => {}
341        }
342
343        // Primitive types
344        self.parse_primitive_type()
345    }
346
347    fn parse_primitive_type(&mut self) -> Result<FieldType, String> {
348        let field_type = match self.current_token() {
349            Token::TypeU32 => FieldType::U32,
350            Token::TypeU64 => FieldType::U64,
351            Token::TypeI32 => FieldType::I32,
352            Token::TypeI64 => FieldType::I64,
353            Token::TypeF64 => FieldType::F64,
354            Token::TypeBool => FieldType::Bool,
355            Token::TypeString => FieldType::String,
356            Token::TypeUuid => FieldType::Uuid,
357            Token::TypeTimestamp => FieldType::Timestamp,
358            Token::TypeChar => {
359                // char(N) - expect (N)
360                self.advance();
361                self.expect(Token::LParen)?;
362                let size = match self.current_token() {
363                    Token::Number(n) => *n as usize,
364                    _ => {
365                        return Err(format!(
366                            "Expected size after 'char(', found {:?}",
367                            self.current_token()
368                        ))
369                    }
370                };
371                self.advance();
372                self.expect(Token::RParen)?;
373                return Ok(FieldType::Char(size));
374            }
375            _ => return Err(format!("Expected type, found {:?}", self.current_token())),
376        };
377        self.advance();
378        Ok(field_type)
379    }
380
381    fn parse_directive(&mut self) -> Result<CompositeIndex, String> {
382        // Expect @
383        self.expect(Token::At)?;
384
385        // Get directive name
386        let directive_name = match self.current_token() {
387            Token::Ident(s) => s.clone(),
388            _ => {
389                return Err(format!(
390                    "Expected directive name after '@', found {:?}",
391                    self.current_token()
392                ))
393            }
394        };
395        self.advance();
396
397        match directive_name.as_str() {
398            "index" => self.parse_index_directive(),
399            _ => Err(format!("Unknown directive: @{}", directive_name)),
400        }
401    }
402
403    fn parse_index_directive(&mut self) -> Result<CompositeIndex, String> {
404        // Expect (
405        self.expect(Token::LParen)?;
406
407        // Parse field list
408        let mut fields = Vec::new();
409        loop {
410            // Parse field name
411            let field_name = match self.current_token() {
412                Token::Ident(s) => s.clone(),
413                _ => {
414                    return Err(format!(
415                        "Expected field name in @index directive, found {:?}",
416                        self.current_token()
417                    ))
418                }
419            };
420            self.advance();
421            fields.push(field_name);
422
423            // Check for comma or closing paren
424            match self.current_token() {
425                Token::Comma => {
426                    self.advance();
427                    // Continue loop to parse next field
428                }
429                Token::RParen => {
430                    self.advance();
431                    break;
432                }
433                _ => {
434                    return Err(format!(
435                        "Expected ',' or ')' in @index directive, found {:?}",
436                        self.current_token()
437                    ))
438                }
439            }
440        }
441
442        if fields.len() < 2 {
443            return Err("Composite index must include at least 2 fields".to_string());
444        }
445
446        Ok(CompositeIndex { fields })
447    }
448
449    fn parse_field(&mut self) -> Result<Field, String> {
450        self.skip_newlines();
451
452        // Parse field name
453        let field_pos = self.get_current_position();
454        let name = match self.current_token() {
455            Token::Ident(s) => s.clone(),
456            _ => {
457                return Err(format!(
458                    "Expected field name, found {:?}",
459                    self.current_token()
460                ))
461            }
462        };
463        self.advance();
464
465        // Validate field name
466        if self.use_validation {
467            if let Err(e) = validate_field_name(&name, field_pos) {
468                return Err(e.to_string());
469            }
470        }
471
472        // Expect colon
473        self.expect(Token::Colon)?;
474
475        // Check for symbols (+, &, and ^)
476        let mut auto_generate = false;
477        let mut unique = false;
478        let mut indexed = false;
479
480        loop {
481            match self.current_token() {
482                Token::Plus => {
483                    auto_generate = true;
484                    self.advance();
485                }
486                Token::Ampersand => {
487                    unique = true;
488                    self.advance();
489                }
490                Token::Caret => {
491                    indexed = true;
492                    self.advance();
493                }
494                _ => break,
495            }
496        }
497
498        // Parse type
499        let mut field_type = self.parse_type()?;
500
501        // Check for postfix nullable marker (Type?)
502        if matches!(self.current_token(), Token::Question) {
503            match field_type {
504                FieldType::StructType(ref name) => {
505                    let name = name.clone();
506                    self.advance();
507                    field_type = FieldType::OptionalStructType(name);
508                }
509                // Nullable primitives with postfix `?`: e.g. `age: i32?`
510                FieldType::U32
511                | FieldType::U64
512                | FieldType::I32
513                | FieldType::I64
514                | FieldType::F64
515                | FieldType::Bool
516                | FieldType::String
517                | FieldType::Uuid
518                | FieldType::Timestamp
519                | FieldType::Char(_)
520                | FieldType::FixedArray(_, _) => {
521                    let inner = field_type.clone();
522                    self.advance();
523                    field_type = FieldType::Nullable(Box::new(inner));
524                }
525                _ => {} // Don't consume `?` for relations or other types
526            }
527        }
528
529        // Validate auto-generate is compatible with type
530        if auto_generate && !field_type.is_auto_generatable() {
531            return Err(format!(
532                "Auto-generate symbol '+' cannot be used with type {:?}. Only u32, u64, uuid, and timestamp support auto-generation",
533                field_type
534            ));
535        }
536
537        // Parse constraints (@directives)
538        let mut constraints = Vec::new();
539        let mut is_computed = false;
540        let mut fulltext_indexed = false;
541        let mut is_materialized = false;
542        while matches!(self.current_token(), Token::At) {
543            let constraint = self.parse_constraint()?;
544            // Check if this is the @relations directive (Sprint 17 - for components)
545            if constraint.name == "relations" {
546                if let FieldType::Component(ref mut comp_ref) = field_type {
547                    // Parse the @relations parameters
548                    if constraint.params.is_empty() {
549                        return Err("@relations directive requires parameters".to_string());
550                    }
551
552                    // Check if it's @relations(*)
553                    if constraint.params.len() == 1 {
554                        if let ConstraintParam::String(s) = &constraint.params[0] {
555                            if s == "*" {
556                                comp_ref.relations = RelationInclusion::All;
557                            } else {
558                                // Single relation field
559                                comp_ref.relations = RelationInclusion::Specific(vec![s.clone()]);
560                            }
561                        } else {
562                            return Err(
563                                "@relations parameter must be a field name or *".to_string()
564                            );
565                        }
566                    } else {
567                        // Multiple specific relations
568                        let mut fields = Vec::new();
569                        for param in &constraint.params {
570                            if let ConstraintParam::String(field_name) = param {
571                                fields.push(field_name.clone());
572                            } else {
573                                return Err("@relations parameters must be field names".to_string());
574                            }
575                        }
576                        comp_ref.relations = RelationInclusion::Specific(fields);
577                    }
578                    // Don't add @relations to constraints list since we've handled it
579                    continue;
580                } else {
581                    return Err(
582                        "@relations directive can only be used with component fields".to_string(),
583                    );
584                }
585            }
586            // Check if this is the @computed directive
587            if constraint.name == "computed" {
588                is_computed = true;
589            }
590            // Check if this is the @fulltext directive (Sprint 18)
591            if constraint.name == "fulltext" {
592                fulltext_indexed = true;
593            }
594            // Check if this is the @materialized directive (Sprint 19)
595            if constraint.name == "materialized" {
596                is_materialized = true;
597            }
598            constraints.push(constraint);
599        }
600
601        // Determine index type based on field type
602        let index_type = field_type.default_index_type();
603
604        Ok(Field {
605            name,
606            field_type,
607            auto_generate,
608            unique,
609            indexed,
610            constraints,
611            index_type,
612            is_computed,
613            fulltext_indexed,
614            is_materialized,
615        })
616    }
617
618    fn parse_struct(&mut self) -> Result<Struct, String> {
619        self.skip_newlines();
620
621        // Expect 'struct' keyword
622        self.expect(Token::KwStruct)?;
623
624        // Parse struct name
625        let struct_pos = self.get_current_position();
626        let name = match self.current_token() {
627            Token::Ident(s) => s.clone(),
628            _ => {
629                return Err(format!(
630                    "Expected struct name, found {:?}",
631                    self.current_token()
632                ))
633            }
634        };
635        self.advance();
636
637        // Validate struct name
638        if self.use_validation {
639            if let Err(e) = validate_model_name(&name, struct_pos) {
640                return Err(e.to_string());
641            }
642        }
643
644        // Expect opening brace
645        self.skip_newlines();
646        self.expect(Token::LBrace)?;
647
648        // Parse fields
649        let mut fields = Vec::new();
650        let mut field_names = std::collections::HashSet::new();
651        self.skip_newlines();
652
653        while !matches!(self.current_token(), Token::RBrace | Token::Eof) {
654            let field = self.parse_field()?;
655
656            // Check for duplicate field name
657            if field_names.contains(&field.name) {
658                return Err(format!(
659                    "Duplicate field name '{}' in struct '{}'",
660                    field.name, name
661                ));
662            }
663            field_names.insert(field.name.clone());
664
665            fields.push(field);
666            self.skip_newlines();
667        }
668
669        // Expect closing brace
670        self.expect(Token::RBrace)?;
671
672        if fields.is_empty() {
673            return Err(format!("Struct '{}' has no fields", name));
674        }
675
676        Ok(Struct { name, fields })
677    }
678
679    fn parse_model(&mut self) -> Result<Model, String> {
680        self.skip_newlines();
681
682        // Parse model name
683        let model_pos = self.get_current_position();
684        let name = match self.current_token() {
685            Token::Ident(s) => s.clone(),
686            _ => {
687                return Err(format!(
688                    "Expected model name, found {:?}",
689                    self.current_token()
690                ))
691            }
692        };
693        self.advance();
694
695        // Validate model name
696        if self.use_validation {
697            if let Err(e) = validate_model_name(&name, model_pos) {
698                return Err(e.to_string());
699            }
700        }
701
702        // Expect opening brace
703        self.skip_newlines();
704        self.expect(Token::LBrace)?;
705
706        // Parse fields and directives
707        let mut fields = Vec::new();
708        let mut field_names = std::collections::HashSet::new();
709        let mut composite_indexes = Vec::new();
710        let mut soft_delete = false;
711        self.skip_newlines();
712
713        while !matches!(self.current_token(), Token::RBrace | Token::Eof) {
714            // Check for directive
715            if matches!(self.current_token(), Token::At) {
716                // Try to parse as composite index first
717                let start_pos = self.position;
718                match self.parse_directive() {
719                    Ok(composite_index) => {
720                        composite_indexes.push(composite_index);
721                        self.skip_newlines();
722                    }
723                    Err(_) => {
724                        // Reset position and try to parse as model-level directive
725                        self.position = start_pos;
726                        self.advance(); // skip @
727                        let directive_name = match self.current_token() {
728                            Token::Ident(s) => s.clone(),
729                            _ => {
730                                return Err(format!(
731                                    "Expected directive name after '@', found {:?}",
732                                    self.current_token()
733                                ))
734                            }
735                        };
736                        self.advance();
737
738                        match directive_name.as_str() {
739                            "soft_delete" => {
740                                soft_delete = true;
741                                self.skip_newlines();
742                            }
743                            _ => {
744                                return Err(format!(
745                                    "Unknown model directive: @{}",
746                                    directive_name
747                                ));
748                            }
749                        }
750                    }
751                }
752            } else {
753                let field = self.parse_field()?;
754
755                // Check for duplicate field name
756                if field_names.contains(&field.name) {
757                    return Err(format!(
758                        "Duplicate field name '{}' in model '{}'",
759                        field.name, name
760                    ));
761                }
762                field_names.insert(field.name.clone());
763
764                fields.push(field);
765                self.skip_newlines();
766            }
767        }
768
769        // Expect closing brace
770        self.expect(Token::RBrace)?;
771
772        if fields.is_empty() {
773            return Err(format!("Model '{}' has no fields", name));
774        }
775
776        // Validate composite indexes reference existing fields
777        for comp_idx in &composite_indexes {
778            for field_name in &comp_idx.fields {
779                if !field_names.contains(field_name) {
780                    return Err(format!(
781                        "Composite index in model '{}' references undefined field '{}'",
782                        name, field_name
783                    ));
784                }
785            }
786        }
787
788        Ok(Model {
789            name,
790            fields,
791            composite_indexes,
792            soft_delete,
793        })
794    }
795
796    pub fn parse(&mut self) -> Result<Schema, String> {
797        let mut structs = Vec::new();
798        let mut models = Vec::new();
799        let mut struct_names = std::collections::HashSet::new();
800        let mut model_names = std::collections::HashSet::new();
801        self.skip_newlines();
802
803        while !matches!(self.current_token(), Token::Eof) {
804            // Check if this is a struct or model declaration
805            if matches!(self.current_token(), Token::KwStruct) {
806                let struct_def = self.parse_struct()?;
807
808                // Check for duplicate struct name
809                if struct_names.contains(&struct_def.name) {
810                    return Err(format!("Duplicate struct name '{}'", struct_def.name));
811                }
812                struct_names.insert(struct_def.name.clone());
813
814                structs.push(struct_def);
815            } else {
816                let model = self.parse_model()?;
817
818                // Check for duplicate model name
819                if model_names.contains(&model.name) {
820                    return Err(format!("Duplicate model name '{}'", model.name));
821                }
822                model_names.insert(model.name.clone());
823
824                models.push(model);
825            }
826
827            self.skip_newlines();
828        }
829
830        if models.is_empty() && structs.is_empty() {
831            return Err("Schema is empty".to_string());
832        }
833
834        let schema = Schema { structs, models };
835
836        // Validate relations and struct references
837        schema.validate_relations()?;
838        schema.validate_struct_references()?;
839
840        Ok(schema)
841    }
842}
843
844#[cfg(test)]
845mod tests {
846    use super::*;
847
848    fn field_constraints<'a>(schema: &'a Schema, model: &str, field: &str) -> &'a [Constraint] {
849        let m = schema
850            .models
851            .iter()
852            .find(|m| m.name == model)
853            .expect("model not found");
854        let f = m
855            .fields
856            .iter()
857            .find(|f| f.name == field)
858            .expect("field not found");
859        &f.constraints
860    }
861
862    #[test]
863    fn parses_string_literal_directive_arg() {
864        let src = r#"
865            User {
866                id: +uuid
867                phone: string @pattern("^[0-9]+$")
868            }
869        "#;
870        let schema = Parser::new(src).unwrap().parse().unwrap();
871        let cons = field_constraints(&schema, "User", "phone");
872        assert_eq!(cons.len(), 1);
873        assert_eq!(cons[0].name, "pattern");
874        assert_eq!(
875            cons[0].params,
876            vec![ConstraintParam::String("^[0-9]+$".to_string())]
877        );
878    }
879
880    #[test]
881    fn parses_string_default_on_nullable_string() {
882        let src = r#"
883            Ticket {
884                id: +uuid
885                status: string? @default("pending")
886            }
887        "#;
888        let schema = Parser::new(src).unwrap().parse().unwrap();
889        let cons = field_constraints(&schema, "Ticket", "status");
890        assert_eq!(cons[0].name, "default");
891        assert_eq!(
892            cons[0].params,
893            vec![ConstraintParam::String("pending".to_string())]
894        );
895    }
896
897    #[test]
898    fn bare_identifier_default_still_parses() {
899        // The prior workaround must keep working — no regression.
900        let src = r#"
901            Ticket {
902                id: +uuid
903                status: string @default(pending)
904            }
905        "#;
906        let schema = Parser::new(src).unwrap().parse().unwrap();
907        let cons = field_constraints(&schema, "Ticket", "status");
908        assert_eq!(
909            cons[0].params,
910            vec![ConstraintParam::String("pending".to_string())]
911        );
912    }
913
914    #[test]
915    fn string_and_number_args_mix() {
916        let src = r#"
917            Thing {
918                id: +uuid
919                code: string @pattern("[A-Z]{3}") @length(3, 3)
920            }
921        "#;
922        let schema = Parser::new(src).unwrap().parse().unwrap();
923        let cons = field_constraints(&schema, "Thing", "code");
924        assert_eq!(cons.len(), 2);
925        assert_eq!(cons[0].name, "pattern");
926        assert_eq!(
927            cons[0].params,
928            vec![ConstraintParam::String("[A-Z]{3}".to_string())]
929        );
930        assert_eq!(cons[1].name, "length");
931        assert_eq!(
932            cons[1].params,
933            vec![ConstraintParam::Number(3), ConstraintParam::Number(3)]
934        );
935    }
936}