use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::str::FromStr as _;
use proc_macro2::{Delimiter, Literal, TokenStream, TokenTree};
use serde::Serialize;
use crate::check::Severity;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Text,
Json,
}
#[derive(Debug, Clone, Copy)]
pub struct A11yVerifyOptions {
pub format: OutputFormat,
pub strict: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Rule {
ImageAlt,
Label,
ButtonName,
LinkName,
}
impl Rule {
const fn rule_id(self) -> &'static str {
match self {
Self::ImageAlt => "image-alt",
Self::Label => "label",
Self::ButtonName => "button-name",
Self::LinkName => "link-name",
}
}
const fn wcag(self) -> &'static str {
match self {
Self::ImageAlt => "1.1.1",
Self::Label => "1.3.1 / 3.3.2 / 4.1.2",
Self::ButtonName => "4.1.2",
Self::LinkName => "2.4.4 / 4.1.2",
}
}
const fn message(self) -> &'static str {
match self {
Self::ImageAlt => "raw <img> has no alt attribute",
Self::Label => {
"raw form control has no associated <label for=…>, aria-label, or aria-labelledby"
}
Self::ButtonName => {
"raw <button> has no text content and no aria-label/aria-labelledby"
}
Self::LinkName => "raw <a href> has no link text and no aria-label/aria-labelledby",
}
}
const fn hint(self) -> &'static str {
match self {
Self::ImageAlt => "use autumn_web::a11y::Img::new(src, alt) / Img::decorative(src)",
Self::Label => "use autumn_web::a11y::TextField::new(..).label(..)",
Self::ButtonName => {
"use autumn_web::a11y::Button::new(name) / Button::icon(icon, name)"
}
Self::LinkName => "use autumn_web::a11y::Link::new(href, text) / Link::icon(..)",
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct Finding {
pub file: String,
pub line: usize,
pub element: String,
pub rule_id: &'static str,
pub wcag: &'static str,
#[serde(serialize_with = "serialize_severity")]
pub severity: Severity,
pub message: &'static str,
pub hint: &'static str,
}
fn serialize_severity<S>(severity: &Severity, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&severity.to_string())
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, Default)]
pub struct Summary {
pub critical: usize,
pub serious: usize,
pub moderate: usize,
pub total: usize,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct Report {
pub files_scanned: usize,
pub html_blocks: usize,
pub findings: Vec<Finding>,
pub summary: Summary,
}
impl Report {
fn from_scan(mut scan: Scan) -> Self {
scan.findings
.sort_by(|a, b| (a.file.as_str(), a.line).cmp(&(b.file.as_str(), b.line)));
let mut summary = Summary::default();
for finding in &scan.findings {
match finding.severity {
Severity::Critical => summary.critical += 1,
Severity::Serious => summary.serious += 1,
Severity::Moderate => summary.moderate += 1,
}
}
summary.total = scan.findings.len();
Self {
files_scanned: scan.files_scanned,
html_blocks: scan.html_blocks,
findings: scan.findings,
summary,
}
}
#[must_use]
pub fn exit_code(&self, strict: bool) -> i32 {
let threshold = if strict {
Severity::Moderate
} else {
Severity::Serious
};
i32::from(self.findings.iter().any(|f| f.severity >= threshold))
}
}
#[derive(Debug, Default)]
struct Scan {
findings: Vec<Finding>,
files_scanned: usize,
html_blocks: usize,
}
impl Scan {
fn push(&mut self, rule: Rule, element: &str, line: usize, file: &str) {
self.findings.push(Finding {
file: file.to_owned(),
line,
element: element.to_owned(),
rule_id: rule.rule_id(),
wcag: rule.wcag(),
severity: Severity::Serious,
message: rule.message(),
hint: rule.hint(),
});
}
}
fn scan_project(root: &Path) -> std::io::Result<Scan> {
std::fs::read_dir(root)?;
let mut scan = Scan::default();
let mut files = Vec::new();
collect_rs_files(root, &mut files);
files.sort();
for path in files {
let Ok(src) = std::fs::read_to_string(&path) else {
continue;
};
let rel = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.into_owned();
scan_source(&src, &rel, &mut scan);
scan.files_scanned += 1;
}
Ok(scan)
}
fn collect_rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_symlink() {
continue;
}
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if file_type.is_dir() {
if name == "target" || name == "tests" || name.starts_with('.') {
continue;
}
collect_rs_files(&path, out);
} else if file_type.is_file() && path.extension().and_then(|s| s.to_str()) == Some("rs") {
out.push(path);
}
}
}
fn scan_source(src: &str, file: &str, scan: &mut Scan) {
let Ok(stream) = TokenStream::from_str(src) else {
return;
};
find_html_blocks(&stream, file, scan);
}
fn find_html_blocks(stream: &TokenStream, file: &str, scan: &mut Scan) {
let trees: Vec<TokenTree> = stream.clone().into_iter().collect();
let mut i = 0;
while i < trees.len() {
if is_cfg_test_attr(&trees, i) {
i = skip_cfg_test_item(&trees, i + 2);
continue;
}
if let TokenTree::Ident(ident) = &trees[i]
&& *ident == "html"
&& matches!(trees.get(i + 1), Some(TokenTree::Punct(p)) if p.as_char() == '!')
&& let Some(TokenTree::Group(group)) = trees.get(i + 2)
{
let body: Vec<TokenTree> = group.stream().into_iter().collect();
if looks_like_jsx(&body) {
i += 3;
continue;
}
let nodes = parse_nodes(&body);
scan.html_blocks += 1;
analyze_block(&nodes, file, scan);
find_html_blocks(&group.stream(), file, scan);
i += 3;
continue;
}
if let TokenTree::Group(group) = &trees[i] {
find_html_blocks(&group.stream(), file, scan);
}
i += 1;
}
}
fn is_cfg_test_attr(trees: &[TokenTree], i: usize) -> bool {
let Some(TokenTree::Punct(p)) = trees.get(i) else {
return false;
};
if p.as_char() != '#' {
return false;
}
let Some(TokenTree::Group(g)) = trees.get(i + 1) else {
return false;
};
if g.delimiter() != Delimiter::Bracket {
return false;
}
let inner: Vec<TokenTree> = g.stream().into_iter().collect();
matches!(inner.first(), Some(TokenTree::Ident(id)) if *id == "cfg")
&& matches!(
inner.get(1),
Some(TokenTree::Group(paren))
if paren.delimiter() == Delimiter::Parenthesis
&& is_single_test_ident(&paren.stream())
)
}
fn is_single_test_ident(stream: &TokenStream) -> bool {
let trees: Vec<TokenTree> = stream.clone().into_iter().collect();
trees.len() == 1 && matches!(&trees[0], TokenTree::Ident(id) if *id == "test")
}
fn skip_cfg_test_item(trees: &[TokenTree], start: usize) -> usize {
let mut i = start;
while i < trees.len() {
match &trees[i] {
TokenTree::Group(g) if g.delimiter() == Delimiter::Brace => return i + 1,
TokenTree::Punct(p) if p.as_char() == ';' => return i + 1,
_ => i += 1,
}
}
i
}
fn looks_like_jsx(trees: &[TokenTree]) -> bool {
for (idx, tree) in trees.iter().enumerate() {
let TokenTree::Punct(p) = tree else {
continue;
};
let next_is =
|c: char| matches!(trees.get(idx + 1), Some(TokenTree::Punct(q)) if q.as_char() == c);
match p.as_char() {
'<' if next_is('/') => return true,
'/' if next_is('>') => return true,
_ => {}
}
}
false
}
#[derive(Debug, Clone)]
enum AttrValue {
Literal(String),
Dynamic,
Boolean,
}
#[derive(Debug, Clone)]
struct Attr {
name: String,
value: AttrValue,
}
#[derive(Debug, Clone)]
struct Element {
name: String,
line: usize,
attrs: Vec<Attr>,
children: Vec<Node>,
}
impl Element {
fn attr(&self, name: &str) -> Option<&AttrValue> {
self.attrs
.iter()
.find(|a| a.name.eq_ignore_ascii_case(name))
.map(|a| &a.value)
}
fn has_attr(&self, name: &str) -> bool {
self.attr(name).is_some()
}
fn has_aria_name(&self) -> bool {
["aria-label", "aria-labelledby"]
.iter()
.any(|n| attr_is_present_name(self.attr(n)))
}
fn has_title_name(&self) -> bool {
attr_is_present_name(self.attr("title"))
}
}
fn is_presentational(el: &Element) -> bool {
match el.attr("role") {
Some(AttrValue::Dynamic) => true,
Some(AttrValue::Literal(r)) => {
let r = r.to_ascii_lowercase();
r == "presentation" || r == "none"
}
_ => false,
}
}
fn attr_is_present_name(value: Option<&AttrValue>) -> bool {
match value {
Some(AttrValue::Dynamic) => true,
Some(AttrValue::Literal(s)) => !s.trim().is_empty(),
_ => false,
}
}
fn has_alt_marker(el: &Element) -> bool {
match el.attr("alt") {
None => false,
Some(AttrValue::Literal(s)) => s.is_empty() || !s.trim().is_empty(),
Some(_) => true,
}
}
#[derive(Debug, Clone)]
enum Node {
Element(Element),
Text(String),
Dynamic {
splice: bool,
},
}
fn parse_markup(stream: &TokenStream) -> Vec<Node> {
let trees: Vec<TokenTree> = stream.clone().into_iter().collect();
parse_nodes(&trees)
}
fn parse_nodes(trees: &[TokenTree]) -> Vec<Node> {
let mut nodes = Vec::new();
let mut i = 0;
while i < trees.len() {
match &trees[i] {
TokenTree::Ident(_) => {
let (element, next) = parse_element(trees, i);
nodes.push(Node::Element(element));
i = next;
}
TokenTree::Literal(lit) => {
let text = literal_text(lit);
if !text.trim().is_empty() {
nodes.push(Node::Text(text));
}
i += 1;
}
TokenTree::Punct(p) if matches!(p.as_char(), '#' | '.') => {
let (element, next) = parse_element(trees, i);
nodes.push(Node::Element(element));
i = next;
}
TokenTree::Punct(p) if p.as_char() == '@' => {
nodes.push(Node::Dynamic { splice: false });
if matches!(trees.get(i + 1), Some(TokenTree::Ident(id)) if *id == "match") {
i = skip_match(trees, i + 2, &mut nodes);
} else if matches!(trees.get(i + 1), Some(TokenTree::Ident(id)) if *id == "let") {
i = skip_let(trees, i + 2);
} else {
i = skip_control(trees, i + 1, &mut nodes);
}
}
TokenTree::Group(g) => {
if g.delimiter() == Delimiter::Brace {
nodes.extend(parse_markup(&g.stream()));
} else {
nodes.push(Node::Dynamic { splice: true });
}
i += 1;
}
TokenTree::Punct(_) => i += 1,
}
}
nodes
}
fn skip_control(trees: &[TokenTree], start: usize, nodes: &mut Vec<Node>) -> usize {
let mut i = start;
let mut in_pattern = false;
let mut saw_cond = false;
let mut pending_block = false;
while i < trees.len() {
match &trees[i] {
TokenTree::Ident(id) => {
if !in_pattern
&& matches!(trees.get(i + 1), Some(TokenTree::Punct(p)) if p.as_char() == '!')
&& matches!(trees.get(i + 2), Some(TokenTree::Group(_)))
{
saw_cond = true;
i += 3;
continue;
}
match id.to_string().as_str() {
"if" | "while" | "else" => {}
"let" | "for" => in_pattern = true,
"in" if in_pattern => in_pattern = false,
"match" | "loop" | "unsafe" if !in_pattern => {
saw_cond = true;
pending_block = true;
}
_ => {
if !in_pattern {
saw_cond = true;
}
}
}
i += 1;
}
TokenTree::Punct(p) if in_pattern && p.as_char() == '=' => {
in_pattern = false;
i += 1;
}
TokenTree::Punct(p) if p.as_char() == ';' => {
i += 1;
break;
}
TokenTree::Group(g) if g.delimiter() == Delimiter::Brace => {
if in_pattern {
i += 1;
continue;
}
if pending_block {
pending_block = false;
i += 1;
continue;
}
if !saw_cond
&& matches!(trees.get(i + 1), Some(TokenTree::Group(n)) if n.delimiter() == Delimiter::Brace)
{
i += 1;
continue;
}
nodes.extend(parse_markup(&g.stream()));
i += 1;
if matches!(trees.get(i), Some(TokenTree::Punct(p)) if p.as_char() == '@')
&& matches!(trees.get(i + 1), Some(TokenTree::Ident(id)) if *id == "else")
{
i += 1;
in_pattern = false;
saw_cond = false;
pending_block = false;
continue;
}
break;
}
_ => {
if !in_pattern {
saw_cond = true;
}
i += 1;
}
}
}
i
}
fn skip_let(trees: &[TokenTree], start: usize) -> usize {
let mut i = start;
while i < trees.len() {
if matches!(&trees[i], TokenTree::Punct(p) if p.as_char() == ';') {
return i + 1;
}
i += 1;
}
i
}
fn skip_match(trees: &[TokenTree], start: usize, nodes: &mut Vec<Node>) -> usize {
let mut i = start;
let mut saw_scrut = false;
while i < trees.len() {
if let TokenTree::Group(g) = &trees[i]
&& g.delimiter() == Delimiter::Brace
{
if !saw_scrut
&& matches!(trees.get(i + 1), Some(TokenTree::Group(n)) if n.delimiter() == Delimiter::Brace)
{
i += 1;
continue;
}
let arms: Vec<TokenTree> = g.stream().into_iter().collect();
parse_match_arms(&arms, nodes);
return i + 1;
}
saw_scrut = true;
i += 1;
}
i
}
fn parse_match_arms(trees: &[TokenTree], nodes: &mut Vec<Node>) {
let mut i = 0;
while i < trees.len() {
let Some(arrow) = find_fat_arrow(trees, i) else {
break;
};
i = parse_arm_body(trees, arrow + 2, nodes);
}
}
fn find_fat_arrow(trees: &[TokenTree], start: usize) -> Option<usize> {
let mut i = start;
while i < trees.len() {
if let TokenTree::Punct(p) = &trees[i]
&& p.as_char() == '='
&& matches!(trees.get(i + 1), Some(TokenTree::Punct(q)) if q.as_char() == '>')
{
return Some(i);
}
i += 1;
}
None
}
fn parse_arm_body(trees: &[TokenTree], start: usize, nodes: &mut Vec<Node>) -> usize {
if let Some(TokenTree::Group(g)) = trees.get(start)
&& g.delimiter() == Delimiter::Brace
{
nodes.extend(parse_markup(&g.stream()));
let mut next = start + 1;
if matches!(trees.get(next), Some(TokenTree::Punct(p)) if p.as_char() == ',') {
next += 1;
}
return next;
}
let mut j = start;
while j < trees.len() {
if matches!(&trees[j], TokenTree::Punct(p) if p.as_char() == ',') {
break;
}
j += 1;
}
nodes.extend(parse_nodes(&trees[start..j]));
if j < trees.len() { j + 1 } else { j }
}
fn parse_element(trees: &[TokenTree], start: usize) -> (Element, usize) {
let line = trees[start].span().start().line;
let (name, shorthand_start) = match &trees[start] {
TokenTree::Punct(p) if matches!(p.as_char(), '#' | '.') => ("div".to_owned(), start),
_ => read_name(trees, start),
};
let mut attrs = Vec::new();
let mut i = parse_shorthand(trees, shorthand_start, &mut attrs);
let mut children = Vec::new();
while i < trees.len() {
match &trees[i] {
TokenTree::Punct(p) if p.as_char() == ';' => {
i += 1;
break;
}
TokenTree::Group(g) if g.delimiter() == Delimiter::Brace => {
children = parse_markup(&g.stream());
i += 1;
break;
}
TokenTree::Punct(p) if matches!(p.as_char(), '#' | '.') => {
i = parse_shorthand(trees, i, &mut attrs);
}
TokenTree::Ident(_) => {
let (attr, next) = parse_attr(trees, i);
attrs.push(attr);
i = next;
}
TokenTree::Literal(lit) if string_literal_value(lit).is_some() => {
let (attr, next) = parse_attr(trees, i);
attrs.push(attr);
i = next;
}
_ => break,
}
}
(
Element {
name,
line,
attrs,
children,
},
i,
)
}
fn parse_shorthand(trees: &[TokenTree], start: usize, attrs: &mut Vec<Attr>) -> usize {
let mut i = start;
while let Some(TokenTree::Punct(p)) = trees.get(i) {
let sym = p.as_char();
if sym != '#' && sym != '.' {
break;
}
let is_id = sym == '#';
i += 1; match trees.get(i) {
Some(TokenTree::Group(g))
if matches!(g.delimiter(), Delimiter::Parenthesis | Delimiter::Brace) =>
{
if is_id {
attrs.push(Attr {
name: "id".to_owned(),
value: AttrValue::Dynamic,
});
}
i += 1;
}
Some(TokenTree::Punct(q)) if q.as_char() == '@' => {
if is_id {
attrs.push(Attr {
name: "id".to_owned(),
value: AttrValue::Dynamic,
});
}
i = skip_shorthand_control(trees, i + 1);
}
_ => {
let (name, next) = read_name(trees, i);
if next > i {
if is_id {
attrs.push(Attr {
name: "id".to_owned(),
value: AttrValue::Literal(name),
});
}
i = next;
}
}
}
if matches!(trees.get(i), Some(TokenTree::Group(g)) if g.delimiter() == Delimiter::Bracket)
{
i += 1;
}
}
i
}
fn skip_shorthand_control(trees: &[TokenTree], start: usize) -> usize {
let mut discard = Vec::new();
if matches!(trees.get(start), Some(TokenTree::Ident(id)) if *id == "match") {
skip_match(trees, start + 1, &mut discard)
} else if matches!(trees.get(start), Some(TokenTree::Ident(id)) if *id == "let") {
skip_let(trees, start + 1)
} else {
skip_control(trees, start, &mut discard)
}
}
fn parse_attr(trees: &[TokenTree], start: usize) -> (Attr, usize) {
let (name, i) = read_name(trees, start);
match trees.get(i) {
Some(TokenTree::Punct(p)) if p.as_char() == '=' => {
let (value, next) = read_attr_value(trees, i + 1);
(Attr { name, value }, next)
}
Some(TokenTree::Group(g)) if g.delimiter() == Delimiter::Bracket => (
Attr {
name,
value: AttrValue::Boolean,
},
i + 1,
),
Some(TokenTree::Punct(p)) if p.as_char() == '?' => {
let next = if matches!(trees.get(i + 1), Some(TokenTree::Group(g)) if g.delimiter() == Delimiter::Bracket)
{
i + 2
} else {
i + 1
};
(
Attr {
name,
value: AttrValue::Boolean,
},
next,
)
}
_ => (
Attr {
name,
value: AttrValue::Boolean,
},
i,
),
}
}
fn read_attr_value(trees: &[TokenTree], i: usize) -> (AttrValue, usize) {
match trees.get(i) {
Some(TokenTree::Literal(lit)) => string_literal_value(lit)
.map_or((AttrValue::Dynamic, i + 1), |v| {
(AttrValue::Literal(v), i + 1)
}),
Some(TokenTree::Group(_) | TokenTree::Ident(_)) => (AttrValue::Dynamic, i + 1),
Some(TokenTree::Punct(p)) if p.as_char() == '@' => {
(AttrValue::Dynamic, skip_shorthand_control(trees, i + 1))
}
_ => (AttrValue::Dynamic, i),
}
}
fn read_name(trees: &[TokenTree], start: usize) -> (String, usize) {
let Some(mut name) = name_segment(trees.get(start)) else {
return (String::new(), start);
};
let mut i = start + 1;
while let Some(TokenTree::Punct(p)) = trees.get(i) {
let sep = p.as_char();
if sep != '-' && sep != ':' {
break;
}
let Some(seg) = name_segment(trees.get(i + 1)) else {
break;
};
name.push(sep);
name.push_str(&seg);
i += 2;
}
(name, i)
}
fn name_segment(tree: Option<&TokenTree>) -> Option<String> {
match tree {
Some(TokenTree::Ident(id)) => Some(id.to_string()),
Some(TokenTree::Literal(lit)) => {
string_literal_value(lit).or_else(|| int_literal_text(lit))
}
_ => None,
}
}
fn int_literal_text(lit: &Literal) -> Option<String> {
syn::parse2::<syn::LitInt>(TokenStream::from(TokenTree::Literal(lit.clone())))
.ok()
.map(|n| n.base10_digits().to_string())
}
fn string_literal_value(lit: &Literal) -> Option<String> {
syn::parse2::<syn::LitStr>(TokenStream::from(TokenTree::Literal(lit.clone())))
.ok()
.map(|s| s.value())
}
fn literal_text(lit: &Literal) -> String {
string_literal_value(lit).unwrap_or_else(|| lit.to_string())
}
fn analyze_block(nodes: &[Node], file: &str, scan: &mut Scan) {
let mut label_fors = BTreeSet::new();
let mut dynamic_label_for = false;
collect_labels(nodes, &mut label_fors, &mut dynamic_label_for);
walk(nodes, file, &label_fors, dynamic_label_for, false, scan);
}
fn collect_labels(nodes: &[Node], fors: &mut BTreeSet<String>, dynamic: &mut bool) {
for node in nodes {
match node {
Node::Element(el) => {
if is_hidden(el) {
continue;
}
if el.name.eq_ignore_ascii_case("label") {
match el.attr("for") {
Some(AttrValue::Literal(v)) if label_provides_name(el) => {
fors.insert(v.clone());
}
Some(AttrValue::Dynamic) if label_provides_name(el) => *dynamic = true,
_ => {}
}
}
collect_labels(&el.children, fors, dynamic);
}
Node::Dynamic { .. } | Node::Text(_) => {}
}
}
}
struct LabelCtx<'a> {
fors: &'a BTreeSet<String>,
dynamic_for: bool,
sibling_splice: bool,
in_named_label: bool,
}
fn walk(
nodes: &[Node],
file: &str,
fors: &BTreeSet<String>,
dynamic_for: bool,
in_named_label: bool,
scan: &mut Scan,
) {
let sibling_splice = nodes
.iter()
.any(|n| matches!(n, Node::Dynamic { splice: true }));
let ctx = LabelCtx {
fors,
dynamic_for,
sibling_splice,
in_named_label,
};
for node in nodes {
let Node::Element(el) = node else {
continue;
};
if is_hidden(el) {
continue;
}
apply_rules(el, file, &ctx, scan);
let child_named_label =
in_named_label || (el.name.eq_ignore_ascii_case("label") && label_provides_name(el));
walk(
&el.children,
file,
fors,
dynamic_for,
child_named_label,
scan,
);
}
}
fn apply_rules(el: &Element, file: &str, ctx: &LabelCtx, scan: &mut Scan) {
match el.name.to_ascii_lowercase().as_str() {
"img"
if !has_alt_marker(el)
&& !el.has_aria_name()
&& !el.has_title_name()
&& !is_presentational(el) =>
{
scan.push(Rule::ImageAlt, "img", el.line, file);
}
name @ ("input" | "select" | "textarea") => {
check_field(el, name, file, ctx, scan);
}
"button" if !named_content(el) => {
scan.push(Rule::ButtonName, "button", el.line, file);
}
"a" if el.has_attr("href") && !named_content(el) => {
scan.push(Rule::LinkName, "a", el.line, file);
}
_ => {}
}
}
fn check_field(el: &Element, name: &str, file: &str, ctx: &LabelCtx, scan: &mut Scan) {
if name == "input" {
match el.attr("type") {
Some(AttrValue::Literal(ty))
if ["hidden", "submit", "button", "reset", "image"]
.contains(&ty.to_ascii_lowercase().as_str()) =>
{
return;
}
Some(AttrValue::Dynamic) => return,
_ => {}
}
}
if el.has_aria_name() || el.has_title_name() || ctx.in_named_label {
return;
}
match el.attr("id") {
Some(AttrValue::Literal(id)) => {
if ctx.fors.contains(id) || ctx.dynamic_for || ctx.sibling_splice {
return;
}
}
Some(AttrValue::Dynamic) => return,
_ => {}
}
scan.push(Rule::Label, name, el.line, file);
}
fn named_content(el: &Element) -> bool {
el.has_aria_name() || el.has_title_name() || content_provides_name(&el.children)
}
fn is_hidden(el: &Element) -> bool {
let aria_hidden = matches!(
el.attr("aria-hidden"),
Some(AttrValue::Literal(v)) if v.eq_ignore_ascii_case("true")
);
let native_hidden = matches!(
el.attr("hidden"),
Some(AttrValue::Boolean | AttrValue::Literal(_))
);
aria_hidden || native_hidden
}
fn content_provides_name(nodes: &[Node]) -> bool {
nodes.iter().any(|n| match n {
Node::Dynamic { splice } => *splice,
Node::Text(t) => !t.trim().is_empty(),
Node::Element(e) if is_hidden(e) => false,
Node::Element(e) => {
e.has_aria_name()
|| e.has_title_name()
|| (e.name.eq_ignore_ascii_case("img") && attr_is_present_name(e.attr("alt")))
|| content_provides_name(&e.children)
}
})
}
fn label_provides_name(el: &Element) -> bool {
content_provides_name(&el.children)
}
const PRIMITIVE_NOTE: &str = "Note: code using the typed autumn_web::a11y primitives (Img, Button, \
Link, MenuItem, TextField) is proven accessible at compile time and is \
intentionally not re-scanned. This pass targets raw html! markup that bypasses \
those primitives.";
#[must_use]
pub fn run_in(root: &Path, opts: A11yVerifyOptions) -> i32 {
let scan = match scan_project(root) {
Ok(scan) => scan,
Err(err) => {
eprintln!("error: cannot read scan path '{}': {err}", root.display());
return 1;
}
};
let report = Report::from_scan(scan);
match opts.format {
OutputFormat::Json => print_json(&report),
OutputFormat::Text => print_text(&report, opts.strict),
}
report.exit_code(opts.strict)
}
fn print_json(report: &Report) {
match serde_json::to_string_pretty(report) {
Ok(json) => println!("{json}"),
Err(err) => eprintln!("autumn a11y verify: failed to serialize report: {err}"),
}
}
fn print_text(report: &Report, strict: bool) {
println!("autumn a11y verify");
println!(
" scanned {} html! block(s) across {} .rs file(s)",
report.html_blocks, report.files_scanned
);
println!(" {PRIMITIVE_NOTE}");
if report.findings.is_empty() {
println!("\nResult: PASS — no accessibility violations in raw html! markup.");
return;
}
println!("\n findings:");
for f in &report.findings {
println!(
" {}:{}: <{}> [WCAG {}] {} — {}",
f.file, f.line, f.element, f.wcag, f.severity, f.message
);
println!(" hint: {}", f.hint);
}
println!(
"\n summary: {} Critical, {} Serious, {} Moderate ({} total)",
report.summary.critical,
report.summary.serious,
report.summary.moderate,
report.summary.total
);
if report.exit_code(strict) == 0 {
println!("Result: PASS with findings — below the failure threshold.");
} else {
println!(
"Result: FAIL — fix the accessibility violations above (or use the typed primitives)."
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn findings_for(src: &str) -> Vec<Finding> {
let mut scan = Scan::default();
scan_source(src, "view.rs", &mut scan);
scan.findings
}
fn rule_ids(src: &str) -> Vec<&'static str> {
let mut ids: Vec<&'static str> = findings_for(src).iter().map(|f| f.rule_id).collect();
ids.sort_unstable();
ids
}
fn wrap(markup: &str) -> String {
format!("fn view() {{ let _ = html! {{ {markup} }}; }}")
}
#[test]
fn alt_less_img_is_flagged() {
let src = wrap(r#"img src="x.png";"#);
let f = findings_for(&src);
assert_eq!(f.len(), 1, "{f:?}");
assert_eq!(f[0].rule_id, "image-alt");
assert_eq!(f[0].wcag, "1.1.1");
assert_eq!(f[0].severity, Severity::Serious);
}
#[test]
fn img_with_alt_is_clean() {
let src = wrap(r#"img src="x.png" alt="A logo";"#);
assert!(findings_for(&src).is_empty());
}
#[test]
fn img_with_empty_alt_is_clean() {
let src = wrap(r#"img src="divider.png" alt="";"#);
assert!(findings_for(&src).is_empty());
}
#[test]
fn img_with_dynamic_alt_is_clean() {
let src = wrap(r#"img src="x.png" alt=(caption);"#);
assert!(findings_for(&src).is_empty());
}
#[test]
fn img_with_whitespace_alt_is_flagged() {
let src = wrap(r#"img src="x" alt=" ";"#);
assert_eq!(
rule_ids(&src),
vec!["image-alt"],
"{:?}",
findings_for(&src)
);
}
#[test]
fn button_with_whitespace_aria_label_is_flagged() {
let src = wrap(r#"button aria-label=" " { }"#);
assert_eq!(
rule_ids(&src),
vec!["button-name"],
"{:?}",
findings_for(&src)
);
}
#[test]
fn button_with_real_aria_label_is_clean() {
let src = wrap(r#"button aria-label="Close" { }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn input_with_whitespace_title_and_no_label_is_flagged() {
let src = wrap(r#"input title=" " id="q";"#);
assert_eq!(rule_ids(&src), vec!["label"], "{:?}", findings_for(&src));
}
#[test]
fn unlabeled_input_is_flagged() {
let src = wrap(r#"input type="text" id="name";"#);
let f = findings_for(&src);
assert_eq!(f.len(), 1, "{f:?}");
assert_eq!(f[0].rule_id, "label");
assert_eq!(f[0].wcag, "1.3.1 / 3.3.2 / 4.1.2");
assert_eq!(f[0].element, "input");
}
#[test]
fn input_with_matching_label_for_is_clean() {
let src = wrap(r#"label for="name" { "Name" } input type="text" id="name";"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn input_with_aria_label_is_clean() {
let src = wrap(r#"input type="text" aria-label="Search";"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn input_with_empty_aria_label_is_flagged() {
let src = wrap(r#"input type="text" aria-label="";"#);
assert_eq!(rule_ids(&src), vec!["label"]);
}
#[test]
fn wrapped_label_with_text_is_clean() {
let src = wrap(r#"label { "Name" input type="text"; }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn hidden_input_needs_no_label() {
let src = wrap(r#"input type="hidden" name="_csrf";"#);
assert!(findings_for(&src).is_empty());
}
#[test]
fn unlabeled_select_and_textarea_are_flagged() {
let src = wrap(r#"select id="role" { } textarea id="bio" { }"#);
assert_eq!(rule_ids(&src), vec!["label", "label"]);
}
#[test]
fn input_with_dynamic_id_is_not_flagged() {
let src = wrap(r#"input type="text" id=(field_id);"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn empty_label_for_does_not_satisfy_association() {
let src = wrap(r#"label for="email" { } input type="text" id="email";"#);
assert_eq!(rule_ids(&src), vec!["label"], "{:?}", findings_for(&src));
}
#[test]
fn labeled_input_via_for_with_text_is_clean() {
let src = wrap(r#"label for="email" { "Email" } input type="text" id="email";"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn label_for_with_dynamic_body_is_clean() {
let src = wrap(r#"label for="email" { (title) } input type="text" id="email";"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn dynamic_for_label_with_empty_body_does_not_satisfy_association() {
let src = wrap(r#"label for=(id) { } input type="text" id="email";"#);
assert_eq!(rule_ids(&src), vec!["label"], "{:?}", findings_for(&src));
}
#[test]
fn dynamic_for_label_with_dynamic_body_is_clean() {
let src = wrap(r#"label for=(id) { (title) } input type="text" id="email";"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn dynamic_for_label_with_text_body_is_clean() {
let src = wrap(r#"label for=(id) { "Email" } input type="text" id="email";"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn input_with_dynamic_type_is_not_flagged() {
let src = wrap(r#"input type=(kind) value="Save";"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn input_with_literal_text_type_still_flagged() {
let src = wrap(r#"input type="text";"#);
assert_eq!(rule_ids(&src), vec!["label"], "{:?}", findings_for(&src));
}
#[test]
fn spliced_label_fragment_beside_control_is_not_flagged() {
let src = wrap(r#"(field_label) input id="email";"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn unlabeled_input_with_no_splice_is_still_flagged() {
let src = wrap(r#"input id="x";"#);
assert_eq!(rule_ids(&src), vec!["label"], "{:?}", findings_for(&src));
}
#[test]
fn empty_button_is_flagged() {
let src = wrap(r"button { }");
let f = findings_for(&src);
assert_eq!(f.len(), 1, "{f:?}");
assert_eq!(f[0].rule_id, "button-name");
assert_eq!(f[0].wcag, "4.1.2");
}
#[test]
fn button_with_text_is_clean() {
let src = wrap(r#"button { "Save" }"#);
assert!(findings_for(&src).is_empty());
}
#[test]
fn button_with_aria_label_is_clean() {
let src = wrap(r#"button aria-label="Close" { }"#);
assert!(findings_for(&src).is_empty());
}
#[test]
fn icon_button_with_alt_image_is_clean() {
let src = wrap(r#"button { img src="trash.svg" alt="Delete"; }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn button_with_dynamic_content_is_not_flagged() {
let src = wrap(r"button { (label_text) }");
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn button_with_only_control_head_is_flagged() {
let src = wrap(r"button { @if disabled { } }");
let f = findings_for(&src);
assert_eq!(f.len(), 1, "{f:?}");
assert_eq!(f[0].rule_id, "button-name");
}
#[test]
fn button_with_real_splice_content_is_clean() {
let src = wrap(r"button { (label) }");
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn textless_anchor_is_flagged() {
let src = wrap(r#"a href="/x" { }"#);
let f = findings_for(&src);
assert_eq!(f.len(), 1, "{f:?}");
assert_eq!(f[0].rule_id, "link-name");
assert_eq!(f[0].wcag, "2.4.4 / 4.1.2");
}
#[test]
fn anchor_with_text_is_clean() {
let src = wrap(r#"a href="/x" { "About us" }"#);
assert!(findings_for(&src).is_empty());
}
#[test]
fn anchor_without_href_is_not_flagged() {
let src = wrap(r"a { }");
assert!(findings_for(&src).is_empty());
}
#[test]
fn icon_anchor_with_aria_label_is_clean() {
let src = wrap(r#"a href="https://example.com" aria-label="GitHub" { }"#);
assert!(findings_for(&src).is_empty());
}
#[test]
fn icon_button_with_aria_hidden_glyph_is_flagged() {
let src = wrap(r#"button { span aria-hidden="true" { "×" } }"#);
let f = findings_for(&src);
assert_eq!(f.len(), 1, "{f:?}");
assert_eq!(f[0].rule_id, "button-name");
}
#[test]
fn icon_anchor_with_aria_hidden_glyph_is_flagged() {
let src = wrap(r#"a href="/x" { span aria-hidden="true" { "🔍" } }"#);
let f = findings_for(&src);
assert_eq!(f.len(), 1, "{f:?}");
assert_eq!(f[0].rule_id, "link-name");
}
#[test]
fn button_with_hidden_glyph_and_visible_text_is_clean() {
let src = wrap(r#"button { span aria-hidden="true" { "×" } "Close" }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn button_with_hidden_glyph_and_aria_label_is_clean() {
let src = wrap(r#"button aria-label="Close" { span aria-hidden="true" { "×" } }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn button_with_dynamic_aria_hidden_glyph_is_not_flagged() {
let src = wrap(r#"button { span aria-hidden=(flag) { "×" } }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn label_with_only_aria_hidden_text_does_not_satisfy_association() {
let src = wrap(r#"label for="q" { span aria-hidden="true" { "Search" } } input id="q";"#);
assert_eq!(rule_ids(&src), vec!["label"], "{:?}", findings_for(&src));
}
#[test]
fn label_with_only_control_head_does_not_satisfy_association() {
let src = wrap(r#"label for="q" { @if show { } } input id="q";"#);
assert_eq!(rule_ids(&src), vec!["label"], "{:?}", findings_for(&src));
}
#[test]
fn label_with_alt_image_satisfies_association() {
let src = wrap(r#"label for="q" { img src="s.svg" alt="Search"; } input id="q";"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn label_with_plain_text_still_satisfies_association() {
let src = wrap(r#"label for="e" { "Email" } input id="e";"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn empty_label_body_still_does_not_satisfy_association() {
let src = wrap(r#"label for="e" { } input id="e";"#);
assert_eq!(rule_ids(&src), vec!["label"], "{:?}", findings_for(&src));
}
#[test]
fn label_in_aria_hidden_container_does_not_satisfy_association() {
let src = wrap(r#"div aria-hidden="true" { label for="q" { "Search" } } input id="q";"#);
assert_eq!(rule_ids(&src), vec!["label"], "{:?}", findings_for(&src));
}
#[test]
fn label_in_visible_container_satisfies_association() {
let src = wrap(r#"div { label for="q" { "Search" } } input id="q";"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn typed_primitive_splice_is_not_flagged() {
let src = wrap(r"(autumn_web::a11y::Img::new(src, alt))");
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn fully_dynamic_splice_is_not_flagged() {
let src = wrap(r"(rendered_widget)");
assert!(findings_for(&src).is_empty());
}
#[test]
fn element_inside_control_block_is_still_scanned() {
let src = wrap(r#"@if show { img src="x.png"; }"#);
assert_eq!(rule_ids(&src), vec!["image-alt"]);
}
#[test]
fn match_arm_pattern_named_input_is_not_flagged() {
let src = wrap(r#"@match field { input => { "text" } }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn match_arm_body_real_input_is_still_flagged() {
let src = wrap(r"@match x { A => { input; } }");
assert_eq!(rule_ids(&src), vec!["label"], "{:?}", findings_for(&src));
}
#[test]
fn match_arm_with_guard_does_not_misparse() {
let src = wrap(r#"@match x { A if ready => { "ok" } B => { "no" } }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn match_multiple_arms_scan_each_body() {
let src = wrap(r#"@match k { input => { "a" } other => { img src="x.png"; } }"#);
assert_eq!(
rule_ids(&src),
vec!["image-alt"],
"{:?}",
findings_for(&src)
);
}
#[test]
fn let_initializer_struct_literal_is_not_parsed_as_markup() {
let src = wrap(r#"@let props = Field { input: true }; div { "ok" }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn let_binding_does_not_suppress_real_input_elsewhere() {
let src = wrap(r#"@let props = Field { input: true }; input type="text" id="name";"#);
assert_eq!(rule_ids(&src), vec!["label"], "{:?}", findings_for(&src));
}
#[test]
fn let_with_plain_initializer_does_not_crash() {
let src = wrap(r#"@let x = foo(); div { "ok" }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn let_after_control_reaches_its_skip_handler() {
let src = wrap(r#"@if show { "ok" } @let props = Field { input: true };"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn if_else_chain_still_parses_and_flags_real_input() {
let src = wrap(r#"@if c { input; } @else { "x" }"#);
assert_eq!(rule_ids(&src), vec!["label"], "{:?}", findings_for(&src));
}
#[test]
fn while_body_element_is_scanned() {
let src = wrap(r"@while more { input; }");
assert_eq!(rule_ids(&src), vec!["label"], "{:?}", findings_for(&src));
}
#[test]
fn for_struct_pattern_is_not_parsed_as_markup() {
let src = wrap(r#"@for Field { input, label } in fields { "row" }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn for_body_real_input_is_still_flagged() {
let src = wrap(r"@for x in items { input; }");
assert_eq!(rule_ids(&src), vec!["label"], "{:?}", findings_for(&src));
}
#[test]
fn if_let_struct_pattern_is_not_parsed_as_markup() {
let src = wrap(r#"@if let Field { input } = props { "ok" }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn while_let_struct_pattern_is_not_parsed_as_markup() {
let src = wrap(r#"@while let Field { input } = next() { "ok" }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn if_let_body_real_input_is_still_flagged() {
let src = wrap(r"@if let Some(x) = maybe { input; }");
assert_eq!(rule_ids(&src), vec!["label"], "{:?}", findings_for(&src));
}
#[test]
fn if_block_expression_condition_is_not_parsed_as_markup() {
let src = wrap(r#"@if { input } { "ok" }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn if_block_expression_condition_body_is_still_scanned() {
let src = wrap(r"@if { ready } { input; }");
assert_eq!(rule_ids(&src), vec!["label"], "{:?}", findings_for(&src));
}
#[test]
fn else_if_block_expression_condition_is_not_parsed_as_markup() {
let src = wrap(r#"@if a { "a" } @else if { input } { "b" }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn if_body_then_sibling_block_still_scans_body() {
let src = wrap(r"@if a { input; } { button { } }");
assert_eq!(
rule_ids(&src),
vec!["button-name", "label"],
"{:?}",
findings_for(&src)
);
}
#[test]
fn match_block_expression_scrutinee_is_not_parsed_as_markup() {
let src = wrap(r#"@match { input } { A => { "a" } }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn match_block_expression_scrutinee_arm_body_still_scanned() {
let src = wrap(r"@match { flag } { A => { input; } }");
assert_eq!(rule_ids(&src), vec!["label"], "{:?}", findings_for(&src));
}
#[test]
fn if_match_condition_arm_list_is_not_parsed_as_markup() {
let src = wrap(r#"@if match state { input => true, _ => false } { "ok" }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn if_simple_condition_body_input_still_flagged() {
let src = wrap(r"@if cond { input; }");
assert_eq!(rule_ids(&src), vec!["label"], "{:?}", findings_for(&src));
}
#[test]
fn while_comparison_condition_body_is_scanned() {
let src = wrap(r"@while x > 0 { button { } }");
assert_eq!(
rule_ids(&src),
vec!["button-name"],
"{:?}",
findings_for(&src)
);
}
#[test]
fn if_macro_condition_is_not_parsed_as_markup() {
let src = wrap(r#"@if ready! { input } { "ok" }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn if_macro_condition_body_is_still_scanned() {
let src = wrap(r"@if ready! { input } { button { } }");
assert_eq!(
rule_ids(&src),
vec!["button-name"],
"{:?}",
findings_for(&src)
);
}
#[test]
fn if_non_macro_condition_body_input_still_flagged() {
let src = wrap(r"@if cond { input; }");
assert_eq!(rule_ids(&src), vec!["label"], "{:?}", findings_for(&src));
}
#[test]
fn custom_hyphenated_tag_is_not_flagged() {
let src = wrap(r"x-input;");
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn custom_hyphenated_tag_with_body_is_not_flagged() {
let src = wrap(r"x-button { }");
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn namespaced_colon_tag_is_not_flagged() {
let src = wrap(r"svg:rect;");
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn plain_input_tag_is_still_flagged() {
let src = wrap(r"input;");
assert_eq!(rule_ids(&src), vec!["label"], "{:?}", findings_for(&src));
}
#[test]
fn plain_button_tag_is_still_flagged() {
let src = wrap(r"button { }");
assert_eq!(
rule_ids(&src),
vec!["button-name"],
"{:?}",
findings_for(&src)
);
}
#[test]
fn dynamic_class_shorthand_does_not_orphan_body() {
let src = wrap(r#"button.(cls) { "Save" }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn dynamic_class_shorthand_empty_button_still_flagged() {
let src = wrap(r"button.(cls) { }");
assert_eq!(
rule_ids(&src),
vec!["button-name"],
"{:?}",
findings_for(&src)
);
}
#[test]
fn toggled_class_shorthand_does_not_orphan_body() {
let src = wrap(r#"a.icon[active] href="/x" { "Home" }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn toggled_class_shorthand_empty_link_still_flagged() {
let src = wrap(r#"a.icon[active] href="/x" { }"#);
assert_eq!(
rule_ids(&src),
vec!["link-name"],
"{:?}",
findings_for(&src)
);
}
#[test]
fn dynamic_id_shorthand_is_treated_as_dynamic() {
let src = wrap(r#"input#(field_id) type="text";"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn static_id_shorthand_matches_label_for() {
let src = wrap(r#"label for="name" { "Name" } input#name type="text";"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn unlabeled_id_shorthand_input_is_flagged() {
let src = wrap(r#"input#name type="text";"#);
assert_eq!(rule_ids(&src), vec!["label"], "{:?}", findings_for(&src));
}
#[test]
fn leading_class_shorthand_is_implicit_div_not_input() {
let src = wrap(r".input { }");
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn leading_id_shorthand_is_implicit_div_not_button() {
let src = wrap(r"#button { }");
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn leading_class_shorthand_with_text_is_implicit_div() {
let src = wrap(r#".input { "x" }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn implicit_div_wrapping_real_input_still_flags_inner_input() {
let src = wrap(r".card { input; }");
assert_eq!(rule_ids(&src), vec!["label"], "{:?}", findings_for(&src));
}
#[test]
fn markup_valued_class_shorthand_does_not_truncate_button() {
let src = wrap(r#"button.@if primary { "primary" } { "Save" }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn markup_valued_class_shorthand_empty_button_still_flagged() {
let src = wrap(r#"button.@if primary { "primary" } { }"#);
assert_eq!(
rule_ids(&src),
vec!["button-name"],
"{:?}",
findings_for(&src)
);
}
#[test]
fn markup_valued_class_shorthand_before_attr_and_body_is_clean() {
let src = wrap(r#"a.@if c { "x" } href="/y" { "Link" }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn splice_valued_class_shorthand_stays_clean() {
let src = wrap(r#"button.(dynamic_class) { "Save" }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn colon_qualified_attr_name_does_not_orphan_button_body() {
let src = wrap(r#"button x-on:click="save" { "Save" }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn colon_qualified_attr_name_empty_button_still_flagged() {
let src = wrap(r#"button x-on:click="save" { }"#);
assert_eq!(
rule_ids(&src),
vec!["button-name"],
"{:?}",
findings_for(&src)
);
}
#[test]
fn colon_qualified_attr_on_link_is_clean() {
let src = wrap(r#"a href="/x" data:foo="y" { "Link" }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn numeric_class_shorthand_does_not_orphan_button_body() {
let src = wrap(r#"button.col-12 { "Save" }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn numeric_attr_name_does_not_orphan_button_body() {
let src = wrap(r#"button data-1="x" { "Save" }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn numeric_class_shorthand_on_link_is_clean() {
let src = wrap(r#"a.col-6 href="/x" { "Link" }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn numeric_class_shorthand_empty_button_still_flagged() {
let src = wrap(r"button.col-12 { }");
assert_eq!(
rule_ids(&src),
vec!["button-name"],
"{:?}",
findings_for(&src)
);
}
#[test]
fn plain_empty_button_without_numeric_name_still_flagged() {
let src = wrap(r"button { }");
assert_eq!(
rule_ids(&src),
vec!["button-name"],
"{:?}",
findings_for(&src)
);
}
#[test]
fn quoted_attr_name_does_not_orphan_button_body() {
let src = wrap(r#"button "@click"="save" { "Save" }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn quoted_aria_label_provides_accessible_name() {
let src = wrap(r#"input "aria-label"="Search";"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn quoted_non_name_attr_consumed_empty_button_still_flagged() {
let src = wrap(r#"button "data-x"="y" { }"#);
assert_eq!(
rule_ids(&src),
vec!["button-name"],
"{:?}",
findings_for(&src)
);
}
#[test]
fn match_valued_attr_does_not_truncate_button() {
let src = wrap(r#"button class=@match state { _ => "primary" } { "Save" }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn if_valued_attr_after_real_attr_stays_clean() {
let src = wrap(r#"a href="/x" class=@if hot { "hot" } { "Link" }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn if_valued_attr_empty_button_still_flagged() {
let src = wrap(r#"button class=@if c { "x" } { }"#);
assert_eq!(
rule_ids(&src),
vec!["button-name"],
"{:?}",
findings_for(&src)
);
}
#[test]
fn match_valued_attr_then_id_unlabeled_input_still_flagged() {
let src = wrap(r#"input class=@match s { _ => "c" } id="q";"#);
assert_eq!(rule_ids(&src), vec!["label"], "{:?}", findings_for(&src));
}
#[test]
fn aria_hidden_button_needs_no_name() {
let src = wrap(r#"button aria-hidden="true" { }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn aria_hidden_link_needs_no_name() {
let src = wrap(r#"a href="/x" aria-hidden="true" { }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn aria_hidden_img_needs_no_alt() {
let src = wrap(r#"img src="x.png" aria-hidden="true";"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn aria_hidden_subtree_control_is_exempt() {
let src = wrap(r#"div aria-hidden="true" { input type="text"; }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn visible_sibling_of_aria_hidden_control_still_flagged() {
let src = wrap(r#"button aria-hidden="true" { } button { }"#);
assert_eq!(
rule_ids(&src),
vec!["button-name"],
"{:?}",
findings_for(&src)
);
}
#[test]
fn dynamic_aria_hidden_control_is_still_checked() {
let src = wrap(r"button aria-hidden=(flag) { }");
assert_eq!(
rule_ids(&src),
vec!["button-name"],
"{:?}",
findings_for(&src)
);
}
#[test]
fn native_hidden_button_needs_no_name() {
let src = wrap(r"button hidden { }");
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn native_hidden_subtree_control_is_exempt() {
let src = wrap(r"div hidden { input; }");
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn native_hidden_with_literal_value_needs_no_name() {
let src = wrap(r#"button hidden="until-found" { }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn button_without_hidden_still_flagged() {
let src = wrap(r"button { }");
assert_eq!(
rule_ids(&src),
vec!["button-name"],
"{:?}",
findings_for(&src)
);
}
#[test]
fn visible_subtree_control_without_hidden_still_flagged() {
let src = wrap(r"div { input; }");
assert_eq!(rule_ids(&src), vec!["label"], "{:?}", findings_for(&src));
}
#[test]
fn dynamic_hidden_control_is_still_checked() {
let src = wrap(r"button hidden=(flag) { }");
assert_eq!(
rule_ids(&src),
vec!["button-name"],
"{:?}",
findings_for(&src)
);
}
#[test]
fn button_with_title_is_clean() {
let src = wrap(r#"button title="Close" { }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn button_with_empty_title_is_flagged() {
let src = wrap(r#"button title="" { }"#);
assert_eq!(
rule_ids(&src),
vec!["button-name"],
"{:?}",
findings_for(&src)
);
}
#[test]
fn link_with_title_is_clean() {
let src = wrap(r#"a href="/x" title="Home" { }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn input_with_title_is_clean() {
let src = wrap(r#"input type="text" title="Full name";"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn input_with_empty_title_is_flagged() {
let src = wrap(r#"input type="text" title="";"#);
assert_eq!(rule_ids(&src), vec!["label"], "{:?}", findings_for(&src));
}
#[test]
fn img_with_aria_label_is_clean() {
let src = wrap(r#"img src="x.png" aria-label="Logo";"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn img_with_presentation_role_is_clean() {
let src = wrap(r#"img src="divider.png" role="presentation";"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
let src = wrap(r#"img src="divider.png" role="none";"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn img_with_dynamic_role_is_clean() {
let src = wrap(r"img src=(s) role=(r);");
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn img_with_title_is_clean() {
let src = wrap(r#"img src="x.png" title="Logo";"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn img_with_no_name_source_is_still_flagged() {
let src = wrap(r#"img src="x.png" role="img";"#);
assert_eq!(
rule_ids(&src),
vec!["image-alt"],
"{:?}",
findings_for(&src)
);
}
#[test]
fn button_named_by_child_aria_label_is_clean() {
let src = wrap(r#"button { span aria-label="Close" { } }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn button_with_nameless_child_is_still_flagged() {
let src = wrap(r"button { span { } }");
assert_eq!(
rule_ids(&src),
vec!["button-name"],
"{:?}",
findings_for(&src)
);
}
#[test]
fn nested_splice_does_not_suppress_unrelated_control() {
let src = wrap(r#"p { (user.name) } input type="text" id="email";"#);
assert_eq!(rule_ids(&src), vec!["label"], "{:?}", findings_for(&src));
}
#[test]
fn direct_sibling_splice_still_suppresses_nested_control() {
let src = wrap(r#"div { (field_label) input type="text" id="email"; }"#);
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn multiple_findings_across_one_block() {
let src = wrap(r#"img src="a.png"; button { } a href="/x" { }"#);
assert_eq!(
rule_ids(&src),
vec!["button-name", "image-alt", "link-name"]
);
}
#[test]
fn json_report_is_keyed_to_wcag() {
let src = wrap(r#"img src="x.png";"#);
let mut scan = Scan::default();
scan_source(&src, "view.rs", &mut scan);
let report = Report::from_scan(scan);
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("\"wcag\":\"1.1.1\""), "{json}");
assert!(json.contains("\"severity\":\"Serious\""), "{json}");
assert!(json.contains("\"rule_id\":\"image-alt\""), "{json}");
}
#[test]
fn jsx_html_button_is_skipped() {
let src = wrap(r"<button>{label}</button>");
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn jsx_html_div_input_is_skipped() {
let src = wrap(r"<div><input/></div>");
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn real_maud_button_still_analyzed_after_jsx_skip() {
let src = wrap(r"button { }");
assert_eq!(
rule_ids(&src),
vec!["button-name"],
"{:?}",
findings_for(&src)
);
}
#[test]
fn maud_comparison_in_if_head_is_not_misread_as_jsx() {
let src = wrap(r"@if count < limit { input; }");
assert_eq!(rule_ids(&src), vec!["label"], "{:?}", findings_for(&src));
}
#[test]
fn maud_greater_than_comparison_is_not_misread_as_jsx() {
let src = wrap(r"@if a > b { button { } }");
assert_eq!(
rule_ids(&src),
vec!["button-name"],
"{:?}",
findings_for(&src)
);
}
#[test]
fn jsx_close_tag_is_still_skipped() {
let src = wrap(r"<button>{label}</button>");
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn jsx_self_closing_tag_is_still_skipped() {
let src = wrap(r"<input/>");
assert!(findings_for(&src).is_empty(), "{:?}", findings_for(&src));
}
#[test]
fn end_to_end_red_then_green() {
let dir = tempfile::tempdir().unwrap();
let view = dir.path().join("view.rs");
std::fs::write(&view, wrap(r#"img src="/logo.png";"#)).unwrap();
let opts = A11yVerifyOptions {
format: OutputFormat::Text,
strict: false,
};
assert_eq!(run_in(dir.path(), opts), 1, "red project must fail CI");
std::fs::write(&view, wrap(r#"img src="/logo.png" alt="Logo";"#)).unwrap();
assert_eq!(run_in(dir.path(), opts), 0, "fixed project must pass CI");
}
#[test]
fn exit_code_zero_when_clean() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("view.rs"), wrap(r#"button { "Ok" }"#)).unwrap();
let opts = A11yVerifyOptions {
format: OutputFormat::Json,
strict: false,
};
assert_eq!(run_in(dir.path(), opts), 0);
}
#[test]
fn nonexistent_scan_root_fails_hard() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("does-not-exist");
let opts = A11yVerifyOptions {
format: OutputFormat::Text,
strict: false,
};
assert_eq!(
run_in(&missing, opts),
1,
"an unreadable scan root must fail, not report an empty clean audit"
);
}
#[test]
fn empty_but_readable_root_passes() {
let dir = tempfile::tempdir().unwrap();
let opts = A11yVerifyOptions {
format: OutputFormat::Text,
strict: false,
};
assert_eq!(run_in(dir.path(), opts), 0);
}
#[test]
fn defect_in_inline_cfg_test_module_is_not_flagged() {
let src = r#"
fn view() { let _ = html! { img src="product.png"; }; }
#[cfg(test)]
mod tests {
fn t() { let _ = html! { img src="scaffold.png"; }; }
}
"#;
let f = findings_for(src);
assert_eq!(f.len(), 1, "only the product defect should flag: {f:?}");
assert_eq!(f[0].rule_id, "image-alt");
}
#[test]
fn defect_in_inline_cfg_test_fn_is_not_flagged() {
let src = r"
#[cfg(test)]
fn helper() { let _ = html! { button { } }; }
";
assert!(findings_for(src).is_empty(), "{:?}", findings_for(src));
}
#[test]
fn cfg_not_test_module_is_still_scanned() {
let src = r#"
#[cfg(not(test))]
mod prod {
fn v() { let _ = html! { img src="x.png"; }; }
}
"#;
let f = findings_for(src);
assert_eq!(f.len(), 1, "{f:?}");
assert_eq!(f[0].rule_id, "image-alt");
}
#[test]
fn defect_under_tests_subdir_is_skipped_but_flagged_at_root() {
let dir = tempfile::tempdir().unwrap();
let opts = A11yVerifyOptions {
format: OutputFormat::Text,
strict: false,
};
let tests_dir = dir.path().join("tests");
std::fs::create_dir(&tests_dir).unwrap();
std::fs::write(tests_dir.join("view.rs"), wrap(r#"img src="x.png";"#)).unwrap();
assert_eq!(
run_in(dir.path(), opts),
0,
"a defect under a tests/ subdir must not be flagged",
);
std::fs::write(dir.path().join("view.rs"), wrap(r#"img src="x.png";"#)).unwrap();
assert_eq!(
run_in(dir.path(), opts),
1,
"the same defect at the scan root must be flagged",
);
}
}