Skip to content

Commit

Permalink
refactor!: Move manpage-finding to main.rs
Browse files Browse the repository at this point in the history
  • Loading branch information
ysthakur committed Aug 12, 2023
1 parent 36e8449 commit d5a57cc
Show file tree
Hide file tree
Showing 6 changed files with 233 additions and 383 deletions.
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ edition = "2021"

[dependencies]
anyhow = "1.0"
clap = { version = "4.3", features = ["derive"] }
clap = { version = "4.3", features = ["derive", "env"] }
env_logger = "0.10"
flate2 = "1.0"
log = "0.4"
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,4 @@ Things to do:
directory.
- Speed improvements - the integration tests currently take over a second to run,
and it seems most of that time is spent in parsing
- Figure out why fish only seems to use man1, man6, and man8
12 changes: 6 additions & 6 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 6 additions & 5 deletions src/gen/bash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,19 @@ use std::{fs, path::Path};
use anyhow::Result;

use crate::{
gen::{
util::{quote_bash, Output},
Completions,
},
gen::{util::Output, Completions},
parse::CommandInfo,
};

pub struct BashCompletions;

impl Completions for BashCompletions {
/// Generate a completion file for Bash
fn generate<P>(cmd_name: &str, _cmd_info: &CommandInfo, out_dir: P) -> Result<()>
fn generate<P>(
cmd_name: &str,
_cmd_info: &CommandInfo,
out_dir: P,
) -> Result<()>
where
P: AsRef<Path>,
{
Expand Down
185 changes: 134 additions & 51 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,22 @@ mod gen;
mod parse;

use std::{
collections::HashMap,
path::{Path, PathBuf},
process::Command,
};

use anyhow::Result;
use anyhow::{anyhow, Result};
use clap::{Parser, ValueEnum};
use log::{debug, error, info, warn};
use parse::{detect_subcommands, parse_from};
use regex::Regex;

use crate::{
gen::{
BashCompletions, Completions, JsonCompletions, NuCompletions,
ZshCompletions,
},
parse::{CommandInfo, ManParseConfig},
parse::{get_cmd_name, CommandInfo},
};

#[derive(Debug, Clone, ValueEnum)]
Expand All @@ -42,77 +44,64 @@ struct Cli {
#[arg(short, long, value_delimiter = ',', required = true)]
shells: Vec<Shell>,

/// Directories to exclude from search
#[arg(short = 'i', long = "ignore", value_delimiter = ',')]
dirs_exclude: Option<Vec<PathBuf>>,
/// Directories to search for man pages in, e.g.
/// `--dirs=/usr/share/man/man1,/usr/share/man/man6`.
#[arg(short, long, value_delimiter = ',')]
dirs: Option<Vec<PathBuf>>,

/// Particular commands to generate completions for. If omitted, generates
/// completions for all found commands.
#[arg(short, long)]
/// Particular commands to generate completions for (regex). If omitted,
/// generates completions for all found commands. If you want to match the
/// whole name, use `^...$`.
#[arg(short, long, value_name = "REGEX")]
cmds: Option<Regex>,

/// Commands to exclude (regex).
#[arg(short = 'C', long)]
/// Commands to exclude (regex). If you want to match the whole name, use
/// `^...$`.
#[arg(short = 'C', long, value_name = "REGEX")]
exclude_cmds: Option<Regex>,

/// Commands that should not be treated as subcommands. This is to help deal
/// with false positives when detecting subcommands.
#[arg(long, value_delimiter = ',')]
#[arg(long, value_name = "CMD_NAMES", value_delimiter = ',')]
not_subcmds: Vec<String>,

/// Explicitly tell man-completions which man pages are for which
/// subcommands, in case it can't detect them. e.g. `git-commit=git
/// commit,foobar=foo bar`.
#[arg(long, value_delimiter = ',', value_parser=subcmd_map_parser)]
#[arg(long, value_name = "MAN-PAGE=SUB CMD...", value_parser=subcmd_map_parser, value_delimiter = ',')]
subcmds: Vec<(String, Vec<String>)>,

/// Manpage sections to exclude (1-8)
#[arg(short = 'S', long, value_parser = section_num_parser, value_delimiter = ',')]
sections_exclude: Vec<u8>,
}

fn subcmd_map_parser(
s: &str,
) -> core::result::Result<(String, Vec<String>), String> {
let Some((page_name, as_subcmd)) = s.split_once("=") else {
let Some((page_name, as_subcmd)) = s.split_once('=') else {
return Err(String::from(
"subcommand mapping should be in the form 'manpage-name=sub command'",
));
};
let as_subcmd = as_subcmd.split(" ").into_iter().map(String::from).collect();
let as_subcmd = as_subcmd.split(' ').map(String::from).collect();
Ok((String::from(page_name), as_subcmd))
}

fn section_num_parser(s: &str) -> core::result::Result<u8, String> {
match str::parse::<u8>(s) {
Ok(num) => {
if (1..=8).contains(&num) {
Ok(num)
} else {
Err(String::from("must be between 1 and 8"))
}
}
_ => Err(String::from("should be an int between 1 and 8")),
}
}

fn gen_shell(
shell: Shell,
manpages: &HashMap<String, CommandInfo>,
shell: &Shell,
cmd_name: &str,
parsed: &CommandInfo,
out_dir: &Path,
) -> Result<()> {
match shell {
Shell::Zsh => {
<ZshCompletions as Completions>::generate_all(manpages.iter(), out_dir)
<ZshCompletions as Completions>::generate(cmd_name, parsed, out_dir)
}
Shell::Json => {
<JsonCompletions as Completions>::generate_all(manpages.iter(), out_dir)
<JsonCompletions as Completions>::generate(cmd_name, parsed, out_dir)
}
Shell::Bash => {
<BashCompletions as Completions>::generate_all(manpages.iter(), out_dir)
<BashCompletions as Completions>::generate(cmd_name, parsed, out_dir)
}
Shell::Nu => {
<NuCompletions as Completions>::generate_all(manpages.iter(), out_dir)
<NuCompletions as Completions>::generate(cmd_name, parsed, out_dir)
}
}
}
Expand All @@ -122,23 +111,117 @@ fn main() -> Result<()> {

let args = Cli::parse();

let mut cfg = ManParseConfig::new()
.exclude_dirs(args.dirs_exclude.unwrap_or_default())
.exclude_sections(args.sections_exclude)
.not_subcommands(args.not_subcmds)
.subcommands(args.subcmds);
if let Some(exclude_cmds) = args.exclude_cmds {
cfg = cfg.exclude_commands(exclude_cmds);
let search_dirs = match args.dirs {
Some(dirs) => dirs.into_iter().collect::<Vec<_>>(),
None => enumerate_dirs(get_manpath()?),
};

let manpages = enumerate_manpages(search_dirs, args.cmds, args.exclude_cmds);

for (cmd_name, cmd_info) in detect_subcommands(manpages, args.subcmds) {
info!("Parsing '{}'", cmd_name);

let (res, errors) = parse_from(&cmd_name, cmd_info);

for error in errors {
error!("{}", error);
}

for shell in &args.shells {
info!("Generating completions for '{}' ({:?})", cmd_name, &shell);
gen_shell(shell, &cmd_name, &res, &args.out)?;
}
}

Ok(())
}

/// Find the search path for man by `manpath`, then `man --path`.
fn get_manpath() -> Result<Vec<PathBuf>> {
if let Ok(manpath) = std::env::var("MANPATH") {
Ok(split_paths(&manpath))
} else {
debug!("Running 'manpath' to find MANPATH...");
if let Some(manpath) = from_cmd(&mut Command::new("manpath")) {
Ok(manpath)
} else {
warn!("Could not get path from 'manpath'. Trying 'man --path'");
if let Some(manpath) = from_cmd(Command::new("man").arg("--path")) {
Ok(manpath)
} else {
error!("Could not get path from 'man --path'");
Err(anyhow!("Please provide either the --dirs flag or set the MANPATH environment variable."))
}
}
}
if let Some(cmds) = args.cmds {
cfg = cfg.restrict_to_commands(cmds);
}

/// Interpret the output of `manpath`/`man --path` as a list of paths
fn from_cmd(cmd: &mut Command) -> Option<Vec<PathBuf>> {
cmd
.output()
.ok()
.map(|out| split_paths(std::str::from_utf8(&out.stdout).unwrap()))
}

fn split_paths(paths: &str) -> Vec<PathBuf> {
paths.split(':').map(PathBuf::from).collect()
}

/// Enumerate all directories containing manpages given the MANPATH (the list of
/// directories in which man search for man pages). It looks for `man1`, `man2`,
/// etc. folders inside each of the given directories and returns those inner
/// `man<n>` folders.
fn enumerate_dirs(manpath: Vec<PathBuf>) -> Vec<PathBuf> {
let section_names: Vec<_> = (1..=8).map(|n| format!("man{n}")).collect();

let mut res = Vec::new();

for parent_path in manpath {
if parent_path.is_dir() {
if let Ok(parent_path) = std::fs::canonicalize(parent_path) {
for section_name in &section_names {
res.push(parent_path.join(section_name));
}
}
}
}

let res = cfg.parse()?;
res
}

for shell in args.shells {
gen_shell(shell, &res, &args.out)?;
/// Enumerate all directories containing manpages given the MANPATH (the list of
/// directories in which man search for man pages). It looks for `man1`, `man2`,
/// etc. folders inside each of the given directories and returns those inner
/// `man<n>` folders.
fn enumerate_manpages(
dirs: Vec<PathBuf>,
include_re: Option<Regex>,
exclude_re: Option<Regex>,
) -> Vec<PathBuf> {
let mut res = Vec::new();
for dir in dirs {
if let Ok(manpages) = std::fs::read_dir(dir) {
for manpage in manpages.flatten() {
let path = manpage.path();
let cmd_name = get_cmd_name(&path);
let &include = &include_re
.as_ref()
.map(|re| re.is_match(&cmd_name))
.unwrap_or(true);
let &exclude = &exclude_re
.as_ref()
.map(|re| re.is_match(&cmd_name))
.unwrap_or(false);
if include && exclude && include_re.is_some() {
warn!("Command {} was both included and excluded explicitly, will exclude", cmd_name)
}
if include && !exclude {
res.push(path)
}
}
}
}

Ok(())
res
}

0 comments on commit d5a57cc

Please sign in to comment.