Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions crates/bashkit/src/builtins/man.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
//! man builtin - show a command's usage as a manual page.
//!
//! Decisions:
//! - bashkit ships no man pages. `man CMD` shows the command's own `--help`
//! text, which every bashkit program builtin provides, so the page always
//! matches what the command accepts.
//! - `--help` is only run for registered program builtins. Shell builtins
//! (`cd`, `exit`, `read`...) use `help CMD` instead: running `exit --help`
//! would exit the shell, and functions or aliases never get `--help`.
//! - Inside a terminal session the page opens in `less`; everywhere else it
//! prints, like `man CMD | cat`.

use async_trait::async_trait;

use super::{Builtin, Context, ExecutionPlan, PlanDriver, PlanStep, SubCommand};
use crate::error::Result;
use crate::interpreter::ExecResult;

/// man builtin.
pub struct Man;

const USAGE: &str = "Usage: man [SECTION] COMMAND\nShow COMMAND's usage as a manual page.\n\nInside a bashkit::terminal::Terminal session the page opens in less.\n\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n";

/// Exit status of real man when no page exists.
const NO_ENTRY: i32 = 16;

#[async_trait]
impl Builtin for Man {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
if let Some(r) = super::check_help_version(ctx.args, USAGE, Some("man (bashkit) 0.1")) {
return Ok(r);
}
// Reached only when there is no plan: nothing to look up.
Ok(ExecResult::err(
"What manual page do you want?\nFor example, try 'man man'.\n",
1,
))
}

async fn execution_plan(&self, ctx: &Context<'_>) -> Result<Option<ExecutionPlan>> {
if ctx.args.iter().any(|a| a == "--help" || a == "--version") {
return Ok(None);
}
// `man 1 ls`: skip a numeric section.
let Some(name) = ctx
.args
.iter()
.find(|a| !a.starts_with('-') && !a.chars().all(|c| c.is_ascii_digit()))
else {
return Ok(None);
};
let program = !super::BASH_BUILTIN_NAMES.contains(&name.as_str())
&& ctx.shell.as_ref().is_some_and(|s| s.has_builtin(name));
#[cfg(feature = "terminal")]
let tty = super::pager::terminal(ctx)?;
Ok(Some(ExecutionPlan::Driver(Box::new(ManRun {
name: name.clone(),
step: if program { Step::Help } else { Step::ShellHelp },
#[cfg(feature = "terminal")]
tty,
}))))
}
}

#[derive(Clone, Copy, PartialEq, Eq)]
enum Step {
/// Next: run `NAME --help`.
Help,
/// Next: run `help NAME`.
ShellHelp,
Done,
}

struct ManRun {
name: String,
step: Step,
#[cfg(feature = "terminal")]
tty: Option<crate::terminal::Tty>,
}

impl ManRun {
fn run(&mut self, name: &str, args: Vec<String>, next: Step) -> PlanStep {
self.step = next;
PlanStep::Run {
command: SubCommand {
name: name.to_string(),
args,
stdin: None,
assignments: Vec::new(),
},
cwd: None,
}
}

async fn show(&self, text: &str) -> Result<PlanStep> {
let page = format!("{}(1)\n\n{}", self.name.to_uppercase(), text);
#[cfg(feature = "terminal")]
if let Some(tty) = &self.tty {
let title = format!("Manual page {}(1)", self.name);
let result =
super::pager::page_text(tty, &page, title, super::pager::Pager::Less, false)
.await?;
return Ok(PlanStep::Done(result));
}
Ok(PlanStep::Done(ExecResult::ok(page)))
}
}

/// Usable help text from a sub-command result, if any.
fn help_text(result: &ExecResult) -> Option<String> {
if result.exit_code != 0 {
return None;
}
let text = result.stdout.text_lossy().into_owned();
(!text.trim().is_empty()).then_some(text)
}

#[async_trait]
impl PlanDriver for ManRun {
async fn next(&mut self, last: Option<ExecResult>) -> Result<PlanStep> {
if let Some(text) = last.as_ref().and_then(help_text) {
return self.show(&text).await;
}
let name = self.name.clone();
Ok(match self.step {
Step::Help => self.run(&name, vec!["--help".into()], Step::ShellHelp),
Step::ShellHelp => self.run("help", vec![name], Step::Done),
Step::Done => PlanStep::Done(ExecResult::err(
format!("No manual entry for {}\n", self.name),
NO_ENTRY,
)),
})
}
}

#[cfg(test)]
mod tests {
use crate::Bash;

async fn run(script: &str) -> crate::ExecResult {
Bash::new().exec(script).await.unwrap()
}

#[tokio::test]
async fn shows_program_help_as_a_page() {
let r = run("man grep").await;
assert_eq!(r.exit_code, 0);
assert!(
r.stdout.starts_with("GREP(1)\n\nUsage: grep"),
"{}",
r.stdout
);
let r = run("man 1 ls").await;
assert!(r.stdout.starts_with("LS(1)\n"), "{}", r.stdout);
}

#[tokio::test]
async fn shell_builtins_use_help_not_dash_dash_help() {
let r = run("man exit; echo still-here").await;
assert!(r.stdout.contains("still-here"), "{}", r.stdout);
assert!(r.stdout.starts_with("EXIT(1)\n"), "{}", r.stdout);
}

#[tokio::test]
async fn missing_page_and_usage_errors() {
let r = run("man no-such-cmd").await;
assert_eq!(r.exit_code, 16);
assert_eq!(r.stderr, "No manual entry for no-such-cmd\n");
let r = run("f() { echo ran; }; man f").await;
assert!(!r.stdout.contains("ran"), "{}", r.stdout);
let r = run("man").await;
assert_eq!(r.exit_code, 1);
assert!(run("man --help").await.stdout.starts_with("Usage: man"));
}
}
2 changes: 2 additions & 0 deletions crates/bashkit/src/builtins/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ mod jq;
mod json;
mod log;
mod ls;
mod man;
mod mapfile;
mod mkfifo;
mod navigation;
Expand Down Expand Up @@ -207,6 +208,7 @@ pub use json::Json;
pub use log::Log;
pub use ls::{Ls, Rmdir};
pub(crate) use ls::{fnmatch, glob_match};
pub use man::Man;
pub use mapfile::Mapfile;
pub use mkfifo::Mkfifo;
pub use navigation::{Cd, Pwd};
Expand Down
12 changes: 11 additions & 1 deletion crates/bashkit/src/builtins/pager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,18 @@ pub(crate) async fn page(ctx: &Context<'_>, tty: &Tty, kind: Pager) -> Result<Ex
}
title = files[0].to_string();
}
page_text(tty, &text, title, kind, quit_if_one_screen).await
}

let mut view = View::new(&text, title, tty.size().cols as usize);
/// Page `text` on the terminal (used by `less`, `more` and `man`).
pub(crate) async fn page_text(
tty: &Tty,
text: &str,
title: String,
kind: Pager,
quit_if_one_screen: bool,
) -> Result<ExecResult> {
let mut view = View::new(text, title, tty.size().cols as usize);
let page_rows = (tty.size().rows as usize).saturating_sub(1).max(1);
if quit_if_one_screen && view.rows.len() <= page_rows {
for row in &view.rows {
Expand Down
Loading
Loading