dampen_cli/commands/check/
attributes.rs

1use dampen_core::ir::WidgetKind;
2use std::collections::HashSet;
3
4/// Helper macro to create a HashSet from string literals.
5macro_rules! hashset {
6    [$($item:expr),* $(,)?] => {{
7        let mut set = HashSet::new();
8        $(set.insert($item);)*
9        set
10    }};
11}
12
13/// Schema defining valid attributes for a widget type.
14#[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    /// Returns the schema for a specific widget kind.
25    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                // Custom widgets will be validated separately via custom widget config
183                required: hashset![],
184                optional: hashset![],
185                events: hashset![],
186                style_attributes: hashset![],
187                layout_attributes: hashset![],
188            },
189        }
190    }
191
192    /// Returns all valid attributes (required + optional + events + style + layout).
193    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    /// Returns a list of all valid attribute names as a vector.
204    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",      // Style class reference
246        "theme",      // Theme reference
247        "theme_ref",  // Theme reference (alternative name)
248    ];
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
263/// Validates widget attributes and detects unknown attributes.
264///
265/// Returns a list of unknown attributes with suggestions.
266pub 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            // Generate suggestion using Levenshtein distance
281            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
291/// Checks if an attribute is valid for a widget.
292pub 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
297/// Validates that all required attributes are present for a widget.
298///
299/// Returns a list of missing required attributes.
300pub fn validate_required_attributes(
301    widget_kind: &WidgetKind,
302    attributes: &[String],
303) -> Vec<String> {
304    let schema = WidgetAttributeSchema::for_widget(widget_kind);
305
306    // Find all required attributes that are not present in the provided attributes
307    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        // Should include required
356        assert!(all.contains("value"));
357
358        // Should include optional
359        assert!(all.contains("size"));
360
361        // Should include style
362        assert!(all.contains("background"));
363
364        // Should include layout
365        assert!(all.contains("width"));
366
367        // Should include events
368        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        // First should have suggestion for "on_click"
394        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        // Second might not have a good suggestion
399        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}