Skip to main content

smix_selector_resolver/
lib.rs

1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3#![deny(rustdoc::broken_intra_doc_links)]
4
5//! smix-selector-resolver — Selector resolution against an [`A11yNode`]
6//! tree (stone, hot path).
7//!
8//! Ported from now-retired TS source: `src/core/resolve-selector.ts` (279 lines, v1.5
9//! c5i-d + v1.6 c4 lock-in). 1:1 semantics:
10//!
11//! 1. **Collect**: DFS pre-order over the tree, collect every node whose
12//!    base form matches (text / id / label / role+name / focused / anchor
13//!    accept-all).
14//! 2. **Visibility filter** (v1.5 c5i-d): drop nodes whose bounds are
15//!    zero or completely outside the tree viewport — unless no candidate
16//!    is visible, then drop nothing (TS semantic preserves miss reports).
17//! 3. **Spatial filter** (v0.3 C5): for each
18//!    `near/below/above/leftOf/rightOf/inside` key (top-level for
19//!    base 1-4, or inside `anchor.*` for base 6), recursively resolve
20//!    the anchor sub-selector; filter candidates by centroid-axis or
21//!    geometric containment. AND semantics. Anchor null → overall null.
22//! 4. **Index pick** (v0.3 C5): apply `first/last/nth` in declaration
23//!    order; later overrides earlier (TS line 273-278 — `nth` wins
24//!    if both `first` and `nth` set).
25//!
26//! # Pattern compile cache
27//!
28//! Selector trees carry [`Pattern`] (wire form). Every text/regex match
29//! site re-compiles the regex unless cached. The resolver builds a
30//! per-call `ResolverContext` that walks the selector tree once, calls
31//! [`Pattern::compile`] on every [`Pattern`] node, and stores the
32//! resulting [`CompiledPattern`] keyed by the raw pointer to the wire
33//! `Pattern` (stable for the lifetime of the borrowed selector).
34//!
35//! This is the key perf gain over the TS resolver (which lacks an
36//! explicit cache — V8 RegExp object internally lazy-compiles, but every
37//! `matchText` call still pays the per-candidate fold). For a 100-node
38//! tree with a single text base, Rust 1× compile + 100 × match_compiled
39//! ≪ TS 100 × compile-and-match.
40
41#![doc(html_root_url = "https://docs.smix.dev/smix-selector-resolver")]
42
43use smix_screen::{A11yNode, Rect, Role, is_visible_enough};
44use smix_selector::{
45    AnchorBox, CompiledPattern, Modifiers, Pattern, Selector, match_text_compiled,
46};
47use std::collections::HashMap;
48
49const NEAR_THRESHOLD_PT: f64 = 100.0; // logical points @1x (1:1 跟 TS line 188)
50
51// -------------------- public API -----------------------------------------
52
53/// Resolve a [`Selector`] against an [`A11yNode`] tree. Returns the first
54/// surviving candidate after collect → visibility → spatial → index.
55///
56/// Returns `None` when the selector has no matching node OR any
57/// transitive regex compilation fails (TS resolver throws on regex
58/// error; we choose silent None — caller can `Pattern::compile()` first
59/// to surface compile errors explicitly).
60#[must_use]
61pub fn resolve_selector<'tree>(
62    tree: &'tree A11yNode,
63    selector: &Selector,
64) -> Option<&'tree A11yNode> {
65    let ctx = ResolverContext::new(selector)?;
66    resolve_selector_compiled(tree, selector, &ctx)
67}
68
69/// Resolve all matching candidates (TS `resolveSelectorAll`, line 89-96).
70///
71/// Same pipeline as [`resolve_selector`] but skips the final index pick —
72/// `first/last/nth` are silently ignored when present (Playwright
73/// `locator(...).all()` semantics: "find all" is incompatible with "pick
74/// one by position").
75#[must_use]
76pub fn resolve_selector_all<'tree>(
77    tree: &'tree A11yNode,
78    selector: &Selector,
79) -> Vec<&'tree A11yNode> {
80    let Some(ctx) = ResolverContext::new(selector) else {
81        return vec![];
82    };
83    resolve_selector_all_compiled(tree, selector, &ctx)
84}
85
86/// Resolve a [`Selector`] against an [`A11yNode`] tree using a caller-
87/// provided [`ResolverContext`]. Same pipeline as [`resolve_selector`]
88/// but skips the per-call cache build — pass a reusable context built
89/// once via [`ResolverContext::new`] to amortize regex compile cost
90/// across many calls (typical retry loop in `smix-driver::wait_for` /
91/// `scroll`). See [`ResolverContext`] doc for the lifetime contract.
92#[must_use]
93pub fn resolve_selector_compiled<'tree>(
94    tree: &'tree A11yNode,
95    selector: &Selector,
96    ctx: &ResolverContext,
97) -> Option<&'tree A11yNode> {
98    resolve_inner(tree, selector, ctx).into_iter().next()
99}
100
101/// Resolve all matching candidates with a caller-provided
102/// [`ResolverContext`]. Same as [`resolve_selector_all`] minus the per-
103/// call cache build. See [`resolve_selector_compiled`] for the reuse
104/// pattern.
105#[must_use]
106pub fn resolve_selector_all_compiled<'tree>(
107    tree: &'tree A11yNode,
108    selector: &Selector,
109    ctx: &ResolverContext,
110) -> Vec<&'tree A11yNode> {
111    resolve_inner_no_index(tree, selector, ctx)
112}
113
114// -------------------- ResolverContext (compile cache) --------------------
115
116/// Per-call cache mapping raw pointer of each [`Pattern`] in the selector
117/// tree to its [`CompiledPattern`]. Built once at entry; lookup is O(1)
118/// hash.
119///
120/// # Cache reuse across calls (v3.31 c1)
121///
122/// Build a single `ResolverContext` outside a retry loop and pass it to
123/// [`resolve_selector_compiled`] / [`resolve_selector_all_compiled`] on
124/// every iteration to skip the per-call regex compile prepass. The
125/// regex hit case drops from ~9.5 µs/iter to ~260 ns/iter on a 15-node
126/// tree (see `PERFORMANCE.md` v3.31 c1 segment). The convenience
127/// wrappers [`resolve_selector`] / [`resolve_selector_all`] keep the
128/// per-call construction for one-shot callers.
129///
130/// # Safety
131///
132/// `*const Pattern` is used only as a HashMap key — never dereferenced.
133/// The borrowed [`Selector`] passed to [`ResolverContext::new`] must
134/// outlive every subsequent `resolve_selector_compiled` call that uses
135/// this context. Pattern addresses stay stable for the lifetime of the
136/// outer `&Selector` borrow. Passing a context built from a different
137/// `Selector` than the one being resolved is a runtime contract
138/// violation (cache misses degrade to silent `None` from `ctx.pattern`).
139pub struct ResolverContext {
140    compiled: HashMap<*const Pattern, CompiledPattern>,
141}
142
143// SAFETY: `*const Pattern` is used only as a HashMap key — never
144// dereferenced. The borrowed Selector (whose Pattern addresses are
145// captured at `new`) outlives the context per the documented lifetime
146// contract. Pointer values are inert bits across threads, so moving a
147// ResolverContext between threads (e.g. a tokio worker carrying the
148// future built by `smix-driver::wait_for` / `scroll`) is safe.
149// CompiledPattern itself is already Send + Sync (regex::Regex +
150// String).
151unsafe impl Send for ResolverContext {}
152unsafe impl Sync for ResolverContext {}
153
154impl ResolverContext {
155    /// Build a context by walking the selector tree once and pre-compiling
156    /// every embedded [`Pattern`]. Returns `None` if any regex pattern
157    /// fails to compile (matches the silent-`None` semantic of
158    /// [`resolve_selector`]).
159    pub fn new(selector: &Selector) -> Option<Self> {
160        let mut compiled = HashMap::new();
161        if !Self::compile_selector(selector, &mut compiled) {
162            return None;
163        }
164        Some(ResolverContext { compiled })
165    }
166
167    /// Walks the selector tree once, compiles every [`Pattern`]. Returns
168    /// false on any compile error.
169    fn compile_selector(
170        selector: &Selector,
171        out: &mut HashMap<*const Pattern, CompiledPattern>,
172    ) -> bool {
173        match selector {
174            Selector::Text { text, modifiers } => {
175                if !Self::cache_pattern(text, out) {
176                    return false;
177                }
178                Self::compile_modifiers(modifiers, out)
179            }
180            Selector::Id { modifiers, .. } | Selector::Label { modifiers, .. } => {
181                Self::compile_modifiers(modifiers, out)
182            }
183            Selector::Role {
184                name, modifiers, ..
185            } => {
186                if let Some(name_pat) = name
187                    && !Self::cache_pattern(name_pat, out)
188                {
189                    return false;
190                }
191                Self::compile_modifiers(modifiers, out)
192            }
193            Selector::Focused { .. } => true,
194            Selector::Anchor { anchor, .. } => Self::compile_anchor(anchor, out),
195            Selector::LocalizedText { modifiers, .. } => {
196                // v5.18 c1 — adapter is expected to desugar LocalizedText →
197                // Selector::Text before invoking the resolver. The variant
198                // reaches compile only when the caller forgot to desugar
199                // (test path / direct SDK use without adapter). Compile the
200                // modifiers so the call doesn't crash; the actual match
201                // (matches_base) will return false for any node, and the
202                // caller will see "no match" — debuggable via describe_selector.
203                Self::compile_modifiers(modifiers, out)
204            }
205            Selector::OcrText { modifiers, .. } => {
206                // v5.19 c1 — adapter handles OcrText dispatch directly via
207                // App::find_by_text_ocr + tap_at_norm_coord, bypassing the
208                // resolver pipeline. Variant reaches resolver only when
209                // adapter forgot to dispatch; treat same as LocalizedText
210                // (compile modifiers; matches_base returns false).
211                Self::compile_modifiers(modifiers, out)
212            }
213            Selector::AnchorRelative { anchor, .. } => {
214                // v5.20 c1 — adapter dispatches AnchorRelative directly via
215                // App::find_norm_coord(anchor) + tap_at_norm_coord, never
216                // calls resolver on the AnchorRelative itself. But the
217                // ANCHOR sub-selector reaches the resolver through SDK
218                // App::find — must compile its patterns recursively.
219                Self::compile_selector(anchor, out)
220            }
221            Selector::Point { .. } => true,
222            Selector::Fallback { fallback } => {
223                // v5.20 c2 — compile every chain element's patterns; adapter
224                // iterates chain in dispatch but each sub-selector may need
225                // its own pattern cache. Returns false on first compile
226                // failure (matches LocalizedText / OcrText semantics).
227                fallback.iter().all(|s| Self::compile_selector(s, out))
228            }
229        }
230    }
231
232    fn cache_pattern(p: &Pattern, out: &mut HashMap<*const Pattern, CompiledPattern>) -> bool {
233        let key = p as *const Pattern;
234        if out.contains_key(&key) {
235            return true;
236        }
237        match p.compile() {
238            Ok(cp) => {
239                out.insert(key, cp);
240                true
241            }
242            Err(_) => false,
243        }
244    }
245
246    fn compile_modifiers(
247        m: &Modifiers,
248        out: &mut HashMap<*const Pattern, CompiledPattern>,
249    ) -> bool {
250        let slots = [
251            m.near.as_deref(),
252            m.below.as_deref(),
253            m.above.as_deref(),
254            m.left_of.as_deref(),
255            m.right_of.as_deref(),
256            m.inside.as_deref(),
257            m.ancestor.as_deref(),
258        ];
259        for child in slots.iter().flatten() {
260            if !Self::compile_selector(child, out) {
261                return false;
262            }
263        }
264        true
265    }
266
267    fn compile_anchor(a: &AnchorBox, out: &mut HashMap<*const Pattern, CompiledPattern>) -> bool {
268        let slots = [
269            a.near.as_deref(),
270            a.below.as_deref(),
271            a.above.as_deref(),
272            a.left_of.as_deref(),
273            a.right_of.as_deref(),
274            a.inside.as_deref(),
275        ];
276        for child in slots.iter().flatten() {
277            if !Self::compile_selector(child, out) {
278                return false;
279            }
280        }
281        true
282    }
283
284    /// O(1) cache lookup. Returns `None` if `p` was not seen during the
285    /// build prepass — typically means `p` belongs to a different
286    /// selector tree than the one passed to [`ResolverContext::new`].
287    pub fn pattern(&self, p: &Pattern) -> Option<&CompiledPattern> {
288        self.compiled.get(&(p as *const Pattern))
289    }
290}
291
292// -------------------- resolve pipeline -----------------------------------
293
294fn resolve_inner<'tree>(
295    tree: &'tree A11yNode,
296    selector: &Selector,
297    ctx: &ResolverContext,
298) -> Vec<&'tree A11yNode> {
299    let raw = dfs_collect(tree, |n| matches_base(n, selector, ctx));
300    let visible: Vec<&A11yNode> = raw
301        .into_iter()
302        .filter(|n| is_visible_enough(n, tree))
303        .collect();
304    let topmost = topmost_modal_filter(tree, visible);
305    // v3.14 c1.6 / v3.19 c2 — explicit structural intent (ancestor /
306    // spatial modifier) overrides implicit interactive preference
307    // (tappable filter). If a non-tappable candidate is the one that
308    // satisfies the user-provided structural / spatial constraint and a
309    // tappable sibling sits in a position that fails it, tappable-first
310    // would drop the non-tappable, leaving only the failing tappable →
311    // empty result. Pipeline: ancestor → spatial → tappable → index.
312    let Some(after_ancestor) = apply_ancestor_filter(tree, topmost, selector) else {
313        return vec![];
314    };
315    let Some(after_spatial) = apply_spatial_filters(tree, after_ancestor, selector, ctx) else {
316        return vec![];
317    };
318    let tappable = tappable_subset_filter(after_spatial);
319    apply_index(tappable, selector)
320}
321
322fn resolve_inner_no_index<'tree>(
323    tree: &'tree A11yNode,
324    selector: &Selector,
325    ctx: &ResolverContext,
326) -> Vec<&'tree A11yNode> {
327    let raw = dfs_collect(tree, |n| matches_base(n, selector, ctx));
328    let visible: Vec<&A11yNode> = raw
329        .into_iter()
330        .filter(|n| is_visible_enough(n, tree))
331        .collect();
332    let topmost = topmost_modal_filter(tree, visible);
333    let Some(after_ancestor) = apply_ancestor_filter(tree, topmost, selector) else {
334        return vec![];
335    };
336    let Some(after_spatial) = apply_spatial_filters(tree, after_ancestor, selector, ctx) else {
337        return vec![];
338    };
339    tappable_subset_filter(after_spatial)
340}
341
342// v3.5 c1 — tappable preference. When candidate set mixes tappable
343// (button / link / cell / tab / menuItem) and non-tappable (alert /
344// staticText / window / group / ...), drop the non-tappable so a single
345// label selector picks the actual interactive element, not the alert
346// container or its title. Same semantic as `XCUIElementQuery.firstMatch`
347// which favours hit-testable leaves over their surrounding chrome.
348// Single-kind candidate lists fall through untouched (v1.x behaviour).
349fn tappable_subset_filter(candidates: Vec<&A11yNode>) -> Vec<&A11yNode> {
350    if candidates.len() <= 1 {
351        return candidates;
352    }
353    let is_tappable = |n: &A11yNode| -> bool {
354        matches!(
355            n.raw_type.as_str(),
356            "button" | "link" | "cell" | "tab" | "menuItem"
357        )
358    };
359    let has_tappable = candidates.iter().any(|n| is_tappable(n));
360    let has_non_tappable = candidates.iter().any(|n| !is_tappable(n));
361    if has_tappable && has_non_tappable {
362        candidates.into_iter().filter(|n| is_tappable(n)).collect()
363    } else {
364        candidates
365    }
366}
367
368// v3.5 c1 — topmost hit-test (Apple XCUIElementQuery.firstMatch +
369// maestro findElement semantic): when ≥1 candidate is inside a
370// Role::Alert / Role::Dialog subtree (modal overlay in play), drop
371// the candidates that live under the underlying drawer/page so a
372// single selector picks the modal button. When no modal overlay is
373// present, the input list passes through unchanged — v1.x DFS
374// pre-order behaviour stays intact.
375// Modal container detection. The Swift `/tree` route does not emit a `role`
376// field (only `rawType`, see TreeRoute.swift `nodeToDict`) — so Rust-side
377// `Role::Alert` / `Role::Dialog` are always None on real-sim payloads.
378// Match `raw_type` strings directly (lower-case wire shape, mirrors
379// `elementTypeName(7 / 8)`); accept the `Role` enum too so unit-test
380// fixtures that set `role` only stay valid.
381fn is_modal_node(n: &A11yNode) -> bool {
382    matches!(n.raw_type.as_str(), "alert" | "dialog")
383        || matches!(n.role, Some(Role::Alert) | Some(Role::Dialog))
384}
385
386fn tree_has_modal_role(node: &A11yNode) -> bool {
387    if is_modal_node(node) {
388        return true;
389    }
390    node.children.iter().any(tree_has_modal_role)
391}
392
393fn topmost_modal_filter<'tree>(
394    tree: &'tree A11yNode,
395    candidates: Vec<&'tree A11yNode>,
396) -> Vec<&'tree A11yNode> {
397    // Fast path: ≤1 candidate has nothing to disambiguate; trees with no
398    // Alert/Dialog node anywhere fall back to v1.x DFS pre-order with
399    // zero overhead (a single shallow tree walk via `any`).
400    if candidates.len() <= 1 || !tree_has_modal_role(tree) {
401        return candidates;
402    }
403    let mut parent: HashMap<*const A11yNode, &'tree A11yNode> = HashMap::new();
404    fn walk<'tree>(n: &'tree A11yNode, parent: &mut HashMap<*const A11yNode, &'tree A11yNode>) {
405        for c in &n.children {
406            parent.insert(c as *const A11yNode, n);
407            walk(c, parent);
408        }
409    }
410    walk(tree, &mut parent);
411
412    let in_modal_subtree = |start: &A11yNode| -> bool {
413        if is_modal_node(start) {
414            return true;
415        }
416        let mut cur: *const A11yNode = start;
417        while let Some(p) = parent.get(&cur) {
418            if is_modal_node(p) {
419                return true;
420            }
421            cur = *p as *const A11yNode;
422        }
423        false
424    };
425
426    let in_modal: Vec<&'tree A11yNode> = candidates
427        .iter()
428        .copied()
429        .filter(|n| in_modal_subtree(n))
430        .collect();
431    if in_modal.is_empty() {
432        candidates
433    } else {
434        in_modal
435    }
436}
437
438fn dfs_collect<'tree, F>(tree: &'tree A11yNode, pred: F) -> Vec<&'tree A11yNode>
439where
440    F: Fn(&A11yNode) -> bool,
441{
442    let mut out: Vec<&'tree A11yNode> = Vec::new();
443    fn walk<'tree, F: Fn(&A11yNode) -> bool>(
444        n: &'tree A11yNode,
445        pred: &F,
446        out: &mut Vec<&'tree A11yNode>,
447    ) {
448        if pred(n) {
449            out.push(n);
450        }
451        for c in &n.children {
452            walk(c, pred, out);
453        }
454    }
455    walk(tree, &pred, &mut out);
456    out
457}
458
459fn matches_base(node: &A11yNode, selector: &Selector, ctx: &ResolverContext) -> bool {
460    match selector {
461        // v1.5 C2 — anchor-only base: every node is a candidate.
462        Selector::Anchor { .. } => true,
463        Selector::Text { text, .. } => match ctx.pattern(text) {
464            Some(cp) => match_text_compiled(node, cp),
465            None => false,
466        },
467        Selector::Id { id, .. } => {
468            if id.is_empty() {
469                return false;
470            }
471            node.identifier.as_deref() == Some(id.as_str())
472        }
473        Selector::Label { label, .. } => {
474            if label.is_empty() {
475                return false;
476            }
477            node.label.as_deref() == Some(label.as_str())
478        }
479        Selector::Role { role, name, .. } => {
480            if node.role != Some(*role) {
481                return false;
482            }
483            match name {
484                None => true,
485                Some(name_pat) => match ctx.pattern(name_pat) {
486                    Some(cp) => match_text_compiled(node, cp),
487                    None => false,
488                },
489            }
490        }
491        Selector::Focused { .. } => node.has_focus,
492        // v5.18 c1 — adapter is expected to desugar LocalizedText → Text
493        // before resolving. If we somehow get here (unit-test path / direct
494        // SDK use), no node matches — describe_selector still renders this
495        // variant for AI-readable error output.
496        Selector::LocalizedText { .. } => false,
497        // v5.19 c1 — adapter dispatches OcrText directly via OCR + tap_at_coord
498        // and never invokes the resolver pipeline. Variant reaches here only
499        // when adapter forgot to dispatch; no node matches.
500        Selector::OcrText { .. } => false,
501        // v5.20 c1 — adapter dispatches AnchorRelative directly (resolve anchor
502        // sub-selector → norm coord + dx/dy → tap_at_norm_coord). Variant
503        // never reaches here through the standard pipeline; if it somehow
504        // does, no node matches.
505        Selector::AnchorRelative { .. } => false,
506        // v5.20 c2 — Point + Fallback are dispatched by adapter without
507        // resolver involvement. Reaching matches_base means a caller
508        // forgot to dispatch; no node matches.
509        Selector::Point { .. } | Selector::Fallback { .. } => false,
510    }
511}
512
513// -------------------- v3.14 c1 — ancestor modifier (G7) ------------------
514
515// Ancestor-chain filter. Keep only candidates whose a11y tree parent chain
516// (recursive ancestors, excluding self) contains at least one node matching
517// the selector's `Modifiers::ancestor` sub-selector. Ancestor sub-selector
518// resolving to empty short-circuits the whole resolve to None (跟 spatial
519// anchor null 同语义).
520//
521// Different from `inside` spatial modifier (geometric bounds-containment):
522// `ancestor` walks the a11y tree parent chain — structural filter, not spatial.
523// Same parent-map idiom as `topmost_modal_filter` (line 283 段).
524//
525// `Selector::Anchor` / `Selector::Focused` have no `Modifiers::ancestor`
526// field on their wire shape (anchor base is already a spatial intent,
527// focused is runtime-resolved without modifiers), so they passthrough.
528fn apply_ancestor_filter<'tree>(
529    tree: &'tree A11yNode,
530    candidates: Vec<&'tree A11yNode>,
531    selector: &Selector,
532) -> Option<Vec<&'tree A11yNode>> {
533    let ancestor_sel = match selector {
534        Selector::Text { modifiers, .. }
535        | Selector::Id { modifiers, .. }
536        | Selector::Label { modifiers, .. }
537        | Selector::Role { modifiers, .. }
538        | Selector::LocalizedText { modifiers, .. }
539        | Selector::OcrText { modifiers, .. } => modifiers.ancestor.as_deref(),
540        // v5.20 c1 — AnchorRelative has no Modifiers (anchor sub-selector
541        // carries its own); adapter dispatches directly, ancestor n/a.
542        // v5.20 c2 — Point + Fallback no Modifiers either.
543        Selector::Anchor { .. }
544        | Selector::Focused { .. }
545        | Selector::AnchorRelative { .. }
546        | Selector::Point { .. }
547        | Selector::Fallback { .. } => None,
548    };
549    let Some(ancestor_sel) = ancestor_sel else {
550        return Some(candidates);
551    };
552    let anchor = resolve_selector(tree, ancestor_sel)?;
553
554    let mut parent: HashMap<*const A11yNode, &'tree A11yNode> = HashMap::new();
555    fn walk<'tree>(n: &'tree A11yNode, parent: &mut HashMap<*const A11yNode, &'tree A11yNode>) {
556        for c in &n.children {
557            parent.insert(c as *const A11yNode, n);
558            walk(c, parent);
559        }
560    }
561    walk(tree, &mut parent);
562
563    let surviving: Vec<&'tree A11yNode> = candidates
564        .into_iter()
565        .filter(|c| {
566            if std::ptr::eq(*c, anchor) {
567                return false;
568            }
569            let mut cur: *const A11yNode = *c;
570            while let Some(p) = parent.get(&cur) {
571                if std::ptr::eq(*p, anchor) {
572                    return true;
573                }
574                cur = *p as *const A11yNode;
575            }
576            false
577        })
578        .collect();
579    Some(surviving)
580}
581
582// -------------------- spatial filter -------------------------------------
583
584#[derive(Clone, Copy)]
585enum SpatialKey {
586    Near,
587    Below,
588    Above,
589    LeftOf,
590    RightOf,
591    Inside,
592}
593
594const SPATIAL_KEYS: [SpatialKey; 6] = [
595    SpatialKey::Near,
596    SpatialKey::Below,
597    SpatialKey::Above,
598    SpatialKey::LeftOf,
599    SpatialKey::RightOf,
600    SpatialKey::Inside,
601];
602
603fn get_spatial(selector: &Selector, key: SpatialKey) -> Option<&Selector> {
604    match selector {
605        Selector::Anchor { anchor, .. } => match key {
606            SpatialKey::Near => anchor.near.as_deref(),
607            SpatialKey::Below => anchor.below.as_deref(),
608            SpatialKey::Above => anchor.above.as_deref(),
609            SpatialKey::LeftOf => anchor.left_of.as_deref(),
610            SpatialKey::RightOf => anchor.right_of.as_deref(),
611            SpatialKey::Inside => anchor.inside.as_deref(),
612        },
613        Selector::Text { modifiers, .. }
614        | Selector::Id { modifiers, .. }
615        | Selector::Label { modifiers, .. }
616        | Selector::Role { modifiers, .. }
617        | Selector::LocalizedText { modifiers, .. }
618        | Selector::OcrText { modifiers, .. } => match key {
619            SpatialKey::Near => modifiers.near.as_deref(),
620            SpatialKey::Below => modifiers.below.as_deref(),
621            SpatialKey::Above => modifiers.above.as_deref(),
622            SpatialKey::LeftOf => modifiers.left_of.as_deref(),
623            SpatialKey::RightOf => modifiers.right_of.as_deref(),
624            SpatialKey::Inside => modifiers.inside.as_deref(),
625        },
626        Selector::Focused { .. }
627        | Selector::AnchorRelative { .. }
628        | Selector::Point { .. }
629        | Selector::Fallback { .. } => None,
630    }
631}
632
633fn apply_spatial_filters<'tree>(
634    tree: &'tree A11yNode,
635    candidates: Vec<&'tree A11yNode>,
636    selector: &Selector,
637    _ctx: &ResolverContext,
638) -> Option<Vec<&'tree A11yNode>> {
639    let mut surviving = candidates;
640    for key in &SPATIAL_KEYS {
641        let Some(anchor_sel) = get_spatial(selector, *key) else {
642            continue;
643        };
644        // Recursive anchor resolution. Each anchor sub-selector compiles
645        // its own ResolverContext (Pattern lifetimes are local — caller's
646        // Pattern pointers don't overlap recursively because Box<Selector>
647        // makes each sub-tree a fresh node tree).
648        let Some(anchor) = resolve_selector(tree, anchor_sel) else {
649            return None; // anchor null short-circuits whole resolve to None
650        };
651        surviving.retain(|c| satisfies(*key, c, anchor));
652    }
653    Some(surviving)
654}
655
656fn satisfies(key: SpatialKey, c: &A11yNode, a: &A11yNode) -> bool {
657    if std::ptr::eq(c, a) {
658        return false;
659    }
660    let cc = centroid(c.bounds);
661    let ac = centroid(a.bounds);
662    match key {
663        SpatialKey::Near => dist(cc, ac) <= NEAR_THRESHOLD_PT,
664        SpatialKey::Below => cc.1 > ac.1,
665        SpatialKey::Above => cc.1 < ac.1,
666        SpatialKey::LeftOf => cc.0 < ac.0,
667        SpatialKey::RightOf => cc.0 > ac.0,
668        SpatialKey::Inside => contains(a.bounds, c.bounds),
669    }
670}
671
672#[inline]
673fn centroid(r: Rect) -> (f64, f64) {
674    (r.x + r.w / 2.0, r.y + r.h / 2.0)
675}
676
677#[inline]
678fn dist(p: (f64, f64), q: (f64, f64)) -> f64 {
679    let dx = p.0 - q.0;
680    let dy = p.1 - q.1;
681    (dx * dx + dy * dy).sqrt()
682}
683
684#[inline]
685fn contains(outer: Rect, inner: Rect) -> bool {
686    inner.x >= outer.x
687        && inner.y >= outer.y
688        && inner.x + inner.w <= outer.x + outer.w
689        && inner.y + inner.h <= outer.y + outer.h
690}
691
692// -------------------- index pick -----------------------------------------
693
694fn apply_index<'tree>(list: Vec<&'tree A11yNode>, selector: &Selector) -> Vec<&'tree A11yNode> {
695    let (first, last, nth) = match selector {
696        Selector::Anchor { index, .. } => (index.first, index.last, index.nth),
697        Selector::Text { modifiers, .. }
698        | Selector::Id { modifiers, .. }
699        | Selector::Label { modifiers, .. }
700        | Selector::Role { modifiers, .. }
701        | Selector::LocalizedText { modifiers, .. }
702        | Selector::OcrText { modifiers, .. } => (modifiers.first, modifiers.last, modifiers.nth),
703        Selector::Focused { .. }
704        | Selector::AnchorRelative { .. }
705        | Selector::Point { .. }
706        | Selector::Fallback { .. } => return list,
707    };
708    let has_first = first == Some(true);
709    let has_last = last == Some(true);
710    let has_nth = nth.is_some();
711    if !has_first && !has_last && !has_nth {
712        return list;
713    }
714    // TS semantics (line 274-278): first → list[0], last overrides → list[len-1],
715    // nth overrides both → list[nth]. Single-shot return [picked] or [].
716    let picked: Option<&'tree A11yNode> = if has_nth {
717        nth.and_then(|i| list.get(i).copied())
718    } else if has_last {
719        list.last().copied()
720    } else {
721        // has_first
722        list.first().copied()
723    };
724    match picked {
725        Some(n) => vec![n],
726        None => vec![],
727    }
728}