Skip to main content

smix_selector/
lib.rs

1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3#![deny(rustdoc::broken_intra_doc_links)]
4
5//! smix-selector — Selector enum + Modifiers + AnchorBox + `match_text`
6//! (stone).
7//!
8//! # Scope
9//!
10//! - Pure types (`Selector`, `Pattern`, `Modifiers`, `AnchorBox`,
11//!   `IndexModifiers`) with serde wire compatibility (camelCase JSON,
12//!   matching SDK / CLI / MCP / runner-client wire shape).
13//! - Pure functions (`match_text`, `describe_selector`) — no I/O, no Tree
14//!   traversal (the resolver lives in `smix-selector-resolver`).
15//!
16//! # Selector forms
17//!
18//! 11 variants. Six are mutually exclusive base forms, whose field
19//! presence is the wire discriminator:
20//! 1. Text (string / regex)
21//! 2. Id (string strict equal vs node.identifier)
22//! 3. Label (string strict equal vs node.label)
23//! 4. Role { role, name?: pattern } (role enum + optional pattern name)
24//! 5. Focused (focused: true — runtime-resolved current focus target)
25//! 6. Anchor (anchor-only, no own base; spatial keys do the filtering)
26//!
27//! The other five are later sense layers, each dispatched directly by
28//! the adapter: LocalizedText, OcrText, AnchorRelative, Point, Fallback.
29//!
30//! # Modifiers / AnchorBox (recursive)
31//!
32//! Base 1-4 stack `Modifiers` (6 spatial + 3 index). Base 6 (Anchor)
33//! stacks only `IndexModifiers` (no nested spatial — anchor IS the
34//! spatial intent already). Base 5 (Focused) stacks nothing.
35//!
36//! # `Pattern` wire form (string-or-regex union, serde-friendly)
37//!
38//! A native regex object doesn't survive JSON serialization, so a
39//! `string | regex` union needs an explicit wire form:
40//! - `"plain"` (untagged string) — string strict equal (case-insensitive,
41//!   for maestro parity)
42//! - `{ "regex": "pattern", "flags": "i" }` (object tag) — regex form,
43//!   auto-/i flag inject if absent
44//!
45//! Runtime compile happens inside `match_text` (no cache at the stone
46//! layer; resolver / SDK callers may memoize externally if needed).
47
48#![doc(html_root_url = "https://docs.smix.dev/smix-selector")]
49
50use std::collections::BTreeMap;
51
52use serde::{Deserialize, Serialize};
53use smix_screen::A11yNode;
54// Re-export `Role` at the crate root so adapter crates
55// building `Selector::Role { role, .. }` can `use smix_selector::Role`
56// alongside `smix_selector::Selector` without pulling `smix-screen`
57// as a direct dependency. Role is fundamentally a wire concept —
58// selectors, adapters, and resolvers all touch it — so this belongs
59// next to the other wire types here.
60pub use smix_screen::Role;
61
62/// Read a role the way a person writes one.
63///
64/// Every surface a human or an agent types a role into — yaml, MCP — needs
65/// the same vocabulary, or the surfaces drift and `role: heading` starts
66/// working in one and failing in the other. So the vocabulary lives here,
67/// next to `Role` itself, and each surface wraps it in its own error type.
68///
69/// Distinct from [`smix_screen::role_from_raw_type`], which reads the wire's
70/// own `rawType` strings. This one reads what people write: case is ignored,
71/// separators are ignored (`text_field`, `textField`, `TextField`), and the
72/// aliases people reach for are accepted (`heading` → `StaticText`, since
73/// iOS has no header element type and heading semantics land on static text
74/// with a trait).
75///
76/// Returns `None` for anything not in the vocabulary; callers report it with
77/// [`ROLE_NAMES`] so the writer sees what was on offer.
78#[must_use]
79pub fn role_from_name(name: &str) -> Option<Role> {
80    let normalized: String = name
81        .chars()
82        .filter(|c| c.is_ascii_alphanumeric())
83        .map(|c| c.to_ascii_lowercase())
84        .collect();
85    Some(match normalized.as_str() {
86        "button" => Role::Button,
87        "link" => Role::Link,
88        "textfield" => Role::TextField,
89        "securetextfield" => Role::SecureTextField,
90        "searchfield" => Role::SearchField,
91        "switch" => Role::Switch,
92        "toggle" => Role::Toggle,
93        "checkbox" => Role::CheckBox,
94        "radio" | "radiobutton" => Role::Radio,
95        "image" => Role::Image,
96        "heading" | "statictext" => Role::StaticText,
97        "tab" => Role::Tab,
98        "tabbar" => Role::TabBar,
99        "navigationbar" => Role::NavigationBar,
100        "cell" => Role::Cell,
101        "alert" => Role::Alert,
102        "dialog" => Role::Dialog,
103        "slider" => Role::Slider,
104        "progressbar" | "progressindicator" => Role::ProgressBar,
105        "picker" => Role::Picker,
106        "menu" => Role::Menu,
107        "menuitem" => Role::MenuItem,
108        "scrollview" => Role::ScrollView,
109        "segmentedcontrol" => Role::SegmentedControl,
110        "table" => Role::Table,
111        "collectionview" => Role::CollectionView,
112        "webview" => Role::WebView,
113        "keyboard" => Role::Keyboard,
114        _ => return None,
115    })
116}
117
118/// The role vocabulary, spelled the way docs spell it — for the "unknown
119/// role" message every surface owes its writer.
120pub const ROLE_NAMES: &str = "button, link, textField, secureTextField, searchField, switch, toggle, checkBox, radio, image, staticText (or heading), tab, tabBar, navigationBar, cell, alert, dialog, slider, progressBar, picker, menu, menuItem, scrollView, segmentedControl, table, collectionView, webView, keyboard";
121
122// -------------------- Pattern (string | regex wire form) -----------------
123
124/// String-or-regex pattern. Wire-compatible: plain JSON string ↔
125/// [`Pattern::Text`]; tagged object `{regex, flags}` ↔ [`Pattern::Regex`].
126///
127/// Matching is always case-insensitive: a regex missing the `/i` flag gets
128/// it injected automatically; plain text compares via
129/// `eq_ignore_ascii_case`.
130#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(untagged)]
132pub enum Pattern {
133    /// Plain literal string. An empty string never matches anything.
134    Text(String),
135    /// Compiled-on-call regex. `flags` defaults to "i" when absent to
136    /// satisfy maestro parity.
137    Regex {
138        /// Regex source string (PCRE-like syntax via the `regex` crate).
139        regex: String,
140        /// Flags string (currently only `"i"` is honored; defaulted to `"i"`).
141        #[serde(default = "default_regex_flags")]
142        flags: String,
143    },
144}
145
146fn default_regex_flags() -> String {
147    "i".to_string()
148}
149
150impl Pattern {
151    /// Construct a plain text pattern.
152    pub fn text<S: Into<String>>(s: S) -> Self {
153        Pattern::Text(s.into())
154    }
155
156    /// Construct a regex pattern. Auto-injects `i` flag if absent.
157    pub fn regex<P: Into<String>>(pattern: P) -> Self {
158        Pattern::Regex {
159            regex: pattern.into(),
160            flags: "i".to_string(),
161        }
162    }
163
164    /// Construct a regex pattern with explicit flags.
165    pub fn regex_with_flags<P: Into<String>, F: Into<String>>(pattern: P, flags: F) -> Self {
166        Pattern::Regex {
167            regex: pattern.into(),
168            flags: flags.into(),
169        }
170    }
171
172    /// Compile the wire-form pattern into a hot-path-ready [`CompiledPattern`].
173    /// One-time cost (~5-50 μs for regex compile, ~ns for text); the
174    /// resulting `CompiledPattern` matches in `5-50 ns` per call — the
175    /// regex compile gets fully amortized after just one re-use.
176    ///
177    /// Use [`match_text`] for one-shot SDK convenience; use [`match_text_compiled`]
178    /// inside any hot resolver loop after `Pattern::compile()` once.
179    ///
180    /// Returns `Err(regex::Error)` only for malformed regex patterns;
181    /// text patterns are infallible.
182    pub fn compile(&self) -> Result<CompiledPattern, regex::Error> {
183        match self {
184            Pattern::Text(s) => Ok(CompiledPattern::Text(s.clone())),
185            Pattern::Regex { regex, flags: _ } => {
186                let final_pattern = if regex.starts_with("(?i)") {
187                    regex.clone()
188                } else {
189                    format!("(?i){}", regex)
190                };
191                regex::Regex::new(&final_pattern).map(CompiledPattern::Regex)
192            }
193        }
194    }
195}
196
197/// Compiled, hot-path-ready pattern. Caches the compiled `regex::Regex`
198/// so the DFA is built once and re-used across matches, rather than
199/// recompiled on every call.
200///
201/// Not serde-serializable (`regex::Regex` does not derive
202/// `Serialize`/`Deserialize`). Wire form is [`Pattern`]; resolver/SDK
203/// convert with `.compile()` once.
204#[derive(Clone, Debug)]
205pub enum CompiledPattern {
206    /// Plain literal string (case-insensitive ASCII equality).
207    Text(String),
208    /// Compiled regex (case-insensitive via auto-injected `(?i)` prefix).
209    Regex(regex::Regex),
210}
211
212// -------------------- Selector enum --------------------------------------
213
214/// Modifier set stackable on Text / Id / Label / Role base forms.
215/// Spatial keys (near/below/above/leftOf/rightOf/inside) carry recursive
216/// selectors that resolve to an anchor node; index keys (nth/first/last)
217/// pick from the surviving candidate list.
218#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
219#[serde(default)]
220pub struct Modifiers {
221    /// Anchor sub-selector — candidate must be geometrically near it.
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub near: Option<Box<Selector>>,
224    /// Anchor sub-selector — candidate must be below it (larger y center).
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub below: Option<Box<Selector>>,
227    /// Anchor sub-selector — candidate must be above it (smaller y center).
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub above: Option<Box<Selector>>,
230    /// Anchor sub-selector — candidate must be to its left.
231    #[serde(rename = "leftOf", skip_serializing_if = "Option::is_none")]
232    pub left_of: Option<Box<Selector>>,
233    /// Anchor sub-selector — candidate must be to its right.
234    #[serde(rename = "rightOf", skip_serializing_if = "Option::is_none")]
235    pub right_of: Option<Box<Selector>>,
236    /// Anchor sub-selector — candidate must be geometrically inside it.
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub inside: Option<Box<Selector>>,
239    /// Ancestor sub-selector — candidate's a11y tree parent chain
240    /// (recursive ancestors, excluding self) must contain at least one
241    /// node matching this sub-selector. Different from
242    /// [`Modifiers::inside`] (geometric bounds-containment): `ancestor`
243    /// walks the a11y tree parent chain — structural filter, not spatial.
244    /// Use this to disambiguate same-label candidates living under
245    /// different ancestor subtrees (e.g. a bottom tab and a sub-tab both
246    /// labeled `"Tracking"`).
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub ancestor: Option<Box<Selector>>,
249    /// Pick the nth match (0-indexed) from the surviving candidates.
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub nth: Option<usize>,
252    /// Pick the first match (`true`) — overridden by `nth` if both set.
253    #[serde(skip_serializing_if = "Option::is_none")]
254    pub first: Option<bool>,
255    /// Pick the last match (`true`) — overridden by `nth` if both set.
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub last: Option<bool>,
258}
259
260/// Anchor base form's spatial keys. Identical shape to the spatial half
261/// of [`Modifiers`] but without nth/first/last (Anchor stacks
262/// IndexModifiers separately).
263#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
264#[serde(default)]
265#[serde(deny_unknown_fields)]
266pub struct AnchorBox {
267    /// Anchor sub-selector — see [`Modifiers::near`].
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub near: Option<Box<Selector>>,
270    /// Anchor sub-selector — see [`Modifiers::below`].
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub below: Option<Box<Selector>>,
273    /// Anchor sub-selector — see [`Modifiers::above`].
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub above: Option<Box<Selector>>,
276    /// Anchor sub-selector — see [`Modifiers::left_of`].
277    #[serde(rename = "leftOf", skip_serializing_if = "Option::is_none")]
278    pub left_of: Option<Box<Selector>>,
279    /// Anchor sub-selector — see [`Modifiers::right_of`].
280    #[serde(rename = "rightOf", skip_serializing_if = "Option::is_none")]
281    pub right_of: Option<Box<Selector>>,
282    /// Anchor sub-selector — see [`Modifiers::inside`].
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub inside: Option<Box<Selector>>,
285    /// Anchor sub-selector — see [`Modifiers::ancestor`]. The top-level
286    /// `Modifiers` has carried this since the resolver learned structural
287    /// containment; the anchor form did not, so all three SDKs (which do
288    /// expose it on their AnchorBox) had it silently dropped by serde and
289    /// got a wider candidate set than they asked for.
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub ancestor: Option<Box<Selector>>,
292}
293
294/// IndexModifiers — the subset BaseAnchor can stack. Kept separate from
295/// [`Modifiers`] so anchor callers can't accidentally write the top-level
296/// spatial form (which would be two surfaces for the same intent).
297#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
298#[serde(default)]
299pub struct IndexModifiers {
300    /// Pick the nth match (0-indexed) from the surviving candidates.
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub nth: Option<usize>,
303    /// Pick the first match (`true`) — overridden by `nth` if both set.
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub first: Option<bool>,
306    /// Pick the last match (`true`) — overridden by `nth` if both set.
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub last: Option<bool>,
309}
310
311/// Selector — 11 variants: 6 base forms (Text / Id / Label / Role /
312/// Focused / Anchor), whose field presence is the wire discriminator,
313/// plus 5 later layers (LocalizedText / OcrText / AnchorRelative /
314/// Point / Fallback).
315///
316/// Each variant carries its base-shape fields *and* the modifier set
317/// stackable on that base. `serde(untagged)` keeps the JSON wire form
318/// free of any explicit tag: there is no `{type: "text", ...}`
319/// discriminator — the *presence* of the `text` / `id` / `label` /
320/// `role` / `focused` / `anchor` field is itself the discriminator.
321/// The wire shape matches the SDK / CLI / MCP / runner-client 1:1.
322#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
323#[serde(untagged)]
324pub enum Selector {
325    /// `{ text: 'hello' | /^hi/, ...modifiers }`
326    Text {
327        /// Text pattern (literal or regex).
328        text: Pattern,
329        /// Stacked spatial + index modifiers.
330        #[serde(flatten)]
331        modifiers: Modifiers,
332    },
333    /// `{ id: 'btn-xyz', ...modifiers }`
334    Id {
335        /// Accessibility identifier (strict equal).
336        id: String,
337        /// Stacked spatial + index modifiers.
338        #[serde(flatten)]
339        modifiers: Modifiers,
340    },
341    /// `{ label: 'Settings', ...modifiers }`
342    Label {
343        /// Accessibility label (strict equal).
344        label: String,
345        /// Stacked spatial + index modifiers.
346        #[serde(flatten)]
347        modifiers: Modifiers,
348    },
349    /// `{ role: 'button', name?: pattern, ...modifiers }`
350    Role {
351        /// Semantic role.
352        role: Role,
353        /// Optional accessible-name pattern (label / title / text scan).
354        #[serde(skip_serializing_if = "Option::is_none")]
355        name: Option<Pattern>,
356        /// Stacked spatial + index modifiers.
357        #[serde(flatten)]
358        modifiers: Modifiers,
359    },
360    /// `{ focused: true }` — no modifiers (focus is runtime-resolved).
361    Focused {
362        /// Always `true` on the wire. Deserialized as a discriminator.
363        focused: True,
364    },
365    /// `{ anchor: { ...spatial keys }, ...indexModifiers }`
366    Anchor {
367        /// Spatial-only anchor sub-selectors (AnchorBox has no nth/first/last).
368        anchor: AnchorBox,
369        /// Index pick applied after spatial filtering.
370        #[serde(flatten)]
371        index: IndexModifiers,
372    },
373    /// `{ localized_text: { en: "Submit", ja: "送信", es: "Enviar" }, ...modifiers }`
374    /// — sense layer L4. Per-locale text table for
375    /// elements whose visible text varies across locales when no stable
376    /// accessibilityIdentifier is exposed. Adapter desugars this variant to
377    /// `Selector::Text { text: <picked-by-current-locale> }` before passing
378    /// to the resolver — the resolver itself never sees `LocalizedText`
379    /// (unreachable arm in resolver match). Locale is read from the last
380    /// `launchApp -AppleLanguages` argument the adapter parsed; fallback to
381    /// "en" when current locale not in table (or no launchApp seen yet).
382    LocalizedText {
383        /// Locale code → text mapping. Locale codes are bare BCP-47 language
384        /// subtag (e.g. "en", "ja", "es"), matching the `(xx)` form
385        /// inside `-AppleLanguages "(xx)"`. BTreeMap for stable iteration
386        /// + deterministic debug / test output.
387        #[serde(rename = "localizedText", alias = "localized_text")]
388        localized_text: BTreeMap<String, String>,
389        /// Stacked spatial + index modifiers.
390        #[serde(flatten)]
391        modifiers: Modifiers,
392    },
393    /// `{ ocr_text: "Submit", locales: ["en"], ...modifiers }` — sense
394    /// layer L5. Apple Vision OCR keyword over the
395    /// current XCUIScreen screenshot. For elements with no stable
396    /// accessibilityIdentifier AND no a11y label (custom-drawn UI / 3rd
397    /// party libs that bypass UIAccessibility). Adapter handles dispatch
398    /// directly (find_by_text_ocr → IOHID synthesize at frame center),
399    /// bypassing the standard resolver pipeline — `OcrText` never reaches
400    /// resolver match arms. Locales are BCP-47 subtags; empty array
401    /// defaults to adapter's `last_locale` (which itself defaults to "en").
402    OcrText {
403        /// Substring keyword to OCR-match against text observations.
404        /// Case-insensitive substring match on `topCandidates(1)` of each
405        /// `VNRecognizedTextObservation`.
406        #[serde(rename = "ocrText", alias = "ocr_text")]
407        ocr_text: String,
408        /// BCP-47 language subtags for Apple Vision `recognitionLanguages`
409        /// (e.g. `["en"]` / `["ja"]`). Empty = adapter fills from
410        /// `last_locale`.
411        #[serde(default)]
412        locales: Vec<String>,
413        /// Stacked spatial + index modifiers (mostly nth for ambiguity
414        /// disambiguation when multiple text observations match).
415        #[serde(flatten)]
416        modifiers: Modifiers,
417    },
418    /// `{ anchored: { anchor: <selector>, dx: -0.15, dy: 0 } }` — sense
419    /// layer L6. Anchor-relative coord for icon-only
420    /// / pure-graphic elements (~15-20% of "lib without a11y" scenarios)
421    /// where no sense layer can locate the target directly, but a STABLE
422    /// nearby element (status label, header text, etc.) is sense-able.
423    ///
424    /// Resolution: adapter finds `anchor` via host-resolve → anchor center
425    /// in normalized `[0, 1]` viewport coords → add `(dx, dy)` shift (also
426    /// normalized — `dx: 0.15` = 15% of screen width to the right) → clamp
427    /// to `[0, 1]` → IOHID synthesize via tap_at_norm_coord.
428    ///
429    /// Compared to `Selector::Anchor` (spatial-relation chain — "find a
430    /// node near/below/leftOf `<anchor>`"), `AnchorRelative` is an escape
431    /// hatch family (sibling to `tap_at_coord`) — the target itself
432    /// has no a11y form, only the anchor does. Adapter dispatches directly
433    /// (find_norm_coord(anchor) → tap_at_coord), bypassing resolver.
434    AnchorRelative {
435        /// Anchor selector — any other Selector form (Id / Text / Role /
436        /// LocalizedText / etc.). Must resolve to exactly one node;
437        /// adapter uses centroid.
438        anchor: Box<Selector>,
439        /// X shift from anchor center in viewport-normalized space
440        /// (`dx: 0.15` = 15% of screen width to the right; `dx: -0.15`
441        /// = 15% to the left).
442        dx: f64,
443        /// Y shift from anchor center in viewport-normalized space
444        /// (`dy: 0.05` = 5% of screen height downward).
445        dy: f64,
446    },
447    /// `{ point: [0.5, 0.9] }` — sense layer L7 (last-resort within a
448    /// fallback chain). Direct viewport-normalized
449    /// coord. Equivalent to `Step::TapAtPoint` for standalone use, but
450    /// expressed as a Selector for uniform composition inside
451    /// `Selector::Fallback { chain: Vec<Selector> }`. Adapter dispatches
452    /// directly via tap_at_coord; never reaches resolver.
453    Point {
454        /// X in viewport-normalized [0, 1].
455        nx: f64,
456        /// Y in viewport-normalized [0, 1].
457        ny: f64,
458    },
459    /// `{ fallback: [<selector1>, <selector2>, ...] }` — sense layer L7.
460    /// Sequential resolution chain — adapter tries
461    /// each sub-selector in order; first hit wins. Misses are recorded
462    /// for AI-readable error report (`sense_layer` / `missing_id_hint` /
463    /// `suggested_fixes`). On all-miss, surfaces ElementNotFound with a
464    /// chain-trace hint listing every attempted layer.
465    ///
466    /// Composes L1-L7 into a single tapOn — typical pattern:
467    /// ```yaml
468    /// tapOn:
469    ///   fallback:
470    ///     - { id: "submit-btn" }                                # L1
471    ///     - { localized_text: { en: "Submit", ja: "送信" } }   # L4
472    ///     - { ocrText: "Submit" }                                # L5
473    ///     - { anchored: { anchor: { id: "form-area" }, dx: 0.0, dy: 0.05 } }  # L6
474    ///     - { point: [0.5, 0.9] }                                # L7
475    /// ```
476    /// Adapter dispatches each sub-selector through the standard run_tap
477    /// path; chain element types are limited only by what run_tap can
478    /// dispatch (any concrete Selector variant). Nesting Fallback inside
479    /// Fallback is allowed but discouraged (no defined precedence beyond
480    /// outer-then-inner).
481    Fallback {
482        /// Sequential chain of sub-selectors. First hit wins.
483        fallback: Vec<Selector>,
484    },
485}
486
487/// Newtype around `true` — used as the [`Selector::Focused`] discriminator.
488/// Serializes/deserializes as the JSON literal `true`.
489#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
490#[serde(transparent)]
491pub struct True(pub bool);
492
493impl Default for True {
494    fn default() -> Self {
495        True(true)
496    }
497}
498
499// -------------------- match_text -----------------------------------------
500
501/// Text matching against an [`A11yNode`].
502///
503/// Scans 6 candidate fields in priority order — `label`, `title`, `value`,
504/// `placeholder_value`, `identifier`, `text`. The first five mirror the
505/// fields maestro scans (`IOSDriver.kt:192-210`); `text` is smix's own
506/// backward-compatible addition.
507///
508/// Case handling:
509/// - `Pattern::Text("")` returns false (empty pattern matches nothing).
510/// - `Pattern::Text("X")` does ASCII-case-insensitive strict equal
511///   against each candidate — `eq_ignore_ascii_case`, chosen because RN
512///   labels are ASCII-dominant.
513/// - `Pattern::Regex {regex, flags}` compiles the regex; if `flags` lacks
514///   `i`, injects it.
515///
516/// Regex compile errors short-circuit to `false` — a deliberate choice
517/// to keep `match_text` infallible at the stone layer; the resolver
518/// layer can wrap with explicit error reporting.
519///
520/// Pure / branch-only / one Vec scan — should be 5-50 ns text path, 50-200
521/// ns regex path after compile-once amortization.
522/// Hot-path match using a pre-compiled pattern. Use this inside any
523/// resolver / DFS loop after calling [`Pattern::compile`] once.
524///
525/// Pure / branch-only / one Vec scan. Regex variant runs cached DFA, so
526/// per-call cost is ~10-50 ns (vs ~17 μs for the compile-on-call
527/// [`match_text`]).
528#[must_use]
529pub fn match_text_compiled(node: &A11yNode, pattern: &CompiledPattern) -> bool {
530    let candidates: [Option<&str>; 6] = [
531        node.label.as_deref(),
532        node.title.as_deref(),
533        node.value.as_deref(),
534        node.placeholder_value.as_deref(),
535        node.identifier.as_deref(),
536        node.text.as_deref(),
537    ];
538    match pattern {
539        CompiledPattern::Text(p) => {
540            if p.is_empty() {
541                return false;
542            }
543            for c in candidates.iter().flatten() {
544                if c.eq_ignore_ascii_case(p) {
545                    return true;
546                }
547            }
548            false
549        }
550        CompiledPattern::Regex(re) => {
551            for c in candidates.iter().flatten() {
552                if re.is_match(c) {
553                    return true;
554                }
555            }
556            false
557        }
558    }
559}
560
561// -------------------- describe_selector ----------------------------------
562
563/// Human / AI-readable rendering of a [`Selector`] for error prompts and
564/// logs. Stable enough that AI can pattern-match against past failures.
565#[must_use]
566pub fn describe_selector(s: &Selector) -> String {
567    let mut parts: Vec<String> = Vec::new();
568    match s {
569        Selector::Anchor { anchor, index } => {
570            if let Some(v) = anchor.near.as_deref() {
571                parts.push(format!("anchor.near=({})", describe_selector(v)));
572            }
573            if let Some(v) = anchor.below.as_deref() {
574                parts.push(format!("anchor.below=({})", describe_selector(v)));
575            }
576            if let Some(v) = anchor.above.as_deref() {
577                parts.push(format!("anchor.above=({})", describe_selector(v)));
578            }
579            if let Some(v) = anchor.left_of.as_deref() {
580                parts.push(format!("anchor.leftOf=({})", describe_selector(v)));
581            }
582            if let Some(v) = anchor.right_of.as_deref() {
583                parts.push(format!("anchor.rightOf=({})", describe_selector(v)));
584            }
585            if let Some(v) = anchor.inside.as_deref() {
586                parts.push(format!("anchor.inside=({})", describe_selector(v)));
587            }
588            if let Some(v) = anchor.ancestor.as_deref() {
589                parts.push(format!("anchor.ancestor=({})", describe_selector(v)));
590            }
591            if parts.is_empty() {
592                parts.push("anchor.empty".into());
593            }
594            if let Some(nth) = index.nth {
595                parts.push(format!("nth={}", nth));
596            }
597            if index.first == Some(true) {
598                parts.push("first".into());
599            }
600            if index.last == Some(true) {
601                parts.push("last".into());
602            }
603            return format!("{{ {} }}", parts.join(", "));
604        }
605        Selector::Focused { .. } => return "{ focused }".into(),
606        Selector::Text { text, modifiers } => {
607            parts.push(format!("text={}", format_pattern(text)));
608            append_modifiers(modifiers, &mut parts);
609        }
610        Selector::Id { id, modifiers } => {
611            parts.push(format!("id=\"{}\"", id));
612            append_modifiers(modifiers, &mut parts);
613        }
614        Selector::Label { label, modifiers } => {
615            parts.push(format!("label=\"{}\"", label));
616            append_modifiers(modifiers, &mut parts);
617        }
618        Selector::Role {
619            role,
620            name,
621            modifiers,
622        } => {
623            match name {
624                Some(n) => parts.push(format!(
625                    "role={}, name={}",
626                    role.as_str(),
627                    format_pattern(n)
628                )),
629                None => parts.push(format!("role={}", role.as_str())),
630            }
631            append_modifiers(modifiers, &mut parts);
632        }
633        Selector::LocalizedText {
634            localized_text,
635            modifiers,
636        } => {
637            let entries: Vec<String> = localized_text
638                .iter()
639                .map(|(k, v)| format!("{}=\"{}\"", k, v))
640                .collect();
641            parts.push(format!("localized_text={{{}}}", entries.join(", ")));
642            append_modifiers(modifiers, &mut parts);
643        }
644        Selector::OcrText {
645            ocr_text,
646            locales,
647            modifiers,
648        } => {
649            if locales.is_empty() {
650                parts.push(format!("ocr_text=\"{}\"", ocr_text));
651            } else {
652                parts.push(format!(
653                    "ocr_text=\"{}\", locales=[{}]",
654                    ocr_text,
655                    locales.join(", ")
656                ));
657            }
658            append_modifiers(modifiers, &mut parts);
659        }
660        Selector::AnchorRelative { anchor, dx, dy } => {
661            parts.push(format!(
662                "anchored.anchor=({}), dx={}, dy={}",
663                describe_selector(anchor),
664                dx,
665                dy
666            ));
667        }
668        Selector::Point { nx, ny } => {
669            parts.push(format!("point=({}, {})", nx, ny));
670        }
671        Selector::Fallback { fallback } => {
672            let chain: Vec<String> = fallback.iter().map(describe_selector).collect();
673            parts.push(format!("fallback=[{}]", chain.join(", ")));
674        }
675    }
676    format!("{{ {} }}", parts.join(", "))
677}
678
679fn append_modifiers(m: &Modifiers, parts: &mut Vec<String>) {
680    if let Some(v) = m.near.as_deref() {
681        parts.push(format!("near=({})", describe_selector(v)));
682    }
683    if let Some(v) = m.below.as_deref() {
684        parts.push(format!("below=({})", describe_selector(v)));
685    }
686    if let Some(v) = m.above.as_deref() {
687        parts.push(format!("above=({})", describe_selector(v)));
688    }
689    if let Some(v) = m.left_of.as_deref() {
690        parts.push(format!("leftOf=({})", describe_selector(v)));
691    }
692    if let Some(v) = m.right_of.as_deref() {
693        parts.push(format!("rightOf=({})", describe_selector(v)));
694    }
695    if let Some(v) = m.inside.as_deref() {
696        parts.push(format!("inside=({})", describe_selector(v)));
697    }
698    if let Some(v) = m.ancestor.as_deref() {
699        parts.push(format!("ancestor=({})", describe_selector(v)));
700    }
701    if let Some(nth) = m.nth {
702        parts.push(format!("nth={}", nth));
703    }
704    if m.first == Some(true) {
705        parts.push("first".into());
706    }
707    if m.last == Some(true) {
708        parts.push("last".into());
709    }
710}
711
712fn format_pattern(p: &Pattern) -> String {
713    match p {
714        Pattern::Text(s) => format!("{:?}", s),
715        Pattern::Regex { regex, flags } => format!("/{}/{}", regex, flags),
716    }
717}
718
719/// Match a [`Pattern`] against the six text-bearing fields of a node
720/// (`label`, `title`, `value`, `placeholderValue`, `identifier`, `text`)
721/// — OR semantics, case-insensitive. SDK convenience wrapper that
722/// compiles the pattern on every call; for hot loops prefer
723/// [`Pattern::compile`] once + [`match_text_compiled`] per node.
724#[must_use]
725pub fn match_text(node: &A11yNode, pattern: &Pattern) -> bool {
726    let candidates: [Option<&str>; 6] = [
727        node.label.as_deref(),
728        node.title.as_deref(),
729        node.value.as_deref(),
730        node.placeholder_value.as_deref(),
731        node.identifier.as_deref(),
732        node.text.as_deref(),
733    ];
734    match pattern {
735        Pattern::Text(p) => {
736            if p.is_empty() {
737                return false;
738            }
739            for c in candidates.iter().flatten() {
740                if c.eq_ignore_ascii_case(p) {
741                    return true;
742                }
743            }
744            false
745        }
746        Pattern::Regex { regex, flags: _ } => {
747            // Always case-insensitive. The `flags` field is purely
748            // wire-shape for round-trip JSON parity; on the matching side we
749            // unconditionally prepend `(?i)` (or skip if user already
750            // wrote it) because Rust `regex::Regex::new` doesn't accept
751            // a separate flags arg — case folding goes in the pattern
752            // as the `(?i)` inline group.
753            let final_pattern = if regex.starts_with("(?i)") {
754                regex.clone()
755            } else {
756                format!("(?i){}", regex)
757            };
758            let Ok(re) = regex::Regex::new(&final_pattern) else {
759                return false;
760            };
761            for c in candidates.iter().flatten() {
762                if re.is_match(c) {
763                    return true;
764                }
765            }
766            false
767        }
768    }
769}