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}