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)]
994#[path = "parser_tests.rs"]
995mod tests;