tokio_pager/
lib.rs

1// SPDX-License-Identifier: MIT
2// Copyright (C) 2025 Michael Dippery <michael@monkey-robot.com>
3
4//! Asynchronous, Tokio-friendly pager implementation.
5//!
6//! Unfortunately, Rust's [pager] crate does not play nicely with Tokio.
7//! It leaves threads open after the Tokio runtime exits, resulting in a
8//! nasty I/O error after a CLI program using both pager and a Tokio runtime
9//! exits. This may be due to the fact that the pager crate actually runs the
10//! pager in the _parent_ process, meaning that the Tokio runtime, in the
11//! child process, exits before the pager, leaving dangling file descriptors
12//! and the aforementioned I/O error from Tokio.
13//!
14//! There isn't a great away to customize the behavior of the pager crate, so
15//! this module implements a [`Pager`] struct that allows the use of a pager
16//! subprocess in a way that plays nicely with Tokio.
17//!
18//! `Pager` pipes its output to program specified in the `$PAGER`
19//! environment variable, except under two conditions:
20//!
21//! 1. If the value of `$PAGER` is `cat`, `/usr/bin/cat`, or anything that
22//!    ends in `/cat` (`/bin/cat`, etc.), then the output is not paged
23//!    at all (`cat` is not launched).
24//! 2. If `stdout` is not a TTY, such as when output is being redirected
25//!    to a file, the output is not paged.
26//!
27//! `Pager` respects the value of the `$LESS` environment variable (with some
28//! caveats---see [`PagerEnv::pager_env()`] for details).
29//!
30//! Right now, `Pager` is also designed specifically to work with `less`, so
31//! it does not make use of any other environment variables, but support
32//! for other pagers may expand in the future.
33//!
34//! [pager]: https://crates.io/crates/pager
35
36use std::process::{ExitStatus, Stdio};
37use std::{env, result};
38use std::io::{self, IsTerminal};
39use tokio::io::AsyncWriteExt;
40use tokio::process::Command;
41
42/// An environmental variable consisting of a name-value pair.
43pub type EnvVar = (String, String);
44
45/// A Tokio-friendly asynchronous pager.
46#[derive(Debug)]
47pub struct Pager {
48    pager_env: PagerEnv,
49}
50
51impl Pager {
52    /// Creates a new pager from the pager env.
53    pub fn new(pager_env: PagerEnv) -> Self {
54        Self { pager_env }
55    }
56
57    /// The program used for pagination.
58    ///
59    /// # Examples
60    ///
61    /// ```
62    /// # use tokio_pager::{Pager, PagerEnv};
63    /// # use temp_env::with_var_unset;
64    /// # with_var_unset("LESS", || {
65    /// let command = Pager::new(PagerEnv::default()).command();
66    /// assert_eq!(command, "/usr/bin/less");
67    /// # });
68    /// ```
69    pub fn command(&self) -> String {
70        self.pager_env.pager()
71    }
72
73    /// Name and value of the environment variable used to control aspects
74    /// of the pager's behavior.
75    ///
76    /// # Examples
77    ///
78    /// ```
79    /// # use tokio_pager::{Pager, PagerEnv};
80    /// # use temp_env::with_var_unset;
81    /// # with_var_unset("LESS", || {
82    /// let (name, value) = Pager::new(PagerEnv::default()).env().unwrap();
83    /// assert_eq!(name, "LESS");
84    /// assert_eq!(value, "FSRX");
85    /// # });
86    /// ```
87    pub fn env(&self) -> Option<EnvVar> {
88        self.pager_env.pager_env()
89    }
90
91    /// True if the pager is `cat`.
92    ///
93    /// # Examples
94    ///
95    /// Returns true if `$PAGER` is `cat`:
96    ///
97    /// ```
98    /// # use tokio_pager::{Pager, PagerEnv};
99    /// # use temp_env::with_var;
100    /// # with_var("PAGER", Some("cat"), || {
101    /// // $PAGER == "cat"
102    /// let is_cat = Pager::new(PagerEnv::default()).is_cat();
103    /// assert!(is_cat);
104    /// # });
105    /// ```
106    ///
107    /// Or if `$PAGER` is `/usr/bin/cat`, or anything ending in `/cat`:
108    ///
109    /// ```
110    /// # use tokio_pager::{Pager, PagerEnv};
111    /// # use temp_env::with_var;
112    /// # with_var("PAGER", Some("/usr/bin/cat"), || {
113    /// // $PAGER == "/usr/bin/cat"
114    /// let is_cat = Pager::new(PagerEnv::default()).is_cat();
115    /// assert!(is_cat);
116    /// # });
117    ///
118    ///
119    /// # with_var("PAGER", Some("/bin/cat"), || {
120    /// // $PAGER == "/bin/cat"
121    /// let is_cat = Pager::new(PagerEnv::default()).is_cat();
122    /// assert!(is_cat);
123    /// # });
124    /// ```
125    ///
126    /// But it returns false if `$PAGER` is something else:
127    ///
128    /// ```
129    /// # use tokio_pager::{Pager, PagerEnv};
130    /// # use temp_env::with_var;
131    /// # with_var("PAGER", Some("less"), || {
132    /// // $PAGER == "less"
133    /// let is_cat = Pager::new(PagerEnv::default()).is_cat();
134    /// assert!(!is_cat);
135    /// # });
136    /// ```
137    pub fn is_cat(&self) -> bool {
138        self.command() == "cat" || self.command().ends_with("/cat")
139    }
140
141    /// True if stdout is a tty.
142    pub fn is_tty(&self) -> bool {
143        io::stdout().is_terminal()
144    }
145
146    /// Pages the output to the pager.
147    ///
148    /// Returns the exit status of the child pager process.
149    async fn page_to_pager_with_error(&self, output: impl AsRef<str>) -> io::Result<ExitStatus> {
150        let mut command = Command::new(self.command());
151
152        if let Some((key, value)) = &self.env() {
153            command.env(key, value);
154        }
155
156        let mut command = command.stdin(Stdio::piped()).spawn().map_err(|e| {
157            let message = format!("failed to spawn pager: {e}");
158            io::Error::other(message)
159        })?;
160
161        if let Some(mut stdin) = command.stdin.take() {
162            stdin.write_all(output.as_ref().as_bytes()).await?;
163        }
164
165        command.wait().await
166    }
167
168    /// Pages output to stdout instead of a separate pager process.
169    async fn page_to_stdout_with_error(&self, output: impl AsRef<str>) -> io::Result<ExitStatus> {
170        let output = output.as_ref();
171        println!("{}", output);
172        Ok(ExitStatus::default())
173    }
174
175    /// Pages the output and returns an I/O result.
176    ///
177    /// The output will be sent to a separate pager process as defined by
178    /// `$PAGER`, unless `$PAGER` is `cat`, in which case the output will
179    /// simply be sent to stdout.
180    async fn page_with_error(&self, output: impl AsRef<str>) -> io::Result<ExitStatus> {
181        if self.is_cat() || !self.is_tty() {
182            self.page_to_stdout_with_error(output).await
183        } else {
184            self.page_to_pager_with_error(output).await
185        }
186    }
187
188    /// Pages the output to the pager.
189    ///
190    /// If the `$PAGER` is `cat` or any variant like `/usr/bin/cat`, the output
191    /// will be sent directly to stdout instead of to a separate paging process.
192    /// The output will also be sent directly to stdout if stdout is not a
193    /// tty (it is a file or pipe, for example).
194    ///
195    /// If no errors occur, `()` is returned; otherwise, a string describing
196    /// the error is returned.
197    pub async fn page(&self, output: impl AsRef<str>) -> Result {
198        let status = self
199            .page_with_error(output)
200            .await
201            .map_err(|e| format!("I/O error: {e}"))?;
202        if status.success() {
203            Ok(())
204        } else {
205            let message = format!("pager {} exited with status {status}", self.command());
206            Err(message)
207        }
208    }
209}
210
211/// A result from the pager subprocess.
212pub type Result = result::Result<(), String>;
213
214// Pager Environment
215// --------------------------------------------------------------------------
216
217// I'm not sure all of this logic really makes sense -- some of it may be
218// specific to my own personal preferences -- but let's use this until
219// someone complains.
220//
221// In the Ruby tool, I do, in fact, force "RS" if --oneline is selected,
222// similarly to what I do here, so perhaps the logic following the
223// retrieval of $LESS should simply be
224//
225//     let less = if *oneline { "RS" } else { less };
226//
227// However, since I send ANSI color codes whenever we are hooked up to a
228// tty, I definitely want "R" to be included, so if I instead respect
229// the user's possible absence of "R", I should make sure I only send
230// ANSI color codes when "R" is included in $LESS.
231//
232// Specifically, the Ruby tool includes this code (spread around the
233// codebase, but listed here contiguously for clarity):
234//
235//    ENV['LESS'] = 'RS' if options[:oneline]
236//    ENV['LESS'] = 'FSRX' unless ENV['LESS']
237//
238// Oy vey.
239//
240// Also, I should test this with various values of $LESS. For example,
241// my $LESS is simply set to "R", but I should test output when the
242// default option of "FSRX is used.
243
244/// Retrieves the pager and pager configuration from the environment.
245///
246/// # Examples
247///
248/// `PagerEnv` structs are created using a builder pattern, allowing for
249/// a wide range of options to be configured by chaining configuration
250/// options.
251///
252/// For example, to create a pager environment that is configured to neatly
253/// print elements of output to single lines, such as when emulating the
254/// behavior of `git log --oneline`:
255///
256/// ```
257/// # use tokio_pager::PagerEnv;
258/// let pager_env = PagerEnv::default().oneline(true);
259/// ```
260///
261/// Or to do the opposite: configure a pager that will not print a listing
262/// of output to a single line, the default:
263///
264/// ```
265/// # use tokio_pager::PagerEnv;
266/// let pager_env = PagerEnv::default().oneline(false);
267/// ```
268///
269/// Currently, there is only one option, so the builder pattern is not particularly
270/// useful, but as additional options are added to `PagerEnv`, this pattern
271/// will allow a great amount of flexibility when creating `PagerEnv` instances,
272/// so know the pattern well.
273#[derive(Debug, Default)]
274pub struct PagerEnv {
275    oneline: bool,
276}
277
278impl PagerEnv {
279    /// The default pager.
280    pub const DEFAULT_PAGER: &'static str = "/usr/bin/less";
281
282    /// Controls whether the pager is outputting single-line output or not,
283    /// which may alter aspects of its configuration or behavior.
284    ///
285    /// Oneline mode may be used when printing a listing of items, such as
286    /// when running `git log --oneline`. In these cases, output will not
287    /// be wrapped; horizontal scrolling will be required to view long strings.
288    /// See [`PagerEnv::pager_env()`] for more discussion.
289    pub fn oneline(self, oneline: bool) -> Self {
290        Self { oneline }
291    }
292
293    /// Returns a path to the program that should be used for paginating output.
294    ///
295    /// If a program is not specified in the environment, the
296    /// [default pager](PagerEnv::DEFAULT_PAGER) is used.
297    pub fn pager(&self) -> String {
298        env::var("PAGER").unwrap_or(String::from(PagerEnv::DEFAULT_PAGER))
299    }
300
301    /// Returns an appropriate 2-tuple of (environment variable name, value)
302    /// to pass to the pager.
303    ///
304    /// By default, this is `FSRX`, unless the user has defined `$LESS` in the
305    /// environment. However, because text is printed in color, `R` is always
306    /// included regardless of the value of `$LESS` (it is appended to `$LESS` if
307    /// not already present), and when output is printed to
308    /// [one line](PagerEnv::oneline()), `S` is appended to `$LESS` if not
309    /// already present.
310    ///
311    /// This ensures that output is pleasant for the user, regardless of the
312    /// definition of `$LESS`.
313    ///
314    /// # Examples
315    ///
316    /// `pager_env` will return a default value if `$LESS` is not set:
317    ///
318    /// ```
319    /// # use tokio_pager::PagerEnv;
320    /// # use temp_env::with_var_unset;
321    /// # with_var_unset("LESS", || {
322    /// let (key, value) = PagerEnv::default().pager_env().unwrap();
323    /// assert_eq!(key, "LESS");
324    /// assert_eq!(value, "FSRX");
325    /// # });
326    /// ```
327    ///
328    /// It will include `S` if `oneline` is `true`:
329    ///
330    /// ```
331    /// # use tokio_pager::PagerEnv;
332    /// # use temp_env::with_var_unset;
333    /// # with_var_unset("LESS", || {
334    /// let (_, value) = PagerEnv::default().oneline(true).pager_env().unwrap();
335    /// assert_eq!(value, "FSRX");
336    /// # });
337    /// ```
338    ///
339    /// In this example, `$LESS` was set to `SX`, but `R` will be appended anyway:
340    ///
341    /// ```
342    /// # use tokio_pager::PagerEnv;
343    /// # use temp_env::with_var;
344    /// # with_var("LESS", Some("SX"), || {
345    /// let (_, value) = PagerEnv::default().pager_env().unwrap();
346    /// assert_eq!(value, "SXR");
347    /// # });
348    /// ```
349    ///
350    /// In this example, `$LESS` was set to `RSX`. Note that `R` is still included,
351    /// but `$LESS` was not altered since `R` was already in it:
352    ///
353    /// ```
354    /// # use tokio_pager::PagerEnv;
355    /// # use temp_env::with_var;
356    /// # with_var("LESS", Some("RSX"), || {
357    /// let (_, value) = PagerEnv::default().pager_env().unwrap();
358    /// assert_eq!(value, "RSX");
359    /// # });
360    /// ```
361    ///
362    /// In this example, `$LESS` was set to `R`. Because the `oneline` option is
363    /// `true`, `S` is also appended:
364    ///
365    /// ```
366    /// # use tokio_pager::PagerEnv;
367    /// # use temp_env::with_var;
368    /// # with_var("LESS", Some("R"), || {
369    /// let (_, value) = PagerEnv::default().oneline(true).pager_env().unwrap();
370    /// assert_eq!(value, "RS");
371    /// # });
372    /// ```
373    ///
374    /// In this example, `$LESS` was set to `SR`. Because the `oneline` option is
375    /// `true`, `S` is still included, but because it is already present, the
376    /// value of `$LESS` does not change:
377    ///
378    /// ```
379    /// # use tokio_pager::PagerEnv;
380    /// # use temp_env::with_var;
381    /// # with_var("LESS", Some("SR"), || {
382    /// let (_, value) = PagerEnv::default().oneline(true).pager_env().unwrap();
383    /// assert_eq!(value, "SR");
384    /// # });
385    /// ```
386    pub fn pager_env(&self) -> Option<EnvVar> {
387        // TODO: Generalize for non-LESS pagers
388
389        // Get the value of $LESS, defaulting to "FSRX" if $LESS is unset.
390        let less = env::var_os("LESS").unwrap_or(
391            "FSRX"
392                .parse()
393                .expect("could not parse 'FSRX' into OsString"),
394        );
395        let less = less.to_string_lossy();
396
397        // Always interpret ANSI color escape sequences.
398        let less = if !less.contains("R") {
399            less + "R"
400        } else {
401            less
402        };
403
404        // When printing to one line, really print to one line, and force scrolling
405        // to the right if lines are too long.
406        let less = if self.oneline && !less.contains("S") {
407            less + "S"
408        } else {
409            less
410        };
411
412        Some((String::from("LESS"), less.to_string()))
413    }
414}