use crate::error::A11yWarning;
use crate::parser::ast::*;
pub fn lint_accessibility(program: &Program) -> Vec<A11yWarning> {
let mut warnings = Vec::new();
for decl in &program.declarations {
match decl {
Declaration::Page(page) => {
let file = format!("src/pages/{}.wf", page.name);
lint_page(page, &file, &mut warnings);
}
Declaration::Component(comp) => {
let file = format!("src/components/{}.wf", comp.name);
lint_statements(&comp.body, &file, &mut warnings, &mut HeadingTracker::new());
}
Declaration::App(app) => {
lint_statements(&app.body, "src/App.wf", &mut warnings, &mut HeadingTracker::new());
}
Declaration::Store(_) => {} }
}
warnings
}
struct HeadingTracker {
levels_seen: Vec<u8>,
h1_count: usize,
}
impl HeadingTracker {
fn new() -> Self {
Self {
levels_seen: Vec::new(),
h1_count: 0,
}
}
fn record(&mut self, level: u8) {
if level == 1 {
self.h1_count += 1;
}
self.levels_seen.push(level);
}
fn last_level(&self) -> Option<u8> {
self.levels_seen.last().copied()
}
}
fn lint_page(page: &PageDecl, file: &str, warnings: &mut Vec<A11yWarning>) {
let mut tracker = HeadingTracker::new();
lint_statements(&page.body, file, warnings, &mut tracker);
if tracker.h1_count == 0 {
warnings.push(A11yWarning::new(
"A12", "Page has no h1 heading", file, 1, 1,
format!("Add a main heading: Heading(\"Page Title\", h1)"),
));
} else if tracker.h1_count > 1 {
warnings.push(A11yWarning::new(
"A12",
format!("Page has {} h1 headings (should be exactly 1)", tracker.h1_count),
file, 1, 1,
"Each page should have a single h1 as the main title".to_string(),
));
}
}
fn lint_statements(
stmts: &[Statement],
file: &str,
warnings: &mut Vec<A11yWarning>,
heading_tracker: &mut HeadingTracker,
) {
for stmt in stmts {
match stmt {
Statement::UIElement(ui) => lint_ui_element(ui, file, warnings, heading_tracker),
Statement::If(if_stmt) => {
lint_statements(&if_stmt.then_body, file, warnings, heading_tracker);
for (_, body) in &if_stmt.else_if_branches {
lint_statements(body, file, warnings, heading_tracker);
}
if let Some(else_body) = &if_stmt.else_body {
lint_statements(else_body, file, warnings, heading_tracker);
}
}
Statement::For(for_stmt) => {
lint_statements(&for_stmt.body, file, warnings, heading_tracker);
}
Statement::Show(show_stmt) => {
lint_statements(&show_stmt.body, file, warnings, heading_tracker);
}
Statement::Fetch(fetch) => {
if let Some(loading) = &fetch.loading_block {
lint_statements(loading, file, warnings, heading_tracker);
}
if let Some((_, error_body)) = &fetch.error_block {
lint_statements(error_body, file, warnings, heading_tracker);
}
if let Some(success) = &fetch.success_block {
lint_statements(success, file, warnings, heading_tracker);
}
}
_ => {}
}
}
}
fn lint_ui_element(
ui: &UIElement,
file: &str,
warnings: &mut Vec<A11yWarning>,
heading_tracker: &mut HeadingTracker,
) {
let (line, col) = (0, 0);
if let ComponentRef::BuiltIn(name) = &ui.component {
match name.as_str() {
"Image" => {
if !has_named_arg(&ui.args, "alt") {
warnings.push(A11yWarning::new(
"A01", "Image missing \"alt\" attribute", file, line, col,
"Add alt text: Image(src: \"...\", alt: \"Description of image\")",
));
}
}
"IconButton" => {
if !has_named_arg(&ui.args, "label") && !has_positional_arg(&ui.args) {
warnings.push(A11yWarning::new(
"A02", "IconButton missing accessible label", file, line, col,
"Add a label: IconButton(icon: \"close\", label: \"Close dialog\")",
));
}
}
"Input" => {
if !has_named_arg(&ui.args, "label") && !has_named_arg(&ui.args, "placeholder") {
warnings.push(A11yWarning::new(
"A03", "Input missing \"label\" or \"placeholder\" attribute", file, line, col,
"Add a label: Input(text, label: \"Username\")",
));
}
}
"Checkbox" | "Radio" | "Switch" | "Slider" => {
if !has_named_arg(&ui.args, "label") {
warnings.push(A11yWarning::new(
"A04",
format!("{} missing \"label\" attribute", name),
file, line, col,
format!("Add a label: {}(bind: value, label: \"Description\")", name),
));
}
}
"Button" => {
if !has_positional_arg(&ui.args) && !has_named_arg(&ui.args, "label") {
warnings.push(A11yWarning::new(
"A05", "Button has no text content", file, line, col,
"Add text: Button(\"Save\", primary)",
));
}
}
"Link" => {
let has_children = ui.children.iter().any(|s| matches!(s, Statement::UIElement(_)));
if !has_children && !has_named_arg(&ui.args, "label") {
warnings.push(A11yWarning::new(
"A06", "Link has no text content", file, line, col,
"Add text: Link(to: \"/about\") { Text(\"About\") }",
));
}
}
"Heading" => {
let level = get_heading_level(&ui.modifiers);
if level > 0 {
heading_tracker.record(level);
if let Some(_prev) = heading_tracker.last_level() {
if heading_tracker.levels_seen.len() >= 2 {
let prev_level = heading_tracker.levels_seen[heading_tracker.levels_seen.len() - 2];
if level > prev_level + 1 {
warnings.push(A11yWarning::new(
"A11",
format!("Heading level skips from h{} to h{}", prev_level, level),
file, line, col,
format!("Use h{} instead, or add the missing intermediate headings", prev_level + 1),
));
}
}
}
}
if !has_positional_arg(&ui.args) {
warnings.push(A11yWarning::new(
"A07", "Heading has no text content", file, line, col,
"Add text: Heading(\"Section Title\", h2)",
));
} else if has_empty_string_arg(&ui.args) {
warnings.push(A11yWarning::new(
"A07", "Heading has empty text content", file, line, col,
"Headings should have meaningful text",
));
}
}
"Modal" | "Dialog" => {
if !has_named_arg(&ui.args, "title") {
warnings.push(A11yWarning::new(
"A08",
format!("{} missing \"title\" attribute", name),
file, line, col,
format!("Add a title: {}(visible: state, title: \"Dialog Title\")", name),
));
}
}
"Video" => {
if !has_named_arg(&ui.args, "controls") && !ui.modifiers.contains(&"controls".to_string()) {
warnings.push(A11yWarning::new(
"A09", "Video missing \"controls\" attribute", file, line, col,
"Add controls: Video(src: \"...\", controls: true)",
));
}
}
"Table" => {
let has_thead = ui.children.iter().any(|s| {
if let Statement::UIElement(child) = s {
matches!(&child.component, ComponentRef::BuiltIn(n) if n == "Thead")
} else {
false
}
});
if !has_thead {
warnings.push(A11yWarning::new(
"A10", "Table missing header row (Thead)", file, line, col,
"Add a header: Table { Thead { Tcell(\"Column Name\") } ... }",
));
}
}
_ => {}
}
}
lint_statements(&ui.children, file, warnings, heading_tracker);
}
fn has_named_arg(args: &[Arg], name: &str) -> bool {
args.iter().any(|a| matches!(a, Arg::Named(n, _) if n == name))
}
fn has_positional_arg(args: &[Arg]) -> bool {
args.iter().any(|a| matches!(a, Arg::Positional(_)))
}
fn has_empty_string_arg(args: &[Arg]) -> bool {
args.iter().any(|a| {
if let Arg::Positional(Expr::StringLiteral(s)) = a {
s.is_empty()
} else {
false
}
})
}
fn get_heading_level(modifiers: &[String]) -> u8 {
for m in modifiers {
match m.as_str() {
"h1" => return 1,
"h2" => return 2,
"h3" => return 3,
"h4" => return 4,
"h5" => return 5,
"h6" => return 6,
_ => {}
}
}
0 }