1use dampen_core::ir::WidgetKind;
2use std::collections::HashSet;
3
4macro_rules! hashset {
6 [$($item:expr),* $(,)?] => {{
7 let mut set = HashSet::new();
8 $(set.insert($item);)*
9 set
10 }};
11}
12
13#[derive(Debug, Clone)]
15pub struct WidgetAttributeSchema {
16 pub required: HashSet<&'static str>,
17 pub optional: HashSet<&'static str>,
18 pub events: HashSet<&'static str>,
19 pub style_attributes: HashSet<&'static str>,
20 pub layout_attributes: HashSet<&'static str>,
21}
22
23impl WidgetAttributeSchema {
24 pub fn for_widget(kind: &WidgetKind) -> Self {
26 match kind {
27 WidgetKind::Text => Self {
28 required: hashset!["value"],
29 optional: hashset!["size", "weight", "color"],
30 events: EVENTS_COMMON.clone(),
31 style_attributes: STYLE_COMMON.clone(),
32 layout_attributes: LAYOUT_COMMON.clone(),
33 },
34 WidgetKind::Image => Self {
35 required: hashset!["src"],
36 optional: hashset!["width", "height", "fit", "filter_method", "path"],
37 events: EVENTS_COMMON.clone(),
38 style_attributes: STYLE_COMMON.clone(),
39 layout_attributes: LAYOUT_COMMON.clone(),
40 },
41 WidgetKind::Button => Self {
42 required: hashset![],
43 optional: hashset!["label", "enabled"],
44 events: hashset!["on_click", "on_press", "on_release"],
45 style_attributes: STYLE_COMMON.clone(),
46 layout_attributes: LAYOUT_COMMON.clone(),
47 },
48 WidgetKind::TextInput => Self {
49 required: hashset![],
50 optional: hashset!["placeholder", "value", "password", "icon"],
51 events: hashset!["on_input", "on_submit", "on_change", "on_paste"],
52 style_attributes: STYLE_COMMON.clone(),
53 layout_attributes: LAYOUT_COMMON.clone(),
54 },
55 WidgetKind::Checkbox => Self {
56 required: hashset![],
57 optional: hashset!["checked", "label", "icon"],
58 events: hashset!["on_toggle"],
59 style_attributes: STYLE_COMMON.clone(),
60 layout_attributes: LAYOUT_COMMON.clone(),
61 },
62 WidgetKind::Radio => Self {
63 required: hashset!["label", "value"],
64 optional: hashset![
65 "selected",
66 "disabled",
67 "size",
68 "text_size",
69 "text_line_height",
70 "text_shaping"
71 ],
72 events: hashset!["on_select"],
73 style_attributes: STYLE_COMMON.clone(),
74 layout_attributes: LAYOUT_COMMON.clone(),
75 },
76 WidgetKind::Slider => Self {
77 required: hashset![],
78 optional: hashset!["min", "max", "value", "step"],
79 events: hashset!["on_change", "on_release"],
80 style_attributes: STYLE_COMMON.clone(),
81 layout_attributes: LAYOUT_COMMON.clone(),
82 },
83 WidgetKind::Column | WidgetKind::Row | WidgetKind::Container => Self {
84 required: hashset![],
85 optional: hashset![],
86 events: EVENTS_COMMON.clone(),
87 style_attributes: STYLE_COMMON.clone(),
88 layout_attributes: LAYOUT_COMMON.clone(),
89 },
90 WidgetKind::Scrollable => Self {
91 required: hashset![],
92 optional: hashset![],
93 events: hashset!["on_scroll"],
94 style_attributes: STYLE_COMMON.clone(),
95 layout_attributes: LAYOUT_COMMON.clone(),
96 },
97 WidgetKind::Stack => Self {
98 required: hashset![],
99 optional: hashset![],
100 events: EVENTS_COMMON.clone(),
101 style_attributes: STYLE_COMMON.clone(),
102 layout_attributes: LAYOUT_COMMON.clone(),
103 },
104 WidgetKind::Svg => Self {
105 required: hashset!["src"],
106 optional: hashset!["width", "height", "path"],
107 events: EVENTS_COMMON.clone(),
108 style_attributes: STYLE_COMMON.clone(),
109 layout_attributes: LAYOUT_COMMON.clone(),
110 },
111 WidgetKind::PickList => Self {
112 required: hashset![],
113 optional: hashset!["placeholder", "selected", "options"],
114 events: hashset!["on_select"],
115 style_attributes: STYLE_COMMON.clone(),
116 layout_attributes: LAYOUT_COMMON.clone(),
117 },
118 WidgetKind::Toggler => Self {
119 required: hashset![],
120 optional: hashset!["checked", "active", "label"],
121 events: hashset!["on_toggle"],
122 style_attributes: STYLE_COMMON.clone(),
123 layout_attributes: LAYOUT_COMMON.clone(),
124 },
125 WidgetKind::Space | WidgetKind::Rule => Self {
126 required: hashset![],
127 optional: hashset![],
128 events: hashset![],
129 style_attributes: STYLE_COMMON.clone(),
130 layout_attributes: LAYOUT_COMMON.clone(),
131 },
132 WidgetKind::ComboBox => Self {
133 required: hashset![],
134 optional: hashset!["placeholder", "value", "options"],
135 events: hashset!["on_input", "on_select"],
136 style_attributes: STYLE_COMMON.clone(),
137 layout_attributes: LAYOUT_COMMON.clone(),
138 },
139 WidgetKind::ProgressBar => Self {
140 required: hashset![],
141 optional: hashset!["value", "min", "max", "style"],
142 events: hashset![],
143 style_attributes: STYLE_COMMON.clone(),
144 layout_attributes: LAYOUT_COMMON.clone(),
145 },
146 WidgetKind::Tooltip => Self {
147 required: hashset![],
148 optional: hashset!["message", "position", "delay"],
149 events: EVENTS_COMMON.clone(),
150 style_attributes: STYLE_COMMON.clone(),
151 layout_attributes: hashset![],
152 },
153 WidgetKind::Grid => Self {
154 required: hashset![],
155 optional: hashset!["columns"],
156 events: EVENTS_COMMON.clone(),
157 style_attributes: STYLE_COMMON.clone(),
158 layout_attributes: LAYOUT_COMMON.clone(),
159 },
160 WidgetKind::Canvas => Self {
161 required: hashset![],
162 optional: hashset!["program"],
163 events: hashset!["on_draw"],
164 style_attributes: STYLE_COMMON.clone(),
165 layout_attributes: LAYOUT_COMMON.clone(),
166 },
167 WidgetKind::Float => Self {
168 required: hashset![],
169 optional: hashset![],
170 events: EVENTS_COMMON.clone(),
171 style_attributes: STYLE_COMMON.clone(),
172 layout_attributes: LAYOUT_COMMON.clone(),
173 },
174 WidgetKind::For => Self {
175 required: hashset!["each", "in"],
176 optional: hashset!["template"],
177 events: hashset![],
178 style_attributes: STYLE_COMMON.clone(),
179 layout_attributes: LAYOUT_COMMON.clone(),
180 },
181 WidgetKind::Custom(_) => Self {
182 required: hashset![],
184 optional: hashset![],
185 events: hashset![],
186 style_attributes: hashset![],
187 layout_attributes: hashset![],
188 },
189 }
190 }
191
192 pub fn all_valid(&self) -> HashSet<&'static str> {
194 let mut all = HashSet::new();
195 all.extend(self.required.iter().copied());
196 all.extend(self.optional.iter().copied());
197 all.extend(self.events.iter().copied());
198 all.extend(self.style_attributes.iter().copied());
199 all.extend(self.layout_attributes.iter().copied());
200 all
201 }
202
203 pub fn all_valid_names(&self) -> Vec<&'static str> {
205 self.all_valid().into_iter().collect()
206 }
207}
208
209lazy_static::lazy_static! {
210 pub static ref STYLE_COMMON: HashSet<&'static str> = hashset![
211 "background",
212 "color",
213 "border_color",
214 "border_width",
215 "border_radius",
216 "border_style",
217 "shadow",
218 "opacity",
219 "transform",
220 "text_color",
221 "shadow_color",
222 "shadow_offset",
223 "shadow_blur_radius",
224 ];
225
226 pub static ref LAYOUT_COMMON: HashSet<&'static str> = hashset![
227 "width",
228 "height",
229 "min_width",
230 "max_width",
231 "min_height",
232 "max_height",
233 "padding",
234 "spacing",
235 "align_items",
236 "justify_content",
237 "align",
238 "direction",
239 "position",
240 "top",
241 "right",
242 "bottom",
243 "left",
244 "z_index",
245 "class", "theme", "theme_ref", ];
249
250 pub static ref EVENTS_COMMON: HashSet<&'static str> = hashset![
251 "on_click",
252 "on_press",
253 "on_release",
254 "on_change",
255 "on_input",
256 "on_submit",
257 "on_select",
258 "on_toggle",
259 "on_scroll",
260 ];
261}
262
263pub fn validate_widget_attributes(
267 widget_kind: &WidgetKind,
268 attributes: &[String],
269) -> Vec<(String, Option<String>)> {
270 use crate::commands::check::suggestions;
271
272 let schema = WidgetAttributeSchema::for_widget(widget_kind);
273 let valid_attrs = schema.all_valid();
274 let valid_names = schema.all_valid_names();
275
276 let mut unknown_attrs = Vec::new();
277
278 for attr in attributes {
279 if !valid_attrs.contains(attr.as_str()) {
280 let suggestion = suggestions::find_closest_match(attr, &valid_names, 3)
282 .map(|(matched, _)| matched.to_string());
283
284 unknown_attrs.push((attr.clone(), suggestion));
285 }
286 }
287
288 unknown_attrs
289}
290
291pub fn is_valid_attribute(widget_kind: &WidgetKind, attribute: &str) -> bool {
293 let schema = WidgetAttributeSchema::for_widget(widget_kind);
294 schema.all_valid().contains(attribute)
295}
296
297pub fn validate_required_attributes(
301 widget_kind: &WidgetKind,
302 attributes: &[String],
303) -> Vec<String> {
304 let schema = WidgetAttributeSchema::for_widget(widget_kind);
305
306 schema
308 .required
309 .iter()
310 .filter(|&&req| !attributes.iter().any(|attr| attr == req))
311 .map(|&s| s.to_string())
312 .collect()
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318
319 #[test]
320 fn test_text_widget_schema() {
321 let schema = WidgetAttributeSchema::for_widget(&WidgetKind::Text);
322 assert!(schema.required.contains("value"));
323 assert!(schema.optional.contains("size"));
324 assert!(!schema.required.contains("size"));
325 }
326
327 #[test]
328 fn test_image_widget_schema() {
329 let schema = WidgetAttributeSchema::for_widget(&WidgetKind::Image);
330 assert!(schema.required.contains("src"));
331 assert!(schema.optional.contains("width"));
332 }
333
334 #[test]
335 fn test_button_widget_schema() {
336 let schema = WidgetAttributeSchema::for_widget(&WidgetKind::Button);
337 assert!(schema.events.contains("on_click"));
338 assert!(schema.optional.contains("label"));
339 }
340
341 #[test]
342 fn test_radio_widget_schema() {
343 let schema = WidgetAttributeSchema::for_widget(&WidgetKind::Radio);
344 assert!(schema.required.contains("label"));
345 assert!(schema.required.contains("value"));
346 assert!(schema.optional.contains("selected"));
347 assert!(schema.events.contains("on_select"));
348 }
349
350 #[test]
351 fn test_all_valid_includes_all_categories() {
352 let schema = WidgetAttributeSchema::for_widget(&WidgetKind::Text);
353 let all = schema.all_valid();
354
355 assert!(all.contains("value"));
357
358 assert!(all.contains("size"));
360
361 assert!(all.contains("background"));
363
364 assert!(all.contains("width"));
366
367 assert!(all.contains("on_click"));
369 }
370
371 #[test]
372 fn test_all_valid_names_returns_vec() {
373 let schema = WidgetAttributeSchema::for_widget(&WidgetKind::Button);
374 let names = schema.all_valid_names();
375
376 assert!(!names.is_empty());
377 assert!(names.contains(&"on_click"));
378 }
379
380 #[test]
381 fn test_validate_widget_attributes_valid() {
382 let attrs = vec!["on_click".to_string(), "label".to_string()];
383 let unknown = validate_widget_attributes(&WidgetKind::Button, &attrs);
384 assert!(unknown.is_empty());
385 }
386
387 #[test]
388 fn test_validate_widget_attributes_unknown() {
389 let attrs = vec!["on_clik".to_string(), "unknown".to_string()];
390 let unknown = validate_widget_attributes(&WidgetKind::Button, &attrs);
391 assert_eq!(unknown.len(), 2);
392
393 assert_eq!(unknown[0].0, "on_clik");
395 assert!(unknown[0].1.is_some());
396 assert_eq!(unknown[0].1.as_ref().unwrap(), "on_click");
397
398 assert_eq!(unknown[1].0, "unknown");
400 }
401
402 #[test]
403 fn test_is_valid_attribute() {
404 assert!(is_valid_attribute(&WidgetKind::Button, "on_click"));
405 assert!(is_valid_attribute(&WidgetKind::Button, "label"));
406 assert!(!is_valid_attribute(&WidgetKind::Button, "on_clik"));
407 }
408
409 #[test]
410 fn test_validate_required_attributes_all_present() {
411 let attrs = vec!["value".to_string(), "size".to_string()];
412 let missing = validate_required_attributes(&WidgetKind::Text, &attrs);
413 assert!(missing.is_empty());
414 }
415
416 #[test]
417 fn test_validate_required_attributes_missing_value() {
418 let attrs = vec!["size".to_string(), "color".to_string()];
419 let missing = validate_required_attributes(&WidgetKind::Text, &attrs);
420 assert_eq!(missing.len(), 1);
421 assert_eq!(missing[0], "value");
422 }
423
424 #[test]
425 fn test_validate_required_attributes_image_missing_src() {
426 let attrs = vec!["width".to_string(), "height".to_string()];
427 let missing = validate_required_attributes(&WidgetKind::Image, &attrs);
428 assert_eq!(missing.len(), 1);
429 assert_eq!(missing[0], "src");
430 }
431
432 #[test]
433 fn test_validate_required_attributes_radio_missing_both() {
434 let attrs = vec!["selected".to_string()];
435 let missing = validate_required_attributes(&WidgetKind::Radio, &attrs);
436 assert_eq!(missing.len(), 2);
437 assert!(missing.contains(&"label".to_string()));
438 assert!(missing.contains(&"value".to_string()));
439 }
440
441 #[test]
442 fn test_validate_required_attributes_button_no_required() {
443 let attrs = vec!["on_click".to_string()];
444 let missing = validate_required_attributes(&WidgetKind::Button, &attrs);
445 assert!(missing.is_empty());
446 }
447}