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;
use crate::routes::CargoFeatures;
#[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,
pub routes: Vec<String>,
#[serde(skip)]
column: usize,
}
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,
pub routes: usize,
pub routes_failing: usize,
pub unrouted: usize,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct RouteConformance {
pub method: String,
pub path: String,
pub handler: String,
pub file: String,
pub line: usize,
pub findings: usize,
pub status: &'static str,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct WcagCriterion {
pub criterion: String,
pub rules: Vec<&'static str>,
pub findings: usize,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct Report {
pub files_scanned: usize,
pub html_blocks: usize,
pub routes: Vec<RouteConformance>,
pub findings: Vec<Finding>,
pub wcag: Vec<WcagCriterion>,
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 graph = RouteGraph::build(&scan.fns);
let reaching: Vec<Vec<usize>> = scan
.findings
.iter()
.map(|f| graph.routes_reaching(&f.file, (f.line, f.column)))
.collect();
for (finding, hit) in scan.findings.iter_mut().zip(&reaching) {
finding.routes = graph.display_keys(hit);
}
let routes = graph.conformance(&reaching);
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,
}
if finding.routes.is_empty() {
summary.unrouted += 1;
}
}
summary.total = scan.findings.len();
summary.routes = routes.len();
summary.routes_failing = routes.iter().filter(|r| r.findings > 0).count();
Self {
files_scanned: scan.files_scanned,
html_blocks: scan.html_blocks,
routes,
wcag: roll_up_wcag(&scan.findings),
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)]
pub struct Scan {
pub findings: Vec<Finding>,
files_scanned: usize,
html_blocks: usize,
fns: Vec<FnDecl>,
}
impl Scan {
fn push(&mut self, rule: Rule, element: &str, line: usize, column: 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(),
routes: Vec::new(),
column,
});
}
}
#[cfg(test)]
pub fn scan_project(root: &Path) -> std::io::Result<Scan> {
scan_project_with(root, &CargoFeatures::default())
}
pub fn scan_project_with(root: &Path, features: &CargoFeatures) -> std::io::Result<Scan> {
std::fs::read_dir(root)?;
let mut crates = CrateFeatures::new(features);
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_with(&src, &rel, crates.for_file(&path), &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);
}
}
}
struct CrateFeatures<'a> {
selection: &'a CargoFeatures,
manifest_of: std::collections::HashMap<PathBuf, Option<PathBuf>>,
by_manifest: std::collections::HashMap<PathBuf, Option<BTreeSet<String>>>,
}
impl<'a> CrateFeatures<'a> {
fn new(selection: &'a CargoFeatures) -> Self {
Self {
selection,
manifest_of: std::collections::HashMap::new(),
by_manifest: std::collections::HashMap::new(),
}
}
fn for_file(&mut self, file: &Path) -> Option<&BTreeSet<String>> {
let dir = std::path::absolute(file).ok()?.parent()?.to_path_buf();
let manifest = self
.manifest_of
.entry(dir)
.or_insert_with_key(|dir| {
dir.ancestors()
.map(|dir| dir.join("Cargo.toml"))
.find(|path| path.is_file())
})
.clone()?;
let selection = self.selection;
self.by_manifest
.entry(manifest)
.or_insert_with_key(|path| {
let text = std::fs::read_to_string(path).ok()?;
enabled_features(&text, selection)
})
.as_ref()
}
}
fn enabled_features(manifest: &str, selection: &CargoFeatures) -> Option<BTreeSet<String>> {
let table = toml::from_str::<toml::Table>(manifest).ok()?;
table.get("package")?;
let mut requested: Vec<String> = selection
.features
.iter()
.flat_map(|entry| entry.split([',', ' ']))
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_owned)
.collect();
if selection.all {
if let Some(features) = table.get("features").and_then(toml::Value::as_table) {
requested.extend(features.keys().cloned());
}
let features = table.get("features").and_then(toml::Value::as_table);
let targets = table
.get("target")
.and_then(toml::Value::as_table)
.into_iter()
.flat_map(|targets| targets.values());
let optional = std::iter::once(table.get("dependencies"))
.chain(targets.map(|target| target.get("dependencies")))
.filter_map(|deps| deps.and_then(toml::Value::as_table))
.flat_map(|deps| deps.iter())
.filter(|(_, dep)| dep.get("optional").and_then(toml::Value::as_bool) == Some(true))
.filter(|(name, _)| !crate::edge_scan::implicit_feature_is_suppressed(features, name))
.map(|(name, _)| name.clone());
requested.extend(optional);
}
let requested: Vec<&str> = requested.iter().map(String::as_str).collect();
Some(crate::edge_scan::enabled_features_with_defaults(
manifest,
&requested,
true,
!selection.no_default,
))
}
#[cfg(test)]
fn scan_source(src: &str, file: &str, scan: &mut Scan) {
scan_source_with(src, file, None, scan);
}
fn scan_source_with(src: &str, file: &str, features: Option<&BTreeSet<String>>, scan: &mut Scan) {
let Ok(stream) = TokenStream::from_str(src) else {
return;
};
find_html_blocks(&stream, file, scan);
let trees: Vec<TokenTree> = stream.into_iter().collect();
collect_fns(&trees, file, features, &mut scan.fns);
}
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, PartialEq, Eq)]
struct RouteDecl {
method: &'static str,
path: String,
}
#[derive(Debug)]
struct FnDecl {
name: String,
file: String,
start: (usize, usize),
end: (usize, usize),
calls: BTreeSet<String>,
routes: Vec<RouteDecl>,
callable: bool,
owner: Option<usize>,
top: Option<usize>,
}
type Span = ((usize, usize), (usize, usize));
fn span_of(trees: &[TokenTree], from: usize, to: usize) -> Span {
let start = trees[from].span().start();
let end = trees[to.max(from + 1) - 1].span().end();
((start.line, start.column), (end.line, end.column))
}
impl FnDecl {
fn excluded(file: &str, (start, end): Span) -> Self {
Self {
name: String::new(),
file: file.to_owned(),
start,
end,
calls: BTreeSet::new(),
routes: Vec::new(),
callable: false,
owner: None,
top: None,
}
}
}
const ROUTE_ATTRS: [(&str, &str); 6] = [
("get", "GET"),
("post", "POST"),
("put", "PUT"),
("patch", "PATCH"),
("delete", "DELETE"),
("static_get", "GET"),
];
const CALL_KEYWORDS: [&str; 6] = ["if", "while", "match", "return", "for", "in"];
const DECL_KEYWORDS: [&str; 6] = ["fn", "struct", "enum", "union", "trait", "mod"];
fn collect_fns(
trees: &[TokenTree],
file: &str,
features: Option<&BTreeSet<String>>,
out: &mut Vec<FnDecl>,
) {
let level = Level {
file,
features,
callable: true,
owner: None,
top: None,
};
collect_fns_in(trees, &level, out);
}
#[derive(Clone, Copy)]
struct Level<'a> {
file: &'a str,
features: Option<&'a BTreeSet<String>>,
callable: bool,
owner: Option<usize>,
top: Option<usize>,
}
fn collect_fns_in(trees: &[TokenTree], level: &Level<'_>, out: &mut Vec<FnDecl>) {
let Level {
file,
features,
callable,
owner,
top,
} = *level;
let mut pending: Vec<RouteDecl> = Vec::new();
let mut assoc_block = false;
let mut i = 0;
while i < trees.len() {
if let Some(next) = skip_unindexed_item(trees, i, features) {
out.push(FnDecl::excluded(file, span_of(trees, i, next)));
i = next;
pending.clear();
continue;
}
if let Some((route, next)) = read_attr(trees, i) {
pending.extend(route);
i = next;
continue;
}
if let TokenTree::Ident(ident) = &trees[i] {
if *ident == "fn"
&& let Some(TokenTree::Ident(name)) = trees.get(i + 1)
&& let Some(body_at) = find_fn_body(trees, i + 2)
&& let TokenTree::Group(body) = &trees[body_at]
{
let inner: Vec<TokenTree> = body.stream().into_iter().collect();
let (calls, excluded) = fn_calls(&trees[i + 2..body_at], &inner, features);
let start = trees[i].span().start();
let end = body.span().end();
out.push(FnDecl {
name: name.to_string(),
file: file.to_owned(),
start: (start.line, start.column),
end: (end.line, end.column),
calls,
routes: std::mem::take(&mut pending),
callable,
owner,
top,
});
let index = out.len() - 1;
out.extend(
excluded
.into_iter()
.map(|span| FnDecl::excluded(file, span)),
);
let body = Level {
callable: false,
owner: Some(index),
top: top.or(Some(index)),
..*level
};
collect_fns_in(&inner, &body, out);
i = body_at + 1;
continue;
}
if matches!(ident.to_string().as_str(), "impl" | "trait") {
assoc_block = true;
}
if !matches!(
ident.to_string().as_str(),
"pub" | "async" | "unsafe" | "extern"
) {
pending.clear();
}
}
match &trees[i] {
TokenTree::Group(group) => {
let brace = group.delimiter() == Delimiter::Brace;
if brace {
pending.clear();
}
let inner: Vec<TokenTree> = group.stream().into_iter().collect();
let nested = Level {
callable: callable && !(brace && assoc_block),
owner: None,
..*level
};
collect_fns_in(&inner, &nested, out);
if brace {
assoc_block = false;
}
}
TokenTree::Literal(_) => pending.clear(),
_ => {}
}
i += 1;
}
}
fn fn_calls(
head: &[TokenTree],
body: &[TokenTree],
features: Option<&BTreeSet<String>>,
) -> (BTreeSet<String>, Vec<Span>) {
let mut calls = BTreeSet::new();
let mut excluded = Vec::new();
collect_calls(body, features, &mut calls, &mut excluded);
let mut bound = BTreeSet::new();
if let Some(params) = head.iter().find_map(|tree| match tree {
TokenTree::Group(g) if g.delimiter() == Delimiter::Parenthesis => Some(g),
_ => None,
}) {
collect_idents(¶ms.stream().into_iter().collect::<Vec<_>>(), &mut bound);
}
collect_bindings(body, &mut bound);
calls.retain(|call| !bound.contains(call));
(calls, excluded)
}
fn skip_unindexed_item(
trees: &[TokenTree],
i: usize,
features: Option<&BTreeSet<String>>,
) -> Option<usize> {
if matches!(
(trees.get(i), trees.get(i + 1)),
(Some(TokenTree::Punct(p)), Some(TokenTree::Group(attr)))
if p.as_char() == '#'
&& attr.delimiter() == Delimiter::Bracket
&& !is_cfg_test_attr(trees, i)
&& cfg_is_off(attr, features)
) {
return Some(skip_gated(trees, i + 2));
}
let off = is_cfg_test_attr(trees, i)
|| (matches!(&trees[i], TokenTree::Ident(ident) if *ident == "macro_rules")
&& matches!(trees.get(i + 1), Some(TokenTree::Punct(p)) if p.as_char() == '!'));
off.then(|| skip_cfg_test_item(trees, i + 2))
}
fn read_attr(trees: &[TokenTree], i: usize) -> Option<(Option<RouteDecl>, usize)> {
let TokenTree::Punct(p) = trees.get(i)? else {
return None;
};
if p.as_char() != '#' {
return None;
}
let TokenTree::Group(group) = trees.get(i + 1)? else {
return None;
};
if group.delimiter() != Delimiter::Bracket {
return None;
}
Some((route_from_attr(&group.stream()), i + 2))
}
fn route_from_attr(stream: &TokenStream) -> Option<RouteDecl> {
let trees: Vec<TokenTree> = stream.clone().into_iter().collect();
let group_at = trees.iter().position(
|t| matches!(t, TokenTree::Group(g) if g.delimiter() == Delimiter::Parenthesis),
)?;
let TokenTree::Ident(name) = trees.get(group_at.checked_sub(1)?)? else {
return None;
};
let name = name.to_string();
let method = ROUTE_ATTRS
.iter()
.find(|(attr, _)| *attr == name)
.map(|(_, method)| *method)?;
let TokenTree::Group(args) = &trees[group_at] else {
return None;
};
let arg_trees: Vec<TokenTree> = args.stream().into_iter().collect();
let path = arg_trees.iter().enumerate().find_map(|(at, tree)| {
let TokenTree::Literal(lit) = tree else {
return None;
};
let named = matches!(
at.checked_sub(1).and_then(|prev| arg_trees.get(prev)),
Some(TokenTree::Punct(p)) if p.as_char() == '='
);
if named {
None
} else {
string_literal_value(lit)
}
})?;
Some(RouteDecl { method, path })
}
fn find_fn_body(trees: &[TokenTree], start: usize) -> Option<usize> {
for (offset, tree) in trees.iter().enumerate().skip(start) {
match tree {
TokenTree::Group(g) if g.delimiter() == Delimiter::Brace => return Some(offset),
TokenTree::Punct(p) if p.as_char() == ';' => return None,
_ => {}
}
}
None
}
fn collect_calls(
trees: &[TokenTree],
features: Option<&BTreeSet<String>>,
out: &mut BTreeSet<String>,
excluded: &mut Vec<Span>,
) {
let mut i = 0;
while i < trees.len() {
if let TokenTree::Ident(ident) = &trees[i]
&& *ident == "fn"
&& matches!(trees.get(i + 1), Some(TokenTree::Ident(_)))
{
i = find_fn_body(trees, i + 2).map_or(trees.len(), |body| body + 1);
continue;
}
if let TokenTree::Punct(p) = &trees[i]
&& p.as_char() == '#'
&& let Some(TokenTree::Group(attr)) = trees.get(i + 1)
&& attr.delimiter() == Delimiter::Bracket
{
i = if cfg_is_off(attr, features) {
let end = skip_gated(trees, i + 2);
excluded.push(span_of(trees, i, end));
end
} else {
i + 2
};
continue;
}
if let TokenTree::Ident(ident) = &trees[i] {
let name = ident.to_string();
let args_at = turbofish_end(trees, i + 1).map_or(i + 1, |end| end + 1);
if is_paren(trees.get(args_at))
&& is_resolvable_call(trees, i)
&& !CALL_KEYWORDS.contains(&name.as_str())
{
out.insert(call_key(trees, i, &name));
}
if let Some(TokenTree::Group(args)) = trees.get(args_at)
&& args.delimiter() == Delimiter::Parenthesis
&& !NOT_CALL_HEADS.contains(&name.as_str())
{
collect_paths_passed(args, out);
}
if args_at > i + 1 {
i = args_at;
continue;
}
}
if let TokenTree::Group(group) = &trees[i] {
let inner: Vec<TokenTree> = group.stream().into_iter().collect();
collect_calls(&inner, features, out, excluded);
}
i += 1;
}
}
fn call_key(trees: &[TokenTree], i: usize, name: &str) -> String {
let path = i
.checked_sub(1)
.is_some_and(|prev| matches!(&trees[prev], TokenTree::Punct(p) if p.as_char() == ':'));
if path {
format!("::{name}")
} else {
name.to_owned()
}
}
const NOT_CALL_HEADS: [&str; 9] = [
"if", "while", "match", "return", "for", "in", "let", "fn", "struct",
];
fn is_paren(tree: Option<&TokenTree>) -> bool {
matches!(tree, Some(TokenTree::Group(g)) if g.delimiter() == Delimiter::Parenthesis)
}
fn turbofish_end(trees: &[TokenTree], start: usize) -> Option<usize> {
let punct =
|at: usize, c: char| matches!(trees.get(at), Some(TokenTree::Punct(p)) if p.as_char() == c);
if !(punct(start, ':') && punct(start + 1, ':') && punct(start + 2, '<')) {
return None;
}
let mut depth = 0usize;
for (at, tree) in trees.iter().enumerate().skip(start + 2) {
let TokenTree::Punct(p) = tree else {
continue;
};
match p.as_char() {
'<' => depth += 1,
'>' if !matches!(trees.get(at - 1), Some(TokenTree::Punct(q))
if q.as_char() == '-' && q.spacing() == proc_macro2::Spacing::Joint) =>
{
depth -= 1;
if depth == 0 {
return Some(at);
}
}
_ => {}
}
}
None
}
fn collect_paths_passed(args: &proc_macro2::Group, out: &mut BTreeSet<String>) {
let trees: Vec<TokenTree> = args.stream().into_iter().collect();
for arg in trees.split(|t| matches!(t, TokenTree::Punct(p) if p.as_char() == ',')) {
let is_path = !arg.is_empty()
&& arg.len() % 3 == 1
&& arg.iter().enumerate().all(|(at, tree)| match at % 3 {
0 => matches!(tree, TokenTree::Ident(_)),
_ => matches!(tree, TokenTree::Punct(p) if p.as_char() == ':'),
});
if !is_path {
continue;
}
let last = arg.len() - 1;
if last == 0 || qualifier_is_module(arg, last) {
out.insert(call_key(arg, last, &arg[last].to_string()));
}
}
}
fn cfg_is_off(attr: &proc_macro2::Group, features: Option<&BTreeSet<String>>) -> bool {
let Some(features) = features else {
return false;
};
if !matches!(attr.stream().into_iter().next(), Some(TokenTree::Ident(id)) if id == "cfg") {
return false;
}
syn::parse2::<syn::Meta>(attr.stream())
.is_ok_and(|meta| crate::edge_scan::eval_cfg_meta(&meta, features) == Some(false))
}
fn skip_gated(trees: &[TokenTree], start: usize) -> usize {
let mut i = start;
while let Some(tree) = trees.get(i) {
match tree {
TokenTree::Punct(p) if p.as_char() == '#' => i += 2,
TokenTree::Ident(id)
if ["pub", "async", "const", "unsafe"]
.iter()
.any(|kw| id == kw) =>
{
i += 1;
}
TokenTree::Group(g) if g.delimiter() == Delimiter::Parenthesis && i > start => i += 1,
_ => break,
}
}
if matches!(trees.get(i), Some(TokenTree::Ident(id)) if *id == "fn") {
return find_fn_body(trees, i + 1).map_or(trees.len(), |body| body + 1);
}
if let Some(arrow) = arm_arrow(trees, i) {
let body = arrow + 2;
let block_ends_arm = matches!(trees.get(body), Some(TokenTree::Group(g)) if g.delimiter() == Delimiter::Brace)
&& !matches!(trees.get(body + 1), Some(TokenTree::Punct(p)) if p.as_char() == '.' || p.as_char() == '?');
if block_ends_arm {
let end = body + 1;
return end
+ usize::from(
matches!(trees.get(end), Some(TokenTree::Punct(p)) if p.as_char() == ','),
);
}
return skip_expr(trees, body, false);
}
let has_generics = matches!(trees.get(i), Some(TokenTree::Ident(id))
if ["let", "impl", "struct", "enum", "union", "trait", "type"].iter().any(|kw| id == kw));
skip_expr(trees, i, has_generics)
}
fn arm_arrow(trees: &[TokenTree], start: usize) -> Option<usize> {
for at in start..trees.len() {
let TokenTree::Punct(p) = &trees[at] else {
continue;
};
match p.as_char() {
';' => return None,
'=' if p.spacing() == proc_macro2::Spacing::Joint
&& matches!(trees.get(at + 1), Some(TokenTree::Punct(q)) if q.as_char() == '>') =>
{
return Some(at);
}
_ => {}
}
}
None
}
fn skip_expr(trees: &[TokenTree], start: usize, has_generics: bool) -> usize {
let punct = |at: usize| match trees.get(at) {
Some(TokenTree::Punct(p)) => Some(p.as_char()),
_ => None,
};
let mut i = start;
let mut angles = 0usize;
let mut in_closure_args = false;
while let Some(tree) = trees.get(i) {
let at = i;
i += 1;
match tree {
TokenTree::Punct(p) => match p.as_char() {
';' => break,
',' if angles == 0 && !in_closure_args => break,
'<' if has_generics || (at >= 1 && punct(at - 1) == Some(':')) => angles += 1,
'>' if angles > 0 && (at == 0 || punct(at - 1) != Some('-')) => angles -= 1,
'|' if angles == 0
&& (in_closure_args
|| at == start
|| matches!(&trees[at - 1], TokenTree::Punct(_))
|| matches!(&trees[at - 1], TokenTree::Ident(id) if id == "move")) =>
{
in_closure_args = !in_closure_args;
}
_ => {}
},
TokenTree::Group(g) if g.delimiter() == Delimiter::Brace => {
let goes_on = match trees.get(i) {
Some(TokenTree::Punct(p)) => ![';', ',', '#'].contains(&p.as_char()),
Some(TokenTree::Ident(id)) => ["else", "in", "as"].iter().any(|kw| id == kw),
Some(TokenTree::Group(_) | TokenTree::Literal(_)) | None => false,
};
if !goes_on {
if matches!(punct(i), Some(';' | ',')) {
i += 1;
}
break;
}
}
_ => {}
}
}
i
}
fn collect_idents(trees: &[TokenTree], out: &mut BTreeSet<String>) {
for tree in trees {
match tree {
TokenTree::Ident(ident) => {
out.insert(ident.to_string());
}
TokenTree::Group(group) => {
collect_idents(&group.stream().into_iter().collect::<Vec<_>>(), out);
}
_ => {}
}
}
}
fn collect_bindings(trees: &[TokenTree], out: &mut BTreeSet<String>) {
let is_punct =
|at: usize, c: char| matches!(trees.get(at), Some(TokenTree::Punct(p)) if p.as_char() == c);
let is_ident =
|at: usize, name: &str| matches!(trees.get(at), Some(TokenTree::Ident(id)) if *id == name);
let is_fat_arrow = |at: usize| {
matches!(trees.get(at), Some(TokenTree::Punct(p)) if p.as_char() == '=' && p.spacing() == proc_macro2::Spacing::Joint)
&& is_punct(at + 1, '>')
};
let mut arm_start = 0;
let mut i = 0;
while i < trees.len() {
match &trees[i] {
TokenTree::Ident(ident) if *ident == "for" && !is_punct(i + 1, '<') => {
let end = trees[i + 1..]
.iter()
.position(|t| match t {
TokenTree::Ident(id) => *id == "in",
TokenTree::Punct(p) => p.as_char() == ';',
TokenTree::Group(g) => g.delimiter() == Delimiter::Brace,
TokenTree::Literal(_) => false,
})
.map(|at| i + 1 + at);
if let Some(end) = end.filter(|end| is_ident(*end, "in")) {
collect_idents(&trees[i + 1..end], out);
i = end;
}
}
TokenTree::Punct(_) if is_fat_arrow(i) => {
let guard = (arm_start..i).find(|at| is_ident(*at, "if")).unwrap_or(i);
collect_idents(&trees[arm_start..guard], out);
i += 1;
arm_start = i + 1;
}
TokenTree::Punct(p) if p.as_char() == ',' || p.as_char() == ';' => {
arm_start = i + 1;
}
TokenTree::Ident(ident) if *ident == "matches" && is_punct(i + 1, '!') => {
if let Some(TokenTree::Group(args)) = trees.get(i + 2) {
let args: Vec<TokenTree> = args.stream().into_iter().collect();
if let Some(comma) = args
.iter()
.position(|t| matches!(t, TokenTree::Punct(p) if p.as_char() == ','))
{
collect_idents(&args[comma + 1..], out);
}
}
}
TokenTree::Ident(ident) if *ident == "let" => {
i += 1;
while let Some(tree) = trees.get(i) {
match tree {
TokenTree::Punct(p) if p.as_char() == '=' || p.as_char() == ';' => break,
TokenTree::Ident(id) => {
out.insert(id.to_string());
}
TokenTree::Group(group) => {
collect_idents(&group.stream().into_iter().collect::<Vec<_>>(), out);
}
TokenTree::Punct(_) | TokenTree::Literal(_) => {}
}
i += 1;
}
}
TokenTree::Punct(p) if p.as_char() == '|' => {
if let Some(end) = closure_args_end(trees, i, out) {
i = end;
}
}
TokenTree::Group(group) => {
collect_bindings(&group.stream().into_iter().collect::<Vec<_>>(), out);
let in_pattern = group.delimiter() != Delimiter::Brace
|| is_fat_arrow(i + 1)
|| is_punct(i + 1, '|')
|| is_ident(i + 1, "if");
if !in_pattern {
arm_start = i + 1;
}
}
_ => {}
}
i += 1;
}
}
fn closure_args_end(trees: &[TokenTree], open: usize, out: &mut BTreeSet<String>) -> Option<usize> {
let opens = open.checked_sub(1).is_none_or(|prev| match &trees[prev] {
TokenTree::Punct(_) => true,
TokenTree::Ident(id) => ["move", "return", "async"].iter().any(|kw| id == kw),
_ => false,
});
if !opens {
return None;
}
let mut names = BTreeSet::new();
for (at, tree) in trees.iter().enumerate().skip(open + 1) {
match tree {
TokenTree::Punct(q) if q.as_char() == '|' => {
out.extend(names);
return Some(at);
}
TokenTree::Punct(q) if q.as_char() == ';' => return None,
TokenTree::Punct(q)
if q.as_char() == '='
&& q.spacing() == proc_macro2::Spacing::Joint
&& matches!(trees.get(at + 1), Some(TokenTree::Punct(r)) if r.as_char() == '>') =>
{
return None;
}
TokenTree::Ident(id) => {
names.insert(id.to_string());
}
TokenTree::Group(group) => {
collect_idents(&group.stream().into_iter().collect::<Vec<_>>(), &mut names);
}
TokenTree::Punct(_) | TokenTree::Literal(_) => {}
}
}
None
}
fn is_resolvable_call(trees: &[TokenTree], i: usize) -> bool {
let Some(prev) = i.checked_sub(1).and_then(|at| trees.get(at)) else {
return true;
};
match prev {
TokenTree::Punct(p) if p.as_char() == '.' => false,
TokenTree::Ident(id) if DECL_KEYWORDS.contains(&id.to_string().as_str()) => false,
TokenTree::Punct(p) if p.as_char() == ':' => qualifier_is_module(trees, i),
_ => true,
}
}
fn qualifier_is_module(trees: &[TokenTree], i: usize) -> bool {
let segment = |at: Option<usize>| match at.and_then(|at| trees.get(at)) {
Some(TokenTree::Ident(id)) => Some(id.to_string()),
_ => None,
};
let Some(qualifier) = segment(i.checked_sub(3)) else {
return false;
};
if !qualifier.chars().next().is_some_and(char::is_lowercase)
|| qualifier == "Self"
|| PRIMITIVE_TYPES.contains(&qualifier.as_str())
{
return false;
}
let mut root = (qualifier, i - 3);
while root.1 >= 3
&& matches!(&trees[root.1 - 1], TokenTree::Punct(p) if p.as_char() == ':')
&& let Some(prev) = segment(Some(root.1 - 3))
{
root = (prev, root.1 - 3);
}
!["std", "core", "alloc"].contains(&root.0.as_str())
}
const PRIMITIVE_TYPES: [&str; 18] = [
"bool", "char", "str", "u8", "u16", "u32", "u64", "u128", "usize", "i8", "i16", "i32", "i64",
"i128", "isize", "f32", "f64", "f16",
];
struct RouteGraph<'a> {
fns: &'a [FnDecl],
routes: Vec<(RouteDecl, usize, BTreeSet<usize>)>,
}
impl<'a> RouteGraph<'a> {
fn build(fns: &'a [FnDecl]) -> Self {
let mut by_name: std::collections::HashMap<&str, Option<usize>> =
std::collections::HashMap::new();
for (index, decl) in fns.iter().enumerate().filter(|(_, d)| d.callable) {
by_name
.entry(decl.name.as_str())
.and_modify(|slot| *slot = None)
.or_insert(Some(index));
}
let mut local: std::collections::HashMap<(usize, &str), Option<usize>> =
std::collections::HashMap::new();
for (index, decl) in fns.iter().enumerate() {
if let Some(owner) = decl.owner {
local
.entry((owner, decl.name.as_str()))
.and_modify(|slot| *slot = None)
.or_insert(Some(index));
}
}
let hidden: BTreeSet<(usize, &str)> = fns
.iter()
.filter(|decl| decl.owner.is_none())
.filter_map(|decl| decl.top.map(|top| (top, decl.name.as_str())))
.collect();
let resolve = |from: usize, name: &str| -> Option<usize> {
if let Some(path) = name.strip_prefix("::") {
return by_name.get(path).copied().flatten();
}
if hidden.contains(&(fns[from].top.unwrap_or(from), name)) {
return None;
}
let mut scope = Some(from);
while let Some(owner) = scope {
if let Some(slot) = local.get(&(owner, name)) {
return *slot;
}
scope = fns[owner].owner;
}
by_name.get(name).copied().flatten()
};
let mut routes = Vec::new();
for (index, decl) in fns.iter().enumerate() {
for route in &decl.routes {
let mut reached = BTreeSet::new();
let mut stack = vec![index];
while let Some(current) = stack.pop() {
if !reached.insert(current) {
continue;
}
for call in &fns[current].calls {
if let Some(target) = resolve(current, call) {
stack.push(target);
}
}
}
routes.push((route.clone(), index, reached));
}
}
Self { fns, routes }
}
fn enclosing_fn(&self, file: &str, at: (usize, usize)) -> Option<usize> {
self.fns
.iter()
.enumerate()
.filter(|(_, decl)| decl.file == file && decl.start <= at && at <= decl.end)
.min_by_key(|(_, decl)| (decl.end.0 - decl.start.0, decl.end.1))
.map(|(index, _)| index)
}
fn routes_reaching(&self, file: &str, at: (usize, usize)) -> Vec<usize> {
let Some(target) = self.enclosing_fn(file, at) else {
return Vec::new();
};
self.routes
.iter()
.enumerate()
.filter(|(_, (_, _, reached))| reached.contains(&target))
.map(|(index, _)| index)
.collect()
}
fn display_keys(&self, indices: &[usize]) -> Vec<String> {
let mut keys: Vec<String> = indices
.iter()
.map(|index| route_key(&self.routes[*index].0))
.collect();
keys.sort();
keys.dedup();
keys
}
fn conformance(&self, reaching: &[Vec<usize>]) -> Vec<RouteConformance> {
let mut out: Vec<RouteConformance> = self
.routes
.iter()
.enumerate()
.map(|(index, (route, handler, _))| {
let count = reaching.iter().filter(|hit| hit.contains(&index)).count();
RouteConformance {
method: route.method.to_owned(),
path: route.path.clone(),
handler: self.fns[*handler].name.clone(),
file: self.fns[*handler].file.clone(),
line: self.fns[*handler].start.0,
findings: count,
status: if count == 0 { "pass" } else { "fail" },
}
})
.collect();
out.sort_by(|a, b| (&a.path, &a.method, &a.handler).cmp(&(&b.path, &b.method, &b.handler)));
out
}
}
fn route_key(route: &RouteDecl) -> String {
format!("{} {}", route.method, route.path)
}
fn roll_up_wcag(findings: &[Finding]) -> Vec<WcagCriterion> {
let mut by_criterion: std::collections::BTreeMap<&str, (BTreeSet<&'static str>, usize)> =
std::collections::BTreeMap::new();
for finding in findings {
for criterion in finding.wcag.split('/').map(str::trim) {
let entry = by_criterion.entry(criterion).or_default();
entry.0.insert(finding.rule_id);
entry.1 += 1;
}
}
by_criterion
.into_iter()
.map(|(criterion, (rules, findings))| WcagCriterion {
criterion: criterion.to_owned(),
rules: rules.into_iter().collect(),
findings,
})
.collect()
}
#[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,
column: 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 column = trees[start].span().start().column;
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,
column,
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, el.column, file);
}
name @ ("input" | "select" | "textarea") => {
check_field(el, name, file, ctx, scan);
}
"button" if !named_content(el) => {
scan.push(Rule::ButtonName, "button", el.line, el.column, file);
}
"a" if el.has_attr("href") && !named_content(el) => {
scan.push(Rule::LinkName, "a", el.line, el.column, 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, el.column, 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 is proven \
accessible at compile time and is intentionally not re-scanned. This pass \
targets raw html! markup that bypasses those primitives.";
#[cfg(test)]
#[must_use]
pub fn run_in(root: &Path, opts: A11yVerifyOptions) -> i32 {
run_in_with(root, opts, &CargoFeatures::default())
}
#[must_use]
pub fn run_in_with(root: &Path, opts: A11yVerifyOptions, features: &CargoFeatures) -> i32 {
let scan = match scan_project_with(root, features) {
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) {
print!("{}", format_text(report, strict));
}
fn format_text(report: &Report, strict: bool) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(out, "autumn a11y verify");
let _ = writeln!(
out,
" scanned {} html! block(s) across {} .rs file(s)",
report.html_blocks, report.files_scanned
);
let _ = writeln!(
out,
" {} route(s) discovered, {} carrying findings",
report.summary.routes, report.summary.routes_failing
);
let _ = writeln!(out, " {PRIMITIVE_NOTE}");
if report.findings.is_empty() {
let _ = writeln!(
out,
"\nResult: PASS — no accessibility violations in raw html! markup."
);
return out;
}
let _ = writeln!(out, "\n findings:");
for f in &report.findings {
let _ = writeln!(
out,
" {}:{}: <{}> [WCAG {}] {} — {}",
f.file, f.line, f.element, f.wcag, f.severity, f.message
);
if !f.routes.is_empty() {
let _ = writeln!(out, " routes: {}", f.routes.join(", "));
}
let _ = writeln!(out, " hint: {}", f.hint);
}
let failing: Vec<&RouteConformance> = report.routes.iter().filter(|r| r.findings > 0).collect();
if !failing.is_empty() {
let _ = writeln!(out, "\n failing routes:");
for route in failing {
let _ = writeln!(
out,
" {} {} ({}) — {} finding(s)",
route.method, route.path, route.handler, route.findings
);
}
}
let _ = writeln!(out, "\n WCAG success criteria breached:");
for criterion in &report.wcag {
let _ = writeln!(
out,
" WCAG {} — {} finding(s) [{}]",
criterion.criterion,
criterion.findings,
criterion.rules.join(", ")
);
}
let _ = writeln!(
out,
"\n summary: {} Critical, {} Serious, {} Moderate ({} total, {} not reached by any route)",
report.summary.critical,
report.summary.serious,
report.summary.moderate,
report.summary.total,
report.summary.unrouted,
);
if report.exit_code(strict) == 0 {
let _ = writeln!(
out,
"Result: PASS with findings — below the failure threshold."
);
} else {
let _ = writeln!(
out,
"Result: FAIL — fix the accessibility violations above (or use the typed primitives)."
);
}
out
}
#[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",
);
}
fn report_for(files: &[(&str, &str)]) -> Report {
let mut scan = Scan::default();
for (name, src) in files {
scan_source(src, name, &mut scan);
scan.files_scanned += 1;
}
Report::from_scan(scan)
}
fn routes_of_only_finding(files: &[(&str, &str)]) -> Vec<String> {
let report = report_for(files);
assert_eq!(report.findings.len(), 1, "{:?}", report.findings);
report.findings[0].routes.clone()
}
#[test]
fn finding_in_a_route_handler_is_keyed_to_that_route() {
let src = r#"
#[get("/settings")]
async fn settings() -> Markup {
html! { img src="/logo.png"; }
}
"#;
assert_eq!(
routes_of_only_finding(&[("views.rs", src)]),
vec!["GET /settings".to_owned()]
);
}
#[test]
fn finding_in_a_called_helper_inherits_the_route() {
let src = r#"
#[get("/settings")]
async fn settings() -> Markup {
html! { main { (sidebar()) } }
}
fn sidebar() -> Markup {
html! { img src="/logo.png"; }
}
"#;
assert_eq!(
routes_of_only_finding(&[("views.rs", src)]),
vec!["GET /settings".to_owned()]
);
}
#[test]
fn attribution_crosses_files() {
let handler = r#"
#[post("/settings")]
async fn save() -> Markup {
html! { main { (sidebar()) } }
}
"#;
let partial = r#"
fn sidebar() -> Markup {
html! { img src="/logo.png"; }
}
"#;
assert_eq!(
routes_of_only_finding(&[("routes.rs", handler), ("partials.rs", partial)]),
vec!["POST /settings".to_owned()]
);
}
#[test]
fn a_helper_reached_from_two_routes_lists_both() {
let src = r#"
#[get("/a")]
async fn a() -> Markup { html! { (sidebar()) } }
#[get("/b")]
async fn b() -> Markup { html! { (sidebar()) } }
fn sidebar() -> Markup {
html! { img src="/logo.png"; }
}
"#;
assert_eq!(
routes_of_only_finding(&[("views.rs", src)]),
vec!["GET /a".to_owned(), "GET /b".to_owned()]
);
}
#[test]
fn an_ambiguous_helper_name_is_not_traversed() {
let handler = r#"
#[get("/a")]
async fn a() -> Markup { html! { (sidebar()) } }
"#;
let one = r#"
fn sidebar() -> Markup { html! { img src="/logo.png"; } }
"#;
let two = r#"
fn sidebar() -> Markup { html! { span { "hi" } } }
"#;
assert!(
routes_of_only_finding(&[("a.rs", handler), ("one.rs", one), ("two.rs", two)])
.is_empty()
);
}
#[test]
fn a_finding_outside_any_route_has_no_route() {
let src = r#"
fn orphan() -> Markup { html! { img src="/logo.png"; } }
"#;
let report = report_for(&[("views.rs", src)]);
assert!(
report.findings[0].routes.is_empty(),
"{:?}",
report.findings
);
assert_eq!(report.summary.unrouted, 1);
}
#[test]
fn mutually_recursive_helpers_terminate() {
let src = r#"
#[get("/a")]
async fn a() -> Markup { html! { (left()) } }
fn left() -> Markup { html! { (right()) } }
fn right() -> Markup { html! { (left()) img src="/logo.png"; } }
"#;
assert_eq!(
routes_of_only_finding(&[("views.rs", src)]),
vec!["GET /a".to_owned()]
);
}
#[test]
fn method_calls_are_not_resolved_as_helpers() {
let src = r#"
#[get("/a")]
async fn a(page: Page) -> Markup { html! { (page.sidebar()) } }
fn sidebar() -> Markup { html! { img src="/logo.png"; } }
"#;
assert!(routes_of_only_finding(&[("views.rs", src)]).is_empty());
}
#[test]
fn routes_section_reports_per_route_status() {
let src = r#"
#[get("/settings")]
async fn settings() -> Markup { html! { img src="/logo.png"; } }
#[get("/about")]
async fn about() -> Markup { html! { img src="/logo.png" alt="Logo"; } }
"#;
let report = report_for(&[("views.rs", src)]);
let paths: Vec<_> = report
.routes
.iter()
.map(|r| (r.path.as_str(), r.findings, r.status))
.collect();
assert_eq!(paths, vec![("/about", 0, "pass"), ("/settings", 1, "fail")]);
assert_eq!(report.routes[1].method, "GET");
assert_eq!(report.routes[1].handler, "settings");
assert_eq!(report.routes[1].file, "views.rs");
assert_eq!(report.summary.routes, 2);
assert_eq!(report.summary.routes_failing, 1);
}
#[test]
fn a_route_without_markup_is_still_listed_as_passing() {
let src = r#"
#[get("/api/todos")]
async fn list() -> Json<Vec<Todo>> { Json(vec![]) }
"#;
let report = report_for(&[("api.rs", src)]);
assert_eq!(report.routes.len(), 1);
assert_eq!(report.routes[0].status, "pass");
}
#[test]
fn a_nested_fn_is_not_a_target_for_a_bare_call() {
let src = r#"
fn outer() -> Markup {
fn render() -> Markup { html! { img src="/x.png"; } }
html! { span { "hi" } }
}
#[get("/a")]
async fn a() -> Markup { html! { (render()) } }
"#;
assert!(routes_of_only_finding(&[("views.rs", src)]).is_empty());
}
#[test]
fn an_associated_fn_is_not_a_target_for_a_bare_call() {
let src = r#"
impl Card {
fn render(&self) -> Markup { html! { img src="/x.png"; } }
}
#[get("/a")]
async fn a() -> Markup { html! { (render()) } }
"#;
assert!(routes_of_only_finding(&[("views.rs", src)]).is_empty());
}
#[test]
fn a_module_level_fn_is_still_a_target_for_a_bare_call() {
let src = r#"
mod views {
fn sidebar() -> Markup { html! { img src="/x.png"; } }
}
#[get("/a")]
async fn a() -> Markup { html! { (sidebar()) } }
"#;
assert_eq!(
routes_of_only_finding(&[("views.rs", src)]),
vec!["GET /a".to_owned()]
);
}
#[test]
fn a_route_on_an_associated_fn_is_still_a_route() {
let src = r#"
impl Dashboard {
#[get("/a")]
async fn show(&self) -> Markup { html! { img src="/x.png"; } }
}
"#;
assert_eq!(
routes_of_only_finding(&[("views.rs", src)]),
vec!["GET /a".to_owned()]
);
}
#[test]
fn a_shadowing_parameter_is_not_resolved_as_a_helper() {
let src = r#"
#[get("/a")]
async fn a(view: impl Fn() -> Markup) -> Markup { html! { (view()) } }
fn view() -> Markup { html! { img src="/x.png"; } }
"#;
assert!(routes_of_only_finding(&[("views.rs", src)]).is_empty());
}
#[test]
fn a_shadowing_local_is_not_resolved_as_a_helper() {
let src = r#"
#[get("/a")]
async fn a() -> Markup {
let view = || html! { span { "local" } };
html! { (view()) }
}
fn view() -> Markup { html! { img src="/x.png"; } }
"#;
assert!(routes_of_only_finding(&[("views.rs", src)]).is_empty());
}
#[test]
fn a_shadowing_closure_parameter_is_not_resolved_as_a_helper() {
let src = r#"
#[get("/a")]
async fn a(rows: Vec<Row>) -> Markup {
html! { @for row in rows { (rows.iter().map(|view| view()).count()) } }
}
fn view() -> Markup { html! { img src="/x.png"; } }
"#;
assert!(routes_of_only_finding(&[("views.rs", src)]).is_empty());
}
#[test]
fn shadowing_in_one_handler_does_not_hide_a_real_call_in_another() {
let src = r#"
#[get("/a")]
async fn a(sidebar: impl Fn() -> Markup) -> Markup { html! { (sidebar()) } }
#[get("/b")]
async fn b() -> Markup { html! { (sidebar()) } }
fn sidebar() -> Markup { html! { img src="/x.png"; } }
"#;
assert_eq!(
routes_of_only_finding(&[("views.rs", src)]),
vec!["GET /b".to_owned()]
);
}
#[test]
fn type_qualified_associated_calls_are_not_resolved_as_helpers() {
let src = r#"
#[get("/dash")]
async fn dash() -> Markup { html! { (Metrics::render()) } }
fn render() -> Markup { html! { img src="/logo.png"; } }
"#;
assert!(routes_of_only_finding(&[("views.rs", src)]).is_empty());
}
#[test]
fn module_qualified_calls_are_resolved_as_helpers() {
let src = r#"
#[get("/dash")]
async fn dash() -> Markup { html! { (crate::views::sidebar()) } }
fn sidebar() -> Markup { html! { img src="/logo.png"; } }
"#;
assert_eq!(
routes_of_only_finding(&[("views.rs", src)]),
vec!["GET /dash".to_owned()]
);
}
fn report_with_features(files: &[(&str, &str)], features: &[&str]) -> Report {
let features: BTreeSet<String> = features.iter().map(|f| (*f).to_owned()).collect();
let mut scan = Scan::default();
for (name, src) in files {
scan_source_with(src, name, Some(&features), &mut scan);
scan.files_scanned += 1;
}
Report::from_scan(scan)
}
fn routes_with_features(src: &str, features: &[&str]) -> Vec<String> {
let report = report_with_features(&[("views.rs", src)], features);
assert_eq!(report.findings.len(), 1, "{:?}", report.findings);
report.findings[0].routes.clone()
}
#[test]
fn a_turbofish_call_is_a_call_edge() {
let src = r#"
#[get("/a")]
async fn a() -> Markup { html! { (sidebar::<Html>()) } }
fn sidebar<T>() -> Markup { html! { img src="/x.png"; } }
"#;
assert_eq!(
routes_of_only_finding(&[("views.rs", src)]),
vec!["GET /a".to_owned()]
);
}
#[test]
fn a_module_qualified_turbofish_call_is_a_call_edge() {
let src = r#"
#[get("/a")]
async fn a() -> Markup { html! { (views::sidebar::<Vec<Box<dyn Fn() -> u8>>>()) } }
fn sidebar<T>() -> Markup { html! { img src="/x.png"; } }
"#;
assert_eq!(
routes_of_only_finding(&[("views.rs", src)]),
vec!["GET /a".to_owned()]
);
}
#[test]
fn a_type_qualified_turbofish_call_is_not_resolved() {
let src = r#"
#[get("/a")]
async fn a() -> Markup { html! { (Widget::render::<Html>()) } }
fn render<T>() -> Markup { html! { img src="/x.png"; } }
"#;
assert!(routes_of_only_finding(&[("views.rs", src)]).is_empty());
}
#[test]
fn a_turbofish_type_is_not_a_call() {
let src = r#"
#[get("/a")]
async fn a() -> Markup { let _v = Vec::<u8>::new(); html! { span { "a" } } }
fn new() -> Markup { html! { img src="/x.png"; } }
"#;
assert!(routes_of_only_finding(&[("views.rs", src)]).is_empty());
}
#[test]
fn a_function_passed_by_name_is_a_call_edge() {
let src = r#"
#[get("/a")]
async fn a(rows: Vec<Row>) -> Markup {
let cells: Vec<Markup> = rows.into_iter().map(render_row).collect();
html! { @for c in cells { (c) } }
}
fn render_row(r: Row) -> Markup { html! { img src="/x.png"; } }
"#;
assert_eq!(
routes_of_only_finding(&[("views.rs", src)]),
vec!["GET /a".to_owned()]
);
}
#[test]
fn a_module_qualified_function_passed_by_name_is_a_call_edge() {
let src = r#"
#[get("/a")]
async fn a(rows: Vec<Row>) -> Markup {
html! { (rows.iter().map_or_else(empty, views::render_row)) }
}
fn render_row(r: Row) -> Markup { html! { img src="/x.png"; } }
"#;
assert_eq!(
routes_of_only_finding(&[("views.rs", src)]),
vec!["GET /a".to_owned()]
);
}
#[test]
fn a_type_qualified_function_passed_by_name_is_not_resolved() {
let src = r#"
#[get("/a")]
async fn a(rows: Vec<Row>) -> Markup { html! { (rows.iter().map(Widget::render)) } }
fn render(r: Row) -> Markup { html! { img src="/x.png"; } }
"#;
assert!(routes_of_only_finding(&[("views.rs", src)]).is_empty());
}
#[test]
fn a_name_in_a_larger_argument_is_not_passed_by_name() {
let src = r#"
#[get("/a")]
async fn a() -> Markup { html! { (total(render_row + 1)) (show(&render_row)) } }
fn render_row() -> Markup { html! { img src="/x.png"; } }
"#;
assert!(routes_of_only_finding(&[("views.rs", src)]).is_empty());
}
#[test]
fn a_macro_argument_is_not_passed_by_name() {
let src = r#"
#[get("/a")]
async fn a() -> Markup { html! { (format!("{}", render_row)) } }
fn render_row() -> Markup { html! { img src="/x.png"; } }
"#;
assert!(routes_of_only_finding(&[("views.rs", src)]).is_empty());
}
#[test]
fn a_for_loop_binding_shadows_a_function_passed_by_name() {
let src = r#"
#[get("/a")]
async fn a(rows: Vec<Markup>) -> Markup {
let mut out = Vec::new();
for render_row in rows { out.push(render_row); }
html! { span { "a" } }
}
fn render_row() -> Markup { html! { img src="/x.png"; } }
"#;
assert!(routes_of_only_finding(&[("views.rs", src)]).is_empty());
}
#[test]
fn a_match_arm_binding_shadows_a_function_passed_by_name() {
let src = r#"
#[get("/a")]
async fn a(row: Option<Markup>) -> Markup {
let mut out = Vec::new();
match row { Some(render_row) => out.push(render_row), None => {} }
html! { span { "a" } }
}
fn render_row() -> Markup { html! { img src="/x.png"; } }
"#;
assert!(routes_of_only_finding(&[("views.rs", src)]).is_empty());
}
#[test]
fn a_nested_helper_called_by_its_enclosing_fn_is_a_call_edge() {
let src = r#"
#[get("/a")]
async fn a() -> Markup {
fn row() -> Markup { html! { img src="/x.png"; } }
html! { (row()) }
}
"#;
assert_eq!(
routes_of_only_finding(&[("views.rs", src)]),
vec!["GET /a".to_owned()]
);
}
#[test]
fn a_nested_helper_can_call_its_sibling() {
let src = r#"
#[get("/a")]
async fn a() -> Markup {
fn outer() -> Markup { inner() }
fn inner() -> Markup { html! { img src="/x.png"; } }
html! { (outer()) }
}
"#;
assert_eq!(
routes_of_only_finding(&[("views.rs", src)]),
vec!["GET /a".to_owned()]
);
}
#[test]
fn a_nested_helper_shadows_a_free_fn_of_the_same_name() {
let src = r#"
#[get("/a")]
async fn a() -> Markup {
fn sidebar() -> Markup { html! { span { "clean" } } }
html! { (sidebar()) }
}
fn sidebar() -> Markup { html! { img src="/x.png"; } }
"#;
assert!(routes_of_only_finding(&[("views.rs", src)]).is_empty());
}
#[test]
fn same_named_nested_helpers_in_two_handlers_stay_apart() {
let src = r#"
#[get("/a")]
async fn a() -> Markup {
fn row() -> Markup { html! { img src="/x.png"; } }
html! { (row()) }
}
#[get("/b")]
async fn b() -> Markup {
fn row() -> Markup { html! { span { "clean" } } }
html! { (row()) }
}
"#;
assert_eq!(
routes_of_only_finding(&[("views.rs", src)]),
vec!["GET /a".to_owned()]
);
}
#[test]
fn a_helper_in_an_inner_block_stays_unresolved() {
let src = r#"
#[get("/a")]
async fn a() -> Markup {
{ fn row() -> Markup { html! { img src="/x.png"; } } }
html! { (row()) }
}
"#;
assert!(routes_of_only_finding(&[("views.rs", src)]).is_empty());
}
const CFG_GATED: &str = r#"
#[get("/a")]
async fn a() -> Markup {
#[cfg(feature = "admin")]
let side = sidebar();
html! { span { "a" } }
}
fn sidebar() -> Markup { html! { img src="/x.png"; } }
"#;
#[test]
fn a_call_gated_on_an_off_feature_is_not_a_call_edge() {
assert!(routes_with_features(CFG_GATED, &["db"]).is_empty());
}
#[test]
fn a_call_gated_on_an_on_feature_is_a_call_edge() {
assert_eq!(
routes_with_features(CFG_GATED, &["admin"]),
vec!["GET /a".to_owned()]
);
}
#[test]
fn a_call_gated_with_no_known_features_is_kept() {
assert_eq!(
routes_of_only_finding(&[("views.rs", CFG_GATED)]),
vec!["GET /a".to_owned()]
);
}
#[test]
fn a_call_gated_on_an_unknown_predicate_is_kept() {
let src = r#"
#[get("/a")]
async fn a() -> Markup {
#[cfg(target_os = "linux")]
sidebar();
html! { span { "a" } }
}
fn sidebar() -> Markup { html! { img src="/x.png"; } }
"#;
assert_eq!(routes_with_features(src, &[]), vec!["GET /a".to_owned()]);
}
#[test]
fn a_gated_statement_drops_only_its_own_calls() {
let src = r#"
#[get("/a")]
async fn a() -> Markup {
#[cfg(not(feature = "db"))]
let side = clean();
let main = sidebar();
html! { (main) }
}
fn clean() -> Markup { html! { span { "clean" } } }
fn sidebar() -> Markup { html! { img src="/x.png"; } }
"#;
assert_eq!(
routes_with_features(src, &["db"]),
vec!["GET /a".to_owned()]
);
}
#[test]
fn a_gated_match_arm_is_not_a_call_edge() {
let src = r#"
#[get("/a")]
async fn a(k: Kind) -> Markup {
match k {
#[cfg(feature = "admin")]
Kind::Admin => sidebar(),
_ => html! { span { "a" } },
}
}
fn sidebar() -> Markup { html! { img src="/x.png"; } }
"#;
assert!(routes_with_features(src, &[]).is_empty());
}
fn cfg_crate() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\
[features]\ndefault = [\"db\"]\ndb = []\nadmin = []\n",
)
.unwrap();
std::fs::create_dir(dir.path().join("src")).unwrap();
std::fs::write(dir.path().join("src/views.rs"), CFG_GATED).unwrap();
dir
}
fn routes_in(root: &Path, features: &CargoFeatures) -> Vec<String> {
let report = Report::from_scan(scan_project_with(root, features).unwrap());
assert_eq!(report.findings.len(), 1, "{:?}", report.findings);
report.findings[0].routes.clone()
}
#[test]
fn a_gated_if_else_drops_both_branches() {
let src = r#"
#[get("/a")]
async fn a(x: bool) -> Markup {
#[cfg(feature = "admin")]
if x { clean() } else { sidebar() }
html! { span { "a" } }
}
fn clean() -> Markup { html! { span { "clean" } } }
fn sidebar() -> Markup { html! { img src="/x.png"; } }
"#;
assert!(routes_with_features(src, &[]).is_empty());
}
const MANIFEST: &str = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\
[features]\ndefault = [\"db\"]\ndb = []\nadmin = []\nbilling = []\n\n\
[dependencies]\nredis = { version = \"1\", optional = true }\n";
#[test]
fn requested_features_split_on_commas() {
let selection = CargoFeatures {
features: vec!["admin, billing".to_owned()],
..CargoFeatures::default()
};
let on = enabled_features(MANIFEST, &selection).expect("a package");
assert!(
on.contains("db") && on.contains("admin") && on.contains("billing"),
"{on:?}"
);
assert!(!on.contains("redis"), "{on:?}");
}
#[test]
fn all_features_turns_on_optional_dependencies() {
let selection = CargoFeatures {
all: true,
..CargoFeatures::default()
};
let on = enabled_features(MANIFEST, &selection).expect("a package");
for name in ["db", "admin", "billing", "redis"] {
assert!(on.contains(name), "{name} is off: {on:?}");
}
}
#[test]
fn a_virtual_workspace_manifest_decides_no_feature() {
let manifest = "[workspace]\nmembers = [\"app\"]\n";
assert!(enabled_features(manifest, &CargoFeatures::default()).is_none());
}
#[test]
fn an_impl_for_in_a_body_does_not_hide_later_calls() {
let src = r#"
#[get("/a")]
async fn a() -> Markup {
impl Render for Row { fn render(&self) -> Markup { html! {} } }
html! { (sidebar()) }
}
fn sidebar() -> Markup { html! { img src="/x.png"; } }
"#;
assert_eq!(
routes_of_only_finding(&[("views.rs", src)]),
vec!["GET /a".to_owned()]
);
}
#[test]
fn a_higher_ranked_bound_does_not_hide_later_calls() {
let src = r#"
#[get("/a")]
async fn a() -> Markup {
fn apply<F>(f: F) where F: for<'x> Fn(&'x str) {}
html! { (sidebar()) }
}
fn sidebar() -> Markup { html! { img src="/x.png"; } }
"#;
assert_eq!(
routes_of_only_finding(&[("views.rs", src)]),
vec!["GET /a".to_owned()]
);
}
#[test]
fn a_gated_generic_nested_fn_is_skipped_whole() {
let src = r#"
#[get("/a")]
async fn a() -> Markup {
#[cfg(feature = "admin")]
fn helper<A, B>(a: A) -> Markup { sidebar() }
html! { span { "a" } }
}
fn sidebar() -> Markup { html! { img src="/x.png"; } }
"#;
assert!(routes_with_features(src, &[]).is_empty());
}
#[test]
fn a_gated_let_with_a_generic_type_is_skipped_whole() {
let src = r#"
#[get("/a")]
async fn a() -> Markup {
#[cfg(feature = "admin")]
let m: HashMap<K, V> = sidebar();
html! { span { "a" } }
}
fn sidebar() -> Markup { html! { img src="/x.png"; } }
"#;
assert!(routes_with_features(src, &[]).is_empty());
}
#[test]
fn a_gated_fn_item_is_not_indexed() {
let src = r#"
#[get("/a")]
async fn a() -> Markup { html! { (sidebar()) } }
#[cfg(feature = "admin")]
fn sidebar() -> Markup { html! { span { "admin" } } }
#[cfg(not(feature = "admin"))]
fn sidebar() -> Markup { html! { img src="/x.png"; } }
"#;
assert_eq!(routes_with_features(src, &[]), vec!["GET /a".to_owned()]);
}
#[test]
fn a_gated_route_is_not_listed() {
let src = r#"
#[cfg(feature = "admin")]
#[get("/admin")]
async fn admin() -> Markup { html! { img src="/x.png"; } }
"#;
let report = report_with_features(&[("views.rs", src)], &[]);
assert!(report.routes.is_empty(), "{:?}", report.routes);
assert_eq!(report.findings.len(), 1, "the finding stays");
}
#[test]
fn a_matches_pattern_is_not_passed_by_name() {
let src = r#"
#[get("/a")]
async fn a(x: Option<u8>) -> Markup {
let _ = matches!(x, Some(render_row));
html! { span { "a" } }
}
fn render_row() -> Markup { html! { img src="/x.png"; } }
"#;
assert!(routes_of_only_finding(&[("views.rs", src)]).is_empty());
}
#[test]
fn a_match_guard_call_is_still_a_call_edge() {
let src = r#"
#[get("/a")]
async fn a(x: Option<u8>) -> Markup {
match x { Some(v) if is_ready(v) => html! {}, _ => html! {} }
}
fn is_ready(v: u8) -> bool { let _ = html! { img src="/x.png"; }; true }
"#;
assert_eq!(
routes_of_only_finding(&[("views.rs", src)]),
vec!["GET /a".to_owned()]
);
}
#[test]
fn no_default_features_turns_the_defaults_off() {
let selection = CargoFeatures {
features: vec!["admin".to_owned()],
all: false,
no_default: true,
};
let on = enabled_features(MANIFEST, &selection).expect("a package");
assert!(on.contains("admin") && !on.contains("db"), "{on:?}");
}
#[test]
fn no_default_features_still_expands_an_explicit_default() {
let selection = CargoFeatures {
features: vec!["default".to_owned()],
all: false,
no_default: true,
};
let on = enabled_features(MANIFEST, &selection).expect("a package");
assert!(on.contains("default") && on.contains("db"), "{on:?}");
}
#[test]
fn all_features_with_no_default_features_turns_on_default() {
let selection = CargoFeatures {
features: Vec::new(),
all: true,
no_default: true,
};
let on = enabled_features(MANIFEST, &selection).expect("a package");
assert!(on.contains("default") && on.contains("db"), "{on:?}");
}
#[test]
fn all_features_skips_an_optional_dependency_named_by_dep() {
let manifest = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\
[features]\nadmin = [\"dep:redis\"]\n\n\
[dependencies]\nredis = { version = \"1\", optional = true }\n";
let selection = CargoFeatures {
all: true,
..CargoFeatures::default()
};
let on = enabled_features(manifest, &selection).expect("a package");
assert!(on.contains("admin") && !on.contains("redis"), "{on:?}");
}
#[test]
fn all_features_turns_on_target_optional_dependencies() {
let manifest = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\
[target.'cfg(unix)'.dependencies]\nnix = { version = \"1\", optional = true }\n";
let selection = CargoFeatures {
all: true,
..CargoFeatures::default()
};
let on = enabled_features(manifest, &selection).expect("a package");
assert!(on.contains("nix"), "{on:?}");
}
#[test]
fn a_closure_tuple_pattern_shadows_a_function_passed_by_name() {
let src = r#"
#[get("/a")]
async fn a(rows: Vec<(u32, Row)>) -> Markup {
html! { @for x in rows.iter().map(|(id, row)| cell(*id, row)) { (x) } }
}
fn row(r: &Row) -> Markup { html! { img src="/x.png"; } }
"#;
assert!(routes_of_only_finding(&[("views.rs", src)]).is_empty());
}
#[test]
fn a_closure_tuple_struct_pattern_shadows_a_function_passed_by_name() {
let src = r#"
#[get("/a")]
async fn a(rows: Vec<Wrapper>) -> Markup {
let n: usize = rows.iter().map(|Wrapper(row)| row.len()).sum();
html! { (n) }
}
fn row() -> Markup { html! { img src="/x.png"; } }
"#;
assert!(routes_of_only_finding(&[("views.rs", src)]).is_empty());
}
#[test]
fn a_struct_pattern_match_arm_shadows_a_function_passed_by_name() {
let src = r#"
#[get("/a")]
async fn a(ev: Msg) -> Markup {
match ev {
Msg::Row { row } => html! { (render(row)) },
Msg::Wrapped { inner: Some(row) } | Msg::Other { row } => html! { (render(row)) },
_ => html! {},
}
}
fn row() -> Markup { html! { img src="/x.png"; } }
"#;
assert!(routes_of_only_finding(&[("views.rs", src)]).is_empty());
}
#[test]
fn a_std_path_passed_by_name_is_not_resolved() {
let src = r#"
#[get("/a")]
async fn a(names: Vec<String>) -> Markup {
let _t: Vec<&str> = names.iter().map(String::as_str).map(str::trim).collect();
let _n: Vec<u32> = names.iter().map(|_| 1u8).map(u32::from).collect();
names.into_iter().for_each(std::mem::drop);
html! { span { "a" } }
}
fn trim() -> Markup { html! { img src="/x.png"; } }
fn from() -> Markup { html! { img src="/x.png"; } }
fn drop() -> Markup { html! { img src="/x.png"; } }
"#;
let report = report_for(&[("views.rs", src)]);
assert_eq!(report.findings.len(), 3, "{:?}", report.findings);
for finding in &report.findings {
assert!(finding.routes.is_empty(), "{finding:?}");
}
}
#[test]
fn an_inner_block_helper_shadows_a_body_level_one() {
let src = r#"
#[get("/a")]
async fn a() -> Markup {
fn row() -> Markup { html! { img src="/x.png"; } }
{ fn row() -> Markup { html! { span { "clean" } } } html! { (row()) } }
}
"#;
assert!(routes_of_only_finding(&[("views.rs", src)]).is_empty());
}
#[test]
fn an_inner_block_helper_shadows_a_free_fn() {
let src = r#"
#[get("/a")]
async fn a(x: bool) -> Markup {
if x { fn sidebar() -> Markup { html! { span { "clean" } } } html! { (sidebar()) } } else { html! {} }
}
fn sidebar() -> Markup { html! { img src="/x.png"; } }
"#;
assert!(routes_of_only_finding(&[("views.rs", src)]).is_empty());
}
fn gated(stmt: &str) -> String {
format!(
r#"
#[get("/a")]
async fn a(x: Page) -> Markup {{
#[cfg(feature = "admin")]
{stmt}
html! {{ span {{ "a" }} }}
}}
fn admin() -> Markup {{ html! {{ img src="/x.png"; }} }}
"#
)
}
#[test]
fn a_gated_statement_is_skipped_past_its_generics_closures_and_patterns() {
for stmt in [
"let m: HashMap<String, Markup> = admin();",
"let f = |a, b| admin();",
"let v = foo::<A, B>(admin());",
"let Ctx { .. } = admin();",
"for Row { id } in rows { admin(); }",
"if let Foo { a } = x { admin(); }",
"let y = unsafe { a() } + admin();",
] {
let src = gated(stmt);
assert!(routes_with_features(&src, &[]).is_empty(), "{stmt}");
}
}
#[test]
fn a_gated_struct_pattern_arm_is_skipped_whole() {
let src = r#"
#[get("/a")]
async fn a(x: Page) -> Markup {
match x {
#[cfg(feature = "admin")]
Page::Admin { user } => admin(user),
_ => html! {},
}
}
fn admin(u: User) -> Markup { html! { img src="/x.png"; } }
"#;
assert!(routes_with_features(src, &[]).is_empty());
}
#[test]
fn markup_in_a_gated_nested_fn_is_not_the_handler_s() {
let src = r#"
#[get("/a")]
async fn a() -> Markup {
#[cfg(feature = "admin")]
fn unused() -> Markup { html! { img src="/x.png"; } }
html! { span { "a" } }
}
"#;
assert!(routes_with_features(src, &[]).is_empty());
}
#[test]
fn a_gated_arm_does_not_exclude_the_next_arm() {
let src = r#"
#[get("/a")]
async fn a(x: Kind) -> Markup {
match x {
#[cfg(feature = "admin")]
Kind::Admin => clean(),
_ => html! { img src="/x.png"; },
}
}
fn clean() -> Markup { html! { span { "clean" } } }
"#;
assert_eq!(routes_with_features(src, &[]), vec!["GET /a".to_owned()]);
}
fn gated_arm(arm: &str) -> String {
format!(
r#"
#[get("/a")]
async fn a(x: Page) -> Markup {{
match x {{
#[cfg(feature = "admin")]
{arm}
_ => html! {{ img src="/x.png"; }},
}}
}}
fn clean() -> Markup {{ html! {{ span {{ "clean" }} }} }}
"#
)
}
#[test]
fn a_gated_arm_ends_at_its_own_end_for_every_pattern_shape() {
for arm in [
"Page::Admin => clean(),",
"Page::Admin | Page::Legacy => clean(),",
"Page::Admin { user } if user.active => clean(),",
"Page::Admin(a, b) if a < b => clean(),",
"Page::Admin => { clean() }",
"Page::Admin => foo::<A, B>(clean()),",
"Page::Admin => rows.map(|a, b| clean()),",
"Page::Admin => { rows }.into_iter().map(clean).count(),",
] {
let src = gated_arm(arm);
assert_eq!(
routes_with_features(&src, &[]),
vec!["GET /a".to_owned()],
"{arm}"
);
}
}
#[test]
fn a_method_chain_after_a_gated_arm_block_is_skipped() {
let src = r#"
#[get("/a")]
async fn a(x: Page, rows: Vec<Row>) -> Markup {
match x {
#[cfg(feature = "admin")]
Page::Admin => { rows }.into_iter().map(sidebar).count(),
_ => html! { span { "a" } },
}
}
fn sidebar(r: Row) -> Markup { html! { img src="/x.png"; } }
"#;
assert!(routes_with_features(src, &[]).is_empty());
}
#[test]
fn markup_in_a_gated_guarded_arm_is_not_the_handler_s() {
let src = r#"
#[get("/a")]
async fn a(x: Page) -> Markup {
match x {
#[cfg(feature = "admin")]
Page::Admin { user } if user.active => html! { img src="/x.png"; },
_ => html! { span { "a" } },
}
}
"#;
assert!(routes_with_features(src, &[]).is_empty());
}
#[test]
fn a_gated_statement_with_a_bitwise_or_still_ends_at_its_comma() {
let src = r#"
#[get("/a")]
async fn a(a: u8, b: u8) -> Markup {
let _f = Flags {
#[cfg(feature = "admin")]
mask: a | b,
page: html! { img src="/x.png"; },
};
html! { span { "a" } }
}
"#;
assert_eq!(routes_with_features(src, &[]), vec!["GET /a".to_owned()]);
}
#[test]
fn a_gated_generic_impl_is_skipped_whole() {
let src = r#"
#[get("/a")]
async fn a() -> Markup { html! { (sidebar()) } }
#[cfg(feature = "admin")]
impl<A, B> Render for Pair<A, B> { fn render(&self) -> Markup { sidebar() } }
fn sidebar() -> Markup { html! { img src="/x.png"; } }
"#;
assert_eq!(routes_with_features(src, &[]), vec!["GET /a".to_owned()]);
}
#[test]
fn markup_in_a_gated_statement_is_not_the_handler_s() {
let src = r#"
#[get("/a")]
async fn a() -> Markup {
#[cfg(feature = "admin")]
let _x = html! { img src="/x.png"; };
html! { span { "a" } }
}
"#;
assert!(routes_with_features(src, &[]).is_empty());
}
#[test]
fn a_module_qualified_call_skips_a_local_helper() {
let local_bad = r#"
#[get("/a")]
async fn a() -> Markup {
fn sidebar() -> Markup { html! { img src="/x.png"; } }
html! { (views::sidebar()) }
}
fn sidebar() -> Markup { html! { span { "clean" } } }
"#;
assert!(routes_of_only_finding(&[("views.rs", local_bad)]).is_empty());
let free_bad = r#"
#[get("/a")]
async fn a() -> Markup {
fn sidebar() -> Markup { html! { span { "clean" } } }
html! { (views::sidebar()) }
}
fn sidebar() -> Markup { html! { img src="/x.png"; } }
"#;
assert_eq!(
routes_of_only_finding(&[("views.rs", free_bad)]),
vec!["GET /a".to_owned()]
);
}
#[test]
fn a_local_binding_does_not_shadow_a_module_qualified_call() {
let src = r#"
#[get("/a")]
async fn a(sidebar: bool) -> Markup { html! { (views::sidebar()) } }
fn sidebar() -> Markup { html! { img src="/x.png"; } }
"#;
assert_eq!(
routes_of_only_finding(&[("views.rs", src)]),
vec!["GET /a".to_owned()]
);
}
#[test]
fn the_manifest_default_features_gate_call_edges() {
let dir = cfg_crate();
assert!(routes_in(dir.path(), &CargoFeatures::default()).is_empty());
}
#[test]
fn a_requested_feature_turns_a_gated_call_edge_on() {
let dir = cfg_crate();
let features = CargoFeatures {
features: vec!["admin".to_owned()],
..CargoFeatures::default()
};
assert_eq!(routes_in(dir.path(), &features), vec!["GET /a".to_owned()]);
}
#[test]
fn all_features_turns_every_gated_call_edge_on() {
let dir = cfg_crate();
let features = CargoFeatures {
all: true,
..CargoFeatures::default()
};
assert_eq!(routes_in(dir.path(), &features), vec!["GET /a".to_owned()]);
}
#[test]
fn a_scan_root_below_the_manifest_still_reads_it() {
let dir = cfg_crate();
assert!(routes_in(&dir.path().join("src"), &CargoFeatures::default()).is_empty());
}
#[test]
fn a_nested_fn_declaration_is_not_a_call_edge() {
let src = r#"
#[get("/a")]
async fn a() -> Markup {
fn never_called() -> Markup { sidebar() }
html! { span { "hi" } }
}
fn sidebar() -> Markup { html! { img src="/logo.png"; } }
"#;
assert!(routes_of_only_finding(&[("views.rs", src)]).is_empty());
}
#[test]
fn a_macro_rules_template_is_not_an_ambiguous_definition() {
let src = r#"
macro_rules! decl {
() => { fn sidebar() -> Markup { html! { span { "gen" } } } };
}
#[get("/a")]
async fn a() -> Markup { html! { (sidebar()) } }
fn sidebar() -> Markup { html! { img src="/logo.png"; } }
"#;
assert_eq!(
routes_of_only_finding(&[("views.rs", src)]),
vec!["GET /a".to_owned()]
);
}
#[test]
fn two_handlers_sharing_a_path_do_not_share_findings() {
let src = r#"
#[get("/a")]
async fn one() -> Markup { html! { img src="/logo.png"; } }
#[get("/a")]
async fn two() -> Markup { html! { span { "ok" } } }
"#;
let report = report_for(&[("views.rs", src)]);
let rows: Vec<_> = report
.routes
.iter()
.map(|r| (r.handler.as_str(), r.findings, r.status))
.collect();
assert_eq!(rows, vec![("one", 1, "fail"), ("two", 0, "pass")]);
assert_eq!(report.summary.routes_failing, 1);
}
#[test]
fn two_fns_on_one_line_do_not_steal_each_others_findings() {
let src = concat!(
"#[get(\"/a\")] fn a() -> Markup { html! { span {} } } ",
"fn b() -> Markup { html! { img src=\"/x.png\"; } }",
);
assert!(routes_of_only_finding(&[("views.rs", src)]).is_empty());
}
#[test]
fn a_route_attribute_does_not_leak_past_a_non_fn_item() {
let src = r#"
#[get("/a")]
extern "C" { }
pub async fn helper() -> Markup { html! { img src="/x.png"; } }
"#;
let report = report_for(&[("views.rs", src)]);
assert!(report.routes.is_empty(), "{:?}", report.routes);
assert!(
report.findings[0].routes.is_empty(),
"{:?}",
report.findings
);
}
#[test]
fn a_leading_crate_override_is_not_read_as_the_path() {
let src = r#"
#[get(crate = "autumn_web", "/a")]
async fn a() -> Markup { html! { img src="/x.png"; } }
"#;
assert_eq!(
routes_of_only_finding(&[("views.rs", src)]),
vec!["GET /a".to_owned()]
);
}
#[test]
fn every_http_route_macro_is_recognised() {
let src = r#"
#[get("/a")] async fn a() -> Markup { html! { span {} } }
#[post("/b")] async fn b() -> Markup { html! { span {} } }
#[put("/c")] async fn c() -> Markup { html! { span {} } }
#[patch("/d")] async fn d() -> Markup { html! { span {} } }
#[delete("/e")] async fn e() -> Markup { html! { span {} } }
#[static_get("/f")] async fn f() -> Markup { html! { span {} } }
"#;
let keys: Vec<String> = report_for(&[("views.rs", src)])
.routes
.iter()
.map(|r| format!("{} {}", r.method, r.path))
.collect();
assert_eq!(
keys,
vec![
"GET /a",
"POST /b",
"PUT /c",
"PATCH /d",
"DELETE /e",
"GET /f",
]
);
}
#[test]
fn one_handler_can_serve_several_routes() {
let src = r#"
#[get("/a")]
#[post("/a")]
async fn a() -> Markup { html! { img src="/x.png"; } }
"#;
assert_eq!(
routes_of_only_finding(&[("views.rs", src)]),
vec!["GET /a".to_owned(), "POST /a".to_owned()]
);
}
#[test]
fn route_conformance_points_at_the_handler_line() {
let src = "\n\n#[get(\"/a\")]\nasync fn a() -> Markup { html! { span {} } }\n";
let report = report_for(&[("views.rs", src)]);
assert_eq!(report.routes[0].line, 4);
}
#[test]
fn an_unrouted_finding_still_fails_the_build() {
let src = r#"fn orphan() -> Markup { html! { img src="/l.png"; } }"#;
let report = report_for(&[("partials.rs", src)]);
assert_eq!(report.summary.unrouted, 1);
assert_ne!(report.exit_code(false), 0, "{report:?}");
}
#[test]
fn unrouted_findings_serialize_an_empty_route_list() {
let src = r#"fn orphan() -> Markup { html! { img src="/l.png"; } }"#;
let json = serde_json::to_string(&report_for(&[("partials.rs", src)])).unwrap();
assert!(json.contains(r#""routes":[]"#), "{json}");
}
#[test]
fn wcag_rollup_splits_multi_criterion_rules() {
let src = r#"
fn view() -> Markup {
html! { img src="/logo.png"; input type="text" name="email"; }
}
"#;
let report = report_for(&[("views.rs", src)]);
let rollup: Vec<_> = report
.wcag
.iter()
.map(|c| (c.criterion.as_str(), c.findings))
.collect();
assert_eq!(
rollup,
vec![("1.1.1", 1), ("1.3.1", 1), ("3.3.2", 1), ("4.1.2", 1),]
);
assert_eq!(report.wcag[0].rules, vec!["image-alt"]);
for criterion in &report.wcag[1..] {
assert_eq!(criterion.rules, vec!["label"], "{criterion:?}");
}
}
#[test]
fn wcag_rollup_is_empty_when_clean() {
let src = r#"fn view() -> Markup { html! { img src="/l.png" alt="Logo"; } }"#;
assert!(report_for(&[("views.rs", src)]).wcag.is_empty());
}
#[test]
fn text_output_names_failing_routes_and_criteria() {
let src = r#"
#[get("/settings")]
async fn settings() -> Markup { html! { img src="/logo.png"; } }
"#;
let text = format_text(&report_for(&[("views.rs", src)]), false);
assert!(
text.contains("1 route(s) discovered, 1 carrying findings"),
"{text}"
);
assert!(text.contains("\n failing routes:\n"), "{text}");
assert!(
text.contains(" GET /settings (settings) — 1 finding(s)"),
"{text}"
);
assert!(
text.contains("\n WCAG success criteria breached:\n"),
"{text}"
);
assert!(
text.contains(" WCAG 1.1.1 — 1 finding(s) [image-alt]"),
"{text}"
);
assert!(
text.contains("1 total, 0 not reached by any route"),
"{text}"
);
assert!(text.contains("Result: FAIL"), "{text}");
}
#[test]
fn text_output_passes_clean() {
let src = r#"fn view() -> Markup { html! { img src="/l.png" alt="Logo"; } }"#;
let text = format_text(&report_for(&[("views.rs", src)]), false);
assert!(
text.contains("0 route(s) discovered, 0 carrying findings"),
"{text}"
);
assert!(
text.contains("Result: PASS — no accessibility violations in raw html! markup."),
"{text}"
);
}
#[test]
fn route_attribution_ignores_cfg_test_handlers() {
let src = r#"
#[cfg(test)]
mod tests {
#[get("/fixture")]
async fn fixture() -> Markup { html! { img src="/logo.png"; } }
}
"#;
let report = report_for(&[("views.rs", src)]);
assert!(report.routes.is_empty(), "{:?}", report.routes);
assert!(report.findings.is_empty(), "{:?}", report.findings);
}
}