From 3b168fc9df282d0de8aa4c170852b7e1f3357a8a Mon Sep 17 00:00:00 2001 From: cpendery Date: Sat, 15 Aug 2026 20:21:52 -0700 Subject: [PATCH] feat: add text locators Signed-off-by: cpendery --- crates/tui-test-cli/src/protocol.rs | 1 + crates/tui-test/src/api.rs | 82 +++++ crates/tui-test/src/engine.rs | 34 +- crates/tui-test/src/terminal/locator.rs | 394 ++++++++++++++++++------ 4 files changed, 424 insertions(+), 87 deletions(-) diff --git a/crates/tui-test-cli/src/protocol.rs b/crates/tui-test-cli/src/protocol.rs index 4bac1daf..9839c810 100644 --- a/crates/tui-test-cli/src/protocol.rs +++ b/crates/tui-test-cli/src/protocol.rs @@ -370,6 +370,7 @@ fn operation_data(result: OperationResult) -> Result, "text": String::from_utf8_lossy(&screen.utf8), })), OperationResult::Cells(cells) => Ok(json!({ "cells": cells })), + OperationResult::Matches(matches) => Ok(json!({ "matches": matches })), OperationResult::Command(value) => Ok(json!({ "value": value })), OperationResult::Output(value) => Ok(json!({ "value": value })), OperationResult::ExitCode(value) => Ok(json!({ "value": value })), diff --git a/crates/tui-test/src/api.rs b/crates/tui-test/src/api.rs index ba295888..fdea49e4 100644 --- a/crates/tui-test/src/api.rs +++ b/crates/tui-test/src/api.rs @@ -75,6 +75,61 @@ pub struct RunOptions { pub timeouts: Timeouts, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WhitespaceMode { + #[default] + Exact, + Normalize, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MatchOccurrence { + Any, + #[default] + Unique, + First, + Last, + Nth(usize), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TextAnchor { + pub text: String, + #[serde(default)] + pub regex: bool, + #[serde(default)] + pub occurrence: MatchOccurrence, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TextScope { + pub after: Option, + pub before: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TextSelector { + pub text: String, + pub regex: bool, + pub full: bool, + pub whitespace: WhitespaceMode, + pub scope: TextScope, + pub occurrence: MatchOccurrence, +} + +impl TextSelector { + pub fn new(text: impl Into) -> Self { + Self { + text: text.into(), + ..Self::default() + } + } +} + #[derive(Debug, Clone)] pub enum Operation { Open(OpenOptions), @@ -144,6 +199,9 @@ pub enum Operation { WaitReady { timeout_ms: Option, }, + FindText { + selector: TextSelector, + }, ExpectText { text: String, regex: bool, @@ -189,6 +247,7 @@ pub enum OperationResult { Text(String), PackedScreen(PackedScreen), Cells(Vec), + Matches(Vec), Command(Option), Output(Option), ExitCode(Option), @@ -291,6 +350,29 @@ pub struct Size { pub rows: u16, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct TextPosition { + pub row: u16, + pub column: u16, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct TextSpan { + pub row: u16, + pub start: u16, + pub end: u16, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TextMatch { + pub text: String, + pub start: TextPosition, + /// Exclusive end position. + pub end: TextPosition, + /// Per-row column ranges with exclusive ends. + pub spans: Vec, +} + #[derive(Debug, Clone, Copy, Serialize)] pub struct EffectiveTimeouts { pub text: u64, diff --git a/crates/tui-test/src/engine.rs b/crates/tui-test/src/engine.rs index e327359e..a9156324 100644 --- a/crates/tui-test/src/engine.rs +++ b/crates/tui-test/src/engine.rs @@ -7,7 +7,7 @@ use std::time::{Duration, Instant}; use crate::api::{ Cell, CellColor, Cursor, EffectiveTimeouts, ErrorKind, OpenOptions, OpenResult, Operation, OperationResult, PackedScreen, RunOptions, RuntimeStatus, ScreenshotResult, Size, - SnapshotResult, TuiTestError, + SnapshotResult, TextAnchor, TextMatch, TextSelector, TuiTestError, }; use crate::assert::color::{self, Expected}; use crate::assert::snapshot::{self, SnapshotStatus}; @@ -610,6 +610,9 @@ fn dispatch( )?; Ok(OperationResult::Unit) } + Operation::FindText { selector } => { + Ok(OperationResult::Matches(find_text(session, &selector)?)) + } Operation::ExpectText { text, regex, @@ -1224,6 +1227,35 @@ fn expect_text( } } +fn find_text( + session: &TerminalSession, + selector: &TextSelector, +) -> Result, TuiTestError> { + validate_selector(selector)?; + locator::locate(&grid(session, selector.full), selector) + .map(|matches| matches.into_iter().map(|matched| matched.value).collect()) + .map_err(|error| TuiTestError::assertion(error.to_string())) +} + +fn validate_selector(selector: &TextSelector) -> Result<(), TuiTestError> { + let validate = |text: &str, regex: bool| { + Pattern::new(text, regex) + .map(|_| ()) + .map_err(|error| TuiTestError::usage(format!("invalid regex: {error}"))) + }; + validate(&selector.text, selector.regex)?; + for TextAnchor { text, regex, .. } in [ + selector.scope.after.as_ref(), + selector.scope.before.as_ref(), + ] + .into_iter() + .flatten() + { + validate(text, *regex)?; + } + Ok(()) +} + fn check_colors( cells: &[locator::MatchedCell], fg: &Option, diff --git a/crates/tui-test/src/terminal/locator.rs b/crates/tui-test/src/terminal/locator.rs index b725c726..1727ac82 100644 --- a/crates/tui-test/src/terminal/locator.rs +++ b/crates/tui-test/src/terminal/locator.rs @@ -1,8 +1,12 @@ -//! Text/regex search over the terminal grid. Maps a flat match range back to -//! grid cells. +//! Text/regex search over the terminal grid, including scoped and normalized +//! selectors. Match offsets are mapped back to terminal cells. use regex::Regex; +use crate::api::{ + MatchOccurrence, TextAnchor, TextMatch, TextPosition, TextSelector, TextSpan, WhitespaceMode, +}; + use super::cell::EmuCell; pub enum Pattern { @@ -21,20 +25,35 @@ impl Pattern { pub fn describe(&self) -> String { match self { - Pattern::Text(t) => t.clone(), - Pattern::Regex(r) => r.as_str().to_string(), + Pattern::Text(text) => text.clone(), + Pattern::Regex(regex) => regex.as_str().to_string(), } } - /// Whether the pattern matches somewhere in `haystack`. - /// - /// For matching against the grid use [`find`], which maps the hit back to - /// cells. This is for the plain strings the terminal reports alongside the - /// grid, such as the window title. pub fn matches(&self, haystack: &str) -> bool { match self { - Pattern::Text(t) => haystack.contains(t.as_str()), - Pattern::Regex(r) => r.is_match(haystack), + Pattern::Text(text) => haystack.contains(text.as_str()), + Pattern::Regex(regex) => regex.is_match(haystack), + } + } + + fn ranges(&self, chars: &[char]) -> Vec<(usize, usize)> { + match self { + Pattern::Text(text) => { + let needle: Vec = text.chars().collect(); + text_ranges(chars, &needle) + } + Pattern::Regex(regex) => { + let block: String = chars.iter().collect(); + regex + .find_iter(&block) + .filter(|matched| !matched.is_empty()) + .map(|matched| { + let start = block[..matched.start()].chars().count(); + (start, start + matched.as_str().chars().count()) + }) + .collect() + } } } } @@ -46,8 +65,41 @@ pub struct MatchedCell { pub cell: EmuCell, } -/// Find the first match of `pattern` in the grid. Returns `Ok(None)` when there -/// is no match, and `Err` on a strict-mode violation (multiple matches). +#[derive(Debug, Clone)] +pub struct LocatedMatch { + pub value: TextMatch, + pub cells: Vec, +} + +struct FlatGrid { + chars: Vec, + sources: Vec, + width: usize, +} + +/// Locate the matches selected by `selector`. +pub fn locate(rows: &[Vec], selector: &TextSelector) -> anyhow::Result> { + if rows.is_empty() { + return Ok(Vec::new()); + } + let flat = flatten(rows, selector.whitespace); + let Some((start, end)) = scope(&flat, selector)? else { + return Ok(Vec::new()); + }; + let pattern = selector_pattern(&selector.text, selector.regex, selector.whitespace)?; + let ranges: Vec<_> = pattern + .ranges(&flat.chars) + .into_iter() + .filter(|(match_start, match_end)| *match_start >= start && *match_end <= end) + .collect(); + let selected = select(ranges, &selector.occurrence, &pattern.describe())?; + Ok(selected + .into_iter() + .filter_map(|range| materialize(rows, &flat, range)) + .collect()) +} + +/// Compatibility helper for the simple text waits and mouse text lookup. pub fn find( rows: &[Vec], pattern: &Pattern, @@ -56,95 +108,265 @@ pub fn find( if rows.is_empty() { return Ok(None); } - let width = rows.iter().map(|r| r.len()).max().unwrap_or(0); - // One char per *column*, so match offsets map straight back to (x, y). - // A continuation cell holds no grapheme but still occupies its column, so - // it gets a filler here rather than being skipped as in `rows_to_strings`. - let chars: Vec = rows - .iter() - .flat_map(|row| { - (0..width).map(move |x| row.get(x).and_then(|c| c.ch.chars().next()).unwrap_or(' ')) - }) - .collect(); + let flat = flatten(rows, WhitespaceMode::Exact); + let occurrence = if strict { + MatchOccurrence::Unique + } else { + MatchOccurrence::First + }; + let selected = select( + pattern.ranges(&flat.chars), + &occurrence, + &pattern.describe(), + )?; + Ok(selected + .into_iter() + .next() + .and_then(|range| materialize(rows, &flat, range)) + .map(|matched| matched.cells)) +} - let (index, length) = match pattern { - Pattern::Text(text) => { - let needle: Vec = text.chars().collect(); - if needle.is_empty() { - return Ok(None); - } - let occurrences = count_occurrences(&chars, &needle); - if occurrences == 0 { - return Ok(None); - } - if occurrences > 1 && strict { - anyhow::bail!( - "strict mode expected one match for '{}', but found {}", - text, - occurrences - ); +fn selector_pattern( + text: &str, + regex: bool, + whitespace: WhitespaceMode, +) -> anyhow::Result { + let text = if !regex && whitespace == WhitespaceMode::Normalize { + normalize(text) + } else { + text.to_string() + }; + Pattern::new(&text, regex) +} + +fn flatten(rows: &[Vec], whitespace: WhitespaceMode) -> FlatGrid { + let width = rows.iter().map(Vec::len).max().unwrap_or(0); + let source = rows.iter().enumerate().flat_map(|(y, row)| { + (0..width).map(move |x| { + ( + x + y * width, + row.get(x) + .and_then(|cell| cell.ch.chars().next()) + .unwrap_or(' '), + ) + }) + }); + let mut chars = Vec::new(); + let mut sources = Vec::new(); + let mut pending_space = None; + for (position, ch) in source { + if whitespace == WhitespaceMode::Normalize && ch.is_whitespace() { + if !chars.is_empty() && pending_space.is_none() { + pending_space = Some(position); } - let first = first_occurrence(&chars, &needle).unwrap(); - (first, needle.len()) + continue; } - Pattern::Regex(re) => { - let block: String = chars.iter().collect(); - let matches: Vec<_> = re.find_iter(&block).collect(); - if matches.is_empty() { - return Ok(None); - } - if matches.len() > 1 && strict { - anyhow::bail!( - "strict mode expected one match for '{}', but found {}", - re.as_str(), - matches.len() - ); - } - let m = &matches[0]; - let start = block[..m.start()].chars().count(); - let len = m.as_str().chars().count(); - (start, len) + if let Some(position) = pending_space.take() { + chars.push(' '); + sources.push(position); } + chars.push(ch); + sources.push(position); + } + FlatGrid { + chars, + sources, + width, + } +} + +fn normalize(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + +fn scope(flat: &FlatGrid, selector: &TextSelector) -> anyhow::Result> { + let start = match &selector.scope.after { + Some(anchor) => match anchor_range(flat, anchor, selector.whitespace, "after")? { + Some((_, end)) => end, + None => return Ok(None), + }, + None => 0, + }; + let end = match &selector.scope.before { + Some(anchor) => match anchor_range(flat, anchor, selector.whitespace, "before")? { + Some((start, _)) => start, + None => return Ok(None), + }, + None => flat.chars.len(), }; + Ok((start <= end).then_some((start, end))) +} + +fn anchor_range( + flat: &FlatGrid, + anchor: &TextAnchor, + whitespace: WhitespaceMode, + name: &str, +) -> anyhow::Result> { + let pattern = selector_pattern(&anchor.text, anchor.regex, whitespace)?; + let ranges = select( + pattern.ranges(&flat.chars), + &anchor.occurrence, + &format!("{name} anchor '{}'", pattern.describe()), + )?; + if ranges.len() > 1 { + anyhow::bail!("{name} anchor must select one match"); + } + Ok(ranges.into_iter().next()) +} + +fn select( + ranges: Vec<(usize, usize)>, + occurrence: &MatchOccurrence, + description: &str, +) -> anyhow::Result> { + let count = ranges.len(); + match occurrence { + MatchOccurrence::Any => Ok(ranges), + MatchOccurrence::Unique if count > 1 => anyhow::bail!( + "unique match expected one occurrence of '{description}', but found {count}" + ), + MatchOccurrence::Unique | MatchOccurrence::First => { + Ok(ranges.into_iter().next().into_iter().collect()) + } + MatchOccurrence::Last => Ok(ranges.into_iter().last().into_iter().collect()), + MatchOccurrence::Nth(index) => Ok(ranges.into_iter().nth(*index).into_iter().collect()), + } +} - let mut cells = Vec::with_capacity(length); - for (y, row) in rows.iter().enumerate() { - for x in 0..width { - let pos = x + y * width; - if pos >= index && pos < index + length { - if let Some(cell) = row.get(x) { - cells.push(MatchedCell { - x, - y, - cell: cell.clone(), - }); - } +fn materialize( + rows: &[Vec], + flat: &FlatGrid, + (start, end): (usize, usize), +) -> Option { + if start >= end { + return None; + } + let source_start = *flat.sources.get(start)?; + let source_end = flat.sources.get(end - 1)?.saturating_add(1); + let mut cells = Vec::new(); + for position in source_start..source_end { + let y = position / flat.width; + let x = position % flat.width; + if let Some(cell) = rows.get(y).and_then(|row| row.get(x)) { + cells.push(MatchedCell { + x, + y, + cell: cell.clone(), + }); + } + } + let first = cells.first()?; + let last = cells.last()?; + let mut spans = Vec::new(); + for cell in &cells { + match spans.last_mut() { + Some(TextSpan { row, end, .. }) + if *row as usize == cell.y && *end as usize == cell.x => + { + *end = end.saturating_add(1); } + _ => spans.push(TextSpan { + row: cell.y.min(u16::MAX as usize) as u16, + start: cell.x.min(u16::MAX as usize) as u16, + end: cell.x.saturating_add(1).min(u16::MAX as usize) as u16, + }), } } - Ok(Some(cells)) + Some(LocatedMatch { + value: TextMatch { + text: flat.chars[start..end].iter().collect(), + start: TextPosition { + row: first.y.min(u16::MAX as usize) as u16, + column: first.x.min(u16::MAX as usize) as u16, + }, + end: TextPosition { + row: last.y.min(u16::MAX as usize) as u16, + column: last.x.saturating_add(1).min(u16::MAX as usize) as u16, + }, + spans, + }, + cells, + }) } -fn count_occurrences(haystack: &[char], needle: &[char]) -> usize { +fn text_ranges(haystack: &[char], needle: &[char]) -> Vec<(usize, usize)> { if needle.is_empty() || haystack.len() < needle.len() { - return 0; - } - let mut count = 0; - let mut i = 0; - while i + needle.len() <= haystack.len() { - if haystack[i..i + needle.len()] == *needle { - count += 1; - i += needle.len(); + return Vec::new(); + } + let mut ranges = Vec::new(); + let mut index = 0; + while index + needle.len() <= haystack.len() { + if haystack[index..index + needle.len()] == *needle { + ranges.push((index, index + needle.len())); + index += needle.len(); } else { - i += 1; + index += 1; } } - count + ranges } -fn first_occurrence(haystack: &[char], needle: &[char]) -> Option { - if needle.is_empty() || haystack.len() < needle.len() { - return None; +#[cfg(test)] +mod tests { + use super::*; + use crate::api::{TextScope, WhitespaceMode}; + + fn grid(lines: &[&str]) -> Vec> { + lines + .iter() + .map(|line| { + line.chars() + .map(|ch| EmuCell { + ch: ch.to_string().into(), + ..EmuCell::blank() + }) + .collect() + }) + .collect() + } + + #[test] + fn normalizes_whitespace_and_preserves_locations() { + let mut selector = TextSelector::new("hello world"); + selector.whitespace = WhitespaceMode::Normalize; + let found = locate(&grid(&[" hello", " world "]), &selector).unwrap(); + assert_eq!(found[0].value.text, "hello world"); + assert_eq!(found[0].value.start, TextPosition { row: 0, column: 2 }); + assert_eq!(found[0].value.end, TextPosition { row: 1, column: 9 }); + } + + #[test] + fn scopes_a_match_after_an_anchor() { + let mut selector = TextSelector::new("Save"); + selector.occurrence = MatchOccurrence::First; + selector.scope = TextScope { + after: Some(TextAnchor { + text: "Settings".into(), + regex: false, + occurrence: MatchOccurrence::Unique, + }), + before: None, + }; + let found = locate(&grid(&["Save", "Settings", "Save"]), &selector).unwrap(); + assert_eq!(found[0].value.start, TextPosition { row: 2, column: 0 }); + } + + #[test] + fn selects_any_last_and_nth_occurrences() { + let rows = grid(&["item item item"]); + let mut selector = TextSelector::new("item"); + selector.occurrence = MatchOccurrence::Any; + assert_eq!(locate(&rows, &selector).unwrap().len(), 3); + selector.occurrence = MatchOccurrence::Last; + assert_eq!(locate(&rows, &selector).unwrap()[0].value.start.column, 10); + selector.occurrence = MatchOccurrence::Nth(1); + assert_eq!(locate(&rows, &selector).unwrap()[0].value.start.column, 5); + } + + #[test] + fn unique_reports_ambiguous_text() { + let error = locate(&grid(&["same same"]), &TextSelector::new("same")).unwrap_err(); + assert!(error.to_string().contains("found 2")); } - (0..=haystack.len() - needle.len()).find(|&i| haystack[i..i + needle.len()] == *needle) }