Skip to content

Commit

Permalink
feat: broken implementation for zsh
Browse files Browse the repository at this point in the history
  • Loading branch information
ysthakur committed Aug 7, 2023
1 parent 4326ec5 commit f19d161
Show file tree
Hide file tree
Showing 6 changed files with 228 additions and 13 deletions.
2 changes: 2 additions & 0 deletions .rustfmt.toml
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
tab_spaces = 2

wrap_comments = true
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# man_completions

Parse manpages to get completions for Zsh and Bash
Parse manpages to get completions for Zsh, Bash, and Nu

Ported from [Fish's completions script](https://github.com/fish-shell/fish-shell/blob/master/share/tools/create_manpage_completions.py)
1 change: 1 addition & 0 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ fn section_num_parser(s: &str) -> core::result::Result<u8, String> {
}

fn gen_shell(shell: Shell, manpages: HashMap<String, CommandInfo>, out_dir: &Path) -> Result<()> {
println!("{:?}", &manpages);
match shell {
Shell::Zsh => <ZshCompletions as Completions>::generate_all(manpages.into_iter(), out_dir),
}
Expand Down
107 changes: 105 additions & 2 deletions lib/src/gen/zsh.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,120 @@
use std::path::Path;
use std::{
fs::{self, File},
io::Write,
path::Path,
};

use crate::parse::CommandInfo;

use super::Completions;
use crate::Result;

/// Indentation to use (for readability)
const INDENT: &str = " ";

pub struct ZshCompletions;

impl Completions for ZshCompletions {
/// Generate a completion file for Zsh
///
/// A shortened example with git
/// ```
/// #compdef _git git
///
/// function _git {
/// local line
///
/// _argument -C \
/// '-h[Show help]' \
/// '--help[Show help]' \
/// ':(pull checkout)' \ # Assume only git pull and checkout exist
/// '*::args->args'
///
/// case $line[1] in
/// pull) _git_pull;;
/// checkout) _git_checkout;;
/// esac
/// }
///
/// function _git_pull {
/// _arguments \
/// '-v[Output additional information]'
/// }
///
/// function _git_checkout {
/// _arguments \
/// '-b[Make new branch]'
/// }
/// ```
fn generate<P>(cmd_name: String, cmd_info: CommandInfo, out_dir: P) -> Result<()>
where
P: AsRef<Path>,
{
todo!()
// TODO make option to not overwrite file
let comp_name = format!("_{cmd_name}");
let mut res = format!("#compdef {comp_name} {cmd_name}");
generate_fn(&cmd_name, cmd_info, &mut res, 0, &comp_name);
fs::write(out_dir.as_ref().join(format!("{comp_name}.zsh")), res)?;
Ok(())
}
}

/// Wrap in single quotes (and escape single quotes inside) so that it's safe
/// for Zsh to read
fn quote(s: &str) -> String {
let s = s.replace(r"\", r"\\").replace(r"'", r"\'");
format!("'{s}'")
}

/// Generate a completion function for a command/subcommand
///
/// ## Arguments
/// * `pos` - If this is a top-level command, 0. Otherwise, if this is a
/// subcommand, which argument number the subcommand is (how deep it is)
/// * `fn` - What to name the completion function. If you have a command `foo`
/// with subcommand `bar`, the completion function for `foo bar` would be
/// named `_foo_bar`
fn generate_fn(cmd_name: &str, cmd_info: CommandInfo, out: &mut String, pos: usize, fn_name: &str) {
out.push_str("\n");
out.push_str(&format!("function {fn_name} {{\n"));
if !cmd_info.subcommands.is_empty() {
out.push_str(&format!("{}{}", INDENT, "local line\n"));
}
out.push_str(&format!("{INDENT} _arguments -C \\\n"));
for opt in cmd_info.args {
for form in opt.forms {
let text = quote(&format!("{form}[{}]", opt.desc));
out.push_str(&format!("{INDENT}{INDENT}{text} \\\n"));
}
}

if !cmd_info.subcommands.is_empty() {
let mut sub_cmds = String::new();
for sub_cmd in cmd_info.subcommands.keys() {
sub_cmds.push_str(sub_cmd);
}
out.push_str(&format!("{INDENT}{INDENT}':({sub_cmds})' \\\n"))
}

out.push_str(&format!("{INDENT}{INDENT}'*::args->args'\n"));

if !cmd_info.subcommands.is_empty() {
out.push_str(&format!("{INDENT}case $line[{}] in\n", pos + 1));
for sub_cmd in cmd_info.subcommands.keys() {
todo!()
}
out.push_str(&format!("{INDENT}esac\n"));
}

out.push_str("}\n");

for (sub_cmd, sub_cmd_info) in cmd_info.subcommands {
generate_fn(
&sub_cmd,
sub_cmd_info,
out,
pos + 1,
&format!("{fn_name}_{sub_cmd}"),
);
}
}
16 changes: 9 additions & 7 deletions lib/src/parse/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use std::{
collections::HashMap,
collections::{HashMap, HashSet},
fs::File,
io::{BufReader, Read},
};
Expand All @@ -9,16 +9,18 @@ use flate2::bufread::GzDecoder;
use crate::Result;
use std::path::Path;

pub mod parse_man;
mod parse_man;

#[derive(Debug)]
pub struct CommandInfo {
opts: Vec<Opt>,
subcommands: HashMap<String, CommandInfo>,
pub args: Vec<Arg>,
pub subcommands: HashMap<String, CommandInfo>,
}

pub struct Opt {
forms: Vec<String>,
desc: String,
#[derive(Debug)]
pub struct Arg {
pub forms: HashSet<String>,
pub desc: String,
}

pub fn read_manpage<P>(manpage_path: P) -> Result<String>
Expand Down
111 changes: 108 additions & 3 deletions lib/src/parse/parse_man.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
use std::collections::{HashMap, HashSet};

use regex::{Regex, RegexBuilder};

use crate::result::Result;

use super::CommandInfo;
use super::{Arg, CommandInfo};

/// Maximum length of a description
///
/// After this, `...` will be added
const MAX_DESC_LEN: usize = 80;

const ELLIPSIS: &str = "...";

/// Regex to get the contents of a section with the given title
fn regex_for_section(title: &str) -> Regex {
Expand All @@ -18,9 +27,105 @@ pub fn parse(cmd_name: &str, page_text: &str) -> Result<Option<CommandInfo>> {
match re.captures(page_text) {
Some(captures) => {
let content = captures.get(1).unwrap().as_str();
println!("{}", content);
todo!()
let mut args = Vec::new();

for para in content.split(".PP") {
if let Some(end) = para.find(".RE") {
let data = &para[3..end];
let data = remove_groff_formatting(data);
let mut data = data.split(".RS 4");
let options = data.next().unwrap();
if let Some(desc) = data.next() {
args.push(make_arg(options, desc));
} else {
println!("No indent in description");
}
}
}

Ok(Some(CommandInfo {
args,
subcommands: HashMap::new(),
}))
}
None => Ok(None),
}
}

// Copied more or less directly from Fish's `built_command`
fn make_arg(options: &str, desc: &str) -> Arg {
let mut forms = HashSet::new();

// Unquote the options
let options = if options.len() == 1 {
options
} else if options.starts_with('"') && options.ends_with('"') {
&options[1..options.len() - 1]
} else if options.starts_with('\'') && options.ends_with('\'') {
&options[1..options.len() - 1]
} else {
options
};
let delim = Regex::new(r#"[ ,="|]"#).unwrap();
for option in delim.split(options) {
let option = Regex::new(r"\[.*\]").unwrap().replace(option, "");
// todo this is ridiculously verbose
let option = option.trim_matches(" \t\r\n[](){}.,:!".chars().collect::<Vec<_>>().as_slice());
if !option.starts_with('-') || option == "-" || option == "--" {
continue;
}
// todo use str.matches instead
if Regex::new(r"\{\}\(\)").unwrap().is_match(option) {
continue;
}
forms.insert(option.to_owned());
}

let desc = desc.trim().replace("\n", " ");
let desc = desc.trim_end_matches('.');
// Remove bogus escapes
let desc = desc.replace(r"\'", "").replace(r"\.", "");

// TODO port the sentence-splitting part too

let desc = if desc.len() > MAX_DESC_LEN {
format!("{}{}", &desc[0..MAX_DESC_LEN - ELLIPSIS.len()], ELLIPSIS)
} else {
desc
};

Arg { forms, desc }
}

// Copied more or less directly from Fish
fn remove_groff_formatting(data: &str) -> String {
let data = data
.replace(r"\fI", "")
.replace(r"\fP", "")
.replace(r"\f1", "")
.replace(r"\fB", "")
.replace(r"\fR", "")
.replace(r"\e", "");
// TODO check if this one is necessary
// also, fish uses a slightly different regex: `.PD( \d+)`, check if that's fine
let re = Regex::new(r"\.PD \d+").unwrap();
let data = re.replace_all(&data, "");
data
.replace(".BI", "")
.replace(".BR", "")
.replace("0.5i", "")
.replace(".rb", "")
.replace(r"\^", "")
.replace("{ ", "")
.replace(" }", "")
.replace(r"\ ", "")
.replace(r"\-", "-")
.replace(r"\&", "")
.replace(".B", "")
.replace(r"\-", "-")
.replace(".I", "")
.replace("\u{C}", "")
.replace(r"\(cq", "'")

// TODO .sp is being left behind, see how Fish handles it
}

0 comments on commit f19d161

Please sign in to comment.