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}