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