playwright_rs_macros/lib.rs
1//! Compile-time-validated selector macros for [playwright-rs].
2//!
3//! Most users get this crate transitively through `playwright-rs`
4//! (the `macros` feature is on by default) and never need to depend on
5//! it directly:
6//!
7//! ```rust,ignore
8//! use playwright_rs::locator;
9//!
10//! let l = page.locator(locator!("#submit")).await;
11//! ```
12//!
13//! See the `playwright-rs` crate root for the broader Observability /
14//! macros story.
15
16use proc_macro::TokenStream;
17use quote::quote;
18use syn::{LitStr, parse_macro_input};
19
20/// Compile-time-validated Playwright selector. Expands to a `&'static
21/// str` containing the same selector verbatim, with the validation a
22/// one-time compile-time check.
23///
24/// Catches:
25/// - empty / whitespace-only selectors
26/// - unbalanced `[]`, `()`, `{}` brackets
27/// - unknown engine prefixes (e.g. `foo=...` instead of one of
28/// `css=`, `xpath=`, `text=`, `role=`, `id=`, `data-testid=`, `nth=`,
29/// `internal:*=...`)
30///
31/// Anything else passes through. Selector engines have rich grammars
32/// of their own (CSS, XPath, role-name semantics, `>>` chaining); this
33/// macro punts on those for the v0.1 surface and lets the Playwright
34/// server give the runtime error if the selector turns out invalid.
35/// Future versions will tighten this up — see issue #81.
36///
37/// # Example
38///
39/// ```ignore
40/// use playwright_rs::locator;
41///
42/// let _ok = locator!("#submit");
43/// let _ok = locator!("text=Hello");
44/// let _ok = locator!("xpath=//button[@id='submit']");
45/// // let _bad = locator!(""); // compile error: empty selector
46/// // let _bad = locator!("button[disabled"); // compile error: unbalanced [
47/// // let _bad = locator!("foo=bar"); // compile error: unknown engine
48/// ```
49#[proc_macro]
50pub fn locator(input: TokenStream) -> TokenStream {
51 let lit = parse_macro_input!(input as LitStr);
52 let value = lit.value();
53
54 if let Err(msg) = validate_selector(&value) {
55 return syn::Error::new(lit.span(), msg).to_compile_error().into();
56 }
57
58 quote! { #lit }.into()
59}
60
61/// Validate a Playwright selector string. Returns `Err(message)` when
62/// the selector is rejectable at compile time; the message is the
63/// diagnostic shown to the user.
64fn validate_selector(s: &str) -> Result<(), String> {
65 if s.trim().is_empty() {
66 return Err("selector is empty or whitespace-only".to_string());
67 }
68
69 check_balanced_brackets(s)?;
70
71 if let Some((engine, _rest)) = split_engine_prefix(s)
72 && !is_known_engine(engine)
73 {
74 return Err(format!(
75 "unknown selector engine `{engine}=...`; expected one of \
76 css, xpath, text, role, id, data-testid, nth, or an \
77 `internal:*=` prefix"
78 ));
79 }
80
81 Ok(())
82}
83
84/// Track depth of `()`, `[]`, `{}`. Returns `Err` on the first
85/// imbalance — either an unmatched closer or any unclosed opener at
86/// end of input. Bracket characters inside string literals (`"..."`,
87/// `'...'`) are skipped because Playwright selectors can carry quoted
88/// values like `text="Hello"` or `[aria-label='go [back]']`.
89fn check_balanced_brackets(s: &str) -> Result<(), String> {
90 let mut stack: Vec<char> = Vec::new();
91 let mut chars = s.chars().peekable();
92
93 while let Some(c) = chars.next() {
94 match c {
95 '\\' => {
96 // skip the escaped char
97 let _ = chars.next();
98 }
99 '"' | '\'' => {
100 // skip until matching closing quote (or end of input);
101 // honour `\<x>` so an escaped quote inside the value
102 // doesn't terminate the run
103 let quote = c;
104 loop {
105 match chars.next() {
106 None => break,
107 Some('\\') => {
108 let _ = chars.next();
109 }
110 Some(q) if q == quote => break,
111 Some(_) => {}
112 }
113 }
114 }
115 '(' | '[' | '{' => stack.push(c),
116 ')' | ']' | '}' => match (stack.pop(), c) {
117 (Some('('), ')') | (Some('['), ']') | (Some('{'), '}') => {}
118 (Some(open), close) => {
119 return Err(format!(
120 "mismatched bracket: `{open}` opened, `{close}` closed"
121 ));
122 }
123 (None, close) => {
124 return Err(format!("unmatched closing `{close}`"));
125 }
126 },
127 _ => {}
128 }
129 }
130
131 if let Some(open) = stack.pop() {
132 return Err(format!("unclosed `{open}`"));
133 }
134
135 Ok(())
136}
137
138/// If the selector starts with `<word>=`, return `(word, rest)`.
139/// Returns `None` for selectors that don't carry an explicit engine
140/// prefix (CSS is the default).
141///
142/// `internal:visible=` and similar internal-prefixed engines also have
143/// an `=`, but they all start with `internal:` so we treat the
144/// `internal:` namespace specially as known.
145fn split_engine_prefix(s: &str) -> Option<(&str, &str)> {
146 let s = s.trim_start();
147 let eq_pos = s.find('=')?;
148 let prefix = &s[..eq_pos];
149 let rest = &s[eq_pos + 1..];
150
151 // The prefix must look like an identifier (letters, digits, `-`,
152 // `:`). Bail otherwise — `[attr=val]` and similar shouldn't be
153 // misread as an engine.
154 if prefix.is_empty()
155 || !prefix
156 .chars()
157 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == ':')
158 {
159 return None;
160 }
161
162 Some((prefix, rest))
163}
164
165fn is_known_engine(engine: &str) -> bool {
166 matches!(
167 engine,
168 "css" | "xpath" | "text" | "role" | "id" | "data-testid" | "nth"
169 ) || engine.starts_with("internal:")
170}