use serde_json::Value;
use std::fmt;
use crate::protocol::TreeNode;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Selector {
Id {
widget_id: String,
window_id: Option<String>,
},
Text(String),
Role(String),
Label(String),
Focused,
}
impl Selector {
pub fn id(id: &str) -> Self {
let window_id = id
.split_once('#')
.filter(|(win, _)| !win.is_empty())
.map(|(win, _)| win.to_string());
Self::Id {
widget_id: id.to_string(),
window_id,
}
}
pub fn id_in_window(id: &str, window_id: &str) -> Self {
Self::Id {
widget_id: id.to_string(),
window_id: Some(window_id.to_string()),
}
}
pub fn text(text: &str) -> Self {
Self::Text(text.to_string())
}
pub fn role(role: &str) -> Self {
Self::Role(role.to_string())
}
pub fn label(label: &str) -> Self {
Self::Label(label.to_string())
}
pub fn focused() -> Self {
Self::Focused
}
pub fn from_wire(value: &Value) -> Option<Self> {
let by = value.get("by")?.as_str()?;
match by {
"focused" => Some(Self::Focused),
_ => {
let raw_value = value.get("value")?.as_str()?.to_string();
let explicit_window = value
.get("window_id")
.and_then(|v| v.as_str())
.map(str::to_string);
match by {
"id" => {
let window_id = raw_value
.split_once('#')
.filter(|(win, _)| !win.is_empty())
.map(|(win, _)| win.to_string())
.or(explicit_window);
Some(Self::Id {
widget_id: raw_value,
window_id,
})
}
"text" => Some(Self::Text(raw_value)),
"role" => Some(Self::Role(raw_value)),
"label" => Some(Self::Label(raw_value)),
_ => None,
}
}
}
}
pub fn to_wire(&self) -> Value {
match self {
Self::Id {
widget_id,
window_id,
} => {
let mut obj = serde_json::json!({"by": "id", "value": widget_id});
if let Some(win) = window_id {
obj["window_id"] = Value::String(win.clone());
}
obj
}
Self::Text(text) => serde_json::json!({"by": "text", "value": text}),
Self::Role(role) => serde_json::json!({"by": "role", "value": role}),
Self::Label(label) => serde_json::json!({"by": "label", "value": label}),
Self::Focused => serde_json::json!({"by": "focused"}),
}
}
}
impl From<&str> for Selector {
fn from(s: &str) -> Self {
Self::id(s)
}
}
impl From<String> for Selector {
fn from(s: String) -> Self {
Self::id(&s)
}
}
impl fmt::Display for Selector {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Id {
widget_id,
window_id: Some(win),
} if !widget_id.starts_with(&format!("{win}#")) => {
write!(f, "{win}#{widget_id}")
}
Self::Id { widget_id, .. } => write!(f, "{widget_id}"),
Self::Text(text) => write!(f, "{{text: {text:?}}}"),
Self::Role(role) => write!(f, "{{role: {role}}}"),
Self::Label(label) => write!(f, "{{label: {label:?}}}"),
Self::Focused => write!(f, "{{focused}}"),
}
}
}
pub const MAX_SELECTOR_SEARCH_DEPTH: usize = 256;
impl Selector {
pub fn find<'a>(&self, root: &'a TreeNode) -> Option<&'a TreeNode> {
match self {
Self::Id {
widget_id,
window_id,
} => find_by_id(root, widget_id, window_id.as_deref(), None, 0),
Self::Text(text) => search(root, 0, &|n| matches_text(n, text)),
Self::Role(role) => search(root, 0, &|n| matches_role(n, role)),
Self::Label(label) => search(root, 0, &|n| matches_label(n, label)),
Self::Focused => search(root, 0, &is_focused),
}
}
pub fn find_all<'a>(&self, root: &'a TreeNode) -> Vec<&'a TreeNode> {
let mut results = Vec::new();
match self {
Self::Text(text) => search_all(root, 0, &|n| matches_text(n, text), &mut results),
Self::Role(role) => search_all(root, 0, &|n| matches_role(n, role), &mut results),
Self::Label(label) => search_all(root, 0, &|n| matches_label(n, label), &mut results),
Self::Focused => search_all(root, 0, &is_focused, &mut results),
Self::Id {
widget_id,
window_id,
} => {
if let Some(node) = find_by_id(root, widget_id, window_id.as_deref(), None, 0) {
results.push(node);
}
}
}
results
}
}
fn search<'a>(
node: &'a TreeNode,
depth: usize,
predicate: &dyn Fn(&TreeNode) -> bool,
) -> Option<&'a TreeNode> {
if depth > MAX_SELECTOR_SEARCH_DEPTH {
return None;
}
if predicate(node) {
return Some(node);
}
node.children
.iter()
.find_map(|child| search(child, depth + 1, predicate))
}
fn search_all<'a>(
node: &'a TreeNode,
depth: usize,
predicate: &dyn Fn(&TreeNode) -> bool,
results: &mut Vec<&'a TreeNode>,
) {
if depth > MAX_SELECTOR_SEARCH_DEPTH {
return;
}
if predicate(node) {
results.push(node);
}
for child in &node.children {
search_all(child, depth + 1, predicate, results);
}
}
fn find_by_id<'a>(
node: &'a TreeNode,
target_id: &str,
target_window: Option<&str>,
current_window: Option<&'a str>,
depth: usize,
) -> Option<&'a TreeNode> {
if depth > MAX_SELECTOR_SEARCH_DEPTH {
return None;
}
let current_window = if node.type_name == "window" {
Some(node.id.as_str())
} else {
current_window
};
let matches_id = node.id == target_id
|| local_name(&node.id) == target_id
|| node.id.ends_with(&format!("/{target_id}"))
|| node.id.ends_with(&format!("#{target_id}"));
if matches_id && target_window.is_none_or(|win| current_window == Some(win)) {
return Some(node);
}
node.children
.iter()
.find_map(|child| find_by_id(child, target_id, target_window, current_window, depth + 1))
}
fn local_name(id: &str) -> &str {
id.rsplit_once('/')
.or_else(|| id.rsplit_once('#'))
.map(|(_, local)| local)
.unwrap_or(id)
}
fn matches_text(node: &TreeNode, text: &str) -> bool {
for key in &["content", "label", "value", "placeholder"] {
if node.props.get_str(key) == Some(text) {
return true;
}
}
false
}
fn matches_role(node: &TreeNode, role: &str) -> bool {
if let Some(a11y) = node.props.get_value("a11y") {
a11y.get("role").and_then(|v| v.as_str()) == Some(role)
} else {
node.type_name == role
}
}
fn matches_label(node: &TreeNode, label: &str) -> bool {
if let Some(a11y) = node.props.get_value("a11y")
&& a11y.get("label").and_then(|v| v.as_str()) == Some(label)
{
return true;
}
for key in &["label", "content"] {
if node.props.get_str(key) == Some(label) {
return true;
}
}
false
}
fn is_focused(node: &TreeNode) -> bool {
if node.props.get_bool("focused") == Some(true) {
return true;
}
if let Some(a11y) = node.props.get_value("a11y")
&& a11y.get("focused").and_then(|v| v.as_bool()) == Some(true)
{
return true;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::Props;
fn node(id: &str, type_name: &str) -> TreeNode {
TreeNode {
id: id.to_string(),
type_name: type_name.to_string(),
props: Props::default(),
children: vec![],
}
}
fn node_with_children(id: &str, type_name: &str, children: Vec<TreeNode>) -> TreeNode {
TreeNode {
id: id.to_string(),
type_name: type_name.to_string(),
props: Props::default(),
children,
}
}
fn text_node_at_depth(depth: usize, text: &str) -> TreeNode {
let mut target = node("target", "text");
target.props = Props::from_json(serde_json::json!({"content": text}));
for level in (0..depth).rev() {
target = node_with_children(&format!("level-{level}"), "column", vec![target]);
}
target
}
#[test]
fn find_by_id_matches_exact_id() {
let root = node_with_children(
"main",
"window",
vec![node("main#save", "button"), node("main#cancel", "button")],
);
let sel = Selector::id("main#save");
let found = sel.find(&root).expect("exact id match");
assert_eq!(found.id, "main#save");
}
#[test]
fn find_by_id_matches_local_name() {
let root = node_with_children(
"main",
"window",
vec![node("main#save", "button"), node("main#cancel", "button")],
);
let sel = Selector::id("save");
let found = sel.find(&root).expect("local-name match");
assert_eq!(found.id, "main#save");
}
#[test]
fn find_by_id_matches_scoped_path_suffix() {
let root = node_with_children(
"main",
"window",
vec![node_with_children(
"main#todos",
"column",
vec![node("main#todo-1/done", "checkbox")],
)],
);
let sel = Selector::id("todo-1/done");
let found = sel
.find(&root)
.expect("scoped-path suffix should match trailing segments");
assert_eq!(found.id, "main#todo-1/done");
}
#[test]
fn find_by_id_matches_deeply_nested_scoped_suffix() {
let root = node_with_children(
"main",
"window",
vec![node_with_children(
"main#page-theme",
"column",
vec![node_with_children(
"main#page-theme/page",
"column",
vec![node_with_children(
"main#page-theme/page/rating-card",
"column",
vec![node("main#page-theme/page/rating-card/stars", "canvas")],
)],
)],
)],
);
let sel = Selector::id("page-theme/page/rating-card/stars");
let found = sel
.find(&root)
.expect("deep scoped-path suffix should match");
assert_eq!(found.id, "main#page-theme/page/rating-card/stars");
}
#[test]
fn find_by_id_local_name_still_matches_for_bare_target() {
let root = node_with_children(
"main",
"window",
vec![node("main#unrelated/done", "checkbox")],
);
let sel = Selector::id("done");
let found = sel
.find(&root)
.expect("local-name rule should still hit here");
assert_eq!(found.id, "main#unrelated/done");
}
#[test]
fn find_by_id_does_not_match_mid_segment_substring() {
let root = node_with_children(
"main",
"window",
vec![node("main#unrelated/done", "checkbox")],
);
let sel = Selector::id("ne/done");
assert!(
sel.find(&root).is_none(),
"suffix match must respect segment boundaries"
);
}
#[test]
fn selector_search_stops_after_max_depth() {
let at_limit = text_node_at_depth(MAX_SELECTOR_SEARCH_DEPTH, "needle");
assert!(Selector::text("needle").find(&at_limit).is_some());
let past_limit = text_node_at_depth(MAX_SELECTOR_SEARCH_DEPTH + 1, "needle");
assert!(Selector::text("needle").find(&past_limit).is_none());
}
fn selector_wire_round_trip(sel: Selector) {
let wire = sel.to_wire();
let parsed = Selector::from_wire(&wire).unwrap_or_else(|| {
panic!("Selector::from_wire returned None for {sel:?} (wire: {wire})")
});
assert_eq!(parsed, sel);
}
#[test]
fn selector_id_round_trips() {
selector_wire_round_trip(Selector::id("save"));
}
#[test]
fn selector_id_with_window_qualification_round_trips() {
let sel = Selector::id("main#save");
selector_wire_round_trip(sel);
let parsed = Selector::from_wire(&serde_json::json!({
"by": "id",
"value": "main#save",
}))
.unwrap();
assert_eq!(
parsed,
Selector::Id {
widget_id: "main#save".into(),
window_id: Some("main".into()),
}
);
}
#[test]
fn selector_id_in_window_round_trips() {
let sel = Selector::id_in_window("save", "popup");
selector_wire_round_trip(sel);
}
#[test]
fn selector_text_round_trips() {
selector_wire_round_trip(Selector::text("Save document"));
}
#[test]
fn selector_role_round_trips() {
selector_wire_round_trip(Selector::role("button"));
}
#[test]
fn selector_label_round_trips() {
selector_wire_round_trip(Selector::label("Save"));
}
#[test]
fn selector_focused_round_trips() {
selector_wire_round_trip(Selector::focused());
}
#[test]
fn selector_unknown_by_returns_none() {
assert!(
Selector::from_wire(&serde_json::json!({
"by": "future_kind",
"value": "x",
}))
.is_none()
);
}
#[test]
fn selector_missing_value_for_non_focused_returns_none() {
for by in ["id", "text", "role", "label"] {
assert!(
Selector::from_wire(&serde_json::json!({"by": by})).is_none(),
"expected None for missing value on by={by}",
);
}
}
fn node_with_a11y(id: &str, type_name: &str, a11y: serde_json::Value) -> TreeNode {
TreeNode {
id: id.into(),
type_name: type_name.into(),
props: Props::from_json(serde_json::json!({"a11y": a11y})),
children: vec![],
}
}
#[test]
fn role_matches_explicit_a11y_role_first() {
let root = node_with_children(
"root",
"container",
vec![
node_with_a11y(
"explicit",
"container",
serde_json::json!({"role": "button"}),
),
node("by-type", "button"),
],
);
let found = Selector::role("button").find(&root).unwrap();
assert_eq!(found.id, "explicit");
}
#[test]
fn role_falls_back_to_type_name_without_a11y() {
let root = node_with_children("root", "container", vec![node("btn", "button")]);
let found = Selector::role("button").find(&root).unwrap();
assert_eq!(found.id, "btn");
}
#[test]
fn label_prefers_a11y_label_then_label_prop_then_content() {
let root = node_with_children(
"root",
"container",
vec![
{
let mut n = node("a11y_match", "button");
n.props =
Props::from_json(serde_json::json!({"a11y": {"label": "Save document"}}));
n
},
{
let mut n = node("label_prop_match", "button");
n.props = Props::from_json(serde_json::json!({"label": "Save"}));
n
},
{
let mut n = node("content_match", "text");
n.props = Props::from_json(serde_json::json!({"content": "Cancel"}));
n
},
],
);
assert_eq!(
Selector::label("Save document").find(&root).unwrap().id,
"a11y_match",
);
assert_eq!(
Selector::label("Save").find(&root).unwrap().id,
"label_prop_match",
);
assert_eq!(
Selector::label("Cancel").find(&root).unwrap().id,
"content_match",
);
}
#[test]
fn focused_matches_props_and_a11y_focused() {
let mut props_focused = node("via-props", "text_input");
props_focused.props = Props::from_json(serde_json::json!({"focused": true}));
let mut a11y_focused = node("via-a11y", "text_input");
a11y_focused.props = Props::from_json(serde_json::json!({"a11y": {"focused": true}}));
let root = node_with_children("root", "container", vec![props_focused, a11y_focused]);
let found = Selector::focused().find(&root).unwrap();
assert_eq!(found.id, "via-props");
}
#[test]
fn focused_returns_none_when_nothing_is_focused() {
let root = node_with_children(
"root",
"container",
vec![node("a", "button"), node("b", "button")],
);
assert!(Selector::focused().find(&root).is_none());
}
#[test]
fn find_all_role_returns_every_match() {
let root = node_with_children(
"root",
"container",
vec![
node("btn1", "button"),
node_with_children("inner", "container", vec![node("btn2", "button")]),
node("not_a_button", "text"),
],
);
let found = Selector::role("button").find_all(&root);
let ids: Vec<&str> = found.iter().map(|n| n.id.as_str()).collect();
assert_eq!(ids, vec!["btn1", "btn2"]);
}
#[test]
fn find_all_text_returns_every_match() {
let mut a = node("a", "text");
a.props = Props::from_json(serde_json::json!({"content": "Cancel"}));
let mut b = node("b", "text");
b.props = Props::from_json(serde_json::json!({"content": "Cancel"}));
let mut c = node("c", "text");
c.props = Props::from_json(serde_json::json!({"content": "Save"}));
let root = node_with_children("root", "container", vec![a, b, c]);
let ids: Vec<&str> = Selector::text("Cancel")
.find_all(&root)
.iter()
.map(|n| n.id.as_str())
.collect();
assert_eq!(ids, vec!["a", "b"]);
}
#[test]
fn find_all_id_returns_at_most_one_match() {
let root = node_with_children(
"root",
"container",
vec![
node("only-once", "button"),
node("only-once", "text"), ],
);
let found = Selector::id("only-once").find_all(&root);
assert_eq!(found.len(), 1);
assert_eq!(found[0].type_name, "button");
}
#[test]
fn find_all_focused_returns_every_focused_node() {
let mut a = node("a", "text_input");
a.props = Props::from_json(serde_json::json!({"focused": true}));
let mut b = node("b", "text_input");
b.props = Props::from_json(serde_json::json!({"a11y": {"focused": true}}));
let root = node_with_children("root", "container", vec![a, b]);
let ids: Vec<&str> = Selector::focused()
.find_all(&root)
.iter()
.map(|n| n.id.as_str())
.collect();
assert_eq!(ids, vec!["a", "b"]);
}
#[test]
fn find_all_returns_empty_when_no_match() {
let root = node_with_children("root", "container", vec![node("a", "text")]);
assert!(Selector::role("button").find_all(&root).is_empty());
}
}