Skip to main content

nextest_runner/
pager.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Pager support for nextest output.
5//!
6//! This module provides functionality to page output through an external pager
7//! (like `less`) or a builtin pager (streampager) when appropriate. Paging is
8//! useful for commands that produce long output, such as `nextest list`.
9
10use crate::{
11    user_config::elements::{PagerSetting, PaginateSetting, StreampagerConfig},
12    write_str::WriteStr,
13};
14use camino::Utf8Path;
15use std::{
16    io::{self, IsTerminal, PipeWriter, Stdout, Write},
17    process::{Child, ChildStdin, Command, Stdio},
18    thread::{self, JoinHandle},
19};
20use tracing::{debug, warn};
21
22/// Output wrapper that optionally pages output through a pager.
23///
24/// When a pager is active, output is piped to the pager process (external) or
25/// thread (builtin). When finalized, the pipe is closed and we wait for the
26/// pager to exit.
27///
28/// Implements [`Drop`] to ensure cleanup happens even if `finalize` is not
29/// called explicitly. During a panic, pipes are closed but we skip waiting for
30/// the pager to avoid potential double-panic.
31///
32/// [`finalize`]: Self::finalize
33pub enum PagedOutput {
34    /// Direct output to terminal (no paging).
35    Terminal {
36        /// Standard output handle.
37        stdout: Stdout,
38    },
39    /// Output through an external pager process.
40    ExternalPager {
41        /// The program name of the spawned pager.
42        command_name: String,
43        /// The pager child process.
44        child: Child,
45        /// Stdin pipe to the pager (for writing output).
46        ///
47        /// This is an `Option` to allow taking ownership in [`Drop`] and
48        /// [`finalize`](Self::finalize).
49        child_stdin: Option<ChildStdin>,
50    },
51    /// Output through the builtin streampager.
52    BuiltinPager {
53        /// Pipe writer for stdout (for writing output).
54        ///
55        /// This is an `Option` to allow taking ownership in [`Drop`] and
56        /// [`finalize`](Self::finalize).
57        out_writer: Option<PipeWriter>,
58        /// The pager thread handle.
59        ///
60        /// This is an `Option` to allow taking ownership in `finalize`.
61        pager_thread: Option<JoinHandle<streampager::Result<()>>>,
62    },
63}
64
65impl PagedOutput {
66    /// Creates a new terminal output (no paging).
67    pub fn terminal() -> Self {
68        Self::Terminal {
69            stdout: io::stdout(),
70        }
71    }
72
73    /// Attempts to spawn a pager if conditions are met.
74    ///
75    /// Returns `Terminal` output if:
76    /// - `paginate` is `Never`
77    /// - stdout is not a TTY
78    /// - the pager command fails to spawn
79    ///
80    /// On pager spawn failure, a warning is logged and terminal output is
81    /// returned.
82    pub fn request_pager(
83        pager: &PagerSetting,
84        paginate: PaginateSetting,
85        streampager_config: &StreampagerConfig,
86    ) -> Self {
87        // Check if paging is disabled.
88        if matches!(paginate, PaginateSetting::Never) {
89            return Self::terminal();
90        }
91
92        // Check if stdout is a TTY.
93        if !io::stdout().is_terminal() {
94            return Self::terminal();
95        }
96
97        match pager {
98            PagerSetting::Builtin => Self::spawn_builtin_pager(streampager_config),
99            PagerSetting::External(command_and_args) => {
100                // Try to spawn the external pager.
101                let mut cmd = command_and_args.to_command();
102                cmd.stdin(Stdio::piped());
103
104                match cmd.spawn() {
105                    Ok(mut child) => {
106                        let child_stdin = child
107                            .stdin
108                            .take()
109                            .expect("child stdin should be present when piped");
110                        Self::ExternalPager {
111                            command_name: command_and_args.command_name().to_owned(),
112                            child,
113                            child_stdin: Some(child_stdin),
114                        }
115                    }
116                    Err(error) => {
117                        warn!(
118                            "failed to spawn pager '{}': {error}",
119                            command_and_args.command_name()
120                        );
121                        Self::terminal()
122                    }
123                }
124            }
125        }
126    }
127
128    /// Spawns the builtin streampager.
129    fn spawn_builtin_pager(config: &StreampagerConfig) -> Self {
130        let streampager_config = streampager::config::Config {
131            wrapping_mode: config.streampager_wrapping_mode(),
132            interface_mode: config.streampager_interface_mode(),
133            show_ruler: config.show_ruler,
134            // Don't scroll past EOF - it can leave empty lines on screen after
135            // exiting with quit-if-one-page mode.
136            scroll_past_eof: false,
137            ..Default::default()
138        };
139
140        // Initialize with tty instead of stdin/stdout. We spawn pager so long
141        // as stdout is a tty, which means stdin may be redirected.
142        let pager_result = streampager::Pager::new_using_system_terminal_with_config(
143            streampager_config,
144        )
145        .and_then(|mut pager| {
146            // Create a pipe for stdout.
147            let (out_reader, out_writer) = io::pipe()?;
148            pager.add_stream(out_reader, "")?;
149            Ok((pager, out_writer))
150        });
151
152        match pager_result {
153            Ok((pager, out_writer)) => Self::BuiltinPager {
154                out_writer: Some(out_writer),
155                pager_thread: Some(thread::spawn(|| pager.run())),
156            },
157            Err(error) => {
158                warn!("failed to set up builtin pager: {error}");
159                Self::terminal()
160            }
161        }
162    }
163
164    /// Returns true if output will be displayed interactively.
165    ///
166    /// This is used to determine whether to use human-readable formatting
167    /// (interactive) or machine-friendly oneline formatting (piped).
168    ///
169    /// - For terminal output: returns whether stdout is a TTY.
170    /// - For paged output: always returns true, since the pager displays
171    ///   output interactively to the user.
172    pub fn is_interactive(&self) -> bool {
173        match self {
174            Self::Terminal { stdout, .. } => stdout.is_terminal(),
175            // Paged output is always interactive - the user sees it in a
176            // terminal via the pager.
177            Self::ExternalPager { .. } | Self::BuiltinPager { .. } => true,
178        }
179    }
180
181    /// Returns true if OSC 8 hyperlinks can be written out to the terminal.
182    ///
183    /// For the `less` pager, this invokes `less --version` to ensure that the
184    /// `less` version is new enough. To avoid this extra process spawn when it
185    /// is unnecessary, only call this after verifying that the terminal
186    /// supports hyperlinks.
187    pub fn forwards_osc8_hyperlinks(&self) -> bool {
188        match self {
189            Self::Terminal { .. } => true,
190            Self::BuiltinPager { .. } => {
191                // sapling-streampager always forwards OSC 8 hyperlinks.
192                true
193            }
194            Self::ExternalPager { command_name, .. } => external_pager_forwards_osc8(command_name),
195        }
196    }
197
198    /// Finalizes the pager output.
199    ///
200    /// For terminal output, this is a no-op.
201    /// For paged output, this closes the pipe and waits for the pager
202    /// process/thread to exit. Errors during wait are logged but not propagated.
203    ///
204    /// This method is also called by [`Drop`], so explicit calls are optional
205    /// but recommended for clarity.
206    pub fn finalize(mut self) {
207        self.finalize_inner();
208    }
209
210    // ---
211    // Helper methods
212    // ---
213
214    // This is not made public: we want everyone to go through WriteStr, which
215    // squelches BrokenPipe errors.
216    fn stdout(&mut self) -> &mut dyn Write {
217        match self {
218            Self::Terminal { stdout, .. } => stdout,
219            Self::ExternalPager { child_stdin, .. } => child_stdin
220                .as_mut()
221                .expect("stdout should not be called after finalize"),
222            Self::BuiltinPager { out_writer, .. } => out_writer
223                .as_mut()
224                .expect("stdout should not be called after finalize"),
225        }
226    }
227
228    fn finalize_inner(&mut self) {
229        match self {
230            Self::Terminal { .. } => {
231                // Nothing to do.
232            }
233            Self::ExternalPager {
234                child, child_stdin, ..
235            } => {
236                // If stdin is already taken, we've already finalized.
237                let Some(stdin) = child_stdin.take() else {
238                    return;
239                };
240
241                // Close stdin to signal EOF to the pager.
242                drop(stdin);
243
244                // Wait for the pager to exit. (Ignore broken pipes -- they're
245                // expected with less.)
246                if let Err(error) = child.wait()
247                    && error.kind() != io::ErrorKind::BrokenPipe
248                {
249                    warn!("failed to wait on pager: {error}");
250                }
251                // Note: We intentionally ignore the exit status from the child process. The pager may
252                // exit with a non-zero status if the user quits early (e.g.,
253                // pressing 'q' in less), which is normal behavior.
254            }
255            Self::BuiltinPager {
256                out_writer,
257                pager_thread,
258            } => {
259                // If writer is already taken, we've already finalized.
260                let Some(writer) = out_writer.take() else {
261                    return;
262                };
263
264                // Close the pipe to signal EOF to the pager.
265                drop(writer);
266
267                // Wait for the pager thread to exit.
268                if let Some(thread) = pager_thread.take() {
269                    match thread.join() {
270                        Ok(Ok(())) => {}
271                        Ok(Err(error)) => {
272                            warn!("failed to run builtin pager: {error}");
273                        }
274                        Err(_) => {
275                            warn!("builtin pager thread panicked");
276                        }
277                    }
278                }
279            }
280        }
281    }
282}
283
284impl Drop for PagedOutput {
285    fn drop(&mut self) {
286        if std::thread::panicking() {
287            // During a panic, close pipes to signal EOF but don't wait for the
288            // pager. This avoids potential issues if wait()/join() were to panic.
289            match self {
290                Self::Terminal { .. } => {}
291                Self::ExternalPager { child_stdin, .. } => {
292                    drop(child_stdin.take());
293                }
294                Self::BuiltinPager { out_writer, .. } => {
295                    drop(out_writer.take());
296                }
297            }
298            return;
299        }
300        self.finalize_inner();
301    }
302}
303
304impl WriteStr for PagedOutput {
305    fn write_str(&mut self, s: &str) -> io::Result<()> {
306        squelch_broken_pipe(self.stdout().write_all(s.as_bytes()))
307    }
308
309    fn write_str_flush(&mut self) -> io::Result<()> {
310        squelch_broken_pipe(self.stdout().flush())
311    }
312}
313
314fn squelch_broken_pipe(res: io::Result<()>) -> io::Result<()> {
315    match res {
316        Ok(()) => Ok(()),
317        Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(()),
318        Err(e) => Err(e),
319    }
320}
321
322/// The first stable `less` version to handle OSC 8 hyperlinks.
323const LESS_MIN_OSC8_VERSION: u32 = 581;
324
325fn external_pager_forwards_osc8(command_name: &str) -> bool {
326    if command_is_less(command_name) {
327        // Trying to figure out whether less is being invoked with -r/-R is too
328        // bothersome, so we don't try and do that.
329        version_forwards_osc8(probe_less_major_version(command_name))
330    } else {
331        // Allowlist a couple other pagers in common use.
332        //
333        // * All modern versions of moor (formerly moar) support hyperlinks.
334        // * bat supports hyperlinks but uses a system pager (default less) to
335        //   do its paging. We assume that the system pager is modern enough
336        //   to forward OSC 8 hyperlinks.
337        command_is_moor(command_name) || command_is_bat(command_name)
338    }
339}
340
341fn version_forwards_osc8(major_version: Option<u32>) -> bool {
342    major_version.is_some_and(|v| v >= LESS_MIN_OSC8_VERSION)
343}
344
345fn command_is_less(command_name: &str) -> bool {
346    pager_basename_matches(command_name, &["less"])
347}
348
349fn command_is_moor(command_name: &str) -> bool {
350    // moor was formerly known as moar.
351    pager_basename_matches(command_name, &["moor", "moar"])
352}
353
354fn command_is_bat(command_name: &str) -> bool {
355    // batcat is the binary name on Debian/Ubuntu.
356    pager_basename_matches(command_name, &["bat", "batcat"])
357}
358
359fn pager_basename_matches(command_name: &str, names: &[&str]) -> bool {
360    let basename = pager_basename(command_name);
361    names.iter().any(|&name| {
362        if cfg!(windows) {
363            basename.eq_ignore_ascii_case(name)
364        } else {
365            basename == name
366        }
367    })
368}
369
370fn pager_basename(command_name: &str) -> &str {
371    let basename = Utf8Path::new(command_name)
372        .file_name()
373        .unwrap_or(command_name);
374    if cfg!(windows) {
375        let bytes = basename.as_bytes();
376        if bytes.len() > 4 && bytes[bytes.len() - 4..].eq_ignore_ascii_case(b".exe") {
377            return &basename[..basename.len() - 4];
378        }
379    }
380    basename
381}
382
383fn probe_less_major_version(command_name: &str) -> Option<u32> {
384    let output = match Command::new(command_name)
385        .arg("--version")
386        .stdin(Stdio::null())
387        .stderr(Stdio::null())
388        .output()
389    {
390        Ok(output) => output,
391        Err(error) => {
392            debug!("failed to run `{command_name} --version` to detect hyperlink support: {error}");
393            return None;
394        }
395    };
396    if !output.status.success() {
397        debug!(
398            "`{command_name} --version` exited with {}; assuming no hyperlink support",
399            output.status
400        );
401        return None;
402    }
403    let stdout = match String::from_utf8(output.stdout) {
404        Ok(stdout) => stdout,
405        Err(error) => {
406            debug!("`{command_name} --version` produced non-UTF-8 output: {error}");
407            return None;
408        }
409    };
410    match parse_less_major_version(&stdout) {
411        Some(version) => Some(version),
412        None => {
413            debug!(
414                "could not parse a `less` version from `{command_name} --version` output: {stdout:?}"
415            );
416            None
417        }
418    }
419}
420
421/// Parses the major version from a `less` `--version` output line, e.g. `less
422/// 581.2` -> 581.
423fn parse_less_major_version(version_output: &str) -> Option<u32> {
424    let first_line = version_output.lines().next()?;
425    let mut tokens = first_line.split_whitespace();
426    if tokens.next()? != "less" {
427        return None;
428    }
429    let version_token = tokens.next()?;
430    let digits_end = version_token
431        .find(|c: char| !c.is_ascii_digit())
432        .unwrap_or(version_token.len());
433    version_token[..digits_end].parse().ok()
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use crate::user_config::elements::{StreampagerInterface, StreampagerWrapping};
440
441    #[cfg(unix)]
442    fn external_pager(child: Child, child_stdin: ChildStdin, name: &str) -> PagedOutput {
443        PagedOutput::ExternalPager {
444            child,
445            child_stdin: Some(child_stdin),
446            command_name: name.to_owned(),
447        }
448    }
449
450    #[test]
451    fn test_terminal_output() {
452        let mut output = PagedOutput::terminal();
453        // Just verify we can get a writer.
454        let _ = output.stdout();
455        output.finalize();
456    }
457
458    #[test]
459    fn test_terminal_output_drop() {
460        // Verify Drop works without explicit finalize.
461        let mut output = PagedOutput::terminal();
462        let _ = output.stdout();
463        // No explicit finalize - Drop handles it.
464    }
465
466    #[test]
467    fn test_request_pager_never_paginate() {
468        let pager = PagerSetting::default();
469        let streampager = StreampagerConfig {
470            interface: StreampagerInterface::QuitIfOnePage,
471            wrapping: StreampagerWrapping::Word,
472            show_ruler: true,
473        };
474        let output = PagedOutput::request_pager(&pager, PaginateSetting::Never, &streampager);
475        assert!(matches!(output, PagedOutput::Terminal { .. }));
476        output.finalize();
477    }
478
479    #[test]
480    #[cfg(unix)]
481    fn test_external_pager_write_and_finalize() {
482        // Spawn `cat` as a simple pager that consumes input.
483        let mut child = std::process::Command::new("cat")
484            .stdin(Stdio::piped())
485            .stdout(Stdio::null())
486            .spawn()
487            .expect("failed to spawn cat");
488
489        let child_stdin = child.stdin.take().expect("stdin should be piped");
490
491        let mut output = external_pager(child, child_stdin, "cat");
492
493        // Write some data.
494        writeln!(output.stdout(), "hello pager").expect("write should succeed");
495
496        // Finalize should close stdin and wait for cat to exit.
497        output.finalize();
498    }
499
500    #[test]
501    #[cfg(unix)]
502    fn test_external_pager_drop_without_finalize() {
503        let mut child = std::process::Command::new("cat")
504            .stdin(Stdio::piped())
505            .stdout(Stdio::null())
506            .spawn()
507            .expect("failed to spawn cat");
508
509        let child_stdin = child.stdin.take().expect("stdin should be piped");
510
511        let mut output = external_pager(child, child_stdin, "cat");
512
513        writeln!(output.stdout(), "hello pager").expect("write should succeed");
514
515        // No explicit finalize, so Drop should handle cleanup.
516        drop(output);
517    }
518
519    #[test]
520    #[cfg(unix)]
521    fn test_external_pager_double_finalize_is_idempotent() {
522        let mut child = std::process::Command::new("cat")
523            .stdin(Stdio::piped())
524            .stdout(Stdio::null())
525            .spawn()
526            .expect("failed to spawn cat");
527
528        let child_stdin = child.stdin.take().expect("stdin should be piped");
529
530        let mut output = external_pager(child, child_stdin, "cat");
531
532        // Call finalize_inner twice - second call should be a no-op.
533        output.finalize_inner();
534        output.finalize_inner();
535        // Drop will also try to finalize, should be safe.
536    }
537
538    #[test]
539    #[cfg(unix)]
540    fn test_external_pager_early_exit_squelches_broken_pipe() {
541        // `true` exits immediately, causing writes to fail with BrokenPipe.
542        let mut child = std::process::Command::new("true")
543            .stdin(Stdio::piped())
544            .spawn()
545            .expect("failed to spawn true");
546
547        let child_stdin = child.stdin.take().expect("stdin should be piped");
548
549        // Wait for the process to exit before constructing PagedOutput.
550        let _ = child.wait();
551
552        let mut output = external_pager(child, child_stdin, "true");
553
554        output
555            .write_str("hello\n")
556            .expect("BrokenPipe should be squelched for write_str");
557        let error = output
558            .stdout()
559            .write(b"hello\n")
560            .expect_err("Write should fail with BrokenPipe");
561        assert_eq!(error.kind(), io::ErrorKind::BrokenPipe);
562        output
563            .write_str_flush()
564            .expect("BrokenPipe should be squelched for write_str_flush");
565        output.finalize();
566    }
567
568    #[test]
569    fn terminal_forwards_osc8_hyperlinks() {
570        assert!(PagedOutput::terminal().forwards_osc8_hyperlinks());
571    }
572
573    #[test]
574    #[cfg(unix)]
575    fn external_non_less_pager_does_not_forward_osc8() {
576        let mut child = std::process::Command::new("cat")
577            .stdin(Stdio::piped())
578            .stdout(Stdio::null())
579            .spawn()
580            .expect("failed to spawn cat");
581        let child_stdin = child.stdin.take().expect("stdin should be piped");
582        let output = external_pager(child, child_stdin, "cat");
583        assert!(!output.forwards_osc8_hyperlinks());
584        output.finalize();
585    }
586
587    #[test]
588    fn parse_less_major_version_cases() {
589        assert_eq!(
590            parse_less_major_version("less 668 (GNU regular expressions)\nCopyright (C) ...\n"),
591            Some(668)
592        );
593        assert_eq!(parse_less_major_version("less 581.2\n"), Some(581));
594        assert_eq!(parse_less_major_version("less 590"), Some(590));
595        assert_eq!(
596            parse_less_major_version("less 551 (POSIX regular expressions)"),
597            Some(551)
598        );
599        assert_eq!(
600            parse_less_major_version("BusyBox v1.36.1 (2023-01-01) multi-call binary."),
601            None
602        );
603        assert_eq!(parse_less_major_version(""), None);
604        assert_eq!(parse_less_major_version("less\n"), None);
605        assert_eq!(parse_less_major_version("less version"), None);
606        assert_eq!(parse_less_major_version("less 99999999999"), None);
607        assert_eq!(parse_less_major_version("  less 643 (extra)"), Some(643));
608        assert_eq!(parse_less_major_version("less\t643"), Some(643));
609    }
610
611    #[test]
612    fn command_is_less_cases() {
613        assert!(command_is_less("less"));
614        assert!(command_is_less("/usr/bin/less"));
615        assert!(!command_is_less("most"));
616        assert!(!command_is_less("lesspipe"));
617        assert!(!command_is_less("/usr/bin/most"));
618        assert!(!command_is_less("moor"));
619    }
620
621    #[test]
622    fn command_is_moor_cases() {
623        assert!(command_is_moor("moor"));
624        assert!(command_is_moor("moar"));
625        assert!(command_is_moor("/usr/local/bin/moor"));
626        assert!(command_is_moor("/opt/homebrew/bin/moar"));
627        assert!(!command_is_moor("less"));
628        assert!(!command_is_moor("most"));
629        assert!(!command_is_moor("moors"));
630        assert!(!command_is_moor("mo"));
631    }
632
633    #[test]
634    fn command_is_bat_cases() {
635        assert!(command_is_bat("bat"));
636        assert!(command_is_bat("batcat"));
637        assert!(command_is_bat("/usr/bin/bat"));
638        assert!(command_is_bat("/usr/bin/batcat"));
639        assert!(!command_is_bat("less"));
640        assert!(!command_is_bat("moor"));
641        assert!(!command_is_bat("bats"));
642        assert!(!command_is_bat("combat"));
643    }
644
645    #[cfg(windows)]
646    #[test]
647    fn command_is_less_windows() {
648        assert!(command_is_less("less.exe"));
649        assert!(command_is_less("LESS.EXE"));
650        assert!(command_is_less(r"C:\tools\less.exe"));
651        assert!(!command_is_less("most.exe"));
652    }
653
654    #[cfg(windows)]
655    #[test]
656    fn command_is_moor_windows() {
657        assert!(command_is_moor("moor.exe"));
658        assert!(command_is_moor("moar.exe"));
659        assert!(command_is_moor(r"C:\tools\moor.exe"));
660        assert!(!command_is_moor("most.exe"));
661    }
662
663    #[cfg(windows)]
664    #[test]
665    fn command_is_bat_windows() {
666        assert!(command_is_bat("bat.exe"));
667        assert!(command_is_bat("batcat.exe"));
668        assert!(command_is_bat(r"C:\tools\bat.exe"));
669        assert!(!command_is_bat("combat.exe"));
670    }
671
672    #[test]
673    fn version_forwards_osc8_threshold() {
674        assert!(!version_forwards_osc8(None));
675        assert!(!version_forwards_osc8(Some(LESS_MIN_OSC8_VERSION - 1)));
676        assert!(version_forwards_osc8(Some(LESS_MIN_OSC8_VERSION)));
677        assert!(version_forwards_osc8(Some(668)));
678    }
679
680    #[test]
681    fn version_forwards_osc8_composed_with_version_parse() {
682        assert!(!version_forwards_osc8(parse_less_major_version(
683            "less 551 (POSIX regular expressions)"
684        )));
685        assert!(version_forwards_osc8(parse_less_major_version(
686            "less 581 (GNU regular expressions)"
687        )));
688        assert!(version_forwards_osc8(parse_less_major_version(
689            "less 668 (GNU regular expressions)"
690        )));
691        assert!(!version_forwards_osc8(parse_less_major_version(
692            "BusyBox v1.36.1 (2023-01-01) multi-call binary."
693        )));
694    }
695
696    #[test]
697    fn other_external_pagers_forward_osc8() {
698        assert!(external_pager_forwards_osc8("moor"));
699        assert!(external_pager_forwards_osc8("moar"));
700        assert!(external_pager_forwards_osc8("/usr/local/bin/moor"));
701        assert!(external_pager_forwards_osc8("bat"));
702        assert!(external_pager_forwards_osc8("batcat"));
703        assert!(external_pager_forwards_osc8("/usr/bin/batcat"));
704
705        assert!(!external_pager_forwards_osc8("most"));
706        assert!(!external_pager_forwards_osc8("lesspipe"));
707        assert!(!external_pager_forwards_osc8("/usr/bin/most"));
708        assert!(!external_pager_forwards_osc8("ov"));
709    }
710}