use std::collections::{HashMap, HashSet};
use crate::{error::OxiforgeError, model::*};
fn is_valid_rust_ident(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
None => false,
Some(c) => (c.is_ascii_alphabetic() || c == '_') && chars.all(|c| c.is_ascii_alphanumeric() || c == '_'),
}
}
pub fn validate(doc: &UiDoc) -> Vec<OxiforgeError> {
let mut errors = Vec::new();
let mut page_ids = HashSet::new();
let mut widget_ids = HashSet::new();
let style_ids: HashSet<&str> = doc
.lvgl
.style_definitions
.as_ref()
.map(|defs| defs.iter().map(|d| d.id.as_str()).collect())
.unwrap_or_default();
let gradient_ids: HashSet<&str> =
doc.lvgl.gradients.as_ref().map(|gs| gs.iter().map(|g| g.id.as_str()).collect()).unwrap_or_default();
let toast_params: HashMap<&str, usize> = doc
.lvgl
.toasts
.iter()
.flatten()
.map(|t| (t.id.as_str(), t.params.as_deref().map_or(0, <[String]>::len)))
.collect();
if let Some(defs) = &doc.lvgl.style_definitions {
for def in defs {
check_style_props(&def.style, &gradient_ids, &mut errors);
}
}
for page in &doc.lvgl.pages {
if !page_ids.insert(page.id.as_str()) {
errors.push(OxiforgeError::Other(format!("duplicate page id '{}'", page.id)));
}
check_style_refs(&page.common, &style_ids, &mut errors);
check_style_props(&page.common.style, &gradient_ids, &mut errors);
check_input_group(page, &mut errors);
check_budget(&page.budget, &page.id, &mut errors);
if let Some(widgets) = &page.common.widgets {
validate_widgets(widgets, &style_ids, &gradient_ids, &mut widget_ids, &mut errors);
reject_page_bind_text(widgets, &page.id, &mut errors);
check_show_toast_refs(widgets, &toast_params, &mut errors);
}
}
let mut toast_ids = HashSet::new();
for toast in doc.lvgl.toasts.iter().flatten() {
if page_ids.contains(toast.id.as_str()) {
errors.push(OxiforgeError::Other(format!(
"toast id '{}' collides with a page id — both emit create_{}",
toast.id, toast.id
)));
}
if !toast_ids.insert(toast.id.as_str()) {
errors.push(OxiforgeError::Other(format!("duplicate toast id '{}'", toast.id)));
}
if toast.duration_ms.is_some() && toast.persistent == Some(true) {
errors.push(OxiforgeError::Other(format!(
"toast '{}' sets both 'duration_ms' and 'persistent' — they are mutually exclusive",
toast.id
)));
}
if toast.common.widgets.as_deref().is_none_or(<[WidgetKind]>::is_empty) {
errors.push(OxiforgeError::Other(format!("toast '{}' has no widgets", toast.id)));
}
check_style_refs(&toast.common, &style_ids, &mut errors);
check_style_props(&toast.common.style, &gradient_ids, &mut errors);
check_budget(&toast.budget, &toast.id, &mut errors);
if let Some(widgets) = &toast.common.widgets {
let mut toast_widget_ids = HashSet::new();
validate_widgets(widgets, &style_ids, &gradient_ids, &mut toast_widget_ids, &mut errors);
}
check_toast_bindings(toast, &mut errors);
}
errors
}
fn check_toast_bindings(toast: &ToastDef, errors: &mut Vec<OxiforgeError>) {
let params: Vec<&str> = toast.params.as_deref().unwrap_or_default().iter().map(String::as_str).collect();
let param_set: HashSet<&str> = params.iter().copied().collect();
let mut seen = HashSet::new();
for p in ¶ms {
if !is_valid_rust_ident(p) {
errors
.push(OxiforgeError::Other(format!("toast '{}' param '{p}' is not a valid Rust identifier", toast.id)));
}
if !seen.insert(*p) {
errors.push(OxiforgeError::Other(format!("toast '{}' declares duplicate param '{p}'", toast.id)));
}
}
let mut used = HashSet::new();
if let Some(widgets) = &toast.common.widgets {
check_bind_text_tree(widgets, &toast.id, ¶m_set, &mut used, errors);
}
for p in ¶ms {
if !used.contains(*p) {
errors.push(OxiforgeError::Other(format!(
"toast '{}' declares param '{p}' but no bind_text uses it",
toast.id
)));
}
}
}
fn check_bind_text_tree<'a>(
widgets: &'a [WidgetKind],
toast_id: &str,
params: &HashSet<&'a str>,
used: &mut HashSet<&'a str>,
errors: &mut Vec<OxiforgeError>,
) {
for w in widgets {
if let WidgetKind::Label(p) = w
&& let Some(bind) = &p.bind_text
{
if p.text.is_some() {
errors.push(OxiforgeError::Other(format!(
"toast '{toast_id}' label sets both 'text' and 'bind_text' — use one"
)));
}
match params.get(bind.as_str()) {
Some(name) => {
used.insert(*name);
}
None => {
let candidates: Vec<&str> = params.iter().copied().collect();
let msg = match crate::hints::closest_match(bind, &candidates, 5) {
Some(s) => {
format!(
"toast '{toast_id}' bind_text '{bind}' is not a declared param, did you mean '{s}'?"
)
}
None => format!("toast '{toast_id}' bind_text '{bind}' is not a declared param"),
};
errors.push(OxiforgeError::Other(msg));
}
}
}
if let Some(children) = w.common().widgets.as_deref() {
check_bind_text_tree(children, toast_id, params, used, errors);
}
}
}
fn reject_page_bind_text(widgets: &[WidgetKind], page_id: &str, errors: &mut Vec<OxiforgeError>) {
for w in widgets {
if let WidgetKind::Label(p) = w
&& p.bind_text.is_some()
{
errors.push(OxiforgeError::Other(format!(
"page '{page_id}' label uses 'bind_text', which is only valid inside a toast"
)));
}
if let Some(children) = w.common().widgets.as_deref() {
reject_page_bind_text(children, page_id, errors);
}
}
}
fn validate_widgets(
widgets: &[WidgetKind],
style_ids: &HashSet<&str>,
gradient_ids: &HashSet<&str>,
widget_ids: &mut HashSet<String>,
errors: &mut Vec<OxiforgeError>,
) {
for widget in widgets {
let common = widget.common();
if let Some(id) = &common.id
&& !widget_ids.insert(id.clone())
{
errors.push(OxiforgeError::Other(format!("duplicate widget id '{id}'")));
}
check_style_refs(common, style_ids, errors);
check_style_props(&common.style, gradient_ids, errors);
check_event_bindings(common, errors);
if let Some(children) = &common.widgets {
validate_widgets(children, style_ids, gradient_ids, widget_ids, errors);
}
#[cfg(feature = "widget-tabview")]
if let WidgetKind::Tabview(p) = widget
&& let Some(tabs) = &p.tabs
{
for tab in tabs {
check_style_refs(&tab.common, style_ids, errors);
check_style_props(&tab.common.style, gradient_ids, errors);
if let Some(children) = &tab.common.widgets {
validate_widgets(children, style_ids, gradient_ids, widget_ids, errors);
}
}
}
#[cfg(feature = "widget-tileview")]
if let WidgetKind::Tileview(p) = widget
&& let Some(tiles) = &p.tiles
{
for tile in tiles {
check_style_refs(&tile.common, style_ids, errors);
check_style_props(&tile.common.style, gradient_ids, errors);
if let Some(children) = &tile.common.widgets {
validate_widgets(children, style_ids, gradient_ids, widget_ids, errors);
}
}
}
}
}
fn check_input_group(page: &Page, errors: &mut Vec<OxiforgeError>) {
if page.input_group.is_some() && page.input_group_fn.is_some() {
errors.push(OxiforgeError::Other(format!(
"page '{}' sets both 'input_group' and 'input_group_fn' — they are mutually exclusive",
page.id
)));
}
if let Some(f) = &page.input_group_fn
&& !is_valid_rust_ident(f)
{
errors.push(OxiforgeError::Other(format!("input_group_fn {f:?} is not a valid Rust identifier")));
}
let Some(group) = &page.input_group else { return };
if group.members.is_empty() {
errors.push(OxiforgeError::Other(format!("page '{}' input_group has no members", page.id)));
return;
}
let ids: Vec<String> = page
.common
.widgets
.as_deref()
.map(crate::codegen::collect_id_widgets)
.unwrap_or_default()
.into_iter()
.map(|(id, _)| id)
.collect();
let candidates: Vec<&str> = ids.iter().map(String::as_str).collect();
let id_set: HashSet<&str> = candidates.iter().copied().collect();
for member in &group.members {
if !id_set.contains(member.as_str()) {
let msg = match crate::hints::closest_match(member, &candidates, 5) {
Some(suggestion) => format!(
"page '{}' input_group member '{member}' is not a widget id on this page, did you mean '{suggestion}'?",
page.id
),
None => {
format!("page '{}' input_group member '{member}' is not a widget id on this page", page.id)
}
};
errors.push(OxiforgeError::Other(msg));
}
}
}
fn check_event_bindings(common: &CommonProps, errors: &mut Vec<OxiforgeError>) {
let Some(on_map) = &common.on else { return };
let widget_id = common.id.as_deref();
for handler_name in on_map.values().filter_map(|a| match a {
EventAction::Handler(h) => Some(h),
EventAction::ShowToast(_) => None,
}) {
if !is_valid_rust_ident(handler_name) {
errors.push(OxiforgeError::Other(format!(
"event handler name {:?} is not a valid Rust identifier",
handler_name
)));
} else if Some(handler_name.as_str()) == widget_id {
errors.push(OxiforgeError::Other(format!(
"event handler name {:?} collides with widget id — the local variable would shadow the function",
handler_name
)));
}
}
}
fn check_show_toast_refs(widgets: &[WidgetKind], toast_params: &HashMap<&str, usize>, errors: &mut Vec<OxiforgeError>) {
for w in widgets {
if let Some(on_map) = &w.common().on {
for st in on_map.values().filter_map(|a| match a {
EventAction::ShowToast(st) => Some(st),
EventAction::Handler(_) => None,
}) {
match toast_params.get(st.show_toast.as_str()) {
None => errors.push(OxiforgeError::Other(format!(
"on show_toast '{}' is not a defined toast or preset",
st.show_toast
))),
Some(&n) if st.args.len() != n => errors.push(OxiforgeError::Other(format!(
"on show_toast '{}' takes {n} arg(s) but {} given",
st.show_toast,
st.args.len()
))),
Some(_) => {}
}
}
}
if let Some(children) = w.common().widgets.as_deref() {
check_show_toast_refs(children, toast_params, errors);
}
}
}
fn check_style_refs(common: &CommonProps, style_ids: &HashSet<&str>, errors: &mut Vec<OxiforgeError>) {
if let Some(style_ref) = &common.styles {
for name in style_ref.names() {
if !style_ids.contains(name) {
errors.push(OxiforgeError::Other(format!("undefined style reference '{name}'")));
}
}
}
}
fn check_budget(budget: &Option<BudgetDef>, owner: &str, errors: &mut Vec<OxiforgeError>) {
if let Some(b) = budget
&& b.max_objects == 0
{
errors.push(OxiforgeError::Other(format!(
"'{owner}' budget.max_objects must be at least 1 (the container itself counts)"
)));
}
}
fn check_style_props(style: &StyleProps, gradient_ids: &HashSet<&str>, errors: &mut Vec<OxiforgeError>) {
if let Some(grad) = &style.bg_grad
&& !gradient_ids.contains(grad.as_str())
{
errors.push(OxiforgeError::Other(format!("undefined gradient reference '{grad}'")));
}
if let Some(sym) = &style.bg_image_src
&& !is_valid_rust_ident(sym)
{
errors.push(OxiforgeError::Other(format!("bg_image_src '{sym}' is not a valid Rust identifier")));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parse::parse_str;
#[test]
fn rejects_duplicate_page_ids() {
let yaml = "lvgl:\n pages:\n - id: main\n - id: main\n";
let doc = parse_str(yaml).unwrap();
let errs = validate(&doc);
assert!(!errs.is_empty());
assert!(errs[0].to_string().contains("duplicate page id"));
}
#[test]
fn rejects_zero_object_budget() {
let yaml = r#"
lvgl:
pages:
- id: main
budget:
max_objects: 0
"#;
let doc = parse_str(yaml).unwrap();
let errs = validate(&doc);
assert!(errs.iter().any(|e| e.to_string().contains("budget.max_objects")));
}
#[test]
fn rejects_invalid_bg_image_src_ident() {
let yaml = r#"
lvgl:
style_definitions:
- id: backdrop
bg_image_src: "bad-symbol-name"
pages:
- id: main
styles: backdrop
"#;
let doc = parse_str(yaml).unwrap();
let errs = validate(&doc);
assert!(errs.iter().any(|e| e.to_string().contains("not a valid Rust identifier")));
}
#[test]
fn rejects_duplicate_widget_ids() {
let yaml = r#"
lvgl:
pages:
- id: main
widgets:
- label:
id: lbl
text: "a"
- label:
id: lbl
text: "b"
"#;
let doc = parse_str(yaml).unwrap();
let errs = validate(&doc);
assert!(!errs.is_empty());
assert!(errs[0].to_string().contains("duplicate widget id"));
}
#[test]
fn rejects_undefined_style_ref() {
let yaml = r#"
lvgl:
pages:
- id: main
widgets:
- label:
styles: nonexistent
text: "hi"
"#;
let doc = parse_str(yaml).unwrap();
let errs = validate(&doc);
assert!(!errs.is_empty());
assert!(errs[0].to_string().contains("nonexistent"));
}
#[test]
fn rejects_undefined_gradient_ref() {
let yaml = r#"
lvgl:
pages:
- id: main
widgets:
- obj:
bg_grad: missing_grad
"#;
let doc = parse_str(yaml).unwrap();
let errs = validate(&doc);
assert!(!errs.is_empty());
assert!(errs[0].to_string().contains("missing_grad"));
}
#[test]
fn rejects_invalid_handler_name_with_hyphen() {
let yaml = r#"
lvgl:
pages:
- id: main
widgets:
- button:
on:
clicked: "my-handler"
"#;
let doc = parse_str(yaml).unwrap();
let errs = validate(&doc);
assert!(!errs.is_empty());
assert!(errs[0].to_string().contains("my-handler"));
}
#[test]
fn rejects_empty_handler_name() {
let yaml = "lvgl:\n pages:\n - id: main\n widgets:\n - button:\n on:\n clicked: \"\"\n";
let doc = parse_str(yaml).unwrap();
let errs = validate(&doc);
assert!(!errs.is_empty());
}
#[test]
fn accepts_underscore_prefixed_handler() {
let yaml = r#"
lvgl:
pages:
- id: main
widgets:
- button:
on:
clicked: _on_btn
"#;
let doc = parse_str(yaml).unwrap();
let errs = validate(&doc);
assert!(errs.is_empty());
}
#[test]
fn accepts_same_handler_on_two_widgets() {
let yaml = r#"
lvgl:
pages:
- id: main
widgets:
- button:
on:
clicked: on_btn_clicked
- button:
on:
clicked: on_btn_clicked
"#;
let doc = parse_str(yaml).unwrap();
let errs = validate(&doc);
assert!(errs.is_empty());
}
#[test]
fn rejects_handler_name_colliding_with_widget_id() {
let yaml = r#"
lvgl:
pages:
- id: main
widgets:
- button:
id: on_clicked
on:
clicked: on_clicked
"#;
let doc = parse_str(yaml).unwrap();
let errs = validate(&doc);
assert!(!errs.is_empty());
assert!(errs[0].to_string().contains("on_clicked"));
}
#[test]
fn accepts_valid_document() {
let yaml = r#"
lvgl:
style_definitions:
- id: card
bg_color: 0x1E1E1E
pages:
- id: main
widgets:
- obj:
styles: card
"#;
let doc = parse_str(yaml).unwrap();
let errs = validate(&doc);
assert!(errs.is_empty());
}
}