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        #[allow(unused_mut)]
8        let mut set = HashSet::new();
9        $(set.insert($item);)*
10        set
11    }};
12}
13
14/// Schema defining valid attributes for a widget type.
15#[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    /// Returns the schema for a specific widget kind.
26    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                // Custom widgets will be validated separately via custom widget config
184                required: hashset![],
185                optional: hashset![],
186                events: hashset![],
187                style_attributes: hashset![],
188                layout_attributes: hashset![],
189            },
190        }
191    }
192
193    /// Returns all valid attributes (required + optional + events + style + layout).
194    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    /// Returns a list of all valid attribute names as a vector.
205    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",      // Style class reference
247        "theme",      // Theme reference
248        "theme_ref",  // Theme reference (alternative name)
249    ];
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
264/// Validates widget attributes and detects unknown attributes.
265///
266/// Returns a list of unknown attributes with suggestions.
267pub 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            // Generate suggestion using Levenshtein distance
282            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
292/// Checks if an attribute is valid for a widget.
293pub 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
298/// Validates that all required attributes are present for a widget.
299///
300/// Returns a list of missing required attributes.
301pub fn validate_required_attributes(
302    widget_kind: &WidgetKind,
303    attributes: &[String],
304) -> Vec<String> {
305    let schema = WidgetAttributeSchema::for_widget(widget_kind);
306
307    // Find all required attributes that are not present in the provided attributes
308    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        // Should include required
357        assert!(all.contains("value"));
358
359        // Should include optional
360        assert!(all.contains("size"));
361
362        // Should include style
363        assert!(all.contains("background"));
364
365        // Should include layout
366        assert!(all.contains("width"));
367
368        // Should include events
369        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        // First should have suggestion for "on_click"
395        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        // Second might not have a good suggestion
400        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}