Skip to main content

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}