Skip to main content

tui_test/terminal/
locator.rs

1//! Text/regex search over the terminal grid. Maps a flat match range back to
2//! grid cells.
3
4use regex::Regex;
5
6use super::cell::EmuCell;
7
8pub enum Pattern {
9    Text(String),
10    Regex(Regex),
11}
12
13impl Pattern {
14    pub fn new(text: &str, is_regex: bool) -> anyhow::Result<Self> {
15        if is_regex {
16            Ok(Pattern::Regex(Regex::new(text)?))
17        } else {
18            Ok(Pattern::Text(text.to_string()))
19        }
20    }
21
22    pub fn describe(&self) -> String {
23        match self {
24            Pattern::Text(t) => t.clone(),
25            Pattern::Regex(r) => r.as_str().to_string(),
26        }
27    }
28
29    /// Whether the pattern matches somewhere in `haystack`.
30    ///
31    /// For matching against the grid use [`find`], which maps the hit back to
32    /// cells. This is for the plain strings the terminal reports alongside the
33    /// grid, such as the window title.
34    pub fn matches(&self, haystack: &str) -> bool {
35        match self {
36            Pattern::Text(t) => haystack.contains(t.as_str()),
37            Pattern::Regex(r) => r.is_match(haystack),
38        }
39    }
40}
41
42#[derive(Debug, Clone)]
43pub struct MatchedCell {
44    pub x: usize,
45    pub y: usize,
46    pub cell: EmuCell,
47}
48
49/// Find the first match of `pattern` in the grid. Returns `Ok(None)` when there
50/// is no match, and `Err` on a strict-mode violation (multiple matches).
51pub fn find(
52    rows: &[Vec<EmuCell>],
53    pattern: &Pattern,
54    strict: bool,
55) -> anyhow::Result<Option<Vec<MatchedCell>>> {
56    if rows.is_empty() {
57        return Ok(None);
58    }
59    let width = rows.iter().map(|r| r.len()).max().unwrap_or(0);
60    // One char per *column*, so match offsets map straight back to (x, y).
61    // A continuation cell holds no grapheme but still occupies its column, so
62    // it gets a filler here rather than being skipped as in `rows_to_strings`.
63    let chars: Vec<char> = rows
64        .iter()
65        .flat_map(|row| {
66            (0..width).map(move |x| row.get(x).and_then(|c| c.ch.chars().next()).unwrap_or(' '))
67        })
68        .collect();
69
70    let (index, length) = match pattern {
71        Pattern::Text(text) => {
72            let needle: Vec<char> = text.chars().collect();
73            if needle.is_empty() {
74                return Ok(None);
75            }
76            let occurrences = count_occurrences(&chars, &needle);
77            if occurrences == 0 {
78                return Ok(None);
79            }
80            if occurrences > 1 && strict {
81                anyhow::bail!(
82                    "strict mode expected one match for '{}', but found {}",
83                    text,
84                    occurrences
85                );
86            }
87            let first = first_occurrence(&chars, &needle).unwrap();
88            (first, needle.len())
89        }
90        Pattern::Regex(re) => {
91            let block: String = chars.iter().collect();
92            let matches: Vec<_> = re.find_iter(&block).collect();
93            if matches.is_empty() {
94                return Ok(None);
95            }
96            if matches.len() > 1 && strict {
97                anyhow::bail!(
98                    "strict mode expected one match for '{}', but found {}",
99                    re.as_str(),
100                    matches.len()
101                );
102            }
103            let m = &matches[0];
104            let start = block[..m.start()].chars().count();
105            let len = m.as_str().chars().count();
106            (start, len)
107        }
108    };
109
110    let mut cells = Vec::with_capacity(length);
111    for (y, row) in rows.iter().enumerate() {
112        for x in 0..width {
113            let pos = x + y * width;
114            if pos >= index && pos < index + length {
115                if let Some(cell) = row.get(x) {
116                    cells.push(MatchedCell {
117                        x,
118                        y,
119                        cell: cell.clone(),
120                    });
121                }
122            }
123        }
124    }
125    Ok(Some(cells))
126}
127
128fn count_occurrences(haystack: &[char], needle: &[char]) -> usize {
129    if needle.is_empty() || haystack.len() < needle.len() {
130        return 0;
131    }
132    let mut count = 0;
133    let mut i = 0;
134    while i + needle.len() <= haystack.len() {
135        if haystack[i..i + needle.len()] == *needle {
136            count += 1;
137            i += needle.len();
138        } else {
139            i += 1;
140        }
141    }
142    count
143}
144
145fn first_occurrence(haystack: &[char], needle: &[char]) -> Option<usize> {
146    if needle.is_empty() || haystack.len() < needle.len() {
147        return None;
148    }
149    (0..=haystack.len() - needle.len()).find(|&i| haystack[i..i + needle.len()] == *needle)
150}