Skip to main content

omnigraph_compiler/schema/
parser.rs

1use std::collections::HashMap;
2
3use pest::Parser;
4use pest::error::InputLocation;
5use pest_derive::Parser;
6
7use crate::error::{
8    NanoError, ParseDiagnostic, Result, SourceSpan, decode_string_literal, render_span,
9};
10use crate::types::{PropType, ScalarType};
11
12use super::ast::*;
13
14#[derive(Parser)]
15#[grammar = "schema/schema.pest"]
16struct SchemaParser;
17
18pub fn parse_schema(input: &str) -> Result<SchemaFile> {
19    parse_schema_diagnostic(input).map_err(|e| NanoError::Parse(e.to_string()))
20}
21
22pub fn parse_schema_diagnostic(input: &str) -> std::result::Result<SchemaFile, ParseDiagnostic> {
23    let pairs = SchemaParser::parse(Rule::schema_file, input).map_err(pest_error_to_diagnostic)?;
24
25    let mut declarations = Vec::new();
26    for pair in pairs {
27        if pair.as_rule() == Rule::schema_file {
28            for inner in pair.into_inner() {
29                if let Rule::schema_decl = inner.as_rule() {
30                    declarations.push(parse_schema_decl(inner).map_err(nano_error_to_diagnostic)?);
31                }
32            }
33        }
34    }
35
36    // Collect interfaces for resolution (clone to avoid borrow conflict)
37    let interfaces: Vec<InterfaceDecl> = declarations
38        .iter()
39        .filter_map(|d| match d {
40            SchemaDecl::Interface(i) => Some(i.clone()),
41            _ => None,
42        })
43        .collect();
44
45    // Resolve implements clauses on nodes
46    let iface_refs: Vec<&InterfaceDecl> = interfaces.iter().collect();
47    for decl in &mut declarations {
48        if let SchemaDecl::Node(node) = decl {
49            resolve_interfaces(node, &iface_refs).map_err(nano_error_to_diagnostic)?;
50        }
51    }
52
53    let schema = SchemaFile { declarations };
54    validate_schema_annotations(&schema).map_err(nano_error_to_diagnostic)?;
55    validate_constraints(&schema).map_err(nano_error_to_diagnostic)?;
56    Ok(schema)
57}
58
59fn pest_error_to_diagnostic(err: pest::error::Error<Rule>) -> ParseDiagnostic {
60    let span = match err.location {
61        InputLocation::Pos(pos) => Some(render_span(SourceSpan::new(pos, pos))),
62        InputLocation::Span((start, end)) => Some(render_span(SourceSpan::new(start, end))),
63    };
64    ParseDiagnostic::new(err.to_string(), span)
65}
66
67fn nano_error_to_diagnostic(err: NanoError) -> ParseDiagnostic {
68    ParseDiagnostic::new(err.to_string(), None)
69}
70
71fn parse_schema_decl(pair: pest::iterators::Pair<Rule>) -> Result<SchemaDecl> {
72    let inner = pair.into_inner().next().unwrap();
73    match inner.as_rule() {
74        Rule::interface_decl => Ok(SchemaDecl::Interface(parse_interface_decl(inner)?)),
75        Rule::node_decl => Ok(SchemaDecl::Node(parse_node_decl(inner)?)),
76        Rule::edge_decl => Ok(SchemaDecl::Edge(parse_edge_decl(inner)?)),
77        _ => Err(NanoError::Parse(format!(
78            "unexpected rule: {:?}",
79            inner.as_rule()
80        ))),
81    }
82}
83
84fn parse_interface_decl(pair: pest::iterators::Pair<Rule>) -> Result<InterfaceDecl> {
85    let mut inner = pair.into_inner();
86    let name = inner.next().unwrap().as_str().to_string();
87
88    let mut properties = Vec::new();
89    for item in inner {
90        if let Rule::prop_decl = item.as_rule() {
91            properties.push(parse_prop_decl(item)?);
92        }
93    }
94
95    Ok(InterfaceDecl { name, properties })
96}
97
98fn parse_node_decl(pair: pest::iterators::Pair<Rule>) -> Result<NodeDecl> {
99    let mut inner = pair.into_inner();
100    let name = inner.next().unwrap().as_str().to_string();
101
102    let mut annotations = Vec::new();
103    let mut implements = Vec::new();
104    let mut properties = Vec::new();
105    let mut constraints = Vec::new();
106
107    for item in inner {
108        match item.as_rule() {
109            Rule::annotation => {
110                annotations.push(parse_annotation(item)?);
111            }
112            Rule::implements_clause => {
113                for iface in item.into_inner() {
114                    if iface.as_rule() == Rule::type_name {
115                        implements.push(iface.as_str().to_string());
116                    }
117                }
118            }
119            Rule::prop_decl => {
120                properties.push(parse_prop_decl(item)?);
121            }
122            Rule::body_constraint => {
123                constraints.push(parse_body_constraint(item)?);
124            }
125            _ => {}
126        }
127    }
128
129    // Desugar property-level @key/@unique/@index annotations into constraints
130    desugar_property_constraints(&properties, &mut constraints);
131
132    Ok(NodeDecl {
133        name,
134        annotations,
135        implements,
136        properties,
137        constraints,
138    })
139}
140
141fn parse_edge_decl(pair: pest::iterators::Pair<Rule>) -> Result<EdgeDecl> {
142    let mut inner = pair.into_inner();
143    let name = inner.next().unwrap().as_str().to_string();
144    let from_type = inner.next().unwrap().as_str().to_string();
145    let to_type = inner.next().unwrap().as_str().to_string();
146
147    let mut cardinality = Cardinality::default();
148    let mut annotations = Vec::new();
149    let mut properties = Vec::new();
150    let mut constraints = Vec::new();
151
152    for item in inner {
153        match item.as_rule() {
154            Rule::cardinality => {
155                cardinality = parse_cardinality(item)?;
156            }
157            Rule::annotation => annotations.push(parse_annotation(item)?),
158            Rule::prop_decl => properties.push(parse_prop_decl(item)?),
159            Rule::body_constraint => constraints.push(parse_body_constraint(item)?),
160            _ => {}
161        }
162    }
163
164    // Desugar property-level @unique/@index on edge properties
165    desugar_property_constraints(&properties, &mut constraints);
166
167    Ok(EdgeDecl {
168        name,
169        from_type,
170        to_type,
171        cardinality,
172        annotations,
173        properties,
174        constraints,
175    })
176}
177
178fn parse_cardinality(pair: pest::iterators::Pair<Rule>) -> Result<Cardinality> {
179    let mut inner = pair.into_inner();
180    let min_str = inner.next().unwrap().as_str();
181    let min = min_str
182        .parse::<u32>()
183        .map_err(|_| NanoError::Parse(format!("invalid cardinality min: {}", min_str)))?;
184    let max = if let Some(max_pair) = inner.next() {
185        let max_str = max_pair.as_str();
186        Some(
187            max_str
188                .parse::<u32>()
189                .map_err(|_| NanoError::Parse(format!("invalid cardinality max: {}", max_str)))?,
190        )
191    } else {
192        None
193    };
194
195    if let Some(max_val) = max {
196        if min > max_val {
197            return Err(NanoError::Parse(format!(
198                "cardinality min ({}) exceeds max ({})",
199                min, max_val
200            )));
201        }
202    }
203
204    Ok(Cardinality { min, max })
205}
206
207fn parse_body_constraint(pair: pest::iterators::Pair<Rule>) -> Result<Constraint> {
208    let mut inner = pair.into_inner();
209    let name_pair = inner.next().unwrap();
210    let constraint_name = name_pair.as_str();
211    let args_pair = inner.next().unwrap();
212    let args: Vec<pest::iterators::Pair<Rule>> = args_pair.into_inner().collect();
213
214    match constraint_name {
215        "key" => {
216            let names: Vec<String> = args
217                .into_iter()
218                .filter(|a| a.as_rule() == Rule::ident || a.as_rule() == Rule::constraint_arg)
219                .map(|a| extract_ident_from_constraint_arg(a))
220                .collect::<Result<Vec<_>>>()?;
221            if names.is_empty() {
222                return Err(NanoError::Parse(
223                    "@key constraint requires at least one property name".to_string(),
224                ));
225            }
226            Ok(Constraint::Key(names))
227        }
228        "unique" => {
229            let names = extract_ident_list_from_args(args)?;
230            if names.is_empty() {
231                return Err(NanoError::Parse(
232                    "@unique constraint requires at least one property name".to_string(),
233                ));
234            }
235            Ok(Constraint::Unique(names))
236        }
237        "index" => {
238            let names = extract_ident_list_from_args(args)?;
239            if names.is_empty() {
240                return Err(NanoError::Parse(
241                    "@index constraint requires at least one property name".to_string(),
242                ));
243            }
244            Ok(Constraint::Index(names))
245        }
246        "range" => {
247            // @range(prop, min..max)
248            if args.len() < 2 {
249                return Err(NanoError::Parse(
250                    "@range requires property name and bounds: @range(prop, min..max)".to_string(),
251                ));
252            }
253            let property = extract_ident_from_constraint_arg(args[0].clone())?;
254            // The second arg should be a range_bound
255            let (min, max) = extract_range_bounds(&args[1])?;
256            Ok(Constraint::Range { property, min, max })
257        }
258        "check" => {
259            // @check(prop, "regex")
260            if args.len() < 2 {
261                return Err(NanoError::Parse(
262                    "@check requires property name and pattern: @check(prop, \"regex\")"
263                        .to_string(),
264                ));
265            }
266            let property = extract_ident_from_constraint_arg(args[0].clone())?;
267            let pattern = extract_string_from_constraint_arg(&args[1])?;
268            Ok(Constraint::Check { property, pattern })
269        }
270        other => Err(NanoError::Parse(format!("unknown constraint: @{}", other))),
271    }
272}
273
274fn extract_ident_from_constraint_arg(pair: pest::iterators::Pair<Rule>) -> Result<String> {
275    if pair.as_rule() == Rule::ident {
276        return Ok(pair.as_str().to_string());
277    }
278    // constraint_arg wraps ident or literal
279    if let Some(inner) = pair.into_inner().next() {
280        if inner.as_rule() == Rule::ident {
281            return Ok(inner.as_str().to_string());
282        }
283    }
284    Err(NanoError::Parse(
285        "expected property name in constraint".to_string(),
286    ))
287}
288
289fn extract_ident_list_from_args(args: Vec<pest::iterators::Pair<Rule>>) -> Result<Vec<String>> {
290    let mut names = Vec::new();
291    for arg in args {
292        names.push(extract_ident_from_constraint_arg(arg)?);
293    }
294    Ok(names)
295}
296
297fn extract_string_from_constraint_arg(pair: &pest::iterators::Pair<Rule>) -> Result<String> {
298    // Navigate into constraint_arg -> literal -> string_lit
299    fn find_string(pair: &pest::iterators::Pair<Rule>) -> Result<Option<String>> {
300        if pair.as_rule() == Rule::string_lit {
301            return decode_string_literal(pair.as_str()).map(Some);
302        }
303        for inner in pair.clone().into_inner() {
304            if let Some(s) = find_string(&inner)? {
305                return Ok(Some(s));
306            }
307        }
308        Ok(None)
309    }
310
311    find_string(pair)?
312        .ok_or_else(|| NanoError::Parse("expected string argument in constraint".to_string()))
313}
314
315fn extract_range_bounds(
316    pair: &pest::iterators::Pair<Rule>,
317) -> Result<(Option<ConstraintBound>, Option<ConstraintBound>)> {
318    // Find the range_bound node inside the constraint_arg
319    let range_pair = if pair.as_rule() == Rule::range_bound {
320        pair.clone()
321    } else {
322        let mut found = None;
323        for inner in pair.clone().into_inner() {
324            if inner.as_rule() == Rule::range_bound {
325                found = Some(inner);
326                break;
327            }
328        }
329        found.ok_or_else(|| {
330            NanoError::Parse("expected range bounds (min..max) in @range constraint".to_string())
331        })?
332    };
333
334    let mut min = None;
335    let mut max = None;
336    let mut seen_bound = false;
337
338    for child in range_pair.into_inner() {
339        if child.as_rule() == Rule::literal
340            || child.as_rule() == Rule::integer
341            || child.as_rule() == Rule::float_lit
342            || child.as_rule() == Rule::signed_integer
343            || child.as_rule() == Rule::signed_float
344        {
345            let bound = parse_constraint_bound(&child)?;
346            if !seen_bound {
347                min = Some(bound);
348                seen_bound = true;
349            } else {
350                max = Some(bound);
351            }
352        }
353    }
354
355    Ok((min, max))
356}
357
358fn parse_constraint_bound(pair: &pest::iterators::Pair<Rule>) -> Result<ConstraintBound> {
359    let text = pair.as_str();
360
361    // Try as integer first
362    if let Ok(n) = text.parse::<i64>() {
363        return Ok(ConstraintBound::Integer(n));
364    }
365    // Try as float
366    if let Ok(f) = text.parse::<f64>() {
367        return Ok(ConstraintBound::Float(f));
368    }
369
370    // Navigate into literal -> integer/float_lit
371    for inner in pair.clone().into_inner() {
372        let s = inner.as_str();
373        if let Ok(n) = s.parse::<i64>() {
374            return Ok(ConstraintBound::Integer(n));
375        }
376        if let Ok(f) = s.parse::<f64>() {
377            return Ok(ConstraintBound::Float(f));
378        }
379    }
380
381    Err(NanoError::Parse(format!(
382        "invalid constraint bound: {}",
383        text
384    )))
385}
386
387/// Desugar property-level @key/@unique/@index annotations into body-level constraints.
388fn desugar_property_constraints(properties: &[PropDecl], constraints: &mut Vec<Constraint>) {
389    for prop in properties {
390        for ann in &prop.annotations {
391            match ann.name.as_str() {
392                "key" if ann.value.is_none() => {
393                    constraints.push(Constraint::Key(vec![prop.name.clone()]));
394                }
395                "unique" if ann.value.is_none() => {
396                    constraints.push(Constraint::Unique(vec![prop.name.clone()]));
397                }
398                "index" if ann.value.is_none() => {
399                    constraints.push(Constraint::Index(vec![prop.name.clone()]));
400                }
401                _ => {}
402            }
403        }
404    }
405}
406
407/// Resolve interface implements clauses — verify properties exist or inject them.
408fn resolve_interfaces(node: &mut NodeDecl, interfaces: &[&InterfaceDecl]) -> Result<()> {
409    let interface_map: HashMap<&str, &InterfaceDecl> =
410        interfaces.iter().map(|i| (i.name.as_str(), *i)).collect();
411
412    for iface_name in &node.implements {
413        let iface = interface_map.get(iface_name.as_str()).ok_or_else(|| {
414            NanoError::Parse(format!(
415                "node {} implements unknown interface '{}'",
416                node.name, iface_name
417            ))
418        })?;
419
420        for iface_prop in &iface.properties {
421            if let Some(existing) = node.properties.iter().find(|p| p.name == iface_prop.name) {
422                // Property exists — verify type compatibility
423                if existing.prop_type != iface_prop.prop_type {
424                    return Err(NanoError::Parse(format!(
425                        "node {} property '{}' has type {} but interface {} declares it as {}",
426                        node.name,
427                        iface_prop.name,
428                        existing.prop_type.display_name(),
429                        iface_name,
430                        iface_prop.prop_type.display_name()
431                    )));
432                }
433            } else {
434                // Property missing — inject it from the interface
435                node.properties.push(iface_prop.clone());
436                // Also desugar any constraint annotations from the injected property
437                desugar_property_constraints(
438                    std::slice::from_ref(iface_prop),
439                    &mut node.constraints,
440                );
441            }
442        }
443    }
444
445    Ok(())
446}
447
448fn parse_prop_decl(pair: pest::iterators::Pair<Rule>) -> Result<PropDecl> {
449    let mut inner = pair.into_inner();
450    let name = inner.next().unwrap().as_str().to_string();
451    let type_ref = inner.next().unwrap();
452    let prop_type = parse_type_ref(type_ref)?;
453
454    let mut annotations = Vec::new();
455    for item in inner {
456        if let Rule::annotation = item.as_rule() {
457            annotations.push(parse_annotation(item)?);
458        }
459    }
460
461    Ok(PropDecl {
462        name,
463        prop_type,
464        annotations,
465    })
466}
467
468fn parse_type_ref(pair: pest::iterators::Pair<Rule>) -> Result<PropType> {
469    let text = pair.as_str();
470    let nullable = text.ends_with('?');
471
472    let mut inner = pair
473        .into_inner()
474        .next()
475        .ok_or_else(|| NanoError::Parse("type reference is missing core type".to_string()))?;
476    if inner.as_rule() == Rule::core_type {
477        inner = inner
478            .into_inner()
479            .next()
480            .ok_or_else(|| NanoError::Parse("type reference is missing core type".to_string()))?;
481    }
482
483    match inner.as_rule() {
484        Rule::base_type => {
485            let scalar = ScalarType::from_str_name(inner.as_str())
486                .ok_or_else(|| NanoError::Parse(format!("unknown type: {}", inner.as_str())))?;
487            Ok(PropType::scalar(scalar, nullable))
488        }
489        Rule::vector_type => {
490            let dim_text = inner
491                .into_inner()
492                .next()
493                .ok_or_else(|| NanoError::Parse("Vector type missing dimension".to_string()))?
494                .as_str();
495            let dim = dim_text
496                .parse::<u32>()
497                .map_err(|e| NanoError::Parse(format!("invalid Vector dimension: {}", e)))?;
498            if dim == 0 {
499                return Err(NanoError::Parse(
500                    "Vector dimension must be greater than zero".to_string(),
501                ));
502            }
503            if dim > i32::MAX as u32 {
504                return Err(NanoError::Parse(format!(
505                    "Vector dimension {} exceeds maximum supported {}",
506                    dim,
507                    i32::MAX
508                )));
509            }
510            Ok(PropType::scalar(ScalarType::Vector(dim), nullable))
511        }
512        Rule::list_type => {
513            let element = inner
514                .into_inner()
515                .next()
516                .ok_or_else(|| NanoError::Parse("list type missing element type".to_string()))?;
517            let scalar = ScalarType::from_str_name(element.as_str()).ok_or_else(|| {
518                NanoError::Parse(format!("unknown list element type: {}", element.as_str()))
519            })?;
520            if matches!(scalar, ScalarType::Blob) {
521                return Err(NanoError::Parse(
522                    "list of Blob is not supported".to_string(),
523                ));
524            }
525            Ok(PropType::list_of(scalar, nullable))
526        }
527        Rule::enum_type => {
528            let mut values = Vec::new();
529            for value in inner.into_inner() {
530                if value.as_rule() == Rule::enum_value {
531                    values.push(value.as_str().to_string());
532                }
533            }
534            if values.is_empty() {
535                return Err(NanoError::Parse(
536                    "enum type must include at least one value".to_string(),
537                ));
538            }
539            let mut dedup = values.clone();
540            dedup.sort();
541            dedup.dedup();
542            if dedup.len() != values.len() {
543                return Err(NanoError::Parse(
544                    "enum type cannot include duplicate values".to_string(),
545                ));
546            }
547            Ok(PropType::enum_type(values, nullable))
548        }
549        other => Err(NanoError::Parse(format!(
550            "unexpected type rule: {:?}",
551            other
552        ))),
553    }
554}
555
556fn parse_annotation(pair: pest::iterators::Pair<Rule>) -> Result<Annotation> {
557    let mut inner = pair.into_inner();
558    let name = inner.next().unwrap().as_str().to_string();
559    let value = inner
560        .next()
561        .map(|p| decode_string_literal(p.as_str()))
562        .transpose()?;
563
564    Ok(Annotation { name, value })
565}
566
567fn validate_string_annotation(
568    annotations: &[Annotation],
569    annotation: &str,
570    target: &str,
571) -> Result<()> {
572    let mut seen = false;
573    for ann in annotations {
574        if ann.name != annotation {
575            continue;
576        }
577        if seen {
578            return Err(NanoError::Parse(format!(
579                "{} declares @{} multiple times",
580                target, annotation
581            )));
582        }
583        let value = ann.value.as_deref().ok_or_else(|| {
584            NanoError::Parse(format!(
585                "@{} on {} requires a non-empty value",
586                annotation, target
587            ))
588        })?;
589        if value.trim().is_empty() {
590            return Err(NanoError::Parse(format!(
591                "@{} on {} requires a non-empty value",
592                annotation, target
593            )));
594        }
595        seen = true;
596    }
597    Ok(())
598}
599
600// ─── Annotation Validation (metadata only) ───────────────────────────────────
601
602fn validate_schema_annotations(schema: &SchemaFile) -> Result<()> {
603    for decl in &schema.declarations {
604        match decl {
605            SchemaDecl::Interface(_) => {} // Interfaces have no type-level annotations
606            SchemaDecl::Node(node) => {
607                // Reject constraint annotations on node level (must be on properties or as body constraints)
608                for ann in &node.annotations {
609                    if ann.name == "key"
610                        || ann.name == "unique"
611                        || ann.name == "index"
612                        || ann.name == "embed"
613                    {
614                        return Err(NanoError::Parse(format!(
615                            "@{} is only supported on node properties or as body constraint (node {})",
616                            ann.name, node.name
617                        )));
618                    }
619                }
620                validate_string_annotation(
621                    &node.annotations,
622                    "description",
623                    &format!("node {}", node.name),
624                )?;
625                validate_string_annotation(
626                    &node.annotations,
627                    "instruction",
628                    &format!("node {}", node.name),
629                )?;
630
631                // Validate property-level annotations
632                for prop in &node.properties {
633                    validate_property_annotations(prop, &node.name, &node.properties, false)?;
634                }
635            }
636            SchemaDecl::Edge(edge) => {
637                for ann in &edge.annotations {
638                    if ann.name == "key"
639                        || ann.name == "unique"
640                        || ann.name == "index"
641                        || ann.name == "embed"
642                    {
643                        return Err(NanoError::Parse(format!(
644                            "@{} is not supported on edges (edge {})",
645                            ann.name, edge.name
646                        )));
647                    }
648                }
649                validate_string_annotation(
650                    &edge.annotations,
651                    "description",
652                    &format!("edge {}", edge.name),
653                )?;
654                validate_string_annotation(
655                    &edge.annotations,
656                    "instruction",
657                    &format!("edge {}", edge.name),
658                )?;
659
660                for prop in &edge.properties {
661                    validate_property_annotations(prop, &edge.name, &edge.properties, true)?;
662                }
663            }
664        }
665    }
666    Ok(())
667}
668
669fn validate_property_annotations(
670    prop: &PropDecl,
671    type_name: &str,
672    all_properties: &[PropDecl],
673    is_edge: bool,
674) -> Result<()> {
675    let is_vector = matches!(prop.prop_type.scalar, ScalarType::Vector(_));
676    let is_blob = matches!(prop.prop_type.scalar, ScalarType::Blob);
677
678    validate_string_annotation(
679        &prop.annotations,
680        "description",
681        &format!("property {}.{}", type_name, prop.name),
682    )?;
683
684    let mut key_seen = false;
685    let mut unique_seen = false;
686    let mut index_seen = false;
687    let mut embed_seen = false;
688
689    for ann in &prop.annotations {
690        // List/vector/blob restrictions on property-level annotations
691        if prop.prop_type.list
692            && (ann.name == "key"
693                || ann.name == "unique"
694                || ann.name == "index"
695                || ann.name == "embed")
696        {
697            return Err(NanoError::Parse(format!(
698                "@{} is not supported on list property {}.{}",
699                ann.name, type_name, prop.name
700            )));
701        }
702        if is_vector && (ann.name == "key" || ann.name == "unique") {
703            return Err(NanoError::Parse(format!(
704                "@{} is not supported on vector property {}.{}",
705                ann.name, type_name, prop.name
706            )));
707        }
708        if is_blob
709            && (ann.name == "key"
710                || ann.name == "unique"
711                || ann.name == "index"
712                || ann.name == "embed")
713        {
714            return Err(NanoError::Parse(format!(
715                "@{} is not supported on blob property {}.{}",
716                ann.name, type_name, prop.name
717            )));
718        }
719        if ann.name == "instruction" {
720            return Err(NanoError::Parse(format!(
721                "@instruction is only supported on node and edge types (property {}.{})",
722                type_name, prop.name
723            )));
724        }
725
726        // Edge-specific restrictions
727        if is_edge && (ann.name == "key" || ann.name == "embed") {
728            return Err(NanoError::Parse(format!(
729                "@{} is not supported on edge properties (edge {}.{})",
730                ann.name, type_name, prop.name
731            )));
732        }
733
734        // Arity checks
735        match ann.name.as_str() {
736            "key" => {
737                if ann.value.is_some() {
738                    return Err(NanoError::Parse(format!(
739                        "@key on {}.{} does not accept a value",
740                        type_name, prop.name
741                    )));
742                }
743                if key_seen {
744                    return Err(NanoError::Parse(format!(
745                        "property {}.{} declares @key multiple times",
746                        type_name, prop.name
747                    )));
748                }
749                key_seen = true;
750            }
751            "unique" => {
752                if ann.value.is_some() {
753                    return Err(NanoError::Parse(format!(
754                        "@unique on {}.{} does not accept a value",
755                        type_name, prop.name
756                    )));
757                }
758                if unique_seen {
759                    return Err(NanoError::Parse(format!(
760                        "property {}.{} declares @unique multiple times",
761                        type_name, prop.name
762                    )));
763                }
764                unique_seen = true;
765            }
766            "index" => {
767                if ann.value.is_some() {
768                    return Err(NanoError::Parse(format!(
769                        "@index on {}.{} does not accept a value",
770                        type_name, prop.name
771                    )));
772                }
773                if index_seen {
774                    return Err(NanoError::Parse(format!(
775                        "property {}.{} declares @index multiple times",
776                        type_name, prop.name
777                    )));
778                }
779                index_seen = true;
780            }
781            "embed" => {
782                if embed_seen {
783                    return Err(NanoError::Parse(format!(
784                        "property {}.{} declares @embed multiple times",
785                        type_name, prop.name
786                    )));
787                }
788                embed_seen = true;
789
790                if !is_vector {
791                    return Err(NanoError::Parse(format!(
792                        "@embed is only supported on vector properties ({}.{})",
793                        type_name, prop.name
794                    )));
795                }
796
797                let source_prop = ann.value.as_deref().ok_or_else(|| {
798                    NanoError::Parse(format!(
799                        "@embed on {}.{} requires a source property name",
800                        type_name, prop.name
801                    ))
802                })?;
803                if source_prop.trim().is_empty() {
804                    return Err(NanoError::Parse(format!(
805                        "@embed on {}.{} requires a non-empty source property name",
806                        type_name, prop.name
807                    )));
808                }
809
810                let source_decl = all_properties
811                    .iter()
812                    .find(|p| p.name == source_prop)
813                    .ok_or_else(|| {
814                        NanoError::Parse(format!(
815                            "@embed on {}.{} references unknown source property {}",
816                            type_name, prop.name, source_prop
817                        ))
818                    })?;
819                if source_decl.prop_type.list || source_decl.prop_type.scalar != ScalarType::String
820                {
821                    return Err(NanoError::Parse(format!(
822                        "@embed source property {}.{} must be String",
823                        type_name, source_prop
824                    )));
825                }
826            }
827            _ => {}
828        }
829    }
830    Ok(())
831}
832
833// ─── Constraint Validation ───────────────────────────────────────────────────
834
835fn validate_constraints(schema: &SchemaFile) -> Result<()> {
836    for decl in &schema.declarations {
837        match decl {
838            SchemaDecl::Interface(_) => {}
839            SchemaDecl::Node(node) => {
840                validate_type_constraints(&node.constraints, &node.properties, &node.name, false)?;
841            }
842            SchemaDecl::Edge(edge) => {
843                validate_type_constraints(&edge.constraints, &edge.properties, &edge.name, true)?;
844            }
845        }
846    }
847    Ok(())
848}
849
850fn validate_type_constraints(
851    constraints: &[Constraint],
852    properties: &[PropDecl],
853    type_name: &str,
854    is_edge: bool,
855) -> Result<()> {
856    let prop_names: HashMap<&str, &PropDecl> =
857        properties.iter().map(|p| (p.name.as_str(), p)).collect();
858
859    let mut key_count = 0usize;
860
861    for constraint in constraints {
862        match constraint {
863            Constraint::Key(cols) => {
864                if is_edge {
865                    return Err(NanoError::Parse(format!(
866                        "@key constraint is not supported on edges (edge {})",
867                        type_name
868                    )));
869                }
870                key_count += 1;
871                if key_count > 1 {
872                    return Err(NanoError::Parse(format!(
873                        "node type {} has multiple @key constraints; only one is supported",
874                        type_name
875                    )));
876                }
877                for col in cols {
878                    let prop = prop_names.get(col.as_str()).ok_or_else(|| {
879                        NanoError::Parse(format!(
880                            "@key on {} references unknown property '{}'",
881                            type_name, col
882                        ))
883                    })?;
884                    if prop.prop_type.nullable {
885                        return Err(NanoError::Parse(format!(
886                            "@key property {}.{} cannot be nullable",
887                            type_name, col
888                        )));
889                    }
890                    if prop.prop_type.list {
891                        return Err(NanoError::Parse(format!(
892                            "@key is not supported on list property {}.{}",
893                            type_name, col
894                        )));
895                    }
896                    if matches!(prop.prop_type.scalar, ScalarType::Vector(_)) {
897                        return Err(NanoError::Parse(format!(
898                            "@key is not supported on vector property {}.{}",
899                            type_name, col
900                        )));
901                    }
902                    if matches!(prop.prop_type.scalar, ScalarType::Blob) {
903                        return Err(NanoError::Parse(format!(
904                            "@key is not supported on blob property {}.{}",
905                            type_name, col
906                        )));
907                    }
908                }
909            }
910            Constraint::Unique(cols) => {
911                for col in cols {
912                    // Allow "src" and "dst" as implicit edge columns
913                    if is_edge && (col == "src" || col == "dst") {
914                        continue;
915                    }
916                    if !prop_names.contains_key(col.as_str()) {
917                        return Err(NanoError::Parse(format!(
918                            "@unique on {} references unknown property '{}'",
919                            type_name, col
920                        )));
921                    }
922                }
923            }
924            Constraint::Index(cols) => {
925                for col in cols {
926                    if is_edge && (col == "src" || col == "dst") {
927                        continue;
928                    }
929                    let prop = prop_names.get(col.as_str()).ok_or_else(|| {
930                        NanoError::Parse(format!(
931                            "@index on {} references unknown property '{}'",
932                            type_name, col
933                        ))
934                    })?;
935                    if matches!(prop.prop_type.scalar, ScalarType::Blob) {
936                        return Err(NanoError::Parse(format!(
937                            "@index is not supported on blob property {}.{}",
938                            type_name, col
939                        )));
940                    }
941                }
942            }
943            Constraint::Range { property, .. } => {
944                if is_edge {
945                    return Err(NanoError::Parse(format!(
946                        "@range constraint is not supported on edges (edge {})",
947                        type_name
948                    )));
949                }
950                let prop = prop_names.get(property.as_str()).ok_or_else(|| {
951                    NanoError::Parse(format!(
952                        "@range on {} references unknown property '{}'",
953                        type_name, property
954                    ))
955                })?;
956                if !prop.prop_type.scalar.is_numeric() {
957                    return Err(NanoError::Parse(format!(
958                        "@range on {}.{} requires a numeric type, got {}",
959                        type_name,
960                        property,
961                        prop.prop_type.display_name()
962                    )));
963                }
964            }
965            Constraint::Check { property, .. } => {
966                if is_edge {
967                    return Err(NanoError::Parse(format!(
968                        "@check constraint is not supported on edges (edge {})",
969                        type_name
970                    )));
971                }
972                let prop = prop_names.get(property.as_str()).ok_or_else(|| {
973                    NanoError::Parse(format!(
974                        "@check on {} references unknown property '{}'",
975                        type_name, property
976                    ))
977                })?;
978                if prop.prop_type.scalar != ScalarType::String {
979                    return Err(NanoError::Parse(format!(
980                        "@check on {}.{} requires String type, got {}",
981                        type_name,
982                        property,
983                        prop.prop_type.display_name()
984                    )));
985                }
986            }
987        }
988    }
989
990    Ok(())
991}
992
993#[cfg(test)]
994mod tests {
995    use super::*;
996
997    #[test]
998    fn test_parse_basic_schema() {
999        let input = r#"
1000node Person {
1001    name: String
1002    age: I32?
1003}
1004
1005node Company {
1006    name: String
1007}
1008
1009edge Knows: Person -> Person {
1010    since: Date?
1011}
1012
1013edge WorksAt: Person -> Company {
1014    title: String?
1015}
1016"#;
1017        let schema = parse_schema(input).unwrap();
1018        assert_eq!(schema.declarations.len(), 4);
1019
1020        match &schema.declarations[0] {
1021            SchemaDecl::Node(n) => {
1022                assert_eq!(n.name, "Person");
1023                assert!(n.annotations.is_empty());
1024                assert!(n.implements.is_empty());
1025                assert_eq!(n.properties.len(), 2);
1026                assert_eq!(n.properties[0].name, "name");
1027                assert!(!n.properties[0].prop_type.nullable);
1028                assert_eq!(n.properties[1].name, "age");
1029                assert!(n.properties[1].prop_type.nullable);
1030            }
1031            _ => panic!("expected Node"),
1032        }
1033
1034        match &schema.declarations[2] {
1035            SchemaDecl::Edge(e) => {
1036                assert_eq!(e.name, "Knows");
1037                assert_eq!(e.from_type, "Person");
1038                assert_eq!(e.to_type, "Person");
1039                assert!(e.annotations.is_empty());
1040                assert_eq!(e.properties.len(), 1);
1041                assert!(e.cardinality.is_default());
1042            }
1043            _ => panic!("expected Edge"),
1044        }
1045    }
1046
1047    #[test]
1048    fn test_parse_interface_basic() {
1049        let input = r#"
1050interface Named {
1051    name: String
1052}
1053node Person implements Named {
1054    age: I32?
1055}
1056"#;
1057        let schema = parse_schema(input).unwrap();
1058        match &schema.declarations[0] {
1059            SchemaDecl::Interface(i) => {
1060                assert_eq!(i.name, "Named");
1061                assert_eq!(i.properties.len(), 1);
1062                assert_eq!(i.properties[0].name, "name");
1063            }
1064            _ => panic!("expected Interface"),
1065        }
1066        match &schema.declarations[1] {
1067            SchemaDecl::Node(n) => {
1068                assert_eq!(n.name, "Person");
1069                assert_eq!(n.implements, vec!["Named"]);
1070                // "name" injected from interface + "age" declared locally
1071                assert_eq!(n.properties.len(), 2);
1072            }
1073            _ => panic!("expected Node"),
1074        }
1075    }
1076
1077    #[test]
1078    fn test_parse_implements_multiple() {
1079        let input = r#"
1080interface Slugged {
1081    slug: String @key
1082}
1083interface Described {
1084    title: String
1085    description: String?
1086}
1087node Signal implements Slugged, Described {
1088    strength: F64
1089}
1090"#;
1091        let schema = parse_schema(input).unwrap();
1092        match &schema.declarations[2] {
1093            SchemaDecl::Node(n) => {
1094                assert_eq!(n.name, "Signal");
1095                assert_eq!(n.implements, vec!["Slugged", "Described"]);
1096                // slug + title + description + strength
1097                assert_eq!(n.properties.len(), 4);
1098                // @key from Slugged should be desugared into constraints
1099                assert!(
1100                    n.constraints
1101                        .iter()
1102                        .any(|c| matches!(c, Constraint::Key(v) if v == &["slug"]))
1103                );
1104            }
1105            _ => panic!("expected Node"),
1106        }
1107    }
1108
1109    #[test]
1110    fn test_reject_implements_unknown_interface() {
1111        let input = r#"
1112node Person implements Unknown {
1113    name: String
1114}
1115"#;
1116        let err = parse_schema(input).unwrap_err();
1117        assert!(err.to_string().contains("unknown interface"));
1118    }
1119
1120    #[test]
1121    fn test_reject_interface_property_type_conflict() {
1122        let input = r#"
1123interface Named {
1124    name: I32
1125}
1126node Person implements Named {
1127    name: String
1128}
1129"#;
1130        let err = parse_schema(input).unwrap_err();
1131        assert!(err.to_string().contains("type") || err.to_string().contains("interface"));
1132    }
1133
1134    #[test]
1135    fn test_parse_annotation() {
1136        let input = r#"
1137node Person {
1138    name: String @unique
1139    id: U64 @key
1140    handle: String @index
1141}
1142"#;
1143        let schema = parse_schema(input).unwrap();
1144        match &schema.declarations[0] {
1145            SchemaDecl::Node(n) => {
1146                assert_eq!(n.properties[0].annotations.len(), 1);
1147                assert_eq!(n.properties[0].annotations[0].name, "unique");
1148                assert_eq!(n.properties[1].annotations[0].name, "key");
1149                assert_eq!(n.properties[2].annotations[0].name, "index");
1150                // Annotations are desugared into constraints
1151                assert!(
1152                    n.constraints
1153                        .iter()
1154                        .any(|c| matches!(c, Constraint::Unique(_)))
1155                );
1156                assert!(
1157                    n.constraints
1158                        .iter()
1159                        .any(|c| matches!(c, Constraint::Key(_)))
1160                );
1161                assert!(
1162                    n.constraints
1163                        .iter()
1164                        .any(|c| matches!(c, Constraint::Index(_)))
1165                );
1166            }
1167            _ => panic!("expected Node"),
1168        }
1169    }
1170
1171    #[test]
1172    fn test_property_level_key_desugars_to_constraint() {
1173        let input = r#"
1174node Person {
1175    name: String @key
1176}
1177"#;
1178        let schema = parse_schema(input).unwrap();
1179        match &schema.declarations[0] {
1180            SchemaDecl::Node(n) => {
1181                assert!(
1182                    n.constraints
1183                        .iter()
1184                        .any(|c| matches!(c, Constraint::Key(v) if v == &["name"]))
1185                );
1186            }
1187            _ => panic!("expected Node"),
1188        }
1189    }
1190
1191    #[test]
1192    fn test_parse_body_constraint_key() {
1193        let input = r#"
1194node Person {
1195    name: String
1196    @key(name)
1197}
1198"#;
1199        let schema = parse_schema(input).unwrap();
1200        match &schema.declarations[0] {
1201            SchemaDecl::Node(n) => {
1202                assert!(
1203                    n.constraints
1204                        .iter()
1205                        .any(|c| matches!(c, Constraint::Key(v) if v == &["name"]))
1206                );
1207            }
1208            _ => panic!("expected Node"),
1209        }
1210    }
1211
1212    #[test]
1213    fn test_parse_body_constraint_unique_composite() {
1214        let input = r#"
1215node Person {
1216    first: String
1217    last: String
1218    @unique(first, last)
1219}
1220"#;
1221        let schema = parse_schema(input).unwrap();
1222        match &schema.declarations[0] {
1223            SchemaDecl::Node(n) => {
1224                assert!(
1225                    n.constraints
1226                        .iter()
1227                        .any(|c| matches!(c, Constraint::Unique(v) if v == &["first", "last"]))
1228                );
1229            }
1230            _ => panic!("expected Node"),
1231        }
1232    }
1233
1234    #[test]
1235    fn test_parse_body_constraint_index_composite() {
1236        let input = r#"
1237node Event {
1238    category: String
1239    date: Date
1240    @index(category, date)
1241}
1242"#;
1243        let schema = parse_schema(input).unwrap();
1244        match &schema.declarations[0] {
1245            SchemaDecl::Node(n) => {
1246                assert!(
1247                    n.constraints
1248                        .iter()
1249                        .any(|c| matches!(c, Constraint::Index(v) if v == &["category", "date"]))
1250                );
1251            }
1252            _ => panic!("expected Node"),
1253        }
1254    }
1255
1256    #[test]
1257    fn test_parse_body_constraint_range() {
1258        let input = r#"
1259node Person {
1260    age: I32?
1261    @range(age, 0..200)
1262}
1263"#;
1264        let schema = parse_schema(input).unwrap();
1265        match &schema.declarations[0] {
1266            SchemaDecl::Node(n) => {
1267                assert!(
1268                    n.constraints.iter().any(
1269                        |c| matches!(c, Constraint::Range { property, .. } if property == "age")
1270                    )
1271                );
1272            }
1273            _ => panic!("expected Node"),
1274        }
1275    }
1276
1277    #[test]
1278    fn test_parse_range_float_bounds() {
1279        let input = r#"
1280node Measurement {
1281    name: String @key
1282    temperature: F64?
1283    @range(temperature, 0.0..100.0)
1284}
1285"#;
1286        let schema = parse_schema(input).unwrap();
1287        match &schema.declarations[0] {
1288            SchemaDecl::Node(n) => {
1289                assert!(n.constraints.iter().any(|c| matches!(
1290                    c,
1291                    Constraint::Range { property, min, max }
1292                    if property == "temperature"
1293                        && matches!(min, Some(ConstraintBound::Float(f)) if *f == 0.0)
1294                        && matches!(max, Some(ConstraintBound::Float(f)) if *f == 100.0)
1295                )));
1296            }
1297            _ => panic!("expected Node"),
1298        }
1299    }
1300
1301    #[test]
1302    fn test_parse_range_negative_float_bounds() {
1303        let input = r#"
1304node Measurement {
1305    name: String @key
1306    temperature: F64?
1307    @range(temperature, -40.0..60.0)
1308}
1309"#;
1310        let schema = parse_schema(input).unwrap();
1311        match &schema.declarations[0] {
1312            SchemaDecl::Node(n) => {
1313                assert!(n.constraints.iter().any(|c| matches!(
1314                    c,
1315                    Constraint::Range { property, min, max }
1316                    if property == "temperature"
1317                        && matches!(min, Some(ConstraintBound::Float(f)) if *f == -40.0)
1318                        && matches!(max, Some(ConstraintBound::Float(f)) if *f == 60.0)
1319                )));
1320            }
1321            _ => panic!("expected Node"),
1322        }
1323    }
1324
1325    #[test]
1326    fn test_parse_range_negative_integer_bounds() {
1327        let input = r#"
1328node Account {
1329    name: String @key
1330    balance: I64?
1331    @range(balance, -1000..1000)
1332}
1333"#;
1334        let schema = parse_schema(input).unwrap();
1335        match &schema.declarations[0] {
1336            SchemaDecl::Node(n) => {
1337                assert!(n.constraints.iter().any(|c| matches!(
1338                    c,
1339                    Constraint::Range { property, min, max }
1340                    if property == "balance"
1341                        && matches!(min, Some(ConstraintBound::Integer(n)) if *n == -1000)
1342                        && matches!(max, Some(ConstraintBound::Integer(n)) if *n == 1000)
1343                )));
1344            }
1345            _ => panic!("expected Node"),
1346        }
1347    }
1348
1349    #[test]
1350    fn test_parse_body_constraint_check() {
1351        let input = r#"
1352node Order {
1353    code: String
1354    @check(code, "[A-Z]{3}-[0-9]+")
1355}
1356"#;
1357        let schema = parse_schema(input).unwrap();
1358        match &schema.declarations[0] {
1359            SchemaDecl::Node(n) => {
1360                assert!(n.constraints.iter().any(|c| matches!(c, Constraint::Check { property, pattern } if property == "code" && pattern == "[A-Z]{3}-[0-9]+")));
1361            }
1362            _ => panic!("expected Node"),
1363        }
1364    }
1365
1366    #[test]
1367    fn test_reject_range_on_string() {
1368        let input = r#"
1369node Person {
1370    name: String
1371    @range(name, 0..100)
1372}
1373"#;
1374        let err = parse_schema(input).unwrap_err();
1375        assert!(err.to_string().contains("numeric"));
1376    }
1377
1378    #[test]
1379    fn test_reject_check_on_integer() {
1380        let input = r#"
1381node Person {
1382    age: I32
1383    @check(age, "[0-9]+")
1384}
1385"#;
1386        let err = parse_schema(input).unwrap_err();
1387        assert!(err.to_string().contains("String"));
1388    }
1389
1390    #[test]
1391    fn test_parse_edge_cardinality() {
1392        let input = r#"
1393node Person { name: String }
1394node Company { name: String }
1395edge WorksAt: Person -> Company @card(0..1)
1396"#;
1397        let schema = parse_schema(input).unwrap();
1398        match &schema.declarations[2] {
1399            SchemaDecl::Edge(e) => {
1400                assert_eq!(e.cardinality.min, 0);
1401                assert_eq!(e.cardinality.max, Some(1));
1402            }
1403            _ => panic!("expected Edge"),
1404        }
1405    }
1406
1407    #[test]
1408    fn test_parse_edge_cardinality_unbounded() {
1409        let input = r#"
1410node Person { name: String }
1411node Paper { title: String }
1412edge Authored: Person -> Paper @card(1..)
1413"#;
1414        let schema = parse_schema(input).unwrap();
1415        match &schema.declarations[2] {
1416            SchemaDecl::Edge(e) => {
1417                assert_eq!(e.cardinality.min, 1);
1418                assert_eq!(e.cardinality.max, None);
1419            }
1420            _ => panic!("expected Edge"),
1421        }
1422    }
1423
1424    #[test]
1425    fn test_parse_edge_default_cardinality() {
1426        let input = r#"
1427node Person { name: String }
1428edge Knows: Person -> Person
1429"#;
1430        let schema = parse_schema(input).unwrap();
1431        match &schema.declarations[1] {
1432            SchemaDecl::Edge(e) => {
1433                assert!(e.cardinality.is_default());
1434            }
1435            _ => panic!("expected Edge"),
1436        }
1437    }
1438
1439    #[test]
1440    fn test_parse_edge_unique_src_dst() {
1441        let input = r#"
1442node Person { name: String }
1443edge Knows: Person -> Person {
1444    @unique(src, dst)
1445}
1446"#;
1447        let schema = parse_schema(input).unwrap();
1448        match &schema.declarations[1] {
1449            SchemaDecl::Edge(e) => {
1450                assert!(
1451                    e.constraints
1452                        .iter()
1453                        .any(|c| matches!(c, Constraint::Unique(v) if v == &["src", "dst"]))
1454                );
1455            }
1456            _ => panic!("expected Edge"),
1457        }
1458    }
1459
1460    #[test]
1461    fn test_parse_edge_property_index() {
1462        let input = r#"
1463node Person { name: String }
1464node Company { name: String }
1465edge WorksAt: Person -> Company {
1466    since: Date? @index
1467}
1468"#;
1469        let schema = parse_schema(input).unwrap();
1470        match &schema.declarations[2] {
1471            SchemaDecl::Edge(e) => {
1472                // @index on since is desugared to Constraint::Index
1473                assert!(
1474                    e.constraints
1475                        .iter()
1476                        .any(|c| matches!(c, Constraint::Index(v) if v == &["since"]))
1477                );
1478            }
1479            _ => panic!("expected Edge"),
1480        }
1481    }
1482
1483    #[test]
1484    fn test_parse_embed_annotation_identifier_arg() {
1485        let input = r#"
1486node Doc {
1487    title: String
1488    embedding: Vector(3) @embed(title)
1489}
1490"#;
1491        let schema = parse_schema(input).unwrap();
1492        match &schema.declarations[0] {
1493            SchemaDecl::Node(n) => {
1494                assert_eq!(n.properties[1].annotations.len(), 1);
1495                assert_eq!(n.properties[1].annotations[0].name, "embed");
1496                assert_eq!(
1497                    n.properties[1].annotations[0].value.as_deref(),
1498                    Some("title")
1499                );
1500            }
1501            _ => panic!("expected Node"),
1502        }
1503    }
1504
1505    #[test]
1506    fn test_parse_edge_no_body() {
1507        let input = "edge WorksAt: Person -> Company\n";
1508        let schema = parse_schema(input).unwrap();
1509        match &schema.declarations[0] {
1510            SchemaDecl::Edge(e) => {
1511                assert_eq!(e.name, "WorksAt");
1512                assert!(e.annotations.is_empty());
1513                assert!(e.properties.is_empty());
1514            }
1515            _ => panic!("expected Edge"),
1516        }
1517    }
1518
1519    #[test]
1520    fn test_parse_type_rename_annotation() {
1521        let input = r#"
1522node Account @rename_from("User") {
1523    full_name: String @rename_from("name")
1524}
1525
1526edge ConnectedTo: Account -> Account @rename_from("Knows")
1527"#;
1528        let schema = parse_schema(input).unwrap();
1529        match &schema.declarations[0] {
1530            SchemaDecl::Node(n) => {
1531                assert_eq!(n.name, "Account");
1532                assert_eq!(n.annotations.len(), 1);
1533                assert_eq!(n.annotations[0].name, "rename_from");
1534                assert_eq!(n.annotations[0].value.as_deref(), Some("User"));
1535                assert_eq!(n.properties[0].annotations[0].name, "rename_from");
1536                assert_eq!(
1537                    n.properties[0].annotations[0].value.as_deref(),
1538                    Some("name")
1539                );
1540            }
1541            _ => panic!("expected Node"),
1542        }
1543        match &schema.declarations[1] {
1544            SchemaDecl::Edge(e) => {
1545                assert_eq!(e.name, "ConnectedTo");
1546                assert_eq!(e.annotations.len(), 1);
1547                assert_eq!(e.annotations[0].name, "rename_from");
1548                assert_eq!(e.annotations[0].value.as_deref(), Some("Knows"));
1549            }
1550            _ => panic!("expected Edge"),
1551        }
1552    }
1553
1554    #[test]
1555    fn test_reject_multiple_node_keys() {
1556        let input = r#"
1557node Person {
1558    id: U64 @key
1559    ext_id: String @key
1560}
1561"#;
1562        let err = parse_schema(input).unwrap_err();
1563        assert!(err.to_string().contains("multiple @key"));
1564    }
1565
1566    #[test]
1567    fn test_reject_unique_with_value() {
1568        // @unique("x") is now a parse error — the grammar parses it as a body_constraint
1569        // which expects ident args, not string literals as the sole argument
1570        let input = r#"
1571node Person {
1572    email: String @unique("x")
1573}
1574"#;
1575        assert!(parse_schema(input).is_err());
1576    }
1577
1578    #[test]
1579    fn test_reject_index_with_value() {
1580        // @index("x") is now a parse error — same reason as above
1581        let input = r#"
1582node Person {
1583    email: String @index("x")
1584}
1585"#;
1586        assert!(parse_schema(input).is_err());
1587    }
1588
1589    #[test]
1590    fn test_reject_unique_on_node_annotation() {
1591        let input = r#"
1592node Person @unique {
1593    email: String
1594}
1595"#;
1596        let err = parse_schema(input).unwrap_err();
1597        assert!(
1598            err.to_string()
1599                .contains("only supported on node properties")
1600        );
1601    }
1602
1603    #[test]
1604    fn test_reject_index_on_node_annotation() {
1605        let input = r#"
1606node Person @index {
1607    email: String
1608}
1609"#;
1610        let err = parse_schema(input).unwrap_err();
1611        assert!(
1612            err.to_string()
1613                .contains("only supported on node properties")
1614        );
1615    }
1616
1617    #[test]
1618    fn test_allow_unique_on_edge_property() {
1619        let input = r#"
1620node Person { name: String }
1621edge Knows: Person -> Person {
1622    weight: I32 @unique
1623}
1624"#;
1625        // Should now succeed (edge property @unique is allowed)
1626        let schema = parse_schema(input).unwrap();
1627        match &schema.declarations[1] {
1628            SchemaDecl::Edge(e) => {
1629                assert!(
1630                    e.constraints
1631                        .iter()
1632                        .any(|c| matches!(c, Constraint::Unique(v) if v == &["weight"]))
1633                );
1634            }
1635            _ => panic!("expected Edge"),
1636        }
1637    }
1638
1639    #[test]
1640    fn test_allow_index_on_edge_property() {
1641        let input = r#"
1642node Person { name: String }
1643edge Knows: Person -> Person {
1644    weight: I32 @index
1645}
1646"#;
1647        // Should now succeed (edge property @index is allowed)
1648        let schema = parse_schema(input).unwrap();
1649        match &schema.declarations[1] {
1650            SchemaDecl::Edge(e) => {
1651                assert!(
1652                    e.constraints
1653                        .iter()
1654                        .any(|c| matches!(c, Constraint::Index(v) if v == &["weight"]))
1655                );
1656            }
1657            _ => panic!("expected Edge"),
1658        }
1659    }
1660
1661    #[test]
1662    fn test_reject_embed_without_source_property() {
1663        let input = r#"
1664node Doc {
1665    title: String
1666    embedding: Vector(3) @embed
1667}
1668"#;
1669        let err = parse_schema(input).unwrap_err();
1670        assert!(err.to_string().contains("requires a source property name"));
1671    }
1672
1673    #[test]
1674    fn test_reject_embed_on_non_vector_property() {
1675        let input = r#"
1676node Doc {
1677    title: String @embed(title)
1678}
1679"#;
1680        let err = parse_schema(input).unwrap_err();
1681        assert!(
1682            err.to_string()
1683                .contains("only supported on vector properties")
1684        );
1685    }
1686
1687    #[test]
1688    fn test_reject_embed_unknown_source_property() {
1689        let input = r#"
1690node Doc {
1691    title: String
1692    embedding: Vector(3) @embed(body)
1693}
1694"#;
1695        let err = parse_schema(input).unwrap_err();
1696        assert!(
1697            err.to_string()
1698                .contains("references unknown source property")
1699        );
1700    }
1701
1702    #[test]
1703    fn test_reject_embed_source_not_string() {
1704        let input = r#"
1705node Doc {
1706    body: I32
1707    embedding: Vector(3) @embed(body)
1708}
1709"#;
1710        let err = parse_schema(input).unwrap_err();
1711        assert!(err.to_string().contains("must be String"));
1712    }
1713
1714    #[test]
1715    fn test_reject_embed_on_edge_property() {
1716        let input = r#"
1717node Doc { title: String }
1718edge Linked: Doc -> Doc {
1719    embedding: Vector(3) @embed(title)
1720}
1721"#;
1722        let err = parse_schema(input).unwrap_err();
1723        assert!(err.to_string().contains("edge properties"));
1724    }
1725
1726    #[test]
1727    fn test_parse_enum_and_list_types() {
1728        let input = r#"
1729node Ticket {
1730    status: enum(open, closed, blocked)
1731    tags: [String]
1732}
1733"#;
1734        let schema = parse_schema(input).unwrap();
1735        match &schema.declarations[0] {
1736            SchemaDecl::Node(n) => {
1737                let status = &n.properties[0].prop_type;
1738                assert!(status.is_enum());
1739                assert!(!status.list);
1740                assert_eq!(
1741                    status.enum_values.as_ref().unwrap(),
1742                    &vec![
1743                        "blocked".to_string(),
1744                        "closed".to_string(),
1745                        "open".to_string()
1746                    ]
1747                );
1748
1749                let tags = &n.properties[1].prop_type;
1750                assert!(tags.list);
1751                assert!(!tags.is_enum());
1752                assert_eq!(tags.scalar, ScalarType::String);
1753            }
1754            _ => panic!("expected Node"),
1755        }
1756    }
1757
1758    #[test]
1759    fn test_reject_duplicate_enum_values() {
1760        let input = r#"
1761node Ticket {
1762    status: enum(open, closed, open)
1763}
1764"#;
1765        let err = parse_schema(input).unwrap_err();
1766        assert!(err.to_string().contains("duplicate values"));
1767    }
1768
1769    #[test]
1770    fn test_parse_description_and_instruction_annotations() {
1771        let input = r#"
1772node Task @description("Tracked work item") @instruction("Prefer querying by slug") {
1773    slug: String @key @description("Stable external identifier")
1774}
1775edge DependsOn: Task -> Task @description("Hard dependency") @instruction("Use only for blockers")
1776"#;
1777        let schema = parse_schema(input).unwrap();
1778        match &schema.declarations[0] {
1779            SchemaDecl::Node(node) => {
1780                assert_eq!(
1781                    node.annotations
1782                        .iter()
1783                        .find(|ann| ann.name == "description")
1784                        .and_then(|ann| ann.value.as_deref()),
1785                    Some("Tracked work item")
1786                );
1787                assert_eq!(
1788                    node.annotations
1789                        .iter()
1790                        .find(|ann| ann.name == "instruction")
1791                        .and_then(|ann| ann.value.as_deref()),
1792                    Some("Prefer querying by slug")
1793                );
1794                assert_eq!(
1795                    node.properties[0]
1796                        .annotations
1797                        .iter()
1798                        .find(|ann| ann.name == "description")
1799                        .and_then(|ann| ann.value.as_deref()),
1800                    Some("Stable external identifier")
1801                );
1802            }
1803            _ => panic!("expected node"),
1804        }
1805        match &schema.declarations[1] {
1806            SchemaDecl::Edge(edge) => {
1807                assert_eq!(
1808                    edge.annotations
1809                        .iter()
1810                        .find(|ann| ann.name == "description")
1811                        .and_then(|ann| ann.value.as_deref()),
1812                    Some("Hard dependency")
1813                );
1814                assert_eq!(
1815                    edge.annotations
1816                        .iter()
1817                        .find(|ann| ann.name == "instruction")
1818                        .and_then(|ann| ann.value.as_deref()),
1819                    Some("Use only for blockers")
1820                );
1821            }
1822            _ => panic!("expected edge"),
1823        }
1824    }
1825
1826    #[test]
1827    fn test_parse_annotation_decodes_escapes() {
1828        let input = r#"
1829node Task @description("Tracked\n\"work\"\\item") {
1830    slug: String @key @description("Stable\tidentifier")
1831}
1832"#;
1833        let schema = parse_schema(input).unwrap();
1834        match &schema.declarations[0] {
1835            SchemaDecl::Node(node) => {
1836                assert_eq!(
1837                    node.annotations[0].value.as_deref(),
1838                    Some("Tracked\n\"work\"\\item")
1839                );
1840                assert_eq!(
1841                    node.properties[0].annotations[1].value.as_deref(),
1842                    Some("Stable\tidentifier")
1843                );
1844            }
1845            _ => panic!("expected node"),
1846        }
1847    }
1848
1849    #[test]
1850    fn test_parse_annotation_rejects_unknown_escape() {
1851        let input = r#"
1852node Task @description("Tracked\q") {
1853    slug: String @key
1854}
1855"#;
1856        let err = parse_schema(input).unwrap_err();
1857        assert!(err.to_string().contains("unsupported escape sequence"));
1858    }
1859
1860    #[test]
1861    fn test_reject_duplicate_description_annotations() {
1862        let input = r#"
1863node Task @description("a") @description("b") {
1864    slug: String @key
1865}
1866"#;
1867        let err = parse_schema(input).unwrap_err();
1868        assert!(
1869            err.to_string()
1870                .contains("declares @description multiple times")
1871        );
1872    }
1873
1874    #[test]
1875    fn test_reject_instruction_on_property() {
1876        let input = r#"
1877node Task {
1878    slug: String @instruction("bad")
1879}
1880"#;
1881        let err = parse_schema(input).unwrap_err();
1882        assert!(
1883            err.to_string()
1884                .contains("@instruction is only supported on node and edge types")
1885        );
1886    }
1887
1888    #[test]
1889    fn test_reject_key_on_list_property() {
1890        let input = r#"
1891node Ticket {
1892    tags: [String] @key
1893}
1894"#;
1895        let err = parse_schema(input).unwrap_err();
1896        assert!(err.to_string().contains("list property"));
1897    }
1898
1899    #[test]
1900    fn test_parse_vector_type() {
1901        let input = r#"
1902node Doc {
1903    embedding: Vector(3)
1904}
1905"#;
1906        let schema = parse_schema(input).unwrap();
1907        match &schema.declarations[0] {
1908            SchemaDecl::Node(n) => match n.properties[0].prop_type.scalar {
1909                ScalarType::Vector(dim) => assert_eq!(dim, 3),
1910                other => panic!("expected vector type, got {:?}", other),
1911            },
1912            _ => panic!("expected node"),
1913        }
1914    }
1915
1916    #[test]
1917    fn test_reject_zero_vector_dimension() {
1918        let input = r#"
1919node Doc {
1920    embedding: Vector(0)
1921}
1922"#;
1923        let err = parse_schema(input).unwrap_err();
1924        assert!(err.to_string().contains("Vector dimension"));
1925    }
1926
1927    #[test]
1928    fn test_reject_vector_dimension_larger_than_arrow_bound() {
1929        let input = r#"
1930node Doc {
1931    embedding: Vector(2147483648)
1932}
1933"#;
1934        let err = parse_schema(input).unwrap_err();
1935        assert!(err.to_string().contains("exceeds maximum supported"));
1936    }
1937
1938    #[test]
1939    fn test_parse_error() {
1940        let input = "node { }"; // missing type name
1941        assert!(parse_schema(input).is_err());
1942    }
1943
1944    #[test]
1945    fn test_parse_error_diagnostic_has_span() {
1946        let input = "node { }";
1947        let err = parse_schema_diagnostic(input).unwrap_err();
1948        assert!(err.span.is_some());
1949    }
1950}