1use 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
24fn 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
33fn 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 "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 for name in props.keys() {
161 if !schemas.contains_key(name) {
162 issue(issues, &format!("{path}.{name}"), "unknown prop");
163 }
164 }
165}
166
167pub 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
175pub fn is_callable_name(name: &str) -> bool {
178 if crate::generated::catalog::FUNCTION_NAMES.contains(&name) {
179 return true;
180 }
181 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
197fn 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
219fn 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
254fn 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
269pub fn validate_node(node: &UiNode) -> Vec<PropIssue> {
271 validate_node_in(node, &CatalogView::core())
272}
273
274pub 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}