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}