use crate::error::OxiforgeError;
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]
}
pub fn closest_match<'a>(input: &str, candidates: &[&'a str], max_dist: usize) -> Option<&'a str> {
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);
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));
}
}
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)
}
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",
"on",
"budget",
"max_objects",
"max_depth",
"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",
"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",
"orientation",
"anim_time",
"arc_length",
"digit_count",
"decimal_point",
"step",
"rollover",
"placeholder",
"password_mode",
"one_line",
"max_length",
"chart_type",
"point_count",
"div_lines_h",
"div_lines_v",
"tab_bar_position",
"tab_bar_size",
"tabs",
"name",
"angle_start",
"angle_size",
"today_year",
"today_month",
"today_day",
"shown_year",
"shown_month",
"items",
"columns",
"column_widths",
"rows",
"tiles",
"col",
"row",
"title",
"close_button",
"buttons",
"overflow",
"span_mode",
"max_lines",
"indent",
"spans",
"button_rows",
"one_checked",
"buf_width",
"buf_height",
"color_format",
"src_released",
"src_pressed",
"src_disabled",
"left",
"mid",
"right",
"frames",
"duration",
"repeat_count",
"auto_start",
"header_mode",
"root_back_button",
"update_fn",
"on_event_fn",
"input_group",
"input_group_fn",
"duration_ms",
"persistent",
"params",
"bind_text",
];
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 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);
}
}
}
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() {
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);
}
}