1use 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
22pub enum PagedOutput {
34 Terminal {
36 stdout: Stdout,
38 },
39 ExternalPager {
41 command_name: String,
43 child: Child,
45 child_stdin: Option<ChildStdin>,
50 },
51 BuiltinPager {
53 out_writer: Option<PipeWriter>,
58 pager_thread: Option<JoinHandle<streampager::Result<()>>>,
62 },
63}
64
65impl PagedOutput {
66 pub fn terminal() -> Self {
68 Self::Terminal {
69 stdout: io::stdout(),
70 }
71 }
72
73 pub fn request_pager(
83 pager: &PagerSetting,
84 paginate: PaginateSetting,
85 streampager_config: &StreampagerConfig,
86 ) -> Self {
87 if matches!(paginate, PaginateSetting::Never) {
89 return Self::terminal();
90 }
91
92 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 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 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 scroll_past_eof: false,
137 ..Default::default()
138 };
139
140 let pager_result = streampager::Pager::new_using_system_terminal_with_config(
143 streampager_config,
144 )
145 .and_then(|mut pager| {
146 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 pub fn is_interactive(&self) -> bool {
173 match self {
174 Self::Terminal { stdout, .. } => stdout.is_terminal(),
175 Self::ExternalPager { .. } | Self::BuiltinPager { .. } => true,
178 }
179 }
180
181 pub fn forwards_osc8_hyperlinks(&self) -> bool {
188 match self {
189 Self::Terminal { .. } => true,
190 Self::BuiltinPager { .. } => {
191 true
193 }
194 Self::ExternalPager { command_name, .. } => external_pager_forwards_osc8(command_name),
195 }
196 }
197
198 pub fn finalize(mut self) {
207 self.finalize_inner();
208 }
209
210 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 }
233 Self::ExternalPager {
234 child, child_stdin, ..
235 } => {
236 let Some(stdin) = child_stdin.take() else {
238 return;
239 };
240
241 drop(stdin);
243
244 if let Err(error) = child.wait()
247 && error.kind() != io::ErrorKind::BrokenPipe
248 {
249 warn!("failed to wait on pager: {error}");
250 }
251 }
255 Self::BuiltinPager {
256 out_writer,
257 pager_thread,
258 } => {
259 let Some(writer) = out_writer.take() else {
261 return;
262 };
263
264 drop(writer);
266
267 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 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
322const LESS_MIN_OSC8_VERSION: u32 = 581;
324
325fn external_pager_forwards_osc8(command_name: &str) -> bool {
326 if command_is_less(command_name) {
327 version_forwards_osc8(probe_less_major_version(command_name))
330 } else {
331 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 pager_basename_matches(command_name, &["moor", "moar"])
352}
353
354fn command_is_bat(command_name: &str) -> bool {
355 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
421fn 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 let _ = output.stdout();
455 output.finalize();
456 }
457
458 #[test]
459 fn test_terminal_output_drop() {
460 let mut output = PagedOutput::terminal();
462 let _ = output.stdout();
463 }
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 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 writeln!(output.stdout(), "hello pager").expect("write should succeed");
495
496 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 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 output.finalize_inner();
534 output.finalize_inner();
535 }
537
538 #[test]
539 #[cfg(unix)]
540 fn test_external_pager_early_exit_squelches_broken_pipe() {
541 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 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}