use std::collections::{BTreeMap, BTreeSet};
use clap::{Arg, Command};
use minijinja::{Environment, Error, context};
use serde::Serialize;
use crate::usage;
pub const VERSION_FRAGMENT: &str = r"{% macro version_line(version) -%}
version: {{ version }}
{%- endmacro %}";
pub const INVOCATION_FRAGMENT: &str = r"{% macro mounted_invocation(surface, examples) -%}
## Invocation
```sh
{% for example in examples -%}
mise run {{ surface.mount }} {{ example }}
{% endfor -%}
```
Never `mise run {{ surface.mount }} --`. The `--` in `#USAGE mount` is mise's
completion bootstrap.
{%- endmacro %}";
pub const COMMANDS_FRAGMENT: &str = r#"{% macro command_inventory(surface) -%}
## Commands
| Command | Aliases | Purpose |
|:--|:--|:--|
{% for command in surface.commands if not command.hidden -%}
| `{{ command.name }}` | {% if command.visible_aliases %}`{{ command.visible_aliases | join("`, `") }}`{% else %}—{% endif %} | {{ command.about | replace("|", "\\|") | replace("\n", " ") }} |
{% endfor -%}
{{- "" -}}
{%- endmacro %}"#;
const FRAGMENTS: [(&str, &str); 3] = [
("ctl/version.md.jinja", VERSION_FRAGMENT),
("ctl/invocation.md.jinja", INVOCATION_FRAGMENT),
("ctl/commands.md.jinja", COMMANDS_FRAGMENT),
];
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct Surface {
pub binary: String,
pub mount: String,
pub about: String,
pub version: Option<String>,
pub arguments: Vec<SurfaceArgument>,
pub inherited_arguments: Vec<SurfaceArgument>,
pub commands: Vec<SurfaceCommand>,
pub usage_kdl: String,
pub mount_line: String,
pub notes: BTreeMap<String, String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct SurfaceCommand {
pub name: String,
pub path: String,
pub aliases: Vec<String>,
pub visible_aliases: Vec<String>,
pub hidden: bool,
pub about: String,
pub arguments: Vec<SurfaceArgument>,
pub inherited_arguments: Vec<SurfaceArgument>,
pub commands: Vec<Self>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct SurfaceArgument {
pub id: String,
pub index: Option<usize>,
pub short: Option<char>,
pub long: Option<String>,
pub visible_short_aliases: Vec<char>,
pub short_aliases: Vec<char>,
pub visible_aliases: Vec<String>,
pub aliases: Vec<String>,
pub value_names: Vec<String>,
pub help: String,
pub requirement: SurfaceRequirement,
pub scope: SurfaceScope,
pub hidden: bool,
pub takes_values: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SurfaceRequirement {
Optional,
Required,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SurfaceScope {
Local,
Global,
}
impl Surface {
#[must_use]
pub fn new<C: clap::CommandFactory>(mount: impl Into<String>) -> Self {
Self::from_command(C::command(), mount)
}
#[must_use]
pub fn from_command(mut command: Command, mount: impl Into<String>) -> Self {
let mut declared_arguments = BTreeMap::new();
collect_declarations(&command, "", &mut declared_arguments);
command.build();
let mount = mount.into();
let binary = command.get_name().to_owned();
let about = command
.get_about()
.map(ToString::to_string)
.unwrap_or_default();
let version = command.get_version().map(ToOwned::to_owned);
let arguments = declared_arguments_for(&command, "", &declared_arguments);
let inherited_arguments = arguments
.iter()
.filter(|argument| argument.scope == SurfaceScope::Global)
.cloned()
.collect::<Vec<_>>();
let commands = command
.get_subcommands()
.filter(|child| declared_arguments.contains_key(child.get_name()))
.map(|child| surface_command(child, "", &declared_arguments, &inherited_arguments))
.collect();
let usage_kdl = usage::spec(command, &mount);
let mount_line = usage::mount_line(&mount);
Self {
binary,
mount,
about,
version,
arguments,
inherited_arguments: Vec::new(),
commands,
usage_kdl,
mount_line,
notes: BTreeMap::new(),
}
}
#[must_use]
pub fn note(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.notes.insert(name.into(), value.into());
self
}
}
pub fn add_fragments(environment: &mut Environment<'static>) -> Result<(), Error> {
for (name, source) in FRAGMENTS {
environment.add_template(name, source)?;
}
Ok(())
}
pub fn environment() -> Result<Environment<'static>, Error> {
let mut environment = Environment::new();
environment.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
environment.set_keep_trailing_newline(true);
add_fragments(&mut environment)?;
Ok(environment)
}
pub fn render<T: Serialize>(
name: &'static str,
source: &'static str,
surface: &Surface,
content: &T,
) -> Result<String, Error> {
let mut environment = environment()?;
environment.add_template(name, source)?;
environment
.get_template(name)?
.render(context! { surface, content })
}
fn surface_command(
command: &Command,
parent: &str,
declared_arguments: &BTreeMap<String, BTreeSet<String>>,
inherited_arguments: &[SurfaceArgument],
) -> SurfaceCommand {
let name = command.get_name().to_owned();
let path = if parent.is_empty() {
name.clone()
} else {
format!("{parent} {name}")
};
let arguments = declared_arguments_for(command, &path, declared_arguments);
let mut child_inherited_arguments = inherited_arguments.to_vec();
child_inherited_arguments.extend(
arguments
.iter()
.filter(|argument| argument.scope == SurfaceScope::Global)
.cloned(),
);
SurfaceCommand {
name,
path: path.clone(),
aliases: command.get_all_aliases().map(ToOwned::to_owned).collect(),
visible_aliases: command
.get_visible_aliases()
.map(ToOwned::to_owned)
.collect(),
hidden: command.is_hide_set(),
about: command
.get_about()
.map(ToString::to_string)
.unwrap_or_default(),
arguments,
inherited_arguments: inherited_arguments.to_vec(),
commands: command
.get_subcommands()
.filter(|child| {
declared_arguments.contains_key(&format!("{path} {}", child.get_name()))
})
.map(|child| {
surface_command(child, &path, declared_arguments, &child_inherited_arguments)
})
.collect(),
}
}
fn declared_arguments_for(
command: &Command,
path: &str,
declared_arguments: &BTreeMap<String, BTreeSet<String>>,
) -> Vec<SurfaceArgument> {
command
.get_arguments()
.filter(|argument| declared_argument(declared_arguments, path, argument))
.map(argument)
.collect()
}
fn collect_declarations(
command: &Command,
path: &str,
declared_arguments: &mut BTreeMap<String, BTreeSet<String>>,
) {
declared_arguments.insert(
path.to_owned(),
command
.get_arguments()
.map(|argument| argument.get_id().to_string())
.collect(),
);
for child in command.get_subcommands() {
let child_path = if path.is_empty() {
child.get_name().to_owned()
} else {
format!("{path} {}", child.get_name())
};
collect_declarations(child, &child_path, declared_arguments);
}
}
fn declared_argument(
declared_arguments: &BTreeMap<String, BTreeSet<String>>,
path: &str,
argument: &Arg,
) -> bool {
declared_arguments
.get(path)
.is_some_and(|arguments| arguments.contains(argument.get_id().as_str()))
}
fn argument(argument: &Arg) -> SurfaceArgument {
SurfaceArgument {
id: argument.get_id().to_string(),
index: argument.get_index(),
short: argument.get_short(),
long: argument.get_long().map(ToOwned::to_owned),
visible_short_aliases: argument.get_visible_short_aliases().unwrap_or_default(),
short_aliases: argument.get_all_short_aliases().unwrap_or_default(),
visible_aliases: argument
.get_visible_aliases()
.unwrap_or_default()
.iter()
.map(|alias| (*alias).to_owned())
.collect(),
aliases: argument
.get_all_aliases()
.unwrap_or_default()
.iter()
.map(|alias| (*alias).to_owned())
.collect(),
value_names: argument
.get_value_names()
.unwrap_or_default()
.iter()
.map(ToString::to_string)
.collect(),
help: argument
.get_help()
.map(ToString::to_string)
.unwrap_or_default(),
requirement: if argument.is_required_set() {
SurfaceRequirement::Required
} else {
SurfaceRequirement::Optional
},
scope: if argument.is_global_set() {
SurfaceScope::Global
} else {
SurfaceScope::Local
},
hidden: argument.is_hide_set(),
takes_values: argument.get_action().takes_values(),
}
}
#[cfg(test)]
mod tests {
use clap::{ArgAction, Parser, Subcommand};
use indoc::indoc;
use serde::Serialize;
use super::{Surface, SurfaceArgument, SurfaceScope, render};
#[derive(Parser)]
#[command(name = "toy", version = "1.2.3", about = "Control toys")]
struct Cli {
#[arg(short, long, global = true, help = "Select a profile")]
profile: Option<String>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
#[command(alias = "state", visible_alias = "ls")]
Status {
#[arg(long, action = ArgAction::SetTrue)]
archived: bool,
},
Item {
#[command(subcommand)]
command: ItemCommand,
},
#[command(hide = true)]
Internal,
}
#[derive(Subcommand)]
enum ItemCommand {
Add {
name: String,
},
}
#[test]
fn extracts_the_complete_clap_surface() {
let surface = Surface::new::<Cli>("t");
assert_eq!(surface.binary, "toy");
assert_eq!(surface.mount, "t");
assert_eq!(surface.version.as_deref(), Some("1.2.3"));
assert_eq!(surface.about, "Control toys");
assert_eq!(
surface.inherited_arguments.as_slice(),
&[] as &[SurfaceArgument]
);
assert!(surface.usage_kdl.contains("status"));
assert_eq!(
surface.mount_line,
r#"#USAGE mount "mise run --quiet t -- --usage-spec=t""#
);
let status = &surface.commands[0];
assert_eq!(status.visible_aliases, ["ls"]);
assert_eq!(status.aliases, ["state", "ls"]);
assert_eq!(status.about, "Show current state");
assert_eq!(status.arguments[0].long.as_deref(), Some("archived"));
assert!(!status.arguments[0].takes_values);
assert_eq!(status.arguments.len(), 1);
assert_eq!(status.inherited_arguments.len(), 1);
assert_eq!(
status.inherited_arguments[0].long.as_deref(),
Some("profile")
);
assert_eq!(status.inherited_arguments[0].scope, SurfaceScope::Global);
assert!(
status
.arguments
.iter()
.all(|argument| !matches!(argument.id.as_str(), "help" | "version" | "profile"))
);
let item = &surface.commands[1];
assert_eq!(item.commands[0].path, "item add");
assert_eq!(item.commands[0].arguments[0].index, Some(1));
assert_eq!(item.commands[0].inherited_arguments.len(), 1);
assert_eq!(
item.commands[0].inherited_arguments[0].long.as_deref(),
Some("profile")
);
assert!(surface.commands[2].hidden);
assert!(
surface
.arguments
.iter()
.any(|arg| arg.long.as_deref() == Some("profile"))
);
assert!(
surface
.arguments
.iter()
.all(|argument| !matches!(argument.id.as_str(), "help" | "version"))
);
let noted = surface.note("skill", "Prefer the mounted task.");
assert_eq!(noted.notes["skill"], "Prefer the mounted task.");
}
#[derive(Serialize)]
struct Content<'a> {
version: &'a str,
invocations: [&'a str; 2],
}
#[test]
fn shared_fragments_render_committed_operator_blocks() {
let surface = Surface::new::<Cli>("t");
let template = indoc! {r#"
{%- from "ctl/version.md.jinja" import version_line -%}
{%- from "ctl/invocation.md.jinja" import mounted_invocation -%}
{%- from "ctl/commands.md.jinja" import command_inventory -%}
---
{{ version_line(content.version) }}
---
{{ mounted_invocation(surface, content.invocations) }}
{{ command_inventory(surface) -}}
"#};
let rendered = render(
"operator.md.jinja",
template,
&surface,
&Content {
version: "1.2.3",
invocations: ["status", "item add demo"],
},
)
.unwrap_or_else(|error| panic!("render operator template: {error}"));
let expected = indoc! {r"
---
version: 1.2.3
---
## Invocation
```sh
mise run t status
mise run t item add demo
```
Never `mise run t --`. The `--` in `#USAGE mount` is mise's
completion bootstrap.
## Commands
| Command | Aliases | Purpose |
|:--|:--|:--|
| `status` | `ls` | Show current state |
| `item` | — | Mutate one item |
"};
assert_eq!(rendered, expected);
assert!(!rendered.contains("internal"));
}
}