Skip to main content

rdx_schema/
validate.rs

1use rdx_ast::{AttributeValue, ComponentNode, Node, Root};
2
3use crate::{PropType, Schema, type_matches, value_type_name};
4
5/// Severity level for validation diagnostics.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum Severity {
8    Error,
9    Warning,
10}
11
12/// A single validation diagnostic tied to a source location.
13#[derive(Debug, Clone)]
14pub struct Diagnostic {
15    pub severity: Severity,
16    pub message: String,
17    /// Component name that caused the diagnostic.
18    pub component: String,
19    /// Line number in the source document (1-indexed).
20    pub line: usize,
21    /// Column number in the source document (1-indexed).
22    pub column: usize,
23}
24
25impl std::fmt::Display for Diagnostic {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        let level = match self.severity {
28            Severity::Error => "error",
29            Severity::Warning => "warning",
30        };
31        write!(
32            f,
33            "{}:{}:{}: {}: {}",
34            self.component, self.line, self.column, level, self.message
35        )
36    }
37}
38
39/// Validate an RDX AST against a schema. Returns a list of diagnostics.
40///
41/// An empty list means the document is valid.
42///
43/// # Example
44///
45/// ```rust
46/// use rdx_schema::{Schema, ComponentSchema, PropSchema, PropType, validate};
47/// use rdx_parser::parse;
48///
49/// let schema = Schema::new()
50///     .strict(true)
51///     .component("Notice", ComponentSchema::new()
52///         .prop("type", PropSchema::enum_required(vec!["info", "warning", "error"]))
53///     );
54///
55/// let root = parse("<Notice type=\"info\">\nSome text.\n</Notice>\n");
56/// let diagnostics = validate(&root, &schema);
57/// assert!(diagnostics.is_empty());
58/// ```
59pub fn validate(root: &Root, schema: &Schema) -> Vec<Diagnostic> {
60    let mut diagnostics = Vec::new();
61    validate_nodes(&root.children, schema, &mut diagnostics, None);
62    diagnostics
63}
64
65fn validate_nodes(
66    nodes: &[Node],
67    schema: &Schema,
68    diagnostics: &mut Vec<Diagnostic>,
69    parent_allowed_children: Option<&[String]>,
70) {
71    for node in nodes {
72        if let Node::Component(comp) = node {
73            validate_component(comp, schema, diagnostics, parent_allowed_children);
74        }
75        // Recurse into non-component nodes that have children
76        if !matches!(node, Node::Component(_))
77            && let Some(children) = node.children()
78        {
79            validate_nodes(children, schema, diagnostics, None);
80        }
81    }
82}
83
84fn validate_component(
85    comp: &ComponentNode,
86    schema: &Schema,
87    diagnostics: &mut Vec<Diagnostic>,
88    parent_allowed_children: Option<&[String]>,
89) {
90    let line = comp.position.start.line;
91    let column = comp.position.start.column;
92    let name = &comp.name;
93
94    // Check if this component is allowed as a child of its parent
95    if let Some(allowed) = parent_allowed_children
96        && !allowed.iter().any(|a| a == name)
97    {
98        diagnostics.push(Diagnostic {
99            severity: Severity::Error,
100            message: format!("<{name}> is not allowed as a child here"),
101            component: name.clone(),
102            line,
103            column,
104        });
105    }
106
107    let Some(comp_schema) = schema.components.get(name.as_str()) else {
108        // Unknown component
109        if schema.strict {
110            diagnostics.push(Diagnostic {
111                severity: Severity::Error,
112                message: format!("unknown component <{name}>"),
113                component: name.clone(),
114                line,
115                column,
116            });
117        }
118        return;
119    };
120
121    // Check self-closing constraint
122    if comp_schema.self_closing && !comp.children.is_empty() {
123        diagnostics.push(Diagnostic {
124            severity: Severity::Error,
125            message: format!("<{name}> must be self-closing (no children)"),
126            component: name.clone(),
127            line,
128            column,
129        });
130    }
131
132    // Check required props
133    for (prop_name, prop_schema) in &comp_schema.props {
134        if prop_schema.required {
135            let found = comp.attributes.iter().any(|a| a.name == *prop_name);
136            if !found {
137                diagnostics.push(Diagnostic {
138                    severity: Severity::Error,
139                    message: format!("missing required prop `{prop_name}`"),
140                    component: name.clone(),
141                    line,
142                    column,
143                });
144            }
145        }
146    }
147
148    // Check each provided attribute
149    for attr in &comp.attributes {
150        let attr_line = attr.position.start.line;
151        let attr_col = attr.position.start.column;
152
153        let Some(prop_schema) = comp_schema.props.get(&attr.name) else {
154            // Unknown prop
155            diagnostics.push(Diagnostic {
156                severity: Severity::Warning,
157                message: format!("unknown prop `{}` on <{name}>", attr.name),
158                component: name.clone(),
159                line: attr_line,
160                column: attr_col,
161            });
162            continue;
163        };
164
165        // Type check (skip variables — they resolve at runtime)
166        if matches!(attr.value, AttributeValue::Variable(_))
167            && prop_schema.prop_type != PropType::Variable
168        {
169            // Variables are accepted for any type — the host resolves them
170            continue;
171        }
172
173        if !type_matches(&attr.value, &prop_schema.prop_type) {
174            diagnostics.push(Diagnostic {
175                severity: Severity::Error,
176                message: format!(
177                    "prop `{}` on <{name}> expects {}, got {}",
178                    attr.name,
179                    format_expected_type(&prop_schema.prop_type),
180                    value_type_name(&attr.value),
181                ),
182                component: name.clone(),
183                line: attr_line,
184                column: attr_col,
185            });
186        }
187
188        // Enum value check
189        if prop_schema.prop_type == PropType::Enum
190            && let (Some(allowed), AttributeValue::String(val)) = (&prop_schema.values, &attr.value)
191            && !allowed.contains(val)
192        {
193            diagnostics.push(Diagnostic {
194                severity: Severity::Error,
195                message: format!(
196                    "prop `{}` on <{name}> must be one of [{}], got \"{}\"",
197                    attr.name,
198                    allowed.join(", "),
199                    val,
200                ),
201                component: name.clone(),
202                line: attr_line,
203                column: attr_col,
204            });
205        }
206    }
207
208    // Recurse into children with allowed_children constraint
209    validate_nodes(
210        &comp.children,
211        schema,
212        diagnostics,
213        comp_schema.allowed_children.as_deref(),
214    );
215}
216
217fn format_expected_type(t: &PropType) -> &'static str {
218    match t {
219        PropType::String => "string",
220        PropType::Number => "number",
221        PropType::Boolean => "boolean",
222        PropType::Enum => "string (enum)",
223        PropType::Object => "object",
224        PropType::Array => "array",
225        PropType::Variable => "variable",
226        PropType::Any => "any",
227    }
228}