Skip to main content

eui_core/
validate.rs

1//! Props validation against a component definition — the catalog's own mini
2//! schema (`PropSchema`), so the reducer, the fixtures and the generator agree
3//! without a JSON Schema engine. Mirrors `src/validate.ts`.
4
5use indexmap::IndexMap;
6use serde_json::Value;
7
8use crate::catalog::{is_known_token, parse_token_ref, CatalogView};
9use crate::macros::{is_responsive_value, BREAKPOINTS};
10use crate::strings::{is_string_ref, parse_string_ref};
11use crate::expr::is_call;
12use crate::style_check::validate_style;
13use crate::types::UiNode;
14use crate::types::{ComponentDef, PropSchema, Props};
15
16#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
17pub struct PropIssue {
18    pub path: String,
19    pub message: String,
20}
21
22pub use crate::expr::is_dynamic;
23
24/// `^\d{4}-\d{2}-\d{2}$`
25fn is_iso_date(s: &str) -> bool {
26    let b = s.as_bytes();
27    b.len() == 10
28        && b[4] == b'-'
29        && b[7] == b'-'
30        && b.iter().enumerate().all(|(i, c)| i == 4 || i == 7 || c.is_ascii_digit())
31}
32
33/// The Icon prop's vocabulary: `packages/icons/icons.json` at generate time.
34fn is_icon_name(value: &Value) -> bool {
35    value.as_str().is_some_and(|s| crate::generated::catalog::ICON_NAMES.contains(&s))
36}
37
38fn issue(issues: &mut Vec<PropIssue>, path: &str, message: impl Into<String>) {
39    issues.push(PropIssue { path: path.to_string(), message: message.into() });
40}
41
42fn check_value(schema: &PropSchema, value: &Value, path: &str, view: &CatalogView, issues: &mut Vec<PropIssue>) {
43    if schema.bindable == Some(true) && is_dynamic(value) {
44        return;
45    }
46    if schema.responsive == Some(true) && is_responsive_value(value) {
47        for (bp, v) in value.as_object().into_iter().flatten() {
48            check_scalar(schema, v, &format!("{path}.{bp}"), view, issues);
49        }
50        return;
51    }
52    if schema.responsive == Some(true) && value.is_object() && !is_dynamic(value) {
53        issue(issues, path, format!("a responsive value needs base and only {} besides", BREAKPOINTS.join("|")));
54        return;
55    }
56    check_scalar(schema, value, path, view, issues);
57}
58
59fn check_scalar(schema: &PropSchema, value: &Value, path: &str, view: &CatalogView, issues: &mut Vec<PropIssue>) {
60    match schema.type_.as_str() {
61        "string" | "markdown" | "url" => match value.as_str() {
62            None => issue(issues, path, "expected a string"),
63            Some(s) => {
64                if parse_string_ref(s).is_some() && !is_string_ref(s) {
65                    issue(issues, path, format!("unknown built-in string {s} (catalog/strings.json)"));
66                }
67            }
68        },
69        "date" => {
70            if !value.as_str().is_some_and(|s| s.is_empty() || is_iso_date(s)) {
71                issue(issues, path, "expected yyyy-mm-dd");
72            }
73        }
74        "number" => match value.as_f64().filter(|n| n.is_finite()) {
75            None => issue(issues, path, "expected a number"),
76            Some(n) => {
77                if let Some(min) = schema.minimum.filter(|min| n < *min) {
78                    issue(issues, path, format!("expected at least {}", crate::json::number_to_string(min)));
79                } else if let Some(max) = schema.maximum.filter(|max| n > *max) {
80                    issue(issues, path, format!("expected at most {}", crate::json::number_to_string(max)));
81                }
82            }
83        },
84        "boolean" => {
85            if !value.is_boolean() {
86                issue(issues, path, "expected a boolean");
87            }
88        }
89        "enum" => {
90            let values = schema.values.clone().or_else(|| {
91                schema.enum_.as_ref().and_then(|e| view.enums.get(e)).map(|v| v.iter().map(|s| Value::String(s.clone())).collect())
92            });
93            match values {
94                None => issue(issues, path, format!("enum {} is not defined", schema.enum_.as_deref().unwrap_or("undefined"))),
95                Some(values) => {
96                    if !values.iter().any(|v| crate::json::strict_eq(v, value)) {
97                        let list = values.iter().map(crate::json::to_js_string).collect::<Vec<_>>().join("|");
98                        issue(issues, path, format!("expected one of {list}"));
99                    }
100                }
101            }
102        }
103        "icon" => {
104            if !is_icon_name(value) {
105                issue(issues, path, "not an icons.json name");
106            }
107        }
108        // Round 4 (VAPP-103): a node's colour is a TOKEN, never a literal, so
109        // the theme's light and dark modes both apply (themes keep literals).
110        "color" => {
111            if is_known_token(value) && parse_token_ref(value).is_some_and(|(group, _)| group == "color") {
112                return;
113            }
114            issue(issues, path, "expected $color.<name>");
115        }
116        "style" => {
117            for i in validate_style(value, path, Some(false)) {
118                issues.push(PropIssue { path: i.path, message: i.message });
119            }
120        }
121        "array" => {
122            let Some(items) = value.as_array() else {
123                issue(issues, path, "expected an array");
124                return;
125            };
126            if let Some(item_schema) = &schema.items {
127                for (i, item) in items.iter().enumerate() {
128                    check_value(item_schema, item, &format!("{path}[{i}]"), view, issues);
129                }
130            }
131        }
132        "object" => {
133            let Some(obj) = value.as_object() else {
134                issue(issues, path, "expected an object");
135                return;
136            };
137            let Some(shape) = &schema.shape else { return };
138            let Some(def) = view.defs.get(shape) else {
139                issue(issues, path, format!("shape {shape} is not defined"));
140                return;
141            };
142            check_props(&def.properties, obj, path, view, issues);
143        }
144        _ => {}
145    }
146}
147
148fn check_props(schemas: &IndexMap<String, PropSchema>, props: &Props, path: &str, view: &CatalogView, issues: &mut Vec<PropIssue>) {
149    for (name, schema) in schemas {
150        match props.get(name) {
151            None | Some(Value::Null) => {
152                if schema.required == Some(true) {
153                    issue(issues, &format!("{path}.{name}"), "required");
154                }
155            }
156            Some(value) => check_value(schema, value, &format!("{path}.{name}"), view, issues),
157        }
158    }
159    // `Props` keeps the author's order (`preserve_order`), like the TS.
160    for name in props.keys() {
161        if !schemas.contains_key(name) {
162            issue(issues, &format!("{path}.{name}"), "unknown prop");
163        }
164    }
165}
166
167/// Every issue in a node's props against its component definition; `path`
168/// prefixes every issue path (the reducer passes `props`).
169pub fn validate_props(def: &ComponentDef, props: &Props, path: &str, view: &CatalogView) -> Vec<PropIssue> {
170    let mut issues = Vec::new();
171    check_props(&def.props, props, path, view, &mut issues);
172    issues
173}
174
175/// A function name a surface may call: a catalog function, or a HOST
176/// function, which is always namespaced (`app.toast`, `harness.toast`).
177pub fn is_callable_name(name: &str) -> bool {
178    if crate::generated::catalog::FUNCTION_NAMES.contains(&name) {
179        return true;
180    }
181    // `^[A-Za-z_][\w-]*(\.[A-Za-z_][\w-]*)+$` (src/validate.ts).
182    let segments: Vec<&str> = name.split('.').collect();
183    segments.len() >= 2
184        && segments.iter().all(|s| {
185            let mut chars = s.chars();
186            chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_') && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
187        })
188}
189
190fn check_call(call: &Value, path: &str, issues: &mut Vec<PropIssue>) {
191    let name = call["call"].as_str().unwrap_or("");
192    if !is_callable_name(name) {
193        issue(issues, path, format!("unknown function \"{name}\"; known: the catalog functions, or a namespaced host function (app.toast)"));
194    }
195}
196
197/// Every `{call}` at any depth of an EXPRESSION (`visible`, a call's args,
198/// an event context): each resolves at any depth, so every `{call}` in it
199/// is a call. `src/validate.ts checkCallsDeep`.
200fn check_calls_deep(value: &Value, path: &str, issues: &mut Vec<PropIssue>) {
201    match value {
202        Value::Array(items) => {
203            for (i, v) in items.iter().enumerate() {
204                check_calls_deep(v, &format!("{path}[{i}]"), issues);
205            }
206        }
207        Value::Object(obj) => {
208            if is_call(value) {
209                check_call(value, path, issues);
210            }
211            for (k, v) in obj {
212                check_calls_deep(v, &format!("{path}.{k}"), issues);
213            }
214        }
215        _ => {}
216    }
217}
218
219/// The calls a PROP makes, walked along its schema exactly as the bind pass
220/// resolves it (`data::resolve_prop`): a `{call}` AT a position is a call
221/// (its args are expressions); a DATA position (Table `rows`) is literal
222/// data, never descended; arrays and shaped objects walk per item /
223/// property; a `style` prop resolves at any depth; anything else is a
224/// literal. `src/validate.ts checkPropCalls`.
225fn check_prop_calls(value: &Value, schema: Option<&PropSchema>, path: &str, view: &CatalogView, issues: &mut Vec<PropIssue>) {
226    if is_call(value) {
227        check_call(value, path, issues);
228        if let Some(args) = value.get("args") {
229            check_calls_deep(args, &format!("{path}.args"), issues);
230        }
231        return;
232    }
233    let Some(schema) = schema else { return };
234    if crate::data::is_data_schema(Some(schema)) {
235        return;
236    }
237    if schema.type_ == "style" {
238        return check_calls_deep(value, path, issues);
239    }
240    if let (Some(items_schema), Value::Array(items)) = (schema.items.as_deref().filter(|_| schema.type_ == "array"), value) {
241        for (i, item) in items.iter().enumerate() {
242            check_prop_calls(item, Some(items_schema), &format!("{path}[{i}]"), view, issues);
243        }
244        return;
245    }
246    let shape = (schema.type_ == "object").then_some(()).and(schema.shape.as_ref()).and_then(|name| view.defs.get(name));
247    if let (Some(shape), Value::Object(map)) = (shape, value) {
248        for (k, v) in map {
249            check_prop_calls(v, shape.properties.get(k), &format!("{path}.{k}"), view, issues);
250        }
251    }
252}
253
254/// The calls an ACTION makes: its `functionCall` (name + args) and its event
255/// context (an expression). Nothing else in it is evaluated.
256fn check_action_calls(action: &Value, path: &str, issues: &mut Vec<PropIssue>) {
257    if let Some(call) = action.get("functionCall").filter(|c| is_call(c)) {
258        let at = format!("{path}.functionCall");
259        check_call(call, &at, issues);
260        if let Some(args) = call.get("args") {
261            check_calls_deep(args, &format!("{at}.args"), issues);
262        }
263    }
264    if let Some(context) = action.get("event").and_then(|e| e.get("context")) {
265        check_calls_deep(context, &format!("{path}.event.context"), issues);
266    }
267}
268
269/// [`validate_node_in`] against the core catalog alone.
270pub fn validate_node(node: &UiNode) -> Vec<PropIssue> {
271    validate_node_in(node, &CatalogView::core())
272}
273
274/// What `validate_props` leaves out, round 4 (VAPP-103; `src/validate.ts
275/// validateNode`): the node's `style` (whitelisted keys, known tokens,
276/// `$color.*` colours), every function a prop (along its schema in `view`:
277/// never inside DATA), `visible` or an `on` action calls, and a Table's slot
278/// columns. In this order.
279pub fn validate_node_in(node: &UiNode, view: &CatalogView) -> Vec<PropIssue> {
280    let mut issues = Vec::new();
281    if let Some(style) = &node.style {
282        for i in validate_style(&Value::Object(style.clone()), "style", None) {
283            issues.push(PropIssue { path: i.path, message: i.message });
284        }
285    }
286    let def = view.component(&node.component);
287    for (k, v) in &node.props {
288        check_prop_calls(v, def.and_then(|d| d.props.get(k)), &format!("props.{k}"), view, &mut issues);
289    }
290    if let Some(visible) = &node.visible {
291        check_calls_deep(visible, "visible", &mut issues);
292    }
293    for (event, action) in node.on.iter().flatten() {
294        check_action_calls(action, &format!("on.{event}"), &mut issues);
295    }
296    if node.component == "Table" {
297        if let Some(Value::Array(columns)) = node.props.get("columns") {
298            let slots: Vec<&str> = node.slots.iter().flat_map(|s| s.keys()).map(String::as_str).collect();
299            for (i, column) in columns.iter().enumerate() {
300                if column.get("type").and_then(Value::as_str) != Some("slot") {
301                    continue;
302                }
303                let path = format!("props.columns[{i}].slot");
304                match column.get("slot").and_then(Value::as_str) {
305                    None => issue(&mut issues, &path, "a slot column names one of the Table's slots"),
306                    Some(slot) if !slots.contains(&slot) => {
307                        let known = if slots.is_empty() { "none".to_string() } else { slots.join("|") };
308                        issue(&mut issues, &path, format!("no slot \"{slot}\" on this Table; slots: {known}"));
309                    }
310                    Some(_) => {}
311                }
312            }
313        }
314    }
315    issues
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use serde_json::json;
322
323    #[test]
324    fn dynamic_values_and_dates() {
325        assert!(is_dynamic(&json!({"path": "/x"})));
326        assert!(!is_dynamic(&json!({"path": "/x", "y": 1})));
327        assert!(is_dynamic(&json!({"call": "regex", "args": {}})));
328        assert!(is_iso_date("2026-10-07") && !is_iso_date("2026-1-07") && !is_iso_date("2026/10/07"));
329    }
330
331    #[test]
332    fn issues_name_their_paths() {
333        let view = CatalogView::core();
334        let def = view.component("Text").unwrap().clone();
335        let props = json!({"text": 3, "bogus": true}).as_object().unwrap().clone();
336        let issues = validate_props(&def, &props, "props", &view);
337        assert!(issues.contains(&PropIssue { path: "props.text".into(), message: "expected a string".into() }));
338        assert!(issues.contains(&PropIssue { path: "props.bogus".into(), message: "unknown prop".into() }));
339    }
340}