Skip to main content

rmux_sdk/
locator.rs

1//! Terminal-native locators over visible pane snapshots.
2//!
3//! A locator is a retryable query against rendered terminal text. It does not
4//! model a DOM tree and does not infer hidden input fields; every match comes
5//! from the latest [`PaneSnapshot`] visible grid.
6
7use std::future::{Future, IntoFuture};
8use std::pin::Pin;
9use std::time::{Duration, Instant};
10
11use crate::{Pane, PaneSnapshot, PaneTextMatch, Result, RmuxError, WaitTimeoutError};
12
13mod query;
14
15/// State awaited by [`Locator::wait_for_state`].
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17#[non_exhaustive]
18pub enum LocatorState {
19    /// At least one visible text match exists.
20    Visible,
21    /// No visible text match exists.
22    Hidden,
23}
24
25/// Text query accepted by [`Pane::get_by_text`](crate::Pane::get_by_text).
26#[derive(Debug, Clone, PartialEq, Eq, Hash)]
27#[non_exhaustive]
28pub enum LocatorText {
29    /// Literal text searched line-by-line in the rendered snapshot.
30    Literal(String),
31    /// Regular expression searched line-by-line in the rendered snapshot.
32    #[cfg(feature = "regex")]
33    Regex(String),
34}
35
36impl From<&str> for LocatorText {
37    fn from(value: &str) -> Self {
38        Self::Literal(value.to_owned())
39    }
40}
41
42impl From<String> for LocatorText {
43    fn from(value: String) -> Self {
44        Self::Literal(value)
45    }
46}
47
48#[cfg(feature = "regex")]
49impl From<regex::Regex> for LocatorText {
50    fn from(value: regex::Regex) -> Self {
51        Self::Regex(value.as_str().to_owned())
52    }
53}
54
55/// Additional constraints applied after the base locator query.
56#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
57pub struct LocatorFilter {
58    /// Keep matches whose matched text contains this literal.
59    pub has_text: Option<String>,
60    /// Drop matches whose matched text contains this literal.
61    pub has_not_text: Option<String>,
62    /// `Some(true)` keeps visible matches.
63    ///
64    /// `Some(false)` is rejected because terminal snapshots cannot prove that
65    /// a matched string is hidden; use [`LocatorState::Hidden`] or
66    /// [`LocatorExpectation::to_be_hidden`] to wait for absence instead.
67    pub visible: Option<bool>,
68}
69
70/// One resolved terminal locator match.
71#[derive(Debug, Clone, PartialEq, Eq, Hash)]
72pub struct LocatorMatch {
73    /// Text coordinates reported by the snapshot search.
74    pub text_match: PaneTextMatch,
75}
76
77/// Terminal text locator bound to one pane.
78#[derive(Debug, Clone)]
79#[must_use = "locators do nothing unless an action, assertion, or wait is awaited"]
80pub struct Locator {
81    pane: Pane,
82    query: LocatorQuery,
83    selection: LocatorSelection,
84    filters: LocatorFilter,
85    timeout: Option<Duration>,
86    poll_interval: Duration,
87    invalid_reason: Option<String>,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Hash)]
91enum LocatorQuery {
92    Text(LocatorText),
93    Or(Box<LocatorQuery>, Box<LocatorQuery>),
94    And(Box<LocatorQuery>, Box<LocatorQuery>),
95}
96
97#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
98enum LocatorSelection {
99    #[default]
100    Strict,
101    First,
102    Last,
103    Nth(usize),
104}
105
106impl Locator {
107    pub(crate) fn get_by_text(pane: Pane, text: impl Into<LocatorText>) -> Self {
108        Self::new(pane, LocatorQuery::Text(text.into()))
109    }
110
111    pub(crate) fn parse(pane: Pane, selector: impl AsRef<str>) -> Self {
112        let selector = selector.as_ref();
113        let text = selector.strip_prefix("text=").unwrap_or(selector);
114        Self::get_by_text(pane, text)
115    }
116
117    fn new(pane: Pane, query: LocatorQuery) -> Self {
118        Self {
119            pane,
120            query,
121            selection: LocatorSelection::Strict,
122            filters: LocatorFilter::default(),
123            timeout: None,
124            poll_interval: crate::wait::TEXT_POLL_INTERVAL,
125            invalid_reason: None,
126        }
127    }
128
129    /// Selects the first current match before applying strict actions.
130    pub const fn first(mut self) -> Self {
131        self.selection = LocatorSelection::First;
132        self
133    }
134
135    /// Selects the last current match before applying strict actions.
136    pub const fn last(mut self) -> Self {
137        self.selection = LocatorSelection::Last;
138        self
139    }
140
141    /// Selects the zero-based `index` match before applying strict actions.
142    pub const fn nth(mut self, index: usize) -> Self {
143        self.selection = LocatorSelection::Nth(index);
144        self
145    }
146
147    /// Adds terminal-native text filters to this locator.
148    pub fn filter(mut self, filter: LocatorFilter) -> Self {
149        self.filters = filter;
150        self
151    }
152
153    /// Creates a locator that matches either locator's text query.
154    ///
155    /// Both locators must target the same pane. If they do not, the mismatch
156    /// is reported when the resulting locator is awaited. Composition accepts
157    /// plain locators only; apply filters, selections, and timeout overrides to
158    /// the combined locator.
159    pub fn or(self, other: Self) -> Self {
160        self.combine(other, LocatorCombiner::Or)
161    }
162
163    /// Creates a locator that keeps matches present in both text queries.
164    ///
165    /// Intersections are based on exact visible coordinates. Composition
166    /// accepts plain locators only; apply filters, selections, and timeout
167    /// overrides to the combined locator.
168    pub fn and(self, other: Self) -> Self {
169        self.combine(other, LocatorCombiner::And)
170    }
171
172    /// Overrides the timeout for waits and assertions derived from this locator.
173    pub const fn timeout(mut self, timeout: Duration) -> Self {
174        self.timeout = Some(timeout);
175        self
176    }
177
178    /// Overrides the snapshot polling interval for this locator.
179    pub const fn poll_interval(mut self, interval: Duration) -> Self {
180        self.poll_interval = interval;
181        self
182    }
183
184    /// Waits until this locator is visible.
185    pub fn wait_for(self) -> LocatorWait {
186        self.wait_for_state(LocatorState::Visible)
187    }
188
189    /// Waits until this locator reaches `state`.
190    pub fn wait_for_state(self, state: LocatorState) -> LocatorWait {
191        LocatorWait {
192            locator: self,
193            state,
194        }
195    }
196
197    /// Starts locator assertions.
198    pub fn expect(self) -> LocatorExpectation {
199        LocatorExpectation { locator: self }
200    }
201
202    pub(crate) async fn resolve(&self, snapshot: &PaneSnapshot) -> Result<Vec<LocatorMatch>> {
203        if let Some(reason) = &self.invalid_reason {
204            return Err(RmuxError::protocol(rmux_proto::RmuxError::Server(
205                reason.clone(),
206            )));
207        }
208        let mut matches = query::evaluate_query(&self.query, snapshot)?;
209        query::apply_filter(&mut matches, &self.filters)?;
210        Ok(query::apply_selection(matches, self.selection))
211    }
212
213    pub(crate) async fn resolve_strict_with_wait(&self) -> Result<(PaneSnapshot, LocatorMatch)> {
214        let timeout = self
215            .timeout
216            .or_else(|| crate::wait::resolved_wait_timeout(self.pane.configured_default_timeout()));
217        let deadline = timeout.map(|timeout| Instant::now() + timeout);
218        loop {
219            let snapshot = self.pane.snapshot().await?;
220            let matches = self.resolve(&snapshot).await?;
221            match matches.len() {
222                1 => {
223                    let item = matches
224                        .into_iter()
225                        .next()
226                        .expect("single match length guarantees one entry");
227                    return Ok((snapshot, item));
228                }
229                0 => {}
230                count => return Err(strict_locator_error(count, self.describe(), &snapshot)),
231            }
232            if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
233                return Err(RmuxError::wait_timeout(WaitTimeoutError::new(
234                    format!("strict locator {}", self.describe()),
235                    timeout.expect("deadline implies timeout"),
236                    snapshot,
237                )));
238            }
239            sleep_until_next_poll(deadline, self.poll_interval).await;
240        }
241    }
242
243    pub(crate) fn pane(&self) -> &Pane {
244        &self.pane
245    }
246
247    fn combine(self, other: Self, combiner: LocatorCombiner) -> Self {
248        let invalid_reason = if self.pane.target() != other.pane.target()
249            || self.pane.endpoint() != other.pane.endpoint()
250        {
251            Some(format!(
252                "locator combination requires the same pane endpoint and target, got {} and {}",
253                self.pane.target().to_proto(),
254                other.pane.target().to_proto()
255            ))
256        } else if let Some(reason) = self.invalid_reason.clone() {
257            Some(reason)
258        } else if let Some(reason) = other.invalid_reason.clone() {
259            Some(reason)
260        } else if !self.is_plain_combinable() || !other.is_plain_combinable() {
261            Some(format!(
262                "locator.{} only supports plain locators; apply first/last/nth, filters, timeout, or poll_interval after combining",
263                combiner.name()
264            ))
265        } else {
266            None
267        };
268        let query = match combiner {
269            LocatorCombiner::Or => LocatorQuery::Or(Box::new(self.query), Box::new(other.query)),
270            LocatorCombiner::And => LocatorQuery::And(Box::new(self.query), Box::new(other.query)),
271        };
272        Self {
273            pane: self.pane,
274            query,
275            selection: LocatorSelection::Strict,
276            filters: LocatorFilter::default(),
277            timeout: None,
278            poll_interval: crate::wait::TEXT_POLL_INTERVAL,
279            invalid_reason,
280        }
281    }
282
283    fn describe(&self) -> String {
284        query::describe_query(&self.query)
285    }
286
287    fn is_plain_combinable(&self) -> bool {
288        self.selection == LocatorSelection::Strict
289            && self.filters == LocatorFilter::default()
290            && self.timeout.is_none()
291            && self.poll_interval == crate::wait::TEXT_POLL_INTERVAL
292            && self.invalid_reason.is_none()
293    }
294}
295
296#[derive(Debug, Clone, Copy)]
297enum LocatorCombiner {
298    Or,
299    And,
300}
301
302impl LocatorCombiner {
303    const fn name(self) -> &'static str {
304        match self {
305            Self::Or => "or",
306            Self::And => "and",
307        }
308    }
309}
310
311impl Pane {
312    /// Creates a terminal-native locator for visible literal or regex text.
313    ///
314    /// The locator evaluates against rendered pane snapshots; it does not
315    /// model hidden controls or a DOM.
316    pub fn get_by_text(&self, text: impl Into<LocatorText>) -> Locator {
317        Locator::get_by_text(self.clone(), text)
318    }
319
320    /// Parses a small terminal locator selector.
321    ///
322    /// P3 supports `text=...`; other selectors are treated as literal text so
323    /// callers do not accidentally opt into a fake CSS/DOM language.
324    pub fn locator(&self, selector: impl AsRef<str>) -> Locator {
325        Locator::parse(self.clone(), selector)
326    }
327}
328
329/// Awaitable locator wait.
330#[derive(Debug)]
331#[must_use = "locator waits do nothing unless awaited"]
332pub struct LocatorWait {
333    locator: Locator,
334    state: LocatorState,
335}
336
337impl LocatorWait {
338    /// Overrides the timeout for this wait.
339    pub fn timeout(mut self, timeout: Duration) -> Self {
340        self.locator.timeout = Some(timeout);
341        self
342    }
343
344    async fn run(self) -> Result<PaneSnapshot> {
345        wait_for_locator_state(self.locator, self.state).await
346    }
347}
348
349impl IntoFuture for LocatorWait {
350    type Output = Result<PaneSnapshot>;
351    type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
352
353    fn into_future(self) -> Self::IntoFuture {
354        Box::pin(self.run())
355    }
356}
357
358/// Assertion builder for one locator.
359#[derive(Debug)]
360#[must_use = "locator assertions do nothing unless awaited"]
361pub struct LocatorExpectation {
362    locator: Locator,
363}
364
365impl LocatorExpectation {
366    /// Asserts that exactly one match is visible.
367    pub fn to_be_visible(self) -> LocatorAssertion {
368        LocatorAssertion::new(self.locator, LocatorAssertionKind::Visible)
369    }
370
371    /// Asserts that no match is visible.
372    pub fn to_be_hidden(self) -> LocatorAssertion {
373        LocatorAssertion::new(self.locator, LocatorAssertionKind::Hidden)
374    }
375
376    /// Asserts that one strict match contains `text`.
377    pub fn to_contain_text(self, text: impl Into<String>) -> LocatorAssertion {
378        LocatorAssertion::new(
379            self.locator,
380            LocatorAssertionKind::ContainsText(text.into()),
381        )
382    }
383
384    /// Asserts that one strict match has exactly `text`.
385    pub fn to_have_text(self, text: impl Into<String>) -> LocatorAssertion {
386        LocatorAssertion::new(self.locator, LocatorAssertionKind::HasText(text.into()))
387    }
388
389    /// Asserts the current match count.
390    pub fn to_have_count(self, count: usize) -> LocatorAssertion {
391        LocatorAssertion::new(self.locator, LocatorAssertionKind::Count(count))
392    }
393}
394
395/// Awaitable locator assertion.
396#[derive(Debug)]
397#[must_use = "locator assertions do nothing unless awaited"]
398pub struct LocatorAssertion {
399    locator: Locator,
400    kind: LocatorAssertionKind,
401}
402
403#[derive(Debug)]
404enum LocatorAssertionKind {
405    Visible,
406    Hidden,
407    ContainsText(String),
408    HasText(String),
409    Count(usize),
410}
411
412impl LocatorAssertion {
413    fn new(locator: Locator, kind: LocatorAssertionKind) -> Self {
414        Self { locator, kind }
415    }
416
417    /// Overrides the timeout for this assertion.
418    pub fn timeout(mut self, timeout: Duration) -> Self {
419        self.locator.timeout = Some(timeout);
420        self
421    }
422
423    async fn run(self) -> Result<PaneSnapshot> {
424        wait_for_assertion(self.locator, self.kind).await
425    }
426}
427
428impl IntoFuture for LocatorAssertion {
429    type Output = Result<PaneSnapshot>;
430    type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
431
432    fn into_future(self) -> Self::IntoFuture {
433        Box::pin(self.run())
434    }
435}
436
437async fn wait_for_locator_state(locator: Locator, state: LocatorState) -> Result<PaneSnapshot> {
438    wait_until(
439        locator,
440        move |matches, _snapshot| match state {
441            LocatorState::Visible => !matches.is_empty(),
442            LocatorState::Hidden => matches.is_empty(),
443        },
444        format!("locator to be {state:?}"),
445    )
446    .await
447}
448
449async fn wait_for_assertion(locator: Locator, kind: LocatorAssertionKind) -> Result<PaneSnapshot> {
450    let description = assertion_description(&kind);
451    let timeout = locator
452        .timeout
453        .or_else(|| crate::wait::resolved_wait_timeout(locator.pane.configured_default_timeout()));
454    let deadline = timeout.map(|timeout| Instant::now() + timeout);
455    loop {
456        let snapshot = locator.pane.snapshot().await?;
457        let matches = locator.resolve(&snapshot).await?;
458        match assertion_outcome(&matches, &kind) {
459            AssertionOutcome::Matched => return Ok(snapshot),
460            AssertionOutcome::Continue => {}
461            AssertionOutcome::StrictViolation => {
462                return Err(strict_locator_error(
463                    matches.len(),
464                    locator.describe(),
465                    &snapshot,
466                ));
467            }
468        }
469        if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
470            return Err(RmuxError::wait_timeout(WaitTimeoutError::new(
471                description,
472                timeout.expect("deadline implies timeout"),
473                snapshot,
474            )));
475        }
476        sleep_until_next_poll(deadline, locator.poll_interval).await;
477    }
478}
479
480async fn wait_until(
481    locator: Locator,
482    predicate: impl Fn(&[LocatorMatch], &PaneSnapshot) -> bool,
483    description: String,
484) -> Result<PaneSnapshot> {
485    let timeout = locator
486        .timeout
487        .or_else(|| crate::wait::resolved_wait_timeout(locator.pane.configured_default_timeout()));
488    let deadline = timeout.map(|timeout| Instant::now() + timeout);
489    loop {
490        let snapshot = locator.pane.snapshot().await?;
491        let matches = locator.resolve(&snapshot).await?;
492        if predicate(&matches, &snapshot) {
493            return Ok(snapshot);
494        }
495        if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
496            return Err(RmuxError::wait_timeout(WaitTimeoutError::new(
497                description,
498                timeout.expect("deadline implies timeout"),
499                snapshot,
500            )));
501        }
502        sleep_until_next_poll(deadline, locator.poll_interval).await;
503    }
504}
505
506#[derive(Debug, Clone, Copy, PartialEq, Eq)]
507enum AssertionOutcome {
508    Matched,
509    Continue,
510    StrictViolation,
511}
512
513fn assertion_outcome(matches: &[LocatorMatch], kind: &LocatorAssertionKind) -> AssertionOutcome {
514    match kind {
515        LocatorAssertionKind::Visible => strict_unary_outcome(matches, |_| true),
516        LocatorAssertionKind::Hidden => {
517            if matches.is_empty() {
518                AssertionOutcome::Matched
519            } else {
520                AssertionOutcome::Continue
521            }
522        }
523        LocatorAssertionKind::ContainsText(text) => {
524            strict_unary_outcome(matches, |item| item.text_match.text.contains(text))
525        }
526        LocatorAssertionKind::HasText(text) => {
527            strict_unary_outcome(matches, |item| item.text_match.text == *text)
528        }
529        LocatorAssertionKind::Count(count) => {
530            if matches.len() == *count {
531                AssertionOutcome::Matched
532            } else {
533                AssertionOutcome::Continue
534            }
535        }
536    }
537}
538
539fn strict_unary_outcome(
540    matches: &[LocatorMatch],
541    predicate: impl FnOnce(&LocatorMatch) -> bool,
542) -> AssertionOutcome {
543    match matches {
544        [] => AssertionOutcome::Continue,
545        [item] if predicate(item) => AssertionOutcome::Matched,
546        [_] => AssertionOutcome::Continue,
547        _ => AssertionOutcome::StrictViolation,
548    }
549}
550
551fn assertion_description(kind: &LocatorAssertionKind) -> String {
552    match kind {
553        LocatorAssertionKind::Visible => "locator to be visible".to_owned(),
554        LocatorAssertionKind::Hidden => "locator to be hidden".to_owned(),
555        LocatorAssertionKind::ContainsText(text) => format!("locator to contain text `{text}`"),
556        LocatorAssertionKind::HasText(text) => format!("locator to have text `{text}`"),
557        LocatorAssertionKind::Count(count) => format!("locator to have count {count}"),
558    }
559}
560
561fn strict_locator_error(count: usize, query: String, snapshot: &PaneSnapshot) -> RmuxError {
562    RmuxError::protocol(rmux_proto::RmuxError::Server(format!(
563        "strict locator violation: expected 1 match, found {count}; locator: {query}; last visible screen:\n{}",
564        snapshot.visible_text()
565    )))
566}
567
568async fn sleep_until_next_poll(deadline: Option<Instant>, poll_interval: Duration) {
569    let Some(deadline) = deadline else {
570        tokio::time::sleep(poll_interval).await;
571        return;
572    };
573    let now = Instant::now();
574    if now < deadline {
575        tokio::time::sleep(poll_interval.min(deadline - now)).await;
576    }
577}