Skip to main content

ui_automata/
selector.rs

1/// CSS-like selector path for navigating UIA element trees.
2///
3/// Syntax:
4/// ```text
5/// [Combinator] Step [Combinator Step]*
6///
7/// Step        ::= Predicate+ [":nth(" n ")"] [":parent" | ":ancestor(" n ")"]
8/// Predicate   ::= "[" attr Op value "]"
9/// attr        ::= "role" | "name" | "title"
10/// Op          ::= "=" | "~=" | "^=" | "$="
11/// Combinator  ::= ">" (immediate child) | ">>" (any descendant)
12/// ```
13///
14/// When a selector starts without a combinator, the first step is matched
15/// against the scope root element itself. When it starts with `>>` or `>`,
16/// the search begins inside the scope root — useful when the scope anchor IS
17/// the container you want to search within.
18///
19/// Examples:
20/// ```text
21/// # Root match: the scope element itself is tested against this step.
22/// # Typically used when the scope is a desktop window list.
23/// Window[title~=Mastercam]
24///
25/// # Immediate child: find the ToolBar that is a direct child of the scope
26/// # root. Does NOT match grandchildren.
27/// Window[title~=Mastercam] > ToolBar[name=Mastercam]
28///
29/// # Any descendant: find the "Open" button anywhere inside the window.
30/// Window[title~=Mastercam] >> Button[name=Open]
31///
32/// # Leading >>: search within the scope root without re-matching it.
33/// # Useful when the scope anchor IS the container (e.g. scope = dialog).
34/// >> [role=combo box][name='File name:'] >> [role=edit]
35///
36/// # :nth — select by position (0-indexed) among matched siblings.
37/// ToolBar[name=Mastercam] > Group:nth(1)
38///
39/// # Bare role shorthand: equivalent to [role=Button].
40/// Button[name=Open]
41///
42/// # Predicate-only (no bare role): matches any element named "File name:"
43/// # regardless of role — useful when role strings differ across OS versions.
44/// [name="File name:"]
45///
46/// # contains (~=): case-insensitive; window whose title contains "mill".
47/// Window[title~=mill]
48///
49/// # starts-with (^=): case-insensitive; window whose title begins with "processing".
50/// Window[title^=Processing]
51///
52/// # ends-with ($=): case-insensitive; combine with starts-with to match without
53/// # quoting special characters — e.g. button named "Don't Save" (apostrophe may be U+2019).
54/// >> [role=button][name^=Don][name$=Save]
55///
56/// # OR values — pipe separates alternatives within one predicate.
57/// # Matches a window whose title contains "Mill" OR "Design".
58/// [name~=Editor|Designer]
59///
60///# Full multi-step path: scope=desktop, find window, then toolbar child,
61/// # then the third Group inside it (0-indexed).
62/// Window[title~=Mastercam] >> ToolBar[name=Mastercam] > Group:nth(2)
63///
64/// # :parent — navigate to the matched element's parent.
65/// # Useful to anchor on a container you can only identify by a child.
66/// >> [role=button][name=Performance]:parent
67///
68/// # :ancestor(n) — navigate n levels up. :parent is :ancestor(1).
69/// >> [role=button][name=Performance]:ancestor(2)
70///
71/// # Mid-selector: find Performance's parent, then select its 9th child.
72/// >> [role=button][name=Performance]:parent > *:nth(9)
73/// ```
74use crate::{AutomataError, Element};
75
76// ── Public types ──────────────────────────────────────────────────────────────
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct SelectorPath {
80    steps: Vec<PathStep>,
81}
82
83impl<'de> serde::Deserialize<'de> for SelectorPath {
84    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
85        let s = String::deserialize(d)?;
86        SelectorPath::parse(&s).map_err(serde::de::Error::custom)
87    }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91struct PathStep {
92    combinator: Combinator,
93    predicates: Vec<Predicate>,
94    nth: Option<usize>, // :nth(n) — 0-indexed among matched siblings
95    ascend: usize,      // :parent = 1, :ancestor(n) = n, 0 = no ascension
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
99enum Combinator {
100    /// The root step — no combinator, matching starts from the given element.
101    Root,
102    /// `>` — immediate children only.
103    Child,
104    /// `>>` — any descendant (depth-first).
105    Descendant,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
109struct Predicate {
110    attr: Attr,
111    op: Op,
112    /// One or more alternatives — the predicate passes if **any** value matches.
113    /// Written as `[name=Editor|Designer]` in selector syntax.
114    values: Vec<String>,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
118enum Attr {
119    Role,
120    Name,
121    Title, // alias for name on Window elements
122    AutomationId,
123    Url, // Tab anchor matching only — ignored on UIA elements
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
127enum Op {
128    Exact,      // =
129    Contains,   // ~=
130    StartsWith, // ^=
131    EndsWith,   // $=
132}
133
134// ── Parse ─────────────────────────────────────────────────────────────────────
135
136impl SelectorPath {
137    /// Returns true if this is a bare wildcard (`*`) with no predicates.
138    pub fn is_wildcard(&self) -> bool {
139        matches!(self.steps.as_slice(), [step] if step.predicates.is_empty())
140    }
141
142    pub fn parse(input: &str) -> Result<Self, AutomataError> {
143        let input = input.trim();
144        if input.is_empty() {
145            return Err(AutomataError::Internal("empty selector".into()));
146        }
147
148        // Split on combinators `>>` then `>` while preserving which combinator
149        // preceded each segment.  We walk char-by-char to avoid splitting inside
150        // `[...]` brackets.
151        let segments = split_segments(input)?;
152        if segments.is_empty() {
153            return Err(AutomataError::Internal("empty selector".into()));
154        }
155
156        let mut steps = Vec::with_capacity(segments.len());
157        for (combinator, seg) in segments {
158            steps.push(parse_step(combinator, seg)?);
159        }
160
161        Ok(SelectorPath { steps })
162    }
163
164    // ── Matching ─────────────────────────────────────────────────────────────
165
166    /// Find the first element matching this path, starting from `root`.
167    ///
168    /// Uses an early-exit strategy: descendant searches stop as soon as the
169    /// first match is found, avoiding a full DFS of the entire subtree.
170    pub fn find_one<E: Element>(&self, root: &E) -> Option<E> {
171        if self.steps.is_empty() {
172            return None;
173        }
174        match_steps(root, &self.steps, Some(1)).into_iter().next()
175    }
176
177    /// Find all elements matching this path, starting from `root`.
178    pub fn find_all<E: Element>(&self, root: &E) -> Vec<E> {
179        if self.steps.is_empty() {
180            return vec![];
181        }
182        match_steps(root, &self.steps, None)
183    }
184
185    /// Test whether a single element matches the first step of this path
186    /// (useful for single-step selectors used as element filters).
187    pub fn matches<E: Element>(&self, element: &E) -> bool {
188        match self.steps.first() {
189            Some(step) => step_matches(step, element),
190            None => false,
191        }
192    }
193
194    /// Like [`find_one`], but also returns the "step parent" — the element from
195    /// which the final selector step was resolved.
196    ///
197    /// The step parent is stored in the found-cache alongside the element so
198    /// that when the element goes stale, a narrow re-search can be attempted
199    /// from the step parent before falling back to a full DFS from the anchor
200    /// root.  The step parent is `None` for root-step selectors that match the
201    /// root element itself.
202    pub fn find_one_with_parent<E: Element>(&self, root: &E) -> Option<(E, Option<E>)> {
203        if self.steps.is_empty() {
204            return None;
205        }
206        find_first_with_step_parent(root, &self.steps)
207    }
208
209    /// Test whether a `TabInfo` matches the predicates in the first step of this selector.
210    ///
211    /// Used for Tab anchor attach mode — polls `Browser::tabs()` until a matching tab
212    /// appears. Recognized attributes: `title` / `name` → tab title, `url` → tab URL.
213    /// Other attributes (role, id) always pass, treating them as "don't care".
214    pub fn matches_tab_info(&self, title: &str, url: &str) -> bool {
215        let Some(step) = self.steps.first() else {
216            return false;
217        };
218        step.predicates.iter().all(|p| {
219            let actual = match p.attr {
220                Attr::Name | Attr::Title => title,
221                Attr::Url => url,
222                // Ignore role / automation_id for tab matching.
223                Attr::Role | Attr::AutomationId => return true,
224            };
225            p.values.iter().any(|v| match p.op {
226                Op::Exact => actual == v.as_str(),
227                Op::Contains => actual
228                    .to_ascii_lowercase()
229                    .contains(v.to_ascii_lowercase().as_str()),
230                Op::StartsWith => actual
231                    .to_ascii_lowercase()
232                    .starts_with(v.to_ascii_lowercase().as_str()),
233                Op::EndsWith => actual
234                    .to_ascii_lowercase()
235                    .ends_with(v.to_ascii_lowercase().as_str()),
236            })
237        })
238    }
239
240    /// Re-find using only the **last step** of this selector, starting from a
241    /// cached step-parent.
242    ///
243    /// This is the fast-path for stale-element re-resolution: instead of a
244    /// full DFS from the anchor root, we search only within the step-parent's
245    /// immediate children or subtree (depending on the final combinator).
246    ///
247    /// Returns `None` if the element is no longer present under `step_parent`.
248    pub fn find_one_from_step_parent<E: Element>(&self, step_parent: &E) -> Option<E> {
249        let step = self.steps.last()?;
250        match &step.combinator {
251            Combinator::Root | Combinator::Child => {
252                let children = step_parent.children().ok()?;
253                apply_nth(
254                    children
255                        .into_iter()
256                        .filter(|c| step_matches(step, c))
257                        .collect(),
258                    step.nth,
259                )
260                .into_iter()
261                .next()
262                .and_then(|el| {
263                    if step.ascend > 0 {
264                        ascend_n(el, step.ascend)
265                    } else {
266                        Some(el)
267                    }
268                })
269            }
270            Combinator::Descendant => {
271                let mut acc = vec![];
272                let limit = if step.nth.is_none() { Some(1) } else { None };
273                collect_descendants(step_parent, step, &mut acc, limit);
274                apply_nth(acc, step.nth).into_iter().next().and_then(|el| {
275                    if step.ascend > 0 {
276                        ascend_n(el, step.ascend)
277                    } else {
278                        Some(el)
279                    }
280                })
281            }
282        }
283    }
284}
285
286// ── Internal matching logic ───────────────────────────────────────────────────
287
288/// Walk up n levels from `el`, returning the ancestor or `None` if the root
289/// is reached before `n` steps.
290fn ascend_n<E: Element>(el: E, n: usize) -> Option<E> {
291    let mut cur = el;
292    for _ in 0..n {
293        cur = cur.parent()?;
294    }
295    Some(cur)
296}
297
298/// Recursively resolve steps against a candidate pool.
299/// `steps[0]` describes how to find candidates relative to `origin`;
300/// remaining steps are applied to each candidate's subtree.
301///
302/// `limit` caps how many total results are collected. `find_one` passes
303/// `Some(1)`; `find_all` passes `None` (unlimited).
304fn match_steps<E: Element>(origin: &E, steps: &[PathStep], limit: Option<usize>) -> Vec<E> {
305    let step = &steps[0];
306    let rest = &steps[1..];
307
308    let candidates: Vec<E> = match &step.combinator {
309        Combinator::Root => {
310            // The root step matches the origin itself.
311            if step_matches(step, origin) {
312                vec![origin.clone()]
313            } else {
314                vec![]
315            }
316        }
317        Combinator::Child => {
318            // Immediate children of the last matched element.
319            match origin.children() {
320                Ok(children) => children
321                    .into_iter()
322                    .filter(|c| step_matches(step, c))
323                    .collect(),
324                Err(e) => {
325                    log::debug!(
326                        "selector: children() failed on '{}' ({}): {e}",
327                        origin.name().unwrap_or_default(),
328                        origin.role()
329                    );
330                    vec![]
331                }
332            }
333        }
334        Combinator::Descendant => {
335            // All descendants (depth-first).
336            let mut acc = vec![];
337            // Apply early-exit only when this is the last step and no :nth
338            // filter is present. :nth needs the full candidate list to pick
339            // the correct sibling; multi-step limits are handled in the
340            // flat_map below.
341            let step_limit = if rest.is_empty() && step.nth.is_none() {
342                limit
343            } else {
344                None
345            };
346            collect_descendants(origin, step, &mut acc, step_limit);
347            acc
348        }
349    };
350
351    // Apply :nth filter across candidates that share the same parent context.
352    let candidates = apply_nth(candidates, step.nth);
353
354    // Apply :parent / :ancestor(n) ascension.
355    let candidates: Vec<E> = if step.ascend > 0 {
356        candidates
357            .into_iter()
358            .filter_map(|c| ascend_n(c, step.ascend))
359            .collect()
360    } else {
361        candidates
362    };
363
364    if rest.is_empty() {
365        candidates
366    } else {
367        // For multi-step paths, stop collecting once the limit is reached.
368        let mut results = Vec::new();
369        for c in candidates {
370            let remaining = limit.map(|l| l.saturating_sub(results.len()));
371            results.extend(match_steps(&c, rest, remaining));
372            if limit.is_some_and(|l| results.len() >= l) {
373                break;
374            }
375        }
376        results
377    }
378}
379
380fn collect_descendants<E: Element>(
381    parent: &E,
382    step: &PathStep,
383    acc: &mut Vec<E>,
384    limit: Option<usize>,
385) {
386    if limit.is_some_and(|l| acc.len() >= l) {
387        return;
388    }
389    let children = match parent.children() {
390        Ok(c) => c,
391        Err(e) => {
392            log::debug!(
393                "selector: children() failed on '{}' ({}): {e}",
394                parent.name().unwrap_or_default(),
395                parent.role()
396            );
397            return;
398        }
399    };
400    for child in children {
401        if step_matches(step, &child) {
402            acc.push(child.clone());
403            if limit.is_some_and(|l| acc.len() >= l) {
404                return;
405            }
406        }
407        collect_descendants(&child, step, acc, limit);
408        if limit.is_some_and(|l| acc.len() >= l) {
409            return;
410        }
411    }
412}
413
414/// Walk the subtree rooted at `parent` DFS-style to find the **first**
415/// element matching `step`.  Returns both the match and its immediate DFS
416/// parent (the element whose `children()` yielded the match) so callers can
417/// store the parent for narrow stale-re-resolution.
418fn collect_first_with_parent<E: Element>(parent: &E, step: &PathStep) -> Option<(E, E)> {
419    let children = match parent.children() {
420        Ok(c) => c,
421        Err(_) => return None,
422    };
423    for child in children {
424        if step_matches(step, &child) {
425            return Some((child, parent.clone()));
426        }
427        if let Some(found) = collect_first_with_parent(&child, step) {
428            return Some(found);
429        }
430    }
431    None
432}
433
434/// Recursive implementation for [`SelectorPath::find_one_with_parent`].
435fn find_first_with_step_parent<E: Element>(
436    origin: &E,
437    steps: &[PathStep],
438) -> Option<(E, Option<E>)> {
439    let step = &steps[0];
440    let rest = &steps[1..];
441
442    if rest.is_empty() {
443        // Final step — track the step parent alongside the result.
444        return match &step.combinator {
445            Combinator::Root => {
446                if step_matches(step, origin) {
447                    let el = origin.clone();
448                    if step.ascend > 0 {
449                        ascend_n(el, step.ascend).map(|a| (a, None))
450                    } else {
451                        Some((el, None))
452                    }
453                } else {
454                    None
455                }
456            }
457            Combinator::Child => {
458                let children = origin.children().ok()?;
459                let matched = apply_nth(
460                    children
461                        .into_iter()
462                        .filter(|c| step_matches(step, c))
463                        .collect(),
464                    step.nth,
465                )
466                .into_iter()
467                .next();
468                match matched {
469                    None => None,
470                    Some(el) if step.ascend > 0 => ascend_n(el, step.ascend).map(|a| (a, None)),
471                    Some(el) => Some((el, Some(origin.clone()))),
472                }
473            }
474            Combinator::Descendant => {
475                if step.nth.is_some() {
476                    // :nth requires the full match list; fall back to using the
477                    // origin as an approximate step parent (still far better
478                    // than the anchor root for narrow re-resolution).
479                    let mut acc = vec![];
480                    collect_descendants(origin, step, &mut acc, None);
481                    apply_nth(acc, step.nth).into_iter().next().and_then(|el| {
482                        if step.ascend > 0 {
483                            ascend_n(el, step.ascend).map(|a| (a, None))
484                        } else {
485                            Some((el, Some(origin.clone())))
486                        }
487                    })
488                } else if step.ascend > 0 {
489                    collect_first_with_parent(origin, step)
490                        .and_then(|(el, _)| ascend_n(el, step.ascend).map(|a| (a, None)))
491                } else {
492                    collect_first_with_parent(origin, step).map(|(el, parent)| (el, Some(parent)))
493                }
494            }
495        };
496    }
497
498    // Not the final step: find candidates for this step (no early-exit needed
499    // since we need all of them to recurse into), then descend.
500    let candidates: Vec<E> = match &step.combinator {
501        Combinator::Root => {
502            if step_matches(step, origin) {
503                vec![origin.clone()]
504            } else {
505                vec![]
506            }
507        }
508        Combinator::Child => match origin.children() {
509            Ok(c) => c.into_iter().filter(|c| step_matches(step, c)).collect(),
510            Err(_) => vec![],
511        },
512        Combinator::Descendant => {
513            let mut acc = vec![];
514            collect_descendants(origin, step, &mut acc, None);
515            acc
516        }
517    };
518    let candidates = apply_nth(candidates, step.nth);
519    let candidates: Vec<E> = if step.ascend > 0 {
520        candidates
521            .into_iter()
522            .filter_map(|c| ascend_n(c, step.ascend))
523            .collect()
524    } else {
525        candidates
526    };
527
528    for candidate in candidates {
529        if let Some(result) = find_first_with_step_parent(&candidate, rest) {
530            return Some(result);
531        }
532    }
533    None
534}
535
536fn apply_nth<E>(candidates: Vec<E>, nth: Option<usize>) -> Vec<E> {
537    match nth {
538        None => candidates,
539        Some(n) => candidates.into_iter().nth(n).into_iter().collect(),
540    }
541}
542
543fn step_matches<E: Element>(step: &PathStep, element: &E) -> bool {
544    step.predicates
545        .iter()
546        .all(|p| predicate_matches(p, element))
547}
548
549fn predicate_matches<E: Element>(pred: &Predicate, element: &E) -> bool {
550    let actual = match pred.attr {
551        Attr::Role => element.role(),
552        Attr::Name | Attr::Title => element.name().unwrap_or_default(),
553        Attr::AutomationId => element.automation_id().unwrap_or_default(),
554        // Url is only meaningful for Tab anchor matching; always returns empty on UIA elements.
555        Attr::Url => String::new(),
556    };
557    pred.values.iter().any(|v| match pred.op {
558        Op::Exact => actual == v.as_str(),
559        Op::Contains => actual
560            .to_ascii_lowercase()
561            .contains(v.to_ascii_lowercase().as_str()),
562        Op::StartsWith => actual
563            .to_ascii_lowercase()
564            .starts_with(v.to_ascii_lowercase().as_str()),
565        Op::EndsWith => actual
566            .to_ascii_lowercase()
567            .ends_with(v.to_ascii_lowercase().as_str()),
568    })
569}
570
571// ── Parser helpers ────────────────────────────────────────────────────────────
572
573/// Split the selector string into `(Combinator, segment_str)` pairs.
574/// Respects `[...]` brackets so that `>>` inside an attribute value is not
575/// treated as a combinator.
576fn split_segments(input: &str) -> Result<Vec<(Combinator, &str)>, AutomataError> {
577    let bytes = input.as_bytes();
578    let mut segments: Vec<(Combinator, &str)> = vec![];
579    let mut depth = 0usize;
580    let mut seg_start = 0;
581    let mut i = 0;
582    // Combinator that the *next* segment should get (set when we consume a `>` or `>>`).
583    let mut pending: Option<Combinator> = None;
584
585    while i < bytes.len() {
586        match bytes[i] {
587            b'[' => depth += 1,
588            b']' => depth = depth.saturating_sub(1),
589            b'>' if depth == 0 => {
590                let seg = input[seg_start..i].trim();
591                if !seg.is_empty() {
592                    let combinator = pending.take().unwrap_or(if segments.is_empty() {
593                        Combinator::Root
594                    } else {
595                        Combinator::Child
596                    });
597                    segments.push((combinator, seg));
598                }
599                // Determine the combinator for the upcoming segment.
600                let is_descendant = bytes.get(i + 1) == Some(&b'>');
601                pending = Some(if is_descendant {
602                    i += 1; // skip second >
603                    Combinator::Descendant
604                } else {
605                    Combinator::Child
606                });
607                seg_start = i + 1;
608            }
609            _ => {}
610        }
611        i += 1;
612    }
613
614    // Last segment
615    let tail = input[seg_start..].trim();
616    if !tail.is_empty() {
617        let combinator = pending.take().unwrap_or(if segments.is_empty() {
618            Combinator::Root
619        } else {
620            Combinator::Child
621        });
622        segments.push((combinator, tail));
623    }
624
625    if depth != 0 {
626        return Err(AutomataError::Internal("unclosed '[' in selector".into()));
627    }
628
629    if segments.is_empty() {
630        return Err(AutomataError::Internal(
631            "selector produced no segments".into(),
632        ));
633    }
634
635    Ok(segments)
636}
637
638fn parse_step(combinator: Combinator, seg: &str) -> Result<PathStep, AutomataError> {
639    let seg = seg.trim();
640
641    // Extract pseudo-class suffixes (:nth, :parent, :ancestor) before anything else
642    let (seg, nth, ascend) = extract_pseudos(seg)?;
643
644    // Wildcard: `*` or `*:nth(n)` matches any element.
645    if seg == "*" {
646        return Ok(PathStep {
647            combinator,
648            predicates: vec![],
649            nth,
650            ascend,
651        });
652    }
653
654    // Remaining text is zero or more [attr op value] predicates optionally
655    // preceded by a bare role shorthand (e.g. `Button[name=Open]`).
656    let (bare_role, rest) = split_bare_role(seg);
657
658    let mut predicates: Vec<Predicate> = vec![];
659
660    if !bare_role.is_empty() {
661        predicates.push(Predicate {
662            attr: Attr::Role,
663            op: Op::Exact,
664            values: vec![bare_role.to_string()],
665        });
666    }
667
668    // Parse bracket predicates
669    let mut s = rest.trim();
670    while s.starts_with('[') {
671        let close = s.find(']').ok_or_else(|| {
672            AutomataError::Internal(format!("unclosed '[' in selector segment: {seg}"))
673        })?;
674        let inner = &s[1..close];
675        predicates.push(parse_predicate(inner)?);
676        s = s[close + 1..].trim();
677    }
678
679    if predicates.is_empty() {
680        return Err(AutomataError::Internal(format!(
681            "selector step has no predicates: '{seg}'"
682        )));
683    }
684
685    Ok(PathStep {
686        combinator,
687        predicates,
688        nth,
689        ascend,
690    })
691}
692
693/// Extract trailing pseudo-class suffixes `:nth(n)`, `:parent`, `:ancestor(n)`
694/// from a step segment. Returns `(remainder, nth, ascend)`.
695/// Pseudos are stripped right-to-left so any order is accepted.
696fn extract_pseudos(seg: &str) -> Result<(&str, Option<usize>, usize), AutomataError> {
697    let mut s = seg;
698    let mut nth: Option<usize> = None;
699    let mut ascend: usize = 0;
700
701    loop {
702        let t = s.trim_end();
703        if t.ends_with(":parent") {
704            s = &t[..t.len() - ":parent".len()];
705            ascend = 1;
706            continue;
707        }
708        if t.ends_with(')') {
709            if let Some(open) = t.rfind('(') {
710                let before = &t[..open];
711                let inner = &t[open + 1..t.len() - 1];
712                if before.ends_with(":ancestor") {
713                    let n = inner.trim().parse::<usize>().map_err(|_| {
714                        AutomataError::Internal(format!("invalid :ancestor index in '{seg}'"))
715                    })?;
716                    s = &before[..before.len() - ":ancestor".len()];
717                    ascend = n;
718                    continue;
719                }
720                if before.ends_with(":nth") {
721                    let n = inner.trim().parse::<usize>().map_err(|_| {
722                        AutomataError::Internal(format!("invalid :nth index in '{seg}'"))
723                    })?;
724                    s = &before[..before.len() - ":nth".len()];
725                    nth = Some(n);
726                    continue;
727                }
728            }
729        }
730        break;
731    }
732
733    Ok((s, nth, ascend))
734}
735
736/// Split a segment like `Button[name=Open]` into `("Button", "[name=Open]")`.
737/// If the segment starts with `[`, the bare role is empty.
738fn split_bare_role(seg: &str) -> (&str, &str) {
739    if let Some(pos) = seg.find('[') {
740        (&seg[..pos], &seg[pos..])
741    } else {
742        (seg, "")
743    }
744}
745
746/// Parse a single `attr op value` string (contents of `[...]`).
747fn parse_predicate(inner: &str) -> Result<Predicate, AutomataError> {
748    // Handle comma-separated multi-predicate shorthand inside one bracket:
749    // e.g. `role=Button, name=Open` — split on `,` and parse first only here;
750    // the caller handles the multi-predicate case by calling us per-bracket.
751    // For simplicity we only parse ONE predicate per bracket call.
752    let inner = inner.trim();
753
754    let (attr_str, op, value) = if let Some(pos) = inner.find("~=") {
755        (&inner[..pos], Op::Contains, inner[pos + 2..].trim())
756    } else if let Some(pos) = inner.find("^=") {
757        (&inner[..pos], Op::StartsWith, inner[pos + 2..].trim())
758    } else if let Some(pos) = inner.find("$=") {
759        (&inner[..pos], Op::EndsWith, inner[pos + 2..].trim())
760    } else if let Some(pos) = inner.find('=') {
761        (&inner[..pos], Op::Exact, inner[pos + 1..].trim())
762    } else {
763        return Err(AutomataError::Internal(format!(
764            "no operator found in predicate: '{inner}'"
765        )));
766    };
767
768    let attr = match attr_str.trim() {
769        "role" => Attr::Role,
770        "name" => Attr::Name,
771        "title" => Attr::Title,
772        "id" | "automation_id" => Attr::AutomationId,
773        "url" => Attr::Url,
774        other => {
775            return Err(AutomataError::Internal(format!(
776                "unknown attribute '{other}' in selector"
777            )));
778        }
779    };
780
781    // Split on `|` for OR semantics: `[name=Editor|Designer]` matches either.
782    // Quotes are stripped from each alternative individually.
783    let values: Vec<String> = value
784        .split('|')
785        .map(|v| v.trim().trim_matches(|c| c == '\'' || c == '"').to_string())
786        .collect();
787
788    Ok(Predicate { attr, op, values })
789}
790
791// ── Display ───────────────────────────────────────────────────────────────────
792
793impl std::fmt::Display for SelectorPath {
794    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
795        for (i, step) in self.steps.iter().enumerate() {
796            if i > 0 {
797                match step.combinator {
798                    Combinator::Child => write!(f, " > ")?,
799                    Combinator::Descendant => write!(f, " >> ")?,
800                    Combinator::Root => {}
801                }
802            }
803            if step.predicates.is_empty() {
804                write!(f, "*")?;
805            }
806            for pred in &step.predicates {
807                let op = match pred.op {
808                    Op::Exact => "=",
809                    Op::Contains => "~=",
810                    Op::StartsWith => "^=",
811                    Op::EndsWith => "$=",
812                };
813                let attr = match pred.attr {
814                    Attr::Role => "role",
815                    Attr::Name | Attr::Title => "name",
816                    Attr::AutomationId => "id",
817                    Attr::Url => "url",
818                };
819                write!(f, "[{attr}{op}{}]", pred.values.join("|"))?;
820            }
821            if let Some(n) = step.nth {
822                write!(f, ":nth({n})")?;
823            }
824            match step.ascend {
825                0 => {}
826                1 => write!(f, ":parent")?,
827                n => write!(f, ":ancestor({n})")?,
828            }
829        }
830        Ok(())
831    }
832}