use crate::backend::{AnyElement, AnyPage};
use crate::error::{FerriError, Result};
#[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,
InternalAnd,
InternalOr,
InternalText,
InternalLabel,
InternalAttr,
InternalTestId,
InternalRole,
}
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=",
"internal:has=",
"has-text=",
"internal:has-text=",
"has-not=",
"internal:has-not=",
"has-not-text=",
"internal:has-not-text=",
"internal:and=",
"internal:or=",
"internal:text=",
"internal:label=",
"internal:attr=",
"internal:testid=",
"internal:role=",
];
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> {
let selector = selector.trim();
if selector.is_empty() {
return Err(FerriError::invalid_selector(selector, "selector cannot be empty"));
}
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(FerriError::invalid_selector(selector, "empty selector part in chain"));
}
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),
("internal:has=", Engine::Has),
("has-text=", Engine::HasText),
("internal:has-text=", Engine::HasText),
("has-not=", Engine::HasNot),
("internal:has-not=", Engine::HasNot),
("has-not-text=", Engine::HasNotText),
("internal:has-not-text=", Engine::HasNotText),
("internal:and=", Engine::InternalAnd),
("internal:or=", Engine::InternalOr),
("internal:text=", Engine::InternalText),
("internal:label=", Engine::InternalLabel),
("internal:attr=", Engine::InternalAttr),
("internal:testid=", Engine::InternalTestId),
("internal:role=", Engine::InternalRole),
];
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",
Engine::InternalAnd => "internal:and",
Engine::InternalOr => "internal:or",
Engine::InternalText => "internal:text",
Engine::InternalLabel => "internal:label",
Engine::InternalAttr => "internal:attr",
Engine::InternalTestId => "internal:testid",
Engine::InternalRole => "internal:role",
}
}
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(crate) const MCP_SUPPORT_JS: &str = include_str!("injected/dist/mcp-support.min.js");
pub(crate) const AX_SUPPORT_JS: &str = include_str!("injected/dist/ax-support.min.js");
pub async fn query_all(page: &AnyPage, selector: &str, frame_id: Option<&str>) -> Result<Vec<MatchedElement>> {
let parsed = parse(selector)?;
page.ensure_engine_injected().await?;
let fd = "window.__fd";
let js = build_query_js(&parsed, fd);
let result_str = match frame_id {
Some(fid) => page.evaluate_in_frame(&js, fid).await,
None => 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(FerriError::evaluation(err.to_string()));
}
}
let elements: Vec<MatchedElement> =
serde_json::from_str(&result_str).map_err(|e| FerriError::Backend(format!("Parse selector results: {e}")))?;
Ok(elements)
}
pub async fn query_one(page: &AnyPage, selector: &str, strict: bool, frame_id: Option<&str>) -> Result<AnyElement> {
let parsed = parse(selector)?;
let parts_json = build_parts_json(&parsed);
if strict {
let matches = query_all(page, selector, frame_id).await?;
if matches.is_empty() {
return Err(FerriError::invalid_selector(selector, "no element found"));
}
if matches.len() > 1 {
cleanup_tags(page).await;
return Err(FerriError::strict(selector, matches.len()));
}
let fd = "window.__fd";
let tagged_js = format!("{fd}.selOne([{{\"engine\":\"css\",\"body\":\"[data-fd-sel='0']\"}}])");
let el = page
.evaluate_to_element(&tagged_js, frame_id)
.await
.map_err(|_| FerriError::invalid_selector(selector, "could not resolve matched element"))?;
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, frame_id)
.await
.map_err(|_| FerriError::invalid_selector(selector, "no element found"))
}
pub async fn query_one_prebuilt(
page: &AnyPage,
sel_js: &str,
selector_display: &str,
frame_id: Option<&str>,
) -> Result<AnyElement> {
page.evaluate_to_element(sel_js, frame_id).await.map_err(|err| {
let msg = err.to_string();
if msg.contains("strict mode violation") {
err
} else {
FerriError::invalid_selector(selector_display, "no element found")
}
})
}
pub fn build_selone_js(selector: &str, fd: &str, strict: bool) -> Result<String> {
let parsed = parse(selector)?;
let parts_json = build_parts_json(&parsed);
let strict_lit = if strict { "true" } else { "false" };
Ok(format!("{fd}.selOne({parts_json},{strict_lit})"))
}
pub async fn normalize_selector(page: &AnyPage, selector: &str, frame_id: Option<&str>) -> Result<String> {
let parsed = parse(selector)?;
let parts_json = build_parts_json(&parsed);
page.ensure_engine_injected().await?;
let js = format!("window.__fd.normalizeSelector({parts_json})");
let result = match frame_id {
Some(fid) => page.evaluate_in_frame(&js, fid).await,
None => page.evaluate(&js).await,
};
let value = result.map_err(|err| {
if let Some(count) = parse_strict_violation_count(&err) {
FerriError::strict(selector, count)
} else {
err
}
})?;
value
.and_then(|v| v.as_str().map(std::string::ToString::to_string))
.ok_or_else(|| FerriError::invalid_selector(selector, "no element found"))
}
#[must_use]
pub fn parse_strict_violation_count<E: std::fmt::Display + ?Sized>(err: &E) -> Option<usize> {
let message = err.to_string();
let needle = "strict mode violation:";
let idx = message.find(needle)?;
let tail = message[idx + needle.len()..].trim();
let count_str: String = tail.chars().take_while(char::is_ascii_digit).collect();
count_str.parse().ok()
}
pub async fn highlight(page: &AnyPage, selector: &str, style: Option<&str>, frame_id: Option<&str>) -> Result<()> {
let parsed = parse(selector)?;
page.ensure_engine_injected().await?;
let parts_json = build_parts_json(&parsed);
let style_arg = match style {
Some(s) => serde_json::to_string(s).unwrap_or_else(|_| "undefined".to_string()),
None => "undefined".to_string(),
};
let js = format!("window.__fd.addHighlight({parts_json},{style_arg})");
run_void(page, &js, frame_id).await
}
pub async fn hide_highlight(page: &AnyPage, frame_id: Option<&str>) -> Result<()> {
page.ensure_engine_injected().await?;
run_void(page, "window.__fd.hideHighlight()", frame_id).await
}
async fn run_void(page: &AnyPage, js: &str, frame_id: Option<&str>) -> Result<()> {
match frame_id {
Some(fid) => page.evaluate_in_frame(js, fid).await,
None => page.evaluate(js).await,
}?;
Ok(())
}
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;
}