1use crate::{Pane, PaneExitState, PaneOutputChunk, PaneOutputStart, PaneSnapshot, Result};
9
10const COLLECT_OUTPUT_UNTIL_EXIT_OPERATION: &str = "collect pane output until exit";
11
12#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
14pub struct CollectedPaneOutput {
15 pub bytes: Vec<u8>,
20 pub exit_state: Option<PaneExitState>,
25 pub truncated: bool,
28 pub lagged: bool,
30 pub missed_events: u64,
32}
33
34impl CollectedPaneOutput {
35 #[must_use]
37 pub fn len(&self) -> usize {
38 self.bytes.len()
39 }
40
41 #[must_use]
43 pub fn is_empty(&self) -> bool {
44 self.bytes.is_empty()
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Hash)]
50pub struct PaneTextMatch {
51 pub start_row: u16,
53 pub start_col: u16,
55 pub end_row: u16,
57 pub end_col: u16,
59 pub text: String,
61}
62
63impl PaneTextMatch {
64 fn new(row: u16, start_col: u16, end_col: u16, text: String) -> Self {
65 Self {
66 start_row: row,
67 start_col,
68 end_row: row,
69 end_col,
70 text,
71 }
72 }
73}
74
75impl PaneSnapshot {
76 #[must_use]
85 pub fn find_text(&self, needle: impl AsRef<str>) -> Option<PaneTextMatch> {
86 self.find_text_all(needle).into_iter().next()
87 }
88
89 #[must_use]
94 pub fn find_text_all(&self, needle: impl AsRef<str>) -> Vec<PaneTextMatch> {
95 find_text_in_snapshot(self, needle.as_ref())
96 }
97}
98
99pub(crate) async fn find_text(pane: &Pane, needle: String) -> Result<Option<PaneTextMatch>> {
100 Ok(pane.snapshot().await?.find_text(needle))
101}
102
103pub(crate) async fn find_text_all(pane: &Pane, needle: String) -> Result<Vec<PaneTextMatch>> {
104 Ok(pane.snapshot().await?.find_text_all(needle))
105}
106
107pub(crate) async fn collect_output_until_exit(
108 pane: &Pane,
109 max_bytes: usize,
110) -> Result<CollectedPaneOutput> {
111 collect_output_until_exit_starting_at(pane, PaneOutputStart::Now, max_bytes).await
112}
113
114pub(crate) async fn collect_output_until_exit_starting_at(
115 pane: &Pane,
116 start: PaneOutputStart,
117 max_bytes: usize,
118) -> Result<CollectedPaneOutput> {
119 let timeout = crate::wait::resolved_wait_timeout(pane.configured_default_timeout());
120 crate::wait::with_wait_timeout(
121 COLLECT_OUTPUT_UNTIL_EXIT_OPERATION,
122 timeout,
123 collect_output_until_exit_without_timeout(pane, start, max_bytes),
124 )
125 .await
126}
127
128async fn collect_output_until_exit_without_timeout(
129 pane: &Pane,
130 start: PaneOutputStart,
131 max_bytes: usize,
132) -> Result<CollectedPaneOutput> {
133 let mut collection = CollectedPaneOutput::default();
134 let mut stream = match pane.output_stream_starting_at(start).await {
135 Ok(stream) => stream,
136 Err(error) if crate::handles::is_already_closed_pane_error(&error, pane.target()) => {
137 collection.exit_state = exit_state_after_stream_close(pane).await?;
138 return Ok(collection);
139 }
140 Err(error) => return Err(error),
141 };
142
143 if let crate::wait::PaneExitObservation::Exited(exit_state) =
144 crate::wait::pane_exit_observation(pane).await?
145 {
146 drain_until_output_eof_or_close(&mut stream, &mut collection, max_bytes).await?;
147 collection.exit_state = exit_state;
148 return Ok(collection);
149 }
150
151 loop {
152 let saw_ready_output =
153 poll_ready_output_once(&mut stream, &mut collection, max_bytes).await?;
154 if saw_ready_output.saw_eof {
155 collection.exit_state = exit_state_after_stream_close(pane).await?;
156 return Ok(collection);
157 }
158 if let crate::wait::PaneExitObservation::Exited(exit_state) =
159 crate::wait::pane_exit_observation(pane).await?
160 {
161 drain_until_output_eof_or_close(&mut stream, &mut collection, max_bytes).await?;
162 collection.exit_state = exit_state;
163 return Ok(collection);
164 }
165 if !saw_ready_output.saw_output {
166 tokio::time::sleep(crate::wait::TEXT_POLL_INTERVAL).await;
167 }
168 }
169}
170
171async fn drain_until_output_eof_or_close(
172 stream: &mut crate::PaneOutputStream,
173 collection: &mut CollectedPaneOutput,
174 max_bytes: usize,
175) -> Result<()> {
176 loop {
177 match stream.next().await? {
178 Some(chunk) => {
179 if ingest_chunk(collection, chunk, max_bytes) {
180 return Ok(());
181 }
182 }
183 None => return Ok(()),
184 }
185 }
186}
187
188#[derive(Debug, Clone, Copy)]
189struct ReadyOutputPoll {
190 saw_output: bool,
191 saw_eof: bool,
192}
193
194async fn poll_ready_output_once(
195 stream: &mut crate::PaneOutputStream,
196 collection: &mut CollectedPaneOutput,
197 max_bytes: usize,
198) -> Result<ReadyOutputPoll> {
199 let chunks = stream.poll_once().await?;
200 let saw_ready_output = !chunks.is_empty();
201 let mut saw_eof = false;
202 for chunk in chunks {
203 saw_eof |= ingest_chunk(collection, chunk, max_bytes);
204 }
205 Ok(ReadyOutputPoll {
206 saw_output: saw_ready_output,
207 saw_eof,
208 })
209}
210
211fn ingest_chunk(
212 collection: &mut CollectedPaneOutput,
213 chunk: PaneOutputChunk,
214 max_bytes: usize,
215) -> bool {
216 match chunk {
217 PaneOutputChunk::Bytes { bytes, .. } => {
218 if bytes.is_empty() {
219 return true;
220 }
221 collection.truncated |= extend_capped(&mut collection.bytes, &bytes, max_bytes);
222 false
223 }
224 PaneOutputChunk::Lag(notice) => {
225 collection.lagged = true;
226 collection.missed_events = collection
227 .missed_events
228 .saturating_add(notice.missed_events);
229 false
230 }
231 }
232}
233
234async fn exit_state_after_stream_close(pane: &Pane) -> Result<Option<PaneExitState>> {
235 loop {
236 match crate::wait::pane_exit_observation(pane).await? {
237 crate::wait::PaneExitObservation::Running => {
238 tokio::time::sleep(crate::wait::TEXT_POLL_INTERVAL).await;
239 }
240 crate::wait::PaneExitObservation::Exited(exit_state) => return Ok(exit_state),
241 }
242 }
243}
244
245fn extend_capped(target: &mut Vec<u8>, bytes: &[u8], max_bytes: usize) -> bool {
246 let remaining = max_bytes.saturating_sub(target.len());
247 if remaining >= bytes.len() {
248 target.extend_from_slice(bytes);
249 false
250 } else {
251 target.extend_from_slice(&bytes[..remaining]);
252 true
253 }
254}
255
256fn find_text_in_snapshot(snapshot: &PaneSnapshot, needle: &str) -> Vec<PaneTextMatch> {
257 if needle.is_empty() {
258 return Vec::new();
259 }
260
261 let lines = snapshot.visible_lines();
262 let mut matches = Vec::new();
263 for (row, line) in lines.iter().enumerate() {
264 let row = row as u16;
265 for (start, end) in literal_match_ranges(line, needle) {
266 if let Some(text_match) =
267 text_match_for_rendered_row_range(snapshot, row, line, start, end)
268 {
269 matches.push(text_match);
270 }
271 }
272 }
273 matches
274}
275
276pub(crate) fn text_match_for_rendered_row_range(
277 snapshot: &PaneSnapshot,
278 row: u16,
279 line: &str,
280 start: usize,
281 end: usize,
282) -> Option<PaneTextMatch> {
283 let coords = rendered_row_byte_coords(snapshot, row, line);
284 let start_coord = coords.get(start)?;
285 let end_coord = end.checked_sub(1).and_then(|index| coords.get(index))?;
286 Some(PaneTextMatch::new(
287 row,
288 start_coord.start_col,
289 end_coord.end_col,
290 line.get(start..end)?.to_owned(),
291 ))
292}
293
294#[derive(Debug, Clone, Copy)]
295struct ByteCoord {
296 start_col: u16,
297 end_col: u16,
298}
299
300fn rendered_row_byte_coords(snapshot: &PaneSnapshot, row: u16, line: &str) -> Vec<ByteCoord> {
301 let mut coords = Vec::new();
302 for col in 0..snapshot.cols {
303 let Some(cell) = snapshot.cell(row, col) else {
304 break;
305 };
306 if cell.is_padding() {
307 continue;
308 }
309
310 let Some(owner_col) = snapshot.owning_cell_col(row, col) else {
311 continue;
312 };
313 let end_col = owner_col
314 .saturating_add(u16::from(cell.glyph.width.max(1)))
315 .min(snapshot.cols);
316 coords.extend(cell.text().bytes().map(|_| ByteCoord {
317 start_col: owner_col,
318 end_col,
319 }));
320 }
321 coords.truncate(line.len());
322 coords
323}
324
325fn literal_match_ranges(haystack: &str, needle: &str) -> Vec<(usize, usize)> {
326 let mut ranges = Vec::new();
327 let mut search_start = 0;
328 while search_start <= haystack.len() {
329 let Some(relative) = haystack[search_start..].find(needle) else {
330 break;
331 };
332 let start = search_start + relative;
333 let end = start + needle.len();
334 ranges.push((start, end));
335 search_start = next_char_boundary_after(haystack, start);
336 }
337 ranges
338}
339
340fn next_char_boundary_after(value: &str, index: usize) -> usize {
341 value[index..]
342 .chars()
343 .next()
344 .map_or(value.len() + 1, |character| index + character.len_utf8())
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350 use crate::{PaneCell, PaneCursor, PaneGlyph};
351
352 fn cell(text: &str) -> PaneCell {
353 PaneCell::new(PaneGlyph::new(text, 1))
354 }
355
356 fn wide(text: &str, width: u8) -> PaneCell {
357 PaneCell::new(PaneGlyph::new(text, width))
358 }
359
360 #[test]
361 fn capped_extend_never_exceeds_limit() {
362 let mut bytes = b"abc".to_vec();
363 assert!(extend_capped(&mut bytes, b"def", 5));
364 assert_eq!(bytes, b"abcde");
365 assert!(extend_capped(&mut bytes, b"g", 5));
366 assert_eq!(bytes, b"abcde");
367 }
368
369 #[test]
370 fn capped_extend_handles_zero_limit() {
371 let mut bytes = Vec::new();
372 assert!(extend_capped(&mut bytes, b"abc", 0));
373 assert!(bytes.is_empty());
374 }
375
376 #[test]
377 fn literal_ranges_include_overlapping_matches() {
378 assert_eq!(
379 literal_match_ranges("aaaa", "aa"),
380 vec![(0, 2), (1, 3), (2, 4)]
381 );
382 }
383
384 #[test]
385 fn find_text_uses_visible_lines_and_wide_cell_owner_columns() {
386 let snapshot = PaneSnapshot::new(
387 6,
388 1,
389 vec![
390 cell("A"),
391 wide("界", 2),
392 PaneCell::padding(),
393 cell("B"),
394 cell(" "),
395 cell(" "),
396 ],
397 PaneCursor::default(),
398 )
399 .expect("valid snapshot");
400
401 let matches = snapshot.find_text_all("界B");
402 assert_eq!(matches.len(), 1);
403 assert_eq!(matches[0].start_row, 0);
404 assert_eq!(matches[0].start_col, 1);
405 assert_eq!(matches[0].end_col, 4);
406 assert_eq!(matches[0].text, "界B");
407 }
408
409 #[test]
410 fn find_text_clamps_malformed_wide_match_end_to_visible_width() {
411 let snapshot = PaneSnapshot::new(
412 3,
413 1,
414 vec![cell("A"), wide("界", 4), PaneCell::padding()],
415 PaneCursor::default(),
416 )
417 .expect("valid snapshot");
418
419 let text_match = snapshot.find_text("界").expect("wide match found");
420 assert_eq!(text_match.start_col, 1);
421 assert_eq!(text_match.end_col, 3);
422 }
423
424 #[test]
425 fn find_text_returns_none_for_empty_needle() {
426 let snapshot = PaneSnapshot::new(
427 3,
428 1,
429 vec![cell("a"), cell("b"), cell("c")],
430 PaneCursor::default(),
431 )
432 .expect("valid snapshot");
433
434 assert!(snapshot.find_text("").is_none());
435 assert!(snapshot.find_text_all("").is_empty());
436 }
437
438 #[test]
439 fn find_text_returns_none_on_default_snapshot() {
440 let snapshot = PaneSnapshot::default();
441 assert!(snapshot.find_text("anything").is_none());
442 assert!(snapshot.find_text_all("anything").is_empty());
443 }
444
445 #[test]
446 fn find_text_returns_none_when_needle_exceeds_visible_text() {
447 let snapshot = PaneSnapshot::new(2, 1, vec![cell("a"), cell("b")], PaneCursor::default())
448 .expect("valid snapshot");
449
450 assert!(snapshot.find_text("abc").is_none());
451 }
452
453 #[test]
454 fn capped_extend_marks_truncated_when_zero_limit_meets_nonempty_bytes() {
455 let mut bytes = Vec::new();
456 assert!(!extend_capped(&mut bytes, b"", 0));
460 assert!(extend_capped(&mut bytes, b"x", 0));
461 assert!(bytes.is_empty());
462 }
463}