Skip to content

Commit

Permalink
feat!: generate carapace but remove yaml support
Browse files Browse the repository at this point in the history
  • Loading branch information
ysthakur committed Jan 8, 2024
1 parent b53d818 commit c429a78
Show file tree
Hide file tree
Showing 10 changed files with 280 additions and 39 deletions.
2 changes: 1 addition & 1 deletion .envrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
use flake
# use flake
30 changes: 30 additions & 0 deletions Cargo.lock

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

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ strip = "debuginfo"
[dependencies]
clap = { version = "4.3", features = ["derive", "env"] }
env_logger = "0.10"
thiserror = "1.0"
indoc = "2"
log = "0.4"
miette = "5.10"
regex = "1.9"
thiserror = "1.0"

# For parsing manpages
bzip2 = "0.4"
Expand All @@ -37,6 +38,7 @@ serde_yaml = "0.9"
assert_cmd = "2.0"
insta = "1"
miette = { version = "5.10", features = ["fancy"] }
pretty_assertions = "1"
tempfile = "3"

[profile.dev.package.insta]
Expand Down
38 changes: 21 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,19 @@
[![Latest version](https://img.shields.io/crates/v/gen-completions.svg)](https://crates.io/crates/gen-completions)
[![License](https://img.shields.io/crates/l/gen-completions.svg)](./LICENSE.md)

> [!warning]
> This project is a work in progress so it's extremely unstable and mostly broken.
This is a crate for parsing manpages to generate shell completions either by parsing
manpages or from KDL/JSON/YAML files. There's both a library and a binary, and if
manpages or from KDL/JSON files. There's both a library and a binary, and if
you're looking for documentation on the library, see https://docs.rs/gen-completions/.
But you're probably here for the binary, and if you want information on that, read on.

Currently, it generates Bash, Zsh, and Nushell completions, although I've only
tested out Zsh and Nushell properly. It also generates KDL, JSON, or YAML files,
in case your shell isn't supported, so you can process it and generate completions
yourself.
tested out Zsh and Nushell properly. If you're using another shell, it also generates
[Carapace](https://github.com/rsteube/carapace-bin) specs. In addition to that,
it generates KDL and JSON files so you can process the command information
to generate completions yourself or something else.

The manpage parsing has been mainly ported from [Fish's completions script](https://github.com/fish-shell/fish-shell/blob/master/share/tools/create_manpage_completions.py),
although this crate doesn't yet support every kind of manpage that the Fish script supports.
Expand Down Expand Up @@ -61,19 +65,19 @@ Arguments:
Shell(s) to generate completions for
Possible values:
- zsh: Generate completions for Zsh
- bash: Generate completions for Bash
- nu: Generate completions for Nushell
- kdl: Output parsed options as KDL
- json: Output parsed options as JSON
- yaml: Output parsed options as YAML
- zsh: Generate completions for Zsh
- bash: Generate completions for Bash
- nu: Generate completions for Nushell
- kdl: Output parsed options as KDL
- json: Output parsed options as JSON
- carapace: Output Carapace spec
<PATH>
Directory to output completions to
Options:
-d, --dirs <PATH,...>
Directories to search for man pages in, e.g. `--dirs=/usr/share/man/man1,/usr/share/man/man6`
Directories to search for man pages in, e.g. `--dirs=/usr/share/man/man1,/usr/share/man/man6` Note that `--dirs` will search directly inside the given directories, not inside `<dir>/man1`, `<dir>/man2`, etc. If you want to search for man pages in a specific set of directories, set `$MANPATH` before running this command
-c, --cmds <REGEX>
Commands to generate completions for. If omitted, generates completions for all found commands. To match the whole name, use "^...$"
Expand Down Expand Up @@ -101,12 +105,12 @@ Arguments:
Shell(s) to generate completions for
Possible values:
- zsh: Generate completions for Zsh
- bash: Generate completions for Bash
- nu: Generate completions for Nushell
- kdl: Output parsed options as KDL
- json: Output parsed options as JSON
- yaml: Output parsed options as YAML
- zsh: Generate completions for Zsh
- bash: Generate completions for Bash
- nu: Generate completions for Nushell
- kdl: Output parsed options as KDL
- json: Output parsed options as JSON
- carapace: Output Carapace spec
<CONF>
File to generate completions from
Expand Down
181 changes: 181 additions & 0 deletions src/gen/carapace.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
use std::collections::HashMap;

use serde::Serialize;

use super::util::{pair_forms, trim_dashes};
use crate::{ArgType, CommandInfo};

const HEADER: &str =
"# yaml-language-server: $schema=https://carapace.sh/schemas/command.json";

/// To let `serde_yaml` serialize the command info
#[derive(Serialize)]
struct CarapaceCmd {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "HashMap::is_empty")]
flags: HashMap<String, String>,
#[serde(skip_serializing_if = "Completion::is_empty")]
completion: Completion,
#[serde(skip_serializing_if = "Vec::is_empty")]
commands: Vec<CarapaceCmd>,
}

#[derive(Serialize)]
struct Completion {
#[serde(skip_serializing_if = "Vec::is_empty")]
positional: Vec<Vec<String>>,
#[serde(skip_serializing_if = "HashMap::is_empty")]
flag: HashMap<String, Vec<String>>,
}

impl Completion {
fn is_empty(&self) -> bool {
self.positional.is_empty() && self.flag.is_empty()
}
}

/// Generate a Carapace spec from a [`CommandInfo`]
pub fn generate(cmd: &CommandInfo) -> String {
let yaml = serde_yaml::to_string(&to_carapace(&cmd))
.expect("Carapace spec should've been serialized to YAML");
format!("{}\n{}", HEADER, yaml)
}

/// Generate a [`CommandInfo`] to a carapace spec so it can be serialized
fn to_carapace(cmd: &CommandInfo) -> CarapaceCmd {
let mut flags = HashMap::new();
let mut flag_completions = HashMap::new();

for flag in &cmd.flags {
let desc = flag.desc.clone().unwrap_or_default();
let typ = flag.typ.as_ref().map(carapace_type);

for (short, long) in pair_forms(&flag.forms) {
let (main_form, combined) = match (short, long) {
(Some(short), Some(long)) => (long, format!("{},{}", short, long)),
(Some(short), None) => (short, short.to_owned()),
(None, Some(long)) => (long, long.to_owned()),
(None, None) => unreachable!(),
};
if let Some(typ) = typ.clone() {
// If there's an argument, the flag name needs a `=` after it
flags.insert(format!("{}=", combined), desc.clone());
flag_completions.insert(trim_dashes(main_form), typ);
} else {
flags.insert(combined, desc.clone());
}
}
}

CarapaceCmd {
name: cmd.name.clone(),
description: cmd.desc.clone(),
flags,
completion: Completion {
positional: cmd.args.iter().map(carapace_type).collect(),
flag: flag_completions,
},
commands: cmd.subcommands.iter().map(to_carapace).collect(),
}
}

/// Turn a type into something Carapace understands
fn carapace_type(typ: &ArgType) -> Vec<String> {
match typ {
ArgType::Strings(strs) => strs
.iter()
.map(|(val, desc)| {
if let Some(desc) = desc {
format!("{}\t{}", val, desc)
} else {
val.to_owned()
}
})
.collect::<Vec<_>>(),
ArgType::Path => vec!["$files".to_owned()],
ArgType::Dir => vec!["$directories".to_owned()],
ArgType::Any(types) => types.iter().flat_map(carapace_type).collect(),
_ => todo!(),
}
}

#[cfg(test)]
mod tests {
use indoc::indoc;
use pretty_assertions::assert_eq;

use super::generate;
use crate::{ArgType, CommandInfo, Flag};

/// Removes the header from the generated YAML and trims both strings
macro_rules! assert_fmt {
($left:expr, $right:expr) => {
assert_eq!(
indoc! { $left }.trim(),
generate(&$right)
.trim()
.lines()
.skip(1)
.collect::<Vec<_>>()
.join("\n")
)
};
}

#[test]
fn test_empty() {
assert_fmt!(
r#"
name: foo
"#,
CommandInfo {
name: "foo".to_owned(),
desc: None,
args: vec![],
flags: vec![],
subcommands: vec![],
}
)
}

#[test]
fn test_multiple_forms() {
assert_fmt!(
r#"
name: foo
description: |-
blah blah
Newline
flags:
-b,--bar=: This flag does nothing
--why=: This flag does nothing
completion:
positional:
- - $files
flag:
why:
- "baz1\tDescription for baz1"
- "baz2\tAnother description"
bar:
- "baz1\tDescription for baz1"
- "baz2\tAnother description"
"#,
CommandInfo {
name: "foo".to_owned(),
desc: Some("blah blah\nNewline".to_owned()),
args: vec![ArgType::Any(vec![ArgType::Path])],
flags: vec![Flag {
forms: vec!["-b".to_owned(), "--bar".to_owned(), "--why".to_owned()],
desc: Some("This flag does nothing".to_owned()),
typ: Some(ArgType::Strings(vec![
("baz1".to_owned(), Some("Description for baz1".to_owned())),
("baz2".to_owned(), Some("Another description".to_owned()))
]))
}],
subcommands: vec![],
}
)
}
}
15 changes: 8 additions & 7 deletions src/gen/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod bash;
mod carapace;
mod kdl;
mod nu;
mod util;
Expand Down Expand Up @@ -29,8 +30,8 @@ pub enum OutputFormat {
Kdl,
/// Output parsed options as JSON
Json,
/// Output parsed options as YAML
Yaml,
/// Output Carapace spec
Carapace,
}

/// Generate completion for the given shell and write to a file
Expand Down Expand Up @@ -67,12 +68,12 @@ fn generate(cmd: &CommandInfo, format: OutputFormat) -> (String, String) {
}
OutputFormat::Json => (
format!("{}.json", cmd.name),
serde_json::to_string(&cmd).unwrap(),
),
OutputFormat::Yaml => (
format!("{}.yaml", cmd.name),
serde_yaml::to_string(&cmd).unwrap(),
serde_json::to_string(&cmd)
.expect("Command info should've been serialized to JSON"),
),
OutputFormat::Carapace => {
(format!("{}.yaml", cmd.name), carapace::generate(&cmd))
}
}
}

Expand Down
Loading

0 comments on commit c429a78

Please sign in to comment.