use proc_macro::TokenStream;
use quote::quote;
use syn::{LitStr, parse_macro_input};
#[proc_macro]
pub fn locator(input: TokenStream) -> TokenStream {
let lit = parse_macro_input!(input as LitStr);
let value = lit.value();
if let Err(msg) = validate_selector(&value) {
return syn::Error::new(lit.span(), msg).to_compile_error().into();
}
quote! { #lit }.into()
}
fn validate_selector(s: &str) -> Result<(), String> {
if s.trim().is_empty() {
return Err("selector is empty or whitespace-only".to_string());
}
check_balanced_brackets(s)?;
if let Some((engine, _rest)) = split_engine_prefix(s)
&& !is_known_engine(engine)
{
return Err(format!(
"unknown selector engine `{engine}=...`; expected one of \
css, xpath, text, role, id, data-testid, nth, or an \
`internal:*=` prefix"
));
}
Ok(())
}
fn check_balanced_brackets(s: &str) -> Result<(), String> {
let mut stack: Vec<char> = Vec::new();
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\\' => {
let _ = chars.next();
}
'"' | '\'' => {
let quote = c;
loop {
match chars.next() {
None => break,
Some('\\') => {
let _ = chars.next();
}
Some(q) if q == quote => break,
Some(_) => {}
}
}
}
'(' | '[' | '{' => stack.push(c),
')' | ']' | '}' => match (stack.pop(), c) {
(Some('('), ')') | (Some('['), ']') | (Some('{'), '}') => {}
(Some(open), close) => {
return Err(format!(
"mismatched bracket: `{open}` opened, `{close}` closed"
));
}
(None, close) => {
return Err(format!("unmatched closing `{close}`"));
}
},
_ => {}
}
}
if let Some(open) = stack.pop() {
return Err(format!("unclosed `{open}`"));
}
Ok(())
}
fn split_engine_prefix(s: &str) -> Option<(&str, &str)> {
let s = s.trim_start();
let eq_pos = s.find('=')?;
let prefix = &s[..eq_pos];
let rest = &s[eq_pos + 1..];
if prefix.is_empty()
|| !prefix
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == ':')
{
return None;
}
Some((prefix, rest))
}
fn is_known_engine(engine: &str) -> bool {
matches!(
engine,
"css" | "xpath" | "text" | "role" | "id" | "data-testid" | "nth"
) || engine.starts_with("internal:")
}