use crate::backend::{AnyElement, AnyPage};
#[derive(Debug, Clone)]
pub struct Selector {
pub parts: Vec<SelectorPart>,
}
#[derive(Debug, Clone)]
pub struct SelectorPart {
pub engine: Engine,
pub body: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Engine {
Css,
Text,
Role,
TestId,
Label,
Placeholder,
Alt,
Title,
XPath,
Id,
Nth,
Visible,
Has,
HasText,
HasNot,
HasNotText,
}
pub const ENGINE_BOOTSTRAP_JS: &str = "window.__fd_promise = null;";
#[must_use]
pub fn build_lazy_inject_js() -> String {
let engine_js = build_inject_js();
format!(
r"(async () => {{
if (window.__fd) return window.__fd;
if (window.__fd_promise) return await window.__fd_promise;
window.__fd_promise = (async () => {{
try {{
{engine_js}
return window.__fd;
}} catch (e) {{
window.__fd_promise = null;
throw e;
}}
}})();
return await window.__fd_promise;
}})()"
)
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct MatchedElement {
pub index: usize,
pub tag: String,
pub text: String,
}
#[must_use]
pub fn is_rich_selector(s: &str) -> bool {
let prefixes = [
"role=",
"text=",
"testid=",
"label=",
"placeholder=",
"alt=",
"title=",
"xpath=",
"id=",
"css=",
"nth=",
"visible=",
"has=",
"has-text=",
"has-not=",
"has-not-text=",
];
let trimmed = s.trim();
if prefixes.iter().any(|p| trimmed.starts_with(p)) {
return true;
}
if trimmed.contains(" >> ") {
return true;
}
false
}
pub fn parse(selector: &str) -> Result<Selector, String> {
let selector = selector.trim();
if selector.is_empty() {
return Err("Selector cannot be empty".into());
}
let raw_parts = split_by_chain(selector);
let mut parts = Vec::new();
for raw in raw_parts {
let raw = raw.trim();
if raw.is_empty() {
return Err("Empty selector part in chain".into());
}
parts.push(parse_part(raw));
}
Ok(Selector { parts })
}
fn split_by_chain(s: &str) -> Vec<String> {
if !s.contains(">>") {
let t = s.trim();
return if t.is_empty() { Vec::new() } else { vec![t.to_string()] };
}
let mut parts = Vec::new();
let bytes = s.as_bytes();
let mut start = 0;
let mut i = 0;
let mut in_quote: u8 = 0;
while i < bytes.len() {
let c = bytes[i];
if c == b'\\' && i + 1 < bytes.len() {
i += 2;
continue;
}
if in_quote != 0 {
if c == in_quote {
in_quote = 0;
}
i += 1;
continue;
}
if c == b'"' || c == b'\'' {
in_quote = c;
i += 1;
continue;
}
if c == b'>' && i + 1 < bytes.len() && bytes[i + 1] == b'>' {
let part = s[start..i].trim();
if !part.is_empty() {
parts.push(part.to_string());
}
i += 2;
while i < bytes.len() && bytes[i] == b' ' {
i += 1;
}
start = i;
continue;
}
i += 1;
}
let part = s[start..].trim();
if !part.is_empty() {
parts.push(part.to_string());
}
parts
}
fn parse_part(s: &str) -> SelectorPart {
let engines = [
("role=", Engine::Role),
("text=", Engine::Text),
("testid=", Engine::TestId),
("label=", Engine::Label),
("placeholder=", Engine::Placeholder),
("alt=", Engine::Alt),
("title=", Engine::Title),
("xpath=", Engine::XPath),
("id=", Engine::Id),
("css=", Engine::Css),
("nth=", Engine::Nth),
("visible=", Engine::Visible),
("has=", Engine::Has),
("has-text=", Engine::HasText),
("has-not=", Engine::HasNot),
("has-not-text=", Engine::HasNotText),
];
for (prefix, engine) in &engines {
if let Some(body) = s.strip_prefix(prefix) {
return SelectorPart {
engine: engine.clone(),
body: body.to_string(),
};
}
}
SelectorPart {
engine: Engine::Css,
body: s.to_string(),
}
}
#[must_use]
pub fn build_inject_js() -> String {
ENGINE_JS.to_string()
}
fn build_query_js(selector: &Selector, fd: &str) -> String {
let parts_json = build_parts_json(selector);
format!("{fd}.sel({parts_json})")
}
#[must_use]
pub fn build_parts_json(selector: &Selector) -> String {
let mut buf = String::with_capacity(selector.parts.len() * 40 + 2);
buf.push('[');
for (i, p) in selector.parts.iter().enumerate() {
if i > 0 {
buf.push(',');
}
let engine = engine_str(&p.engine);
buf.push_str(r#"{"engine":""#);
buf.push_str(engine);
buf.push_str(r#"","body":"#);
json_escape_string_into(&mut buf, &p.body);
buf.push('}');
}
buf.push(']');
buf
}
fn engine_str(engine: &Engine) -> &'static str {
match engine {
Engine::Css => "css",
Engine::Text => "text",
Engine::Role => "role",
Engine::TestId => "testid",
Engine::Label => "label",
Engine::Placeholder => "placeholder",
Engine::Alt => "alt",
Engine::Title => "title",
Engine::XPath => "xpath",
Engine::Id => "id",
Engine::Nth => "nth",
Engine::Visible => "visible",
Engine::Has => "has",
Engine::HasText => "has-text",
Engine::HasNot => "has-not",
Engine::HasNotText => "has-not-text",
}
}
fn json_escape_string_into(buf: &mut String, s: &str) {
use std::fmt::Write as _;
buf.push('"');
for ch in s.chars() {
match ch {
'"' => buf.push_str(r#"\""#),
'\\' => buf.push_str(r"\\"),
'\n' => buf.push_str(r"\n"),
'\r' => buf.push_str(r"\r"),
'\t' => buf.push_str(r"\t"),
c if c.is_control() => {
let _ = write!(buf, "\\u{:04x}", c as u32);
},
c => buf.push(c),
}
}
buf.push('"');
}
const ENGINE_JS: &str = include_str!("injected/dist/engine.min.js");
pub async fn query_all(page: &AnyPage, selector: &str) -> Result<Vec<MatchedElement>, String> {
let parsed = parse(selector)?;
page.ensure_engine_injected().await?;
let fd = "window.__fd";
let js = build_query_js(&parsed, fd);
let result_str = page
.evaluate(&js)
.await?
.and_then(|v| v.as_str().map(std::string::ToString::to_string))
.unwrap_or_else(|| "[]".into());
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&result_str) {
if let Some(err) = val.get("error").and_then(|e| e.as_str()) {
return Err(err.to_string());
}
}
let elements: Vec<MatchedElement> =
serde_json::from_str(&result_str).map_err(|e| format!("Parse selector results: {e}"))?;
Ok(elements)
}
pub async fn query_one(page: &AnyPage, selector: &str, strict: bool) -> Result<AnyElement, String> {
let parsed = parse(selector)?;
let parts_json = build_parts_json(&parsed);
if strict {
let matches = query_all(page, selector).await?;
if matches.is_empty() {
return Err(format!("No element found for selector: {selector}"));
}
if matches.len() > 1 {
cleanup_tags(page).await;
return Err(format!(
"Selector \"{selector}\" resolved to {} elements. Use a more specific selector.",
matches.len()
));
}
let el = page
.find_element("[data-fd-sel='0']")
.await
.map_err(|_| format!("Could not resolve matched element for: {selector}"))?;
cleanup_tags(page).await;
return Ok(el);
}
page.ensure_engine_injected().await?;
let fd = "window.__fd";
let js = format!("{fd}.selOne({parts_json})");
page
.evaluate_to_element(&js)
.await
.map_err(|_| format!("No element found for selector: {selector}"))
}
pub async fn query_one_prebuilt(page: &AnyPage, sel_js: &str, selector_display: &str) -> Result<AnyElement, String> {
page
.evaluate_to_element(sel_js)
.await
.map_err(|_| format!("No element found for selector: {selector_display}"))
}
pub fn build_selone_js(selector: &str, fd: &str) -> Result<String, String> {
let parsed = parse(selector)?;
let parts_json = build_parts_json(&parsed);
Ok(format!("{fd}.selOne({parts_json})"))
}
pub async fn cleanup_tags(page: &AnyPage) {
let _ = page
.evaluate(
"(function() { \
document.querySelectorAll('[data-fd-sel]').forEach(function(e) { \
e.removeAttribute('data-fd-sel'); \
}); \
})()",
)
.await;
}