1use serde_json::Value;
35use std::fmt;
36
37use crate::protocol::TreeNode;
38
39#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum Selector {
46 Id {
55 widget_id: String,
57 window_id: Option<String>,
59 },
60 Text(String),
65 Role(String),
67 Label(String),
69 Focused,
71}
72
73impl Selector {
74 pub fn id(id: &str) -> Self {
79 let window_id = id
80 .split_once('#')
81 .filter(|(win, _)| !win.is_empty())
82 .map(|(win, _)| win.to_string());
83 Self::Id {
84 widget_id: id.to_string(),
85 window_id,
86 }
87 }
88
89 pub fn id_in_window(id: &str, window_id: &str) -> Self {
91 Self::Id {
92 widget_id: id.to_string(),
93 window_id: Some(window_id.to_string()),
94 }
95 }
96
97 pub fn text(text: &str) -> Self {
99 Self::Text(text.to_string())
100 }
101
102 pub fn role(role: &str) -> Self {
104 Self::Role(role.to_string())
105 }
106
107 pub fn label(label: &str) -> Self {
109 Self::Label(label.to_string())
110 }
111
112 pub fn focused() -> Self {
114 Self::Focused
115 }
116
117 pub fn from_wire(value: &Value) -> Option<Self> {
121 let by = value.get("by")?.as_str()?;
122 match by {
123 "focused" => Some(Self::Focused),
124 _ => {
125 let raw_value = value.get("value")?.as_str()?.to_string();
126 let explicit_window = value
127 .get("window_id")
128 .and_then(|v| v.as_str())
129 .map(str::to_string);
130 match by {
131 "id" => {
132 let window_id = raw_value
133 .split_once('#')
134 .filter(|(win, _)| !win.is_empty())
135 .map(|(win, _)| win.to_string())
136 .or(explicit_window);
137 Some(Self::Id {
138 widget_id: raw_value,
139 window_id,
140 })
141 }
142 "text" => Some(Self::Text(raw_value)),
143 "role" => Some(Self::Role(raw_value)),
144 "label" => Some(Self::Label(raw_value)),
145 _ => None,
146 }
147 }
148 }
149 }
150
151 pub fn to_wire(&self) -> Value {
153 match self {
154 Self::Id {
155 widget_id,
156 window_id,
157 } => {
158 let mut obj = serde_json::json!({"by": "id", "value": widget_id});
159 if let Some(win) = window_id {
160 obj["window_id"] = Value::String(win.clone());
161 }
162 obj
163 }
164 Self::Text(text) => serde_json::json!({"by": "text", "value": text}),
165 Self::Role(role) => serde_json::json!({"by": "role", "value": role}),
166 Self::Label(label) => serde_json::json!({"by": "label", "value": label}),
167 Self::Focused => serde_json::json!({"by": "focused"}),
168 }
169 }
170}
171
172impl From<&str> for Selector {
177 fn from(s: &str) -> Self {
178 Self::id(s)
179 }
180}
181
182impl From<String> for Selector {
183 fn from(s: String) -> Self {
184 Self::id(&s)
185 }
186}
187
188impl fmt::Display for Selector {
189 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190 match self {
191 Self::Id {
192 widget_id,
193 window_id: Some(win),
194 } if !widget_id.starts_with(&format!("{win}#")) => {
195 write!(f, "{win}#{widget_id}")
196 }
197 Self::Id { widget_id, .. } => write!(f, "{widget_id}"),
198 Self::Text(text) => write!(f, "{{text: {text:?}}}"),
199 Self::Role(role) => write!(f, "{{role: {role}}}"),
200 Self::Label(label) => write!(f, "{{label: {label:?}}}"),
201 Self::Focused => write!(f, "{{focused}}"),
202 }
203 }
204}
205
206pub const MAX_SELECTOR_SEARCH_DEPTH: usize = 256;
212
213impl Selector {
214 pub fn find<'a>(&self, root: &'a TreeNode) -> Option<&'a TreeNode> {
219 match self {
220 Self::Id {
221 widget_id,
222 window_id,
223 } => find_by_id(root, widget_id, window_id.as_deref(), None, 0),
224 Self::Text(text) => search(root, 0, &|n| matches_text(n, text)),
225 Self::Role(role) => search(root, 0, &|n| matches_role(n, role)),
226 Self::Label(label) => search(root, 0, &|n| matches_label(n, label)),
227 Self::Focused => search(root, 0, &is_focused),
228 }
229 }
230
231 pub fn find_all<'a>(&self, root: &'a TreeNode) -> Vec<&'a TreeNode> {
235 let mut results = Vec::new();
236 match self {
237 Self::Text(text) => search_all(root, 0, &|n| matches_text(n, text), &mut results),
238 Self::Role(role) => search_all(root, 0, &|n| matches_role(n, role), &mut results),
239 Self::Label(label) => search_all(root, 0, &|n| matches_label(n, label), &mut results),
240 Self::Focused => search_all(root, 0, &is_focused, &mut results),
241 Self::Id {
242 widget_id,
243 window_id,
244 } => {
245 if let Some(node) = find_by_id(root, widget_id, window_id.as_deref(), None, 0) {
247 results.push(node);
248 }
249 }
250 }
251 results
252 }
253}
254
255fn search<'a>(
258 node: &'a TreeNode,
259 depth: usize,
260 predicate: &dyn Fn(&TreeNode) -> bool,
261) -> Option<&'a TreeNode> {
262 if depth > MAX_SELECTOR_SEARCH_DEPTH {
263 return None;
264 }
265 if predicate(node) {
266 return Some(node);
267 }
268 node.children
269 .iter()
270 .find_map(|child| search(child, depth + 1, predicate))
271}
272
273fn search_all<'a>(
274 node: &'a TreeNode,
275 depth: usize,
276 predicate: &dyn Fn(&TreeNode) -> bool,
277 results: &mut Vec<&'a TreeNode>,
278) {
279 if depth > MAX_SELECTOR_SEARCH_DEPTH {
280 return;
281 }
282 if predicate(node) {
283 results.push(node);
284 }
285 for child in &node.children {
286 search_all(child, depth + 1, predicate, results);
287 }
288}
289
290fn find_by_id<'a>(
307 node: &'a TreeNode,
308 target_id: &str,
309 target_window: Option<&str>,
310 current_window: Option<&'a str>,
311 depth: usize,
312) -> Option<&'a TreeNode> {
313 if depth > MAX_SELECTOR_SEARCH_DEPTH {
314 return None;
315 }
316
317 let current_window = if node.type_name == "window" {
318 Some(node.id.as_str())
319 } else {
320 current_window
321 };
322
323 let matches_id = node.id == target_id
324 || local_name(&node.id) == target_id
325 || node.id.ends_with(&format!("/{target_id}"))
326 || node.id.ends_with(&format!("#{target_id}"));
327 if matches_id && target_window.is_none_or(|win| current_window == Some(win)) {
328 return Some(node);
329 }
330
331 node.children
332 .iter()
333 .find_map(|child| find_by_id(child, target_id, target_window, current_window, depth + 1))
334}
335
336fn local_name(id: &str) -> &str {
342 id.rsplit_once('/')
343 .or_else(|| id.rsplit_once('#'))
344 .map(|(_, local)| local)
345 .unwrap_or(id)
346}
347
348fn matches_text(node: &TreeNode, text: &str) -> bool {
353 for key in &["content", "label", "value", "placeholder"] {
354 if node.props.get_str(key) == Some(text) {
355 return true;
356 }
357 }
358 false
359}
360
361fn matches_role(node: &TreeNode, role: &str) -> bool {
364 if let Some(a11y) = node.props.get_value("a11y") {
365 a11y.get("role").and_then(|v| v.as_str()) == Some(role)
366 } else {
367 node.type_name == role
368 }
369}
370
371fn matches_label(node: &TreeNode, label: &str) -> bool {
374 if let Some(a11y) = node.props.get_value("a11y")
375 && a11y.get("label").and_then(|v| v.as_str()) == Some(label)
376 {
377 return true;
378 }
379 for key in &["label", "content"] {
380 if node.props.get_str(key) == Some(label) {
381 return true;
382 }
383 }
384 false
385}
386
387fn is_focused(node: &TreeNode) -> bool {
389 if node.props.get_bool("focused") == Some(true) {
390 return true;
391 }
392 if let Some(a11y) = node.props.get_value("a11y")
393 && a11y.get("focused").and_then(|v| v.as_bool()) == Some(true)
394 {
395 return true;
396 }
397 false
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403 use crate::protocol::Props;
404
405 fn node(id: &str, type_name: &str) -> TreeNode {
407 TreeNode {
408 id: id.to_string(),
409 type_name: type_name.to_string(),
410 props: Props::default(),
411 children: vec![],
412 }
413 }
414
415 fn node_with_children(id: &str, type_name: &str, children: Vec<TreeNode>) -> TreeNode {
417 TreeNode {
418 id: id.to_string(),
419 type_name: type_name.to_string(),
420 props: Props::default(),
421 children,
422 }
423 }
424
425 fn text_node_at_depth(depth: usize, text: &str) -> TreeNode {
426 let mut target = node("target", "text");
427 target.props = Props::from_json(serde_json::json!({"content": text}));
428
429 for level in (0..depth).rev() {
430 target = node_with_children(&format!("level-{level}"), "column", vec![target]);
431 }
432
433 target
434 }
435
436 #[test]
437 fn find_by_id_matches_exact_id() {
438 let root = node_with_children(
439 "main",
440 "window",
441 vec![node("main#save", "button"), node("main#cancel", "button")],
442 );
443 let sel = Selector::id("main#save");
444 let found = sel.find(&root).expect("exact id match");
445 assert_eq!(found.id, "main#save");
446 }
447
448 #[test]
449 fn find_by_id_matches_local_name() {
450 let root = node_with_children(
451 "main",
452 "window",
453 vec![node("main#save", "button"), node("main#cancel", "button")],
454 );
455 let sel = Selector::id("save");
456 let found = sel.find(&root).expect("local-name match");
457 assert_eq!(found.id, "main#save");
458 }
459
460 #[test]
461 fn find_by_id_matches_scoped_path_suffix() {
462 let root = node_with_children(
463 "main",
464 "window",
465 vec![node_with_children(
466 "main#todos",
467 "column",
468 vec![node("main#todo-1/done", "checkbox")],
469 )],
470 );
471 let sel = Selector::id("todo-1/done");
472 let found = sel
473 .find(&root)
474 .expect("scoped-path suffix should match trailing segments");
475 assert_eq!(found.id, "main#todo-1/done");
476 }
477
478 #[test]
479 fn find_by_id_matches_deeply_nested_scoped_suffix() {
480 let root = node_with_children(
481 "main",
482 "window",
483 vec![node_with_children(
484 "main#page-theme",
485 "column",
486 vec![node_with_children(
487 "main#page-theme/page",
488 "column",
489 vec![node_with_children(
490 "main#page-theme/page/rating-card",
491 "column",
492 vec![node("main#page-theme/page/rating-card/stars", "canvas")],
493 )],
494 )],
495 )],
496 );
497 let sel = Selector::id("page-theme/page/rating-card/stars");
498 let found = sel
499 .find(&root)
500 .expect("deep scoped-path suffix should match");
501 assert_eq!(found.id, "main#page-theme/page/rating-card/stars");
502 }
503
504 #[test]
505 fn find_by_id_local_name_still_matches_for_bare_target() {
506 let root = node_with_children(
510 "main",
511 "window",
512 vec![node("main#unrelated/done", "checkbox")],
513 );
514 let sel = Selector::id("done");
515 let found = sel
516 .find(&root)
517 .expect("local-name rule should still hit here");
518 assert_eq!(found.id, "main#unrelated/done");
519 }
520
521 #[test]
522 fn find_by_id_does_not_match_mid_segment_substring() {
523 let root = node_with_children(
527 "main",
528 "window",
529 vec![node("main#unrelated/done", "checkbox")],
530 );
531 let sel = Selector::id("ne/done");
532 assert!(
533 sel.find(&root).is_none(),
534 "suffix match must respect segment boundaries"
535 );
536 }
537
538 #[test]
539 fn selector_search_stops_after_max_depth() {
540 let at_limit = text_node_at_depth(MAX_SELECTOR_SEARCH_DEPTH, "needle");
541 assert!(Selector::text("needle").find(&at_limit).is_some());
542
543 let past_limit = text_node_at_depth(MAX_SELECTOR_SEARCH_DEPTH + 1, "needle");
544 assert!(Selector::text("needle").find(&past_limit).is_none());
545 }
546
547 fn selector_wire_round_trip(sel: Selector) {
557 let wire = sel.to_wire();
558 let parsed = Selector::from_wire(&wire).unwrap_or_else(|| {
559 panic!("Selector::from_wire returned None for {sel:?} (wire: {wire})")
560 });
561 assert_eq!(parsed, sel);
562 }
563
564 #[test]
565 fn selector_id_round_trips() {
566 selector_wire_round_trip(Selector::id("save"));
567 }
568
569 #[test]
570 fn selector_id_with_window_qualification_round_trips() {
571 let sel = Selector::id("main#save");
574 selector_wire_round_trip(sel);
575 let parsed = Selector::from_wire(&serde_json::json!({
576 "by": "id",
577 "value": "main#save",
578 }))
579 .unwrap();
580 assert_eq!(
581 parsed,
582 Selector::Id {
583 widget_id: "main#save".into(),
584 window_id: Some("main".into()),
585 }
586 );
587 }
588
589 #[test]
590 fn selector_id_in_window_round_trips() {
591 let sel = Selector::id_in_window("save", "popup");
594 selector_wire_round_trip(sel);
595 }
596
597 #[test]
598 fn selector_text_round_trips() {
599 selector_wire_round_trip(Selector::text("Save document"));
600 }
601
602 #[test]
603 fn selector_role_round_trips() {
604 selector_wire_round_trip(Selector::role("button"));
605 }
606
607 #[test]
608 fn selector_label_round_trips() {
609 selector_wire_round_trip(Selector::label("Save"));
610 }
611
612 #[test]
613 fn selector_focused_round_trips() {
614 selector_wire_round_trip(Selector::focused());
615 }
616
617 #[test]
618 fn selector_unknown_by_returns_none() {
619 assert!(
622 Selector::from_wire(&serde_json::json!({
623 "by": "future_kind",
624 "value": "x",
625 }))
626 .is_none()
627 );
628 }
629
630 #[test]
631 fn selector_missing_value_for_non_focused_returns_none() {
632 for by in ["id", "text", "role", "label"] {
636 assert!(
637 Selector::from_wire(&serde_json::json!({"by": by})).is_none(),
638 "expected None for missing value on by={by}",
639 );
640 }
641 }
642
643 fn node_with_a11y(id: &str, type_name: &str, a11y: serde_json::Value) -> TreeNode {
652 TreeNode {
653 id: id.into(),
654 type_name: type_name.into(),
655 props: Props::from_json(serde_json::json!({"a11y": a11y})),
656 children: vec![],
657 }
658 }
659
660 #[test]
661 fn role_matches_explicit_a11y_role_first() {
662 let root = node_with_children(
666 "root",
667 "container",
668 vec![
669 node_with_a11y(
670 "explicit",
671 "container",
672 serde_json::json!({"role": "button"}),
673 ),
674 node("by-type", "button"),
675 ],
676 );
677
678 let found = Selector::role("button").find(&root).unwrap();
679 assert_eq!(found.id, "explicit");
681 }
682
683 #[test]
684 fn role_falls_back_to_type_name_without_a11y() {
685 let root = node_with_children("root", "container", vec![node("btn", "button")]);
688 let found = Selector::role("button").find(&root).unwrap();
689 assert_eq!(found.id, "btn");
690 }
691
692 #[test]
693 fn label_prefers_a11y_label_then_label_prop_then_content() {
694 let root = node_with_children(
695 "root",
696 "container",
697 vec![
698 {
699 let mut n = node("a11y_match", "button");
700 n.props =
701 Props::from_json(serde_json::json!({"a11y": {"label": "Save document"}}));
702 n
703 },
704 {
705 let mut n = node("label_prop_match", "button");
706 n.props = Props::from_json(serde_json::json!({"label": "Save"}));
707 n
708 },
709 {
710 let mut n = node("content_match", "text");
711 n.props = Props::from_json(serde_json::json!({"content": "Cancel"}));
712 n
713 },
714 ],
715 );
716
717 assert_eq!(
718 Selector::label("Save document").find(&root).unwrap().id,
719 "a11y_match",
720 );
721 assert_eq!(
722 Selector::label("Save").find(&root).unwrap().id,
723 "label_prop_match",
724 );
725 assert_eq!(
726 Selector::label("Cancel").find(&root).unwrap().id,
727 "content_match",
728 );
729 }
730
731 #[test]
732 fn focused_matches_props_and_a11y_focused() {
733 let mut props_focused = node("via-props", "text_input");
737 props_focused.props = Props::from_json(serde_json::json!({"focused": true}));
738 let mut a11y_focused = node("via-a11y", "text_input");
739 a11y_focused.props = Props::from_json(serde_json::json!({"a11y": {"focused": true}}));
740
741 let root = node_with_children("root", "container", vec![props_focused, a11y_focused]);
742
743 let found = Selector::focused().find(&root).unwrap();
745 assert_eq!(found.id, "via-props");
746 }
747
748 #[test]
749 fn focused_returns_none_when_nothing_is_focused() {
750 let root = node_with_children(
751 "root",
752 "container",
753 vec![node("a", "button"), node("b", "button")],
754 );
755 assert!(Selector::focused().find(&root).is_none());
756 }
757
758 #[test]
766 fn find_all_role_returns_every_match() {
767 let root = node_with_children(
768 "root",
769 "container",
770 vec![
771 node("btn1", "button"),
772 node_with_children("inner", "container", vec![node("btn2", "button")]),
773 node("not_a_button", "text"),
774 ],
775 );
776 let found = Selector::role("button").find_all(&root);
777 let ids: Vec<&str> = found.iter().map(|n| n.id.as_str()).collect();
778 assert_eq!(ids, vec!["btn1", "btn2"]);
779 }
780
781 #[test]
782 fn find_all_text_returns_every_match() {
783 let mut a = node("a", "text");
784 a.props = Props::from_json(serde_json::json!({"content": "Cancel"}));
785 let mut b = node("b", "text");
786 b.props = Props::from_json(serde_json::json!({"content": "Cancel"}));
787 let mut c = node("c", "text");
788 c.props = Props::from_json(serde_json::json!({"content": "Save"}));
789
790 let root = node_with_children("root", "container", vec![a, b, c]);
791 let ids: Vec<&str> = Selector::text("Cancel")
792 .find_all(&root)
793 .iter()
794 .map(|n| n.id.as_str())
795 .collect();
796 assert_eq!(ids, vec!["a", "b"]);
797 }
798
799 #[test]
800 fn find_all_id_returns_at_most_one_match() {
801 let root = node_with_children(
804 "root",
805 "container",
806 vec![
807 node("only-once", "button"),
808 node("only-once", "text"), ],
810 );
811 let found = Selector::id("only-once").find_all(&root);
812 assert_eq!(found.len(), 1);
813 assert_eq!(found[0].type_name, "button");
814 }
815
816 #[test]
817 fn find_all_focused_returns_every_focused_node() {
818 let mut a = node("a", "text_input");
822 a.props = Props::from_json(serde_json::json!({"focused": true}));
823 let mut b = node("b", "text_input");
824 b.props = Props::from_json(serde_json::json!({"a11y": {"focused": true}}));
825
826 let root = node_with_children("root", "container", vec![a, b]);
827 let ids: Vec<&str> = Selector::focused()
828 .find_all(&root)
829 .iter()
830 .map(|n| n.id.as_str())
831 .collect();
832 assert_eq!(ids, vec!["a", "b"]);
833 }
834
835 #[test]
836 fn find_all_returns_empty_when_no_match() {
837 let root = node_with_children("root", "container", vec![node("a", "text")]);
838 assert!(Selector::role("button").find_all(&root).is_empty());
839 }
840}