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