oxiforge 0.3.0

YAML-to-Rust code generator for oxivgl LVGL UIs
Documentation
// SPDX-License-Identifier: GPL-3.0-only
//! Fuzzy "did you mean?" hints for unknown YAML keys.

use crate::error::OxiforgeError;

/// Levenshtein edit distance.
pub fn edit_distance(a: &str, b: &str) -> usize {
    let a_bytes = a.as_bytes();
    let b_bytes = b.as_bytes();
    let m = a_bytes.len();
    let n = b_bytes.len();

    let mut prev = (0..=n).collect::<Vec<_>>();
    let mut curr = vec![0; n + 1];

    for i in 1..=m {
        curr[0] = i;
        for j in 1..=n {
            let cost = if a_bytes[i - 1] == b_bytes[j - 1] { 0 } else { 1 };
            curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
        }
        std::mem::swap(&mut prev, &mut curr);
    }
    prev[n]
}

/// Find closest match from candidates using edit distance or substring
/// matching.
pub fn closest_match<'a>(input: &str, candidates: &[&'a str], max_dist: usize) -> Option<&'a str> {
    // Score each candidate: lower is better
    // Uses edit distance, but also checks if input suffix (after last _) matches
    let input_parts: Vec<&str> = input.split('_').collect();
    let input_suffix = *input_parts.last().unwrap_or(&input);

    let mut best: Option<(&str, usize)> = None;
    for &c in candidates {
        if c == input {
            continue;
        }
        let dist = edit_distance(input, c);

        // Bonus: if both share the same suffix after '_', reduce effective distance
        let c_parts: Vec<&str> = c.split('_').collect();
        let c_suffix = *c_parts.last().unwrap_or(&c);
        let effective = if input_suffix.len() >= 3 && input_suffix == c_suffix {
            dist.saturating_sub(input_suffix.len())
        } else {
            dist
        };

        if effective <= max_dist && (best.is_none() || effective < best.unwrap().1) {
            best = Some((c, effective));
        }
    }

    // Also check: input contains candidate (for "pressed" → "state_pressed")
    if best.is_none() || best.unwrap().1 > 2 {
        let substr = candidates.iter().filter(|c| **c != input && c.contains(input)).min_by_key(|c| c.len());
        if let Some(m) = substr {
            return Some(m);
        }
    }

    best.map(|(c, _)| c)
}

/// Known keys for common widget properties (CommonProps + StyleProps
/// flattened).
const KNOWN_WIDGET_KEYS: &[&str] = &[
    "id",
    "x",
    "y",
    "width",
    "height",
    "align",
    "hidden",
    "clickable",
    "checkable",
    "scrollable",
    "scroll_dir",
    "scrollbar_mode",
    "flex_grow",
    "grid_cell",
    "widgets",
    "styles",
    "layout",
    "flex_flow",
    "flex_align_main",
    "flex_align_cross",
    "flex_align_track",
    "grid_columns",
    "grid_rows",
    "grid_align_column",
    "grid_align_row",
    "state_default",
    "state_pressed",
    "state_focused",
    "state_checked",
    "state_disabled",
    "state_scrolled",
    "part_indicator",
    "part_knob",
    "part_items",
    "part_scrollbar",
    "part_selected",
    "part_cursor",
    // Event bindings
    "on",
    // Resource budget (pages/toasts)
    "budget",
    "max_objects",
    "max_depth",
    // StyleProps
    "opa",
    "bg_color",
    "bg_opa",
    "bg_grad",
    "bg_image_src",
    "bg_image_opa",
    "bg_image_recolor",
    "bg_image_recolor_opa",
    "bg_image_tiled",
    "border_color",
    "border_width",
    "border_opa",
    "border_side",
    "outline_color",
    "outline_width",
    "outline_opa",
    "outline_pad",
    "shadow_color",
    "shadow_width",
    "shadow_ofs_x",
    "shadow_ofs_y",
    "shadow_spread",
    "shadow_opa",
    "pad_all",
    "pad_top",
    "pad_bottom",
    "pad_left",
    "pad_right",
    "pad_row",
    "pad_column",
    "radius",
    "clip_corner",
    "translate_x",
    "translate_y",
    "text_color",
    "text_opa",
    "text_font",
    "text_align",
    "text_decor",
    "line_color",
    "line_width",
    "line_opa",
    "line_rounded",
    "arc_color",
    "arc_width",
    "arc_opa",
    "arc_rounded",
    "transform_angle",
    "transform_zoom",
    // Widget-specific keys (any widget type might have these)
    "text",
    "unit",
    "long_mode",
    "min_value",
    "max_value",
    "value",
    "start_angle",
    "end_angle",
    "rotation",
    "mode",
    "adjustable",
    "options",
    "selected",
    "dir",
    "symbol",
    "visible_row_count",
    "total_tick_count",
    "major_tick_every",
    "label_show",
    "range_min",
    "range_max",
    "angle_range",
    "major_tick_length",
    "minor_tick_length",
    "color",
    "brightness",
    "src",
    "points",
    "skip",
    // Slider/Bar orientation (core widgets)
    "orientation",
    // widget-spinner
    "anim_time",
    "arc_length",
    // widget-spinbox
    "digit_count",
    "decimal_point",
    "step",
    "rollover",
    // widget-textarea
    "placeholder",
    "password_mode",
    "one_line",
    "max_length",
    // widget-chart
    "chart_type",
    "point_count",
    "div_lines_h",
    "div_lines_v",
    // widget-tabview
    "tab_bar_position",
    "tab_bar_size",
    "tabs",
    "name",
    // widget-arc-label
    "angle_start",
    "angle_size",
    // widget-calendar
    "today_year",
    "today_month",
    "today_day",
    "shown_year",
    "shown_month",
    // widget-list
    "items",
    // widget-table
    "columns",
    "column_widths",
    "rows",
    // widget-tileview
    "tiles",
    "col",
    "row",
    // widget-win / widget-msgbox
    "title",
    "close_button",
    "buttons",
    // widget-spangroup
    "overflow",
    "span_mode",
    "max_lines",
    "indent",
    "spans",
    // widget-buttonmatrix
    "button_rows",
    "one_checked",
    // widget-canvas
    "buf_width",
    "buf_height",
    "color_format",
    // widget-imagebutton
    "src_released",
    "src_pressed",
    "src_disabled",
    "left",
    "mid",
    "right",
    // widget-animimg
    "frames",
    "duration",
    "repeat_count",
    "auto_start",
    // widget-menu
    "header_mode",
    "root_back_button",
    // View codegen hooks (page-level)
    "update_fn",
    "on_event_fn",
    "input_group",
    "input_group_fn",
    // Toast timing (toast-level)
    "duration_ms",
    "persistent",
    "params",
    // Toast text binding (label-level)
    "bind_text",
];

/// Pre-validate raw YAML Value tree for unknown keys with fuzzy suggestions.
pub fn check_unknown_keys(yaml: &str) -> Vec<OxiforgeError> {
    let value: serde_yaml::Value = match serde_yaml::from_str(yaml) {
        Ok(v) => v,
        Err(_) => return vec![], // let serde report parse errors
    };
    let mut errors = Vec::new();
    if let Some(lvgl) = value.get("lvgl")
        && let Some(pages) = lvgl.get("pages")
        && let Some(pages_seq) = pages.as_sequence()
    {
        for page in pages_seq {
            check_mapping_keys(page, KNOWN_WIDGET_KEYS, &mut errors);
            if let Some(widgets) = page.get("widgets") {
                check_widgets(widgets, &mut errors);
            }
        }
    }
    // Toasts share the page/widget key vocabulary plus the toast timing keys.
    if let Some(lvgl) = value.get("lvgl")
        && let Some(toasts) = lvgl.get("toasts")
        && let Some(toasts_seq) = toasts.as_sequence()
    {
        for toast in toasts_seq {
            check_mapping_keys(toast, KNOWN_WIDGET_KEYS, &mut errors);
            if let Some(widgets) = toast.get("widgets") {
                check_widgets(widgets, &mut errors);
            }
        }
    }
    errors
}

fn check_widgets(value: &serde_yaml::Value, errors: &mut Vec<OxiforgeError>) {
    if let Some(seq) = value.as_sequence() {
        for item in seq {
            if let Some(map) = item.as_mapping() {
                // Each widget is a single-key map like { label: { ... } }
                for (_widget_type, widget_props) in map {
                    check_mapping_keys(widget_props, KNOWN_WIDGET_KEYS, errors);
                    if let Some(children) = widget_props.get("widgets") {
                        check_widgets(children, errors);
                    }
                }
            }
        }
    }
}

fn check_mapping_keys(value: &serde_yaml::Value, known: &[&str], errors: &mut Vec<OxiforgeError>) {
    if let Some(map) = value.as_mapping() {
        for key in map.keys() {
            if let Some(key_str) = key.as_str()
                && !known.contains(&key_str)
            {
                let msg = if let Some(suggestion) = closest_match(key_str, known, 5) {
                    format!("unknown property '{key_str}', did you mean '{suggestion}'?")
                } else {
                    format!("unknown property '{key_str}'")
                };
                errors.push(OxiforgeError::Other(msg));
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn levenshtein_identical() {
        assert_eq!(edit_distance("abc", "abc"), 0);
    }

    #[test]
    fn levenshtein_one_edit() {
        assert_eq!(edit_distance("bg_color", "bg_colr"), 1);
    }

    #[test]
    fn closest_finds_bg_color() {
        let candidates = &["bg_color", "bg_opa", "border_color"];
        assert_eq!(closest_match("background_color", candidates, 10), Some("bg_color"));
    }

    #[test]
    fn closest_finds_state_pressed() {
        let candidates = &["state_pressed", "state_focused", "state_checked"];
        assert_eq!(closest_match("pressed", candidates, 7), Some("state_pressed"));
    }

    #[test]
    fn no_match_too_far() {
        let candidates = &["bg_color"];
        assert_eq!(closest_match("zzzzzzzzz", candidates, 3), None);
    }
}