diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 8363be1..fc09c3a 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -40,7 +40,7 @@ serde = { version = "1", features = ["derive"] } strum = { version = "0.26", features = ["derive"] } tera = "1" thiserror = "1" -usage-lib = { workspace = true, features = ["clap"] } +usage-lib = { workspace = true, features = ["clap", "docs"] } xx = "1" [dev-dependencies] diff --git a/cli/src/cli/generate/markdown.rs b/cli/src/cli/generate/markdown.rs index 03cd035..3af2341 100644 --- a/cli/src/cli/generate/markdown.rs +++ b/cli/src/cli/generate/markdown.rs @@ -1,505 +1,74 @@ -use std::collections::HashMap; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use clap::Args; -use contracts::requires; -use kdl::{KdlDocument, KdlNode}; -use miette::{Context, IntoDiagnostic, NamedSource, SourceOffset, SourceSpan}; -use strum::EnumIs; -use tera::Tera; -use thiserror::Error; -use xx::file; - -use usage::spec::config::SpecConfig; -use usage::{Spec, SpecCommand}; - -use crate::errors::UsageCLIError; +use usage::docs::markdown::MarkdownRenderer; +use usage::Spec; #[derive(Args)] #[clap(visible_alias = "md")] pub struct Markdown { - // /// A usage spec taken in as a file - // #[clap()] - // file: Option, + /// A usage spec taken in as a file + #[clap(short, long)] + file: PathBuf, // /// Pass a usage spec in an argument instead of a file // #[clap(short, long, required_unless_present = "file", overrides_with = "file")] - // spec_str: Option, - /// A markdown file taken as input - /// This file should have a comment like this: - /// - #[clap( - required_unless_present = "out_dir", verbatim_doc_comment, value_hint = clap::ValueHint::FilePath - )] - inject: Option, + // spec: Option, + /// Render each subcommand as a separate markdown file + #[clap(short, long, requires = "out_dir", conflicts_with = "out_file")] + multi: bool, + + /// Prefix to add to all URLs + #[clap(long)] + url_prefix: Option, /// Output markdown files to this directory - #[clap(short, long, value_hint = clap::ValueHint::DirPath)] + #[clap(long, value_hint = clap::ValueHint::DirPath)] out_dir: Option, + + #[clap(long, value_hint = clap::ValueHint::FilePath, required_unless_present = "multi")] + out_file: Option, } impl Markdown { pub fn run(&self) -> miette::Result<()> { - if let Some(inject) = &self.inject { - self.inject_file(inject)?; - } - // let spec = file_or_spec(&self.file, &self.spec_str)?; - // for cmd in spec.cmd.subcommands.values() { - // self.print(&spec, self.out_dir.as_ref().unwrap(), &[&spec.cmd, cmd])?; - // } - Ok(()) - } - - fn inject_file(&self, inject: &Path) -> miette::Result<()> { - let raw = file::read_to_string(inject).into_diagnostic()?; - let directives = parse_readme_directives(inject, &raw)?; - let b = MarkdownBuilder::new(inject, directives); - for (file, out) in b.load()?.render()? { - print!("{}", out); - file::write(file, &out).into_diagnostic()?; - } - Ok(()) - } -} - -const USAGE_TITLE_TEMPLATE: &str = r#" -# {{name}} -"#; - -const USAGE_OVERVIEW_TEMPLATE: &str = r#" -## Usage - -```bash -{{usage}} -``` -"#; - -const CONFIG_TEMPLATE: &str = r#" -### `!KEY!` - -!ENV! -!DEFAULT! - -!HELP! -!LONG_HELP! -"#; - -const COMMANDS_INDEX_TEMPLATE: &str = r#" -## CLI Command Reference - -{% for cmd in commands -%} -{% if multi_dir -%} -* [`{{ bin }} {{ cmd.full_cmd | join(sep=" ") }}`]({{ multi_dir }}/{% for c in cmd.full_cmd %}{{ c | slugify }}{% if not loop.last %}/{% endif %}{% endfor %}.md) -{% elif cmd.deprecated -%} -* ~~[`{{ bin }} {{ cmd.full_cmd | join(sep=" ") }}`](#{{ bin | slugify }}-{{ cmd.full_cmd | join(sep=" ") | slugify }}-deprecated)~~ [deprecated] -{% else -%} -* [`{{ bin }} {{ cmd.full_cmd | join(sep=" ") }}`](#{{ bin | slugify }}-{{ cmd.full_cmd | join(sep=" ") | slugify }}) -{% endif -%} -{% endfor -%} -"#; - -const COMMAND_TEMPLATE: &str = r##" -{% set deprecated = "" %}{% if cmd.deprecated %}{% set deprecated = "~~" %}{% endif %} -{{ header }} {{deprecated}}`{{ bin }} {{ cmd.full_cmd | join(sep=" ") }}`{{deprecated}}{% if cmd.deprecated %} [deprecated]{% endif -%} - -{% if cmd.before_long_help %} - -{{ cmd.before_long_help | trim }} - -{% elif cmd.before_help %} -{{ cmd.before_help | trim }} -{% endif -%} - -{% if cmd.aliases %} - -###### Aliases: `{{ cmd.aliases | join(sep="`, `") }}`{{""-}} -{% endif -%} - -{% if cmd.long_help %} - -{{ cmd.long_help | trim -}} -{% elif cmd.help %} - -{{ cmd.help | trim -}} -{% endif -%} - -{% for name, cmd in cmd.subcommands -%} -{% if loop.first %} -{{header}}# Subcommands -{% endif %} -* `{{ cmd.usage }}` - {{ cmd.help -}} -{% endfor -%} - -{% if cmd.args -%} -{% for arg in cmd.args %} - -###### Arg `{{ arg.usage }}` - -{% if arg.required %}(required){% endif -%} -{{ arg.long_help | default(value=arg.help) -}} - -{% endfor -%} -{% endif -%} -{% if cmd.flags -%} -{% for flag in cmd.flags %} - -{% if flag.deprecated -%} -##### Flag ~~`{{ flag.usage }}`~~ [deprecated] -{% else -%} -##### Flag `{{ flag.usage }}` -{% endif %} -{{ flag.long_help | default(value=flag.help) -}} -{% endfor -%} -{% endif -%} - -{% for ex in cmd.examples -%} -{% if loop.first %} - -##### Examples -{% endif %} -{% if ex.header -%} -###### {{ ex.header }} -{% endif %} -```{{ ex.lang | default(value="") }} -{{ ex.code }} -``` -{% if ex.help %} -{{ ex.help -}} -{% endif -%} -{% endfor -%} - -{% if cmd.after_long_help %} - -{{ cmd.after_long_help | trim }} -{% elif cmd.after_help %} - -{{ cmd.after_help | trim }} -{% endif -%} -"##; - -#[derive(Debug, EnumIs)] -#[strum(serialize_all = "snake_case")] -enum UsageMdDirective { - Load { - token: String, - file: PathBuf, - }, - Title { - token: String, - }, - UsageOverview { - token: String, - }, - GlobalArgs { - token: String, - }, - GlobalFlags { - token: String, - }, - Commands { - token: String, - multi_dir: Option, - }, - Config { - token: String, - }, - EndToken {}, - Plain { - token: String, - }, -} - -fn render_template(template: &str, ctx: &tera::Context) -> miette::Result { - let out = Tera::one_off(template, ctx, false).into_diagnostic()?; - Ok(out) -} - -fn print_config(config: &SpecConfig) -> miette::Result { - let mut all = vec![]; - for (key, prop) in &config.props { - let mut out = CONFIG_TEMPLATE.to_string(); - let mut tmpl = |k, d: String| { - out = out.replace(k, &d); + let write = |path: &PathBuf, md: &str| -> miette::Result<()> { + println!("writing to {}", path.display()); + xx::file::write(path, md)?; + Ok(()) }; - tmpl("!KEY!", key.to_string()); - // out = out.replace("!KEY!", &format!("### `{key}`")); - if let Some(env) = &prop.env { - tmpl("!ENV!", format!("* env: `{env}`")); - // out = out.replace("!ENV!", &format!("* env: `{env}`")); - } - if let Some(default) = prop.default_note.clone().or_else(|| prop.default.clone()) { - tmpl("!DEFAULT!", format!("* default: `{default}`")); - // out = out.replace("!DEFAULT!", &format!("* default: `{default}`")); - } - if let Some(help) = prop.long_help.clone().or(prop.help.clone()) { - // out = out.replace("!HELP!", &format!("* help: `{help}`")); - tmpl("!HELP!", help); - } - out = regex!(r#"!.+!\n"#) - .replace_all(&out, "") - .trim_start() - .trim_end() - .to_string() - + "\n"; - all.push(out) - // TODO: data type - // TODO: show which commands use this prop ctx.push("Used by commnds: global|*".to_string()); - } - Ok(all.join("\n")) -} - -#[derive(Error, Diagnostic, Debug)] -#[error("Error parsing markdown directive")] -#[diagnostic()] -struct MarkdownError { - msg: String, - - #[source_code] - src: String, - - #[label("{msg}")] - err_span: SourceSpan, -} - -fn parse_readme_directives(path: &Path, full: &str) -> miette::Result> { - let mut directives = vec![]; - for (line_num, line) in full.lines().enumerate() { - if line == "" { - directives.push(UsageMdDirective::EndToken {}); - continue; + let (spec, _) = Spec::parse_file(&self.file)?; + let mut ctx = MarkdownRenderer::new(&spec); + if let Some(url_prefix) = &self.url_prefix { + ctx = ctx.with_url_prefix(url_prefix); } - let directive = if let Some(x) = regex!(r#""#).captures(line) { - let doc: KdlDocument = x.get(1).unwrap().as_str().parse()?; - if !doc.nodes().len() == 1 { - miette::bail!("only one node allowed in usage directive"); - } - let node = doc.nodes().first().unwrap(); - let err = |msg: String, span| MarkdownError { - msg, - src: doc.to_string(), - err_span: span, - }; - let get_prop = |node: &KdlNode, key: &'static str| { - Ok(node.get(key).map(|v| v.value().clone()).clone()) - }; - let get_string = |node: &KdlNode, key: &'static str| match get_prop(node, key)? { - Some(v) => v - .as_string() - .map(|s| s.to_string()) - .ok_or_else(|| err(format!("{key} must be a string"), *node.span())) - .map(Some), - None => Ok(None), - }; - match node.name().value() { - "load" => UsageMdDirective::Load { - file: PathBuf::from( - get_string(node, "file") - .with_context(|| miette!("load directive must have a file"))? - .ok_or_else(|| { - err("load directive must have a file".into(), *node.span()) - })?, - ), - token: line.into(), - }, - "title" => UsageMdDirective::Title { token: line.into() }, - "usage_overview" => UsageMdDirective::UsageOverview { token: line.into() }, - "global_args" => UsageMdDirective::GlobalArgs { token: line.into() }, - "global_flags" => UsageMdDirective::GlobalFlags { token: line.into() }, - "config" => UsageMdDirective::Config { token: line.into() }, - "commands" => UsageMdDirective::Commands { - token: line.into(), - multi_dir: get_string(node, "multi_dir")?, - }, - k => Err(UsageCLIError::MarkdownParseError { - message: format!("unknown directive type: {k}"), - src: get_named_source(path, full), - label: get_source_span(full, line_num, k.len()), - })?, + if self.multi { + ctx = ctx.with_multi(true); + let commands = spec.cmd.all_subcommands().into_iter().filter(|c| !c.hide); + for cmd in commands { + let md = ctx.render_cmd(cmd)?; + let dir = cmd + .full_cmd + .iter() + .take(cmd.full_cmd.len() - 1) + .map(|c| c.to_string()) + .collect::>() + .join("/"); + let path = self + .out_dir + .as_ref() + .unwrap() + .join(dir) + .join(format!("{}.md", cmd.name)); + write(&path, &md)?; } + let md_idx = ctx.render_index()?; + let path_idx = self.out_dir.as_ref().unwrap().join("index.md"); + write(&path_idx, &md_idx)?; } else { - UsageMdDirective::Plain { token: line.into() } - }; - directives.push(directive); - } - Ok(directives) -} - -fn get_named_source(path: &Path, full: &str) -> NamedSource { - NamedSource::new(path.to_string_lossy(), full.to_string()) -} - -fn get_source_span(full: &str, line_num: usize, len: usize) -> SourceSpan { - let offset = SourceOffset::from_location(full, line_num + 1, 14).offset(); - (offset, len).into() -} - -struct MarkdownBuilder { - inject: PathBuf, - root: PathBuf, - directives: Vec, - - spec: Option, -} - -impl MarkdownBuilder { - fn new(inject: &Path, directives: Vec) -> Self { - let inject = inject.to_path_buf(); - Self { - root: inject.parent().unwrap().to_path_buf(), - inject, - directives, - spec: None, - } - } - - #[requires(self.spec.is_none())] - fn load(mut self) -> miette::Result { - for dct in &self.directives { - if let UsageMdDirective::Load { file, .. } = dct { - let file = match file.is_relative() { - true => self.root.join(file), - false => file.to_path_buf(), - }; - let (spec, _) = Spec::parse_file(&file)?; - self.spec = Some(spec); - } + let md = ctx.render_spec()?; + let path = self.out_file.as_ref().unwrap(); + write(path, &md)?; } - ensure!(self.spec.is_some(), "spec must be loaded before title"); - Ok(self) - } - - #[requires(self.spec.is_some())] - fn render(&self) -> miette::Result> { - let spec = self.spec.as_ref().unwrap(); - let commands = gather_subcommands(&[&spec.cmd]); - let ctx = tera::Context::from_serialize(&self.spec).into_diagnostic()?; - let mut outputs = HashMap::new(); - let mut plain = true; - for dct in &self.directives { - let main = outputs - .entry(self.inject.clone()) - .or_insert_with(std::vec::Vec::new); - match dct { - UsageMdDirective::Plain { .. } | UsageMdDirective::Load { .. } => {} - UsageMdDirective::EndToken { .. } => { - plain = true; - } - _ => plain = false, - } - match dct { - UsageMdDirective::Load { token, .. } => { - main.push(token.clone()); - } - UsageMdDirective::Title { token } => { - main.push(token.clone()); - main.push(render_template(USAGE_TITLE_TEMPLATE, &ctx)?); - main.push("".to_string()); - } - UsageMdDirective::UsageOverview { token } => { - main.push(token.clone()); - main.push(render_template(USAGE_OVERVIEW_TEMPLATE, &ctx)?); - main.push("".to_string()); - } - UsageMdDirective::GlobalArgs { token } => { - main.push(token.clone()); - let args = spec.cmd.args.iter().filter(|a| !a.hide).collect::>(); - if !args.is_empty() { - for arg in args { - // let name = &arg.usage(); - let name = "USAGE"; - if let Some(about) = &arg.long_help { - main.push(format!("### {name}", name = name)); - main.push(about.to_string()); - } else if let Some(about) = &arg.help { - main.push(format!("- `{name}`: {about}",)); - } else { - main.push(format!("- `{name}`", name = name)); - } - } - } - main.push("".to_string()); - } - UsageMdDirective::GlobalFlags { token } => { - main.push(token.clone()); - let flags = spec - .cmd - .flags - .iter() - .filter(|f| !f.hide) - .collect::>(); - if !flags.is_empty() { - for flag in flags { - let name = flag.usage(); - if let Some(about) = &flag.long_help { - main.push(format!("### {name}")); - main.push(about.to_string()); - } else if let Some(about) = &flag.help { - main.push(format!("- `{name}`: {about}",)); - } else { - main.push(format!("- `{name}`")); - } - } - } - main.push("".to_string()); - } - UsageMdDirective::Commands { token, multi_dir } => { - main.push(token.clone()); - let mut ctx = ctx.clone(); - ctx.insert("commands", &commands); - ctx.insert("multi_dir", &multi_dir); - main.push(render_template(COMMANDS_INDEX_TEMPLATE, &ctx)?); - for cmd in &commands { - let mut ctx = ctx.clone(); - ctx.insert("cmd", &cmd); - let output_file = match &multi_dir { - Some(multi_dir) => { - ctx.insert("header", "#"); - self.root - .join(multi_dir) - .join(format!("{}.md", cmd.full_cmd.join("/"))) - } - None => { - ctx.insert("header", &"#".repeat(cmd.full_cmd.len() + 1)); - self.inject.clone() - } - }; - let out = outputs.entry(output_file).or_insert_with(Vec::new); - let s = render_template(COMMAND_TEMPLATE, &ctx)?.trim().to_string(); - out.push(s + "\n"); - } - let main = outputs.get_mut(&self.inject).unwrap(); - main.push("".to_string()); - } - UsageMdDirective::Config { token } => { - main.push(token.clone()); - main.push(print_config(&spec.config)?); - main.push("".to_string()); - } - UsageMdDirective::EndToken { .. } => {} - UsageMdDirective::Plain { token } => { - if plain { - main.push(token.clone()); - } - } - }; - } - Ok(outputs - .into_iter() - .map(|(k, v)| (k, v.join("\n").trim_start().trim_end().to_string() + "\n")) - .collect()) - } -} - -fn gather_subcommands(cmds: &[&SpecCommand]) -> Vec { - let mut subcommands = vec![]; - for cmd in cmds { - if cmd.hide { - continue; - } - if !cmd.name.is_empty() { - subcommands.push((*cmd).clone()); - } - let more = gather_subcommands(&cmd.subcommands.values().collect::>()); - subcommands.extend(more); + Ok(()) } - subcommands } diff --git a/cli/src/errors.rs b/cli/src/errors.rs index dbcc107..a11b606 100644 --- a/cli/src/errors.rs +++ b/cli/src/errors.rs @@ -1,14 +1,14 @@ -use miette::{Diagnostic, NamedSource, SourceSpan}; +use miette::Diagnostic; use thiserror::Error; #[derive(Error, Diagnostic, Debug)] pub enum UsageCLIError { - #[error("Invalid markdown template")] - MarkdownParseError { - message: String, - #[label = "{message}"] - label: SourceSpan, - #[source_code] - src: NamedSource, - }, + // #[error("Invalid markdown template")] + // MarkdownParseError { + // message: String, + // #[label = "{message}"] + // label: SourceSpan, + // #[source_code] + // src: NamedSource, + // }, } diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 834516a..6a23394 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -1,8 +1,6 @@ #[macro_use] extern crate log; -#[macro_use] extern crate miette; -#[macro_use] extern crate xx; use std::path::PathBuf; diff --git a/lib/src/complete/bash.rs b/lib/src/complete/bash.rs index 71db33f..c51f720 100644 --- a/lib/src/complete/bash.rs +++ b/lib/src/complete/bash.rs @@ -35,6 +35,7 @@ shopt -u hostcomplete && complete -o nospace -o bashdefault -o nosort -F _{bin} #[cfg(test)] mod tests { use super::*; + use insta::assert_snapshot; #[test] fn test_complete_bash() { diff --git a/lib/src/complete/fish.rs b/lib/src/complete/fish.rs index 552a0af..aafafc5 100644 --- a/lib/src/complete/fish.rs +++ b/lib/src/complete/fish.rs @@ -21,6 +21,7 @@ complete -xc {bin} -a '(usage complete-word --shell fish -s "$_usage_spec_{bin}" #[cfg(test)] mod tests { use super::*; + use insta::assert_snapshot; #[test] fn test_complete_fish() { diff --git a/lib/src/complete/zsh.rs b/lib/src/complete/zsh.rs index d516ebe..bbc542c 100644 --- a/lib/src/complete/zsh.rs +++ b/lib/src/complete/zsh.rs @@ -64,6 +64,7 @@ fi #[cfg(test)] mod tests { use super::*; + use insta::assert_snapshot; #[test] fn test_complete_zsh() { diff --git a/lib/src/docs/cli/spec_no_args_template.tera b/lib/src/docs/cli/spec_no_args_template.tera new file mode 100644 index 0000000..2c13cac --- /dev/null +++ b/lib/src/docs/cli/spec_no_args_template.tera @@ -0,0 +1,2 @@ +{before-help}{about-with-newline} +{usage-heading} {usage}{after-help}\ diff --git a/lib/src/docs/cli/spec_template.tera b/lib/src/docs/cli/spec_template.tera new file mode 100644 index 0000000..34327ad --- /dev/null +++ b/lib/src/docs/cli/spec_template.tera @@ -0,0 +1,4 @@ +{before-help}{about-with-newline} +{usage-heading} {usage} + +{all-args}{after-help}\ diff --git a/lib/src/docs/markdown/arg.rs b/lib/src/docs/markdown/arg.rs new file mode 100644 index 0000000..bb90730 --- /dev/null +++ b/lib/src/docs/markdown/arg.rs @@ -0,0 +1,32 @@ +use crate::docs::markdown::renderer::MarkdownRenderer; +use crate::docs::markdown::tera::TERA; +use crate::error::UsageErr; +use crate::SpecArg; + +impl MarkdownRenderer { + pub fn render_arg(&self, arg: &SpecArg) -> Result { + let tera = TERA.clone(); + let mut ctx = self.clone(); + ctx.insert("arg", arg); + + Ok(tera.render("arg_template.md.tera", &ctx.tera_ctx())?) + } +} + +#[cfg(test)] +mod tests { + use crate::docs::markdown::renderer::MarkdownRenderer; + use crate::spec; + use insta::assert_snapshot; + + #[test] + fn test_render_markdown_arg() { + let spec = spec! { r#"arg "arg1" help="arg1 description""# }.unwrap(); + let ctx = MarkdownRenderer::new(&spec); + assert_snapshot!(ctx.render_arg(&spec.cmd.args[0]).unwrap(), @r#" + + + arg1 description + "#); + } +} diff --git a/lib/src/docs/markdown/cmd.rs b/lib/src/docs/markdown/cmd.rs new file mode 100644 index 0000000..5f03fff --- /dev/null +++ b/lib/src/docs/markdown/cmd.rs @@ -0,0 +1,66 @@ +use crate::docs::markdown::renderer::MarkdownRenderer; +use crate::docs::markdown::tera::TERA; +use crate::error::UsageErr; +use crate::SpecCommand; + +impl MarkdownRenderer { + pub fn render_cmd(&self, cmd: &SpecCommand) -> Result { + let mut ctx = self.clone(); + + ctx.insert("cmd", cmd); + + Ok(TERA.render("cmd_template.md.tera", &ctx.tera_ctx())?) + } +} + +#[cfg(test)] +mod tests { + use crate::docs::markdown::renderer::MarkdownRenderer; + use crate::test::SPEC_KITCHEN_SINK; + use insta::assert_snapshot; + + #[test] + fn test_render_markdown_cmd() { + let ctx = MarkdownRenderer::new(&SPEC_KITCHEN_SINK).with_multi(true); + assert_snapshot!(ctx.render_cmd(&SPEC_KITCHEN_SINK.cmd).unwrap(), @r####" + + # `mycli mycli [args] [flags] [subcommand]` + + ## Arguments + + ### `` + + arg1 description + + ### `` + + arg2 description + + ### `` + + arg3 long description + + ### `...` + + ## Flags + + ### `--flag1` + + flag1 description + + ### `--flag2` + + flag2 long description + + ### `--flag3` + + flag3 description + + ### `--shell ` + + ## Subcommands + + * [`mycli plugin [subcommand]`](/plugin.md) + "####); + } +} diff --git a/lib/src/docs/markdown/cmd_template.tera b/lib/src/docs/markdown/cmd_template.tera deleted file mode 100644 index ccb7ee9..0000000 --- a/lib/src/docs/markdown/cmd_template.tera +++ /dev/null @@ -1,76 +0,0 @@ -{% set deprecated = "" %}{% if cmd.deprecated %}{% set deprecated = "~~" %}{% endif %} -{{ header }} {{deprecated}}`{{ bin }}{{ cmd.full_cmd | join(sep=" ") }}`{{deprecated}}{% if cmd.deprecated %} [deprecated]{% endif -%} - -{% if cmd.before_long_help %} - -{{ cmd.before_long_help | trim }} - -{% elif cmd.before_help %} -{{ cmd.before_help | trim }} -{% endif -%} - -{% if cmd.aliases %} - -{{ header }}# Aliases: `{{ cmd.aliases | join(sep="`, `") }}`{{""-}} -{% endif -%} - -{% if cmd.long_help %} - -{{ cmd.long_help | trim -}} -{% elif cmd.help %} - -{{ cmd.help | trim -}} -{% endif -%} - -{% for name, cmd in cmd.subcommands -%} -{% if loop.first %} -{{ header }}# Subcommands -{% endif %} -* `{{ cmd.usage }}` - {{ cmd.help -}} -{% endfor -%} - -{% if cmd.args -%} -{% for arg in cmd.args %} - -{{ header }}# Arg `{{ arg.usage }}` - -{% if arg.required %}(required) {% endif -%} -{{ arg.long_help | default(value=arg.help) -}} - -{% endfor -%} -{% endif -%} -{% if cmd.flags -%} -{% for flag in cmd.flags %} - -{% if flag.deprecated -%} -{{ header }}# Flag ~~`{{ flag.usage }}`~~ [deprecated] -{% else -%} -{{ header }}# Flag `{{ flag.usage }}` -{% endif %} -{{ flag.long_help | default(value=flag.help) -}} -{% endfor -%} -{% endif -%} - -{% for ex in cmd.examples -%} -{% if loop.first %} - -{{ header}}# Examples -{% endif %} -{% if ex.header -%} -{{ header }}# {{ ex.header }} -{% endif %} -```{{ ex.lang | default(value="") }} -{{ ex.code }} -``` -{% if ex.help %} -{{ ex.help -}} -{% endif -%} -{% endfor -%} - -{% if cmd.after_long_help %} - -{{ cmd.after_long_help | trim }} -{% elif cmd.after_help %} - -{{ cmd.after_help | trim }} -{% endif -%} \ No newline at end of file diff --git a/lib/src/docs/markdown/flag.rs b/lib/src/docs/markdown/flag.rs new file mode 100644 index 0000000..aee4c32 --- /dev/null +++ b/lib/src/docs/markdown/flag.rs @@ -0,0 +1,32 @@ +use crate::docs::markdown::renderer::MarkdownRenderer; +use crate::docs::markdown::tera::TERA; +use crate::error::UsageErr; +use crate::SpecFlag; + +impl MarkdownRenderer { + pub fn render_flag(&self, flag: &SpecFlag) -> Result { + let tera = TERA.clone(); + let mut ctx = self.clone(); + ctx.insert("flag", &flag); + + Ok(tera.render("flag_template.md.tera", &ctx.tera_ctx())?) + } +} + +#[cfg(test)] +mod tests { + use crate::docs::markdown::renderer::MarkdownRenderer; + use crate::spec; + use insta::assert_snapshot; + + #[test] + fn test_render_markdown_flag() { + let spec = spec! { r#"flag "--flag1" help="flag1 description""# }.unwrap(); + let ctx = MarkdownRenderer::new(&spec); + assert_snapshot!(ctx.render_flag(&spec.cmd.flags[0]).unwrap(), @r#" + + + flag1 description + "#); + } +} diff --git a/lib/src/docs/markdown/mod.rs b/lib/src/docs/markdown/mod.rs index a93693d..3a305cc 100644 --- a/lib/src/docs/markdown/mod.rs +++ b/lib/src/docs/markdown/mod.rs @@ -1,41 +1,8 @@ -use crate::error::UsageErr; -use tera::{Context, Tera}; - -const SPEC_TEMPLATE: &str = include_str!("cmd_template.tera"); - -impl crate::spec::Spec { - pub fn render_markdown(&self) -> Result { - let mut ctx = Context::new(); - ctx.insert("header", "#"); - ctx.insert("bin", &self.bin); - ctx.insert("cmd", &self.cmd); - let out = Tera::one_off(SPEC_TEMPLATE, &ctx, false)?; - Ok(out) - } -} - -#[cfg(test)] -mod tests { - use crate::Spec; - use insta::assert_snapshot; - - #[test] - fn test_render_markdown() { - let spec: Spec = r#" - bin "mycli" - arg "arg1" help="arg1 description" - arg "arg2" help="arg2 description" default="default value" { - choices "choice1" "choice2" "choice3" - } - arg "arg3" help="arg3 description" required=true long_help="arg3 long description" - arg "argrest" var=true - - flag "--flag1" help="flag1 description" - flag "--flag2" help="flag2 description" long_help="flag2 long description" - flag "--flag3" help="flag3 description" negate="--no-flag3" - "# - .parse() - .unwrap(); - assert_snapshot!(spec.render_markdown().unwrap()); - } -} +mod arg; +mod cmd; +mod flag; +mod renderer; +mod spec; +mod tera; + +pub use renderer::MarkdownRenderer; diff --git a/lib/src/docs/markdown/renderer.rs b/lib/src/docs/markdown/renderer.rs new file mode 100644 index 0000000..bd41ccd --- /dev/null +++ b/lib/src/docs/markdown/renderer.rs @@ -0,0 +1,51 @@ +use crate::Spec; +use serde::Serialize; + +#[derive(Debug, Clone)] +pub struct MarkdownRenderer { + pub(crate) header_level: usize, + pub(crate) multi: bool, + pub(crate) spec: Spec, + url_prefix: Option, + tera_ctx: tera::Context, +} + +impl MarkdownRenderer { + pub fn new(spec: &Spec) -> Self { + Self { + header_level: 1, + multi: false, + spec: spec.clone(), + tera_ctx: tera::Context::new(), + url_prefix: None, + } + } + + pub fn with_header_level(mut self, header_level: usize) -> Self { + self.header_level = header_level; + self + } + + pub fn with_multi(mut self, index: bool) -> Self { + self.multi = index; + self + } + + pub fn with_url_prefix>(mut self, url_prefix: S) -> Self { + self.url_prefix = Some(url_prefix.into()); + self + } + + pub(crate) fn insert>(&mut self, key: S, val: &T) { + self.tera_ctx.insert(key, val); + } + + pub(crate) fn tera_ctx(&self) -> tera::Context { + let mut ctx = self.tera_ctx.clone(); + ctx.insert("header_level", &self.header_level); + ctx.insert("multi", &self.multi); + ctx.insert("spec", &self.spec); + ctx.insert("url_prefix", &self.url_prefix); + ctx + } +} diff --git a/lib/src/docs/markdown/snapshots/usage__docs__markdown__tests__render_markdown.snap b/lib/src/docs/markdown/snapshots/usage__docs__markdown__tests__render_markdown.snap deleted file mode 100644 index 3934c84..0000000 --- a/lib/src/docs/markdown/snapshots/usage__docs__markdown__tests__render_markdown.snap +++ /dev/null @@ -1,33 +0,0 @@ ---- -source: lib/src/docs/markdown/mod.rs -expression: spec.render_markdown().unwrap() ---- -# `mycli` - -## Arg `` - -(required) arg1 description - -## Arg `` - -(required) arg2 description - -## Arg `` - -(required) arg3 long description - -## Arg `...` - -(required) - -## Flag `--flag1` - -flag1 description - -## Flag `--flag2` - -flag2 long description - -## Flag `--flag3` - -flag3 description diff --git a/lib/src/docs/markdown/spec.rs b/lib/src/docs/markdown/spec.rs new file mode 100644 index 0000000..1d270d8 --- /dev/null +++ b/lib/src/docs/markdown/spec.rs @@ -0,0 +1,89 @@ +use crate::docs::markdown::renderer::MarkdownRenderer; +use crate::docs::markdown::tera::TERA; +use crate::error::UsageErr; + +impl MarkdownRenderer { + pub fn render_spec(&self) -> Result { + let mut ctx = self.clone(); + + ctx.insert("all_commands", &self.spec.cmd.all_subcommands()); + + // TODO: global flags + + Ok(TERA.render("spec_template.md.tera", &ctx.tera_ctx())?) + } + + pub fn render_index(&self) -> Result { + let mut ctx = self.clone(); + + ctx.multi = false; + ctx.insert("all_commands", &self.spec.cmd.all_subcommands()); + + Ok(TERA.render("index_template.md.tera", &ctx.tera_ctx())?) + } +} + +#[cfg(test)] +mod tests { + use crate::docs::markdown::renderer::MarkdownRenderer; + use crate::test::SPEC_KITCHEN_SINK; + use insta::assert_snapshot; + + #[test] + fn test_render_markdown_spec() { + let ctx = MarkdownRenderer::new(&SPEC_KITCHEN_SINK); + assert_snapshot!(ctx.render_spec().unwrap(), @r#####" + # `mycli [args] [flags] [subcommand]` + + ## Arguments + + ### `` + + arg1 description + + ### `` + + arg2 description + + ### `` + + arg3 long description + + ### `...` + + ## Flags + + ### `--flag1` + + flag1 description + + ### `--flag2` + + flag2 long description + + ### `--flag3` + + flag3 description + + ### `--shell ` + + ## `mycli plugin [subcommand]` + + ## `mycli install [args] [flags]` + + ### Arguments + + #### `` + + #### `` + + ### Flags + + #### `-g --global` + + #### `-d --dir ` + + #### `-f --force` + "#####); + } +} diff --git a/lib/src/docs/markdown/templates/arg_template.md.tera b/lib/src/docs/markdown/templates/arg_template.md.tera new file mode 100644 index 0000000..e163f02 --- /dev/null +++ b/lib/src/docs/markdown/templates/arg_template.md.tera @@ -0,0 +1,6 @@ +{%- if arg.help_long_md %}{% set help = arg.long_help %}{% elif arg.help_long %}{% set help = arg.help_long %}{% else %}{% set help = arg.help %}{% endif %} + +{%- if help %} + +{{ help }} +{%- endif -%} \ No newline at end of file diff --git a/lib/src/docs/markdown/templates/cmd_template.md.tera b/lib/src/docs/markdown/templates/cmd_template.md.tera new file mode 100644 index 0000000..31077b1 --- /dev/null +++ b/lib/src/docs/markdown/templates/cmd_template.md.tera @@ -0,0 +1,70 @@ +{%- if cmd.before_help_md %}{% set before_help = cmd.before_help_md %}{% elif cmd.before_help_long %}{% set before_help = cmd.before_help_long %}{% else %}{% set before_help = cmd.before_help %}{% endif %} +{%- if cmd.help_md %}{% set help = cmd.help_md %}{% elif cmd.help_long %}{% set help = cmd.help_long %}{% else %}{% set help = cmd.help %}{% endif %} +{%- if cmd.after_help_md %}{% set after_help = cmd.after_help_md %}{% elif cmd.after_help_long %}{% set after_help = cmd.after_help_long %}{% else %}{% set after_help = cmd.after_help %}{% endif %} +{%- if multi %} +{{ "#" | repeat(count=header_level) }} `{{ spec.bin }} {{ cmd.usage }}` +{%- endif %} +{%- if before_help %} + +{{ before_help }} +{%- endif %} + +{%- if help %} + +{{ help }} +{%- endif %} + +{%- set args = cmd.args | filter(attribute="hide", value=false) %} +{%- set flags = cmd.flags | filter(attribute="hide", value=false) %} +{%- set global_flags = flags | filter(attribute="global", value=true) %} +{%- set local_flags = flags | filter(attribute="global", value=false) %} + +{%- if args %} + +{{ "#" | repeat(count=header_level) }}# Arguments + +{%- for arg in args %} + +{{ "#" | repeat(count=header_level) }}## `{{ arg.usage }}` +{%- include "arg_template.md.tera" %} +{%- endfor %} +{%- endif %} + +{%- if global_flags %} + +{{ "#" | repeat(count=header_level) }}# Global Flags + +{%- for flag in global_flags %} + +{{ "#" | repeat(count=header_level) }}## `{{ flag.usage }}` +{%- include "flag_template.md.tera" %} +{%- endfor %} +{%- endif %} + +{%- if local_flags %} + +{{ "#" | repeat(count=header_level) }}# Flags + +{%- for flag in local_flags %} + +{{ "#" | repeat(count=header_level) }}## `{{ flag.usage }}` +{%- include "flag_template.md.tera" %} +{%- endfor %} +{%- endif %} + +{%- if multi %} +{%- for name, cmd in cmd.subcommands %} +{%- if cmd.hide == false %} +{%- if loop.first %} + +{{ "#" | repeat(count=header_level) }}# Subcommands +{% endif %} +* [`{{ spec.bin }} {{ cmd.usage }}`]({{ url_prefix }}/{{ cmd.full_cmd | join(sep="/") }}.md) +{%- endif -%} +{%- endfor -%} +{%- endif -%} + +{%- if after_help %} + +{{ after_help }} +{%- endif -%} diff --git a/lib/src/docs/markdown/templates/flag_template.md.tera b/lib/src/docs/markdown/templates/flag_template.md.tera new file mode 100644 index 0000000..3a38229 --- /dev/null +++ b/lib/src/docs/markdown/templates/flag_template.md.tera @@ -0,0 +1,5 @@ +{%- if flag.help_long_md %}{% set help = flag.long_help %}{% elif flag.help_long %}{% set help = flag.help_long %}{% else %}{% set help = flag.help %}{% endif %} +{%- if help %} + +{{ help }} +{%- endif -%} \ No newline at end of file diff --git a/lib/src/docs/markdown/templates/index_template.md.tera b/lib/src/docs/markdown/templates/index_template.md.tera new file mode 100644 index 0000000..9eac110 --- /dev/null +++ b/lib/src/docs/markdown/templates/index_template.md.tera @@ -0,0 +1,29 @@ +{%- set about = spec.about_long | default(value=spec.about) %} +{%- set cmd = spec.cmd %} +{{- "#" | repeat(count=header_level) }} `{{ spec.cmd.usage }}` + +{%- if spec.version %} +* **version**: {{ spec.version }}{% endif %} + +{%- if about %} + +{{ about }} +{% endif %} + +{%- include "cmd_template.md.tera" %} + +{%- set header_level = header_level + 1 %} + +{%- for cmd in all_commands | filter(attribute="hide", value=false) %} +{%- if loop.first %} + +{{ "#" | repeat(count=header_level) }} Subcommands +{% endif %} +{%- if cmd.full_cmd | length >= 2 %} +{%- set full_cmd = cmd.full_cmd | slice(end=1) | join(sep=" ") %} +{%- set full_cmd = spec.bin ~ " " ~ full_cmd %} +{%- else %} +{%- set full_cmd = spec.bin %} +{%- endif %} +* [`{{ full_cmd }} {{ cmd.usage }}`]({{ url_prefix }}/{{ cmd.full_cmd | join(sep="/") }}.md) +{%- endfor -%} diff --git a/lib/src/docs/markdown/templates/spec_template.md.tera b/lib/src/docs/markdown/templates/spec_template.md.tera new file mode 100644 index 0000000..41cd4e9 --- /dev/null +++ b/lib/src/docs/markdown/templates/spec_template.md.tera @@ -0,0 +1,21 @@ +{%- set about = spec.about_long | default(value=spec.about) %} +{%- set cmd = spec.cmd %} +{{- "#" | repeat(count=header_level) }} `{{ spec.cmd.usage }}` + +{%- if spec.version %} +* **version**: {{ spec.version }}{% endif %} + +{%- if about %} + +{{ about }} +{% endif %} + +{%- include "cmd_template.md.tera" %} +{%- set header_level = header_level + 1 %} + +{%- for cmd in all_commands %} + +{{ "#" | repeat(count=header_level) }} `{{ spec.bin }} {{ cmd.usage }}` + +{%- include "cmd_template.md.tera" %} +{%- endfor -%} diff --git a/lib/src/docs/markdown/tera.rs b/lib/src/docs/markdown/tera.rs new file mode 100644 index 0000000..6c56227 --- /dev/null +++ b/lib/src/docs/markdown/tera.rs @@ -0,0 +1,27 @@ +use once_cell::sync::Lazy; +use std::collections::HashMap; +use tera::Tera; + +pub(crate) static TERA: Lazy = Lazy::new(|| { + let mut tera = Tera::default(); + + #[rustfmt::skip] + tera.add_raw_templates([ + ("arg_template.md.tera", include_str!("templates/arg_template.md.tera")), + ("cmd_template.md.tera", include_str!("templates/cmd_template.md.tera")), + ("flag_template.md.tera", include_str!("templates/flag_template.md.tera")), + ("spec_template.md.tera", include_str!("templates/spec_template.md.tera")), + ("index_template.md.tera", include_str!("templates/index_template.md.tera")), + ]).unwrap(); + + tera.register_filter( + "repeat", + move |value: &tera::Value, args: &HashMap| { + let value = value.as_str().unwrap(); + let count = args.get("count").unwrap().as_u64().unwrap(); + Ok(value.repeat(count as usize).into()) + }, + ); + + tera +}); diff --git a/lib/src/docs/mod.rs b/lib/src/docs/mod.rs index 577affa..163a4fb 100644 --- a/lib/src/docs/mod.rs +++ b/lib/src/docs/mod.rs @@ -1 +1 @@ -mod markdown; +pub mod markdown; diff --git a/lib/src/lib.rs b/lib/src/lib.rs index e0dcd2c..a90a203 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -1,7 +1,5 @@ #[cfg(test)] -#[macro_use] extern crate insta; -#[macro_use] extern crate log; pub use crate::parse::parse; @@ -19,8 +17,9 @@ pub mod complete; pub mod spec; #[cfg(feature = "docs")] -mod docs; +pub mod docs; pub mod parse; pub(crate) mod sh; +pub(crate) mod string; #[cfg(test)] mod test; diff --git a/lib/src/parse.rs b/lib/src/parse.rs index 82de4f3..668aa75 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -295,22 +295,21 @@ mod tests { #[test] fn test_parse() { + let mut cmd = SpecCommand::default(); + cmd.name = "test".to_string(); + cmd.args = vec![SpecArg { + name: "arg".to_string(), + ..Default::default() + }]; + cmd.flags = vec![SpecFlag { + name: "flag".to_string(), + long: vec!["flag".to_string()], + ..Default::default() + }]; let spec = Spec { name: "test".to_string(), bin: "test".to_string(), - cmd: SpecCommand { - name: "test".to_string(), - args: vec![SpecArg { - name: "arg".to_string(), - ..Default::default() - }], - flags: vec![SpecFlag { - name: "flag".to_string(), - long: vec!["flag".to_string()], - ..Default::default() - }], - ..Default::default() - }, + cmd, ..Default::default() }; let input = vec!["test".to_string(), "arg1".to_string(), "--flag".to_string()]; @@ -324,26 +323,29 @@ mod tests { #[test] fn test_as_env() { + let mut cmd = SpecCommand::default(); + cmd.name = "test".to_string(); + cmd.args = vec![SpecArg { + name: "arg".to_string(), + ..Default::default() + }]; + cmd.flags = vec![ + SpecFlag { + name: "flag".to_string(), + long: vec!["flag".to_string()], + ..Default::default() + }, + SpecFlag { + name: "force".to_string(), + long: vec!["force".to_string()], + negate: Some("--no-force".to_string()), + ..Default::default() + }, + ]; let spec = Spec { name: "test".to_string(), bin: "test".to_string(), - cmd: SpecCommand { - name: "test".to_string(), - flags: vec![ - SpecFlag { - name: "flag".to_string(), - long: vec!["flag".to_string()], - ..Default::default() - }, - SpecFlag { - name: "force".to_string(), - long: vec!["force".to_string()], - negate: Some("--no-force".to_string()), - ..Default::default() - }, - ], - ..Default::default() - }, + cmd, ..Default::default() }; let input = vec![ diff --git a/lib/src/spec/arg.rs b/lib/src/spec/arg.rs index 3690fab..9d9822f 100644 --- a/lib/src/spec/arg.rs +++ b/lib/src/spec/arg.rs @@ -1,23 +1,24 @@ -use std::fmt::Display; -use std::hash::Hash; -use std::str::FromStr; - #[cfg(feature = "clap")] use itertools::Itertools; use kdl::{KdlDocument, KdlEntry, KdlNode}; use serde::Serialize; +use std::fmt::Display; +use std::hash::Hash; +use std::str::FromStr; use crate::error::UsageErr; use crate::spec::context::ParsingContext; use crate::spec::helpers::NodeHelper; -use crate::SpecChoices; +use crate::{string, SpecChoices}; #[derive(Debug, Default, Clone, Serialize)] pub struct SpecArg { pub name: String, pub usage: String, pub help: Option, - pub long_help: Option, + pub help_long: Option, + pub help_md: Option, + pub help_first_line: Option, pub required: bool, pub var: bool, pub var_min: Option, @@ -33,7 +34,9 @@ impl SpecArg { for (k, v) in node.props() { match k { "help" => arg.help = Some(v.ensure_string()?), - "long_help" => arg.long_help = Some(v.ensure_string()?), + "long_help" => arg.help_long = Some(v.ensure_string()?), + "help_long" => arg.help_long = Some(v.ensure_string()?), + "help_md" => arg.help_md = Some(v.ensure_string()?), "required" => arg.required = v.ensure_bool()?, "var" => arg.var = v.ensure_bool()?, "hide" => arg.hide = v.ensure_bool()?, @@ -50,12 +53,15 @@ impl SpecArg { } } arg.usage = arg.usage(); + if let Some(help) = &arg.help { + arg.help_first_line = Some(string::first_line(help)); + } Ok(arg) } } impl SpecArg { - pub(crate) fn usage(&self) -> String { + pub fn usage(&self) -> String { let mut name = if self.required { format!("<{}>", self.name) } else { @@ -75,8 +81,11 @@ impl From<&SpecArg> for KdlNode { if let Some(desc) = &arg.help { node.push(KdlEntry::new_prop("help", desc.clone())); } - if let Some(desc) = &arg.long_help { - node.push(KdlEntry::new_prop("long_help", desc.clone())); + if let Some(desc) = &arg.help_long { + node.push(KdlEntry::new_prop("help_long", desc.clone())); + } + if let Some(desc) = &arg.help_md { + node.push(KdlEntry::new_prop("help_md", desc.clone())); } if arg.var { node.push(KdlEntry::new_prop("var", true)); @@ -139,7 +148,8 @@ impl From<&clap::Arg> for SpecArg { fn from(arg: &clap::Arg) -> Self { let required = arg.is_required_set(); let help = arg.get_help().map(|s| s.to_string()); - let long_help = arg.get_long_help().map(|s| s.to_string()); + let help_long = arg.get_long_help().map(|s| s.to_string()); + let help_first_line = help.as_ref().map(|s| string::first_line(s)); let hide = arg.is_hide_set(); let var = matches!( arg.get_action(), @@ -156,7 +166,9 @@ impl From<&clap::Arg> for SpecArg { usage: "".into(), required, help, - long_help, + help_long, + help_md: None, + help_first_line, var, var_max: None, var_min: None, diff --git a/lib/src/spec/cmd.rs b/lib/src/spec/cmd.rs index a4b7560..ab68b88 100644 --- a/lib/src/spec/cmd.rs +++ b/lib/src/spec/cmd.rs @@ -12,7 +12,7 @@ use itertools::Itertools; use kdl::{KdlDocument, KdlEntry, KdlNode, KdlValue}; use serde::Serialize; -#[derive(Debug, Default, Serialize, Clone)] +#[derive(Debug, Serialize, Clone)] pub struct SpecCommand { pub full_cmd: Vec, pub usage: String, @@ -24,18 +24,52 @@ pub struct SpecCommand { pub hide: bool, pub subcommand_required: bool, pub help: Option, - pub long_help: Option, + pub help_long: Option, + pub help_md: Option, pub name: String, pub aliases: Vec, pub hidden_aliases: Vec, pub before_help: Option, - pub before_long_help: Option, + pub before_help_long: Option, + pub before_help_md: Option, pub after_help: Option, - pub after_long_help: Option, + pub after_help_long: Option, + pub after_help_md: Option, pub examples: Vec, + // TODO: make this non-public #[serde(skip)] - pub subcommand_lookup: OnceLock>, + subcommand_lookup: OnceLock>, +} + +impl Default for SpecCommand { + fn default() -> Self { + Self { + full_cmd: vec![], + usage: "".to_string(), + subcommands: IndexMap::new(), + args: vec![], + flags: vec![], + mounts: vec![], + deprecated: None, + hide: false, + subcommand_required: false, + help: None, + help_long: None, + help_md: None, + name: "".to_string(), + aliases: vec![], + hidden_aliases: vec![], + before_help: None, + before_help_long: None, + before_help_md: None, + after_help: None, + after_help_long: None, + after_help_md: None, + examples: vec![], + subcommand_lookup: OnceLock::new(), + } + } } #[derive(Debug, Default, Serialize, Clone)] @@ -65,13 +99,21 @@ impl SpecCommand { for (k, v) in node.props() { match k { "help" => cmd.help = Some(v.ensure_string()?), - "long_help" => cmd.long_help = Some(v.ensure_string()?), + "long_help" => cmd.help_long = Some(v.ensure_string()?), + "help_long" => cmd.help_long = Some(v.ensure_string()?), + "help_md" => cmd.help_md = Some(v.ensure_string()?), "before_help" => cmd.before_help = Some(v.ensure_string()?), - "before_long_help" => cmd.before_long_help = Some(v.ensure_string()?), + "before_long_help" => cmd.before_help_long = Some(v.ensure_string()?), + "before_help_long" => cmd.before_help_long = Some(v.ensure_string()?), + "before_help_md" => cmd.before_help_md = Some(v.ensure_string()?), "after_help" => cmd.after_help = Some(v.ensure_string()?), "after_long_help" => { - cmd.after_long_help = Some(v.ensure_string()?); + cmd.after_help_long = Some(v.ensure_string()?); + } + "after_help_long" => { + cmd.after_help_long = Some(v.ensure_string()?); } + "after_help_md" => cmd.after_help_md = Some(v.ensure_string()?), "subcommand_required" => cmd.subcommand_required = v.ensure_bool()?, "hide" => cmd.hide = v.ensure_bool()?, "deprecated" => { @@ -126,20 +168,20 @@ impl SpecCommand { cmd.help = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?); } "long_help" => { - cmd.long_help = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?); + cmd.help_long = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?); } "before_help" => { cmd.before_help = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?); } "before_long_help" => { - cmd.before_long_help = + cmd.before_help_long = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?); } "after_help" => { cmd.after_help = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?); } "after_long_help" => { - cmd.after_long_help = + cmd.after_help_long = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?); } "subcommand_required" => { @@ -164,21 +206,38 @@ impl SpecCommand { && self.mounts.is_empty() && self.subcommands.is_empty() } - pub(crate) fn usage(&self) -> String { - let mut name = self.name.clone(); + pub fn usage(&self) -> String { + let mut usage = self.name.clone(); + let total_count = self.args.len() + self.flags.len(); + if self.subcommands.is_empty() && total_count <= 2 { + let inlines = self + .args + .iter() + .filter(|a| !a.hide) + .map(|a| a.usage()) + .chain( + self.flags + .iter() + .filter(|f| !f.hide) + .map(|f| format!("[{f}]")), + ) + .join(" "); + return format!("{usage} {inlines}").trim().to_string(); + } if !self.args.is_empty() { - name = format!("{name} [args]"); + usage = format!("{usage} [args]"); } if !self.flags.is_empty() { - name = format!("{name} [flags]"); + usage = format!("{usage} [flags]"); } + // TODO: mounts? // if !self.mounts.is_empty() { // name = format!("{name} [mounts]"); // } if !self.subcommands.is_empty() { - name = format!("{name} [subcommand]"); + usage = format!("{usage} [subcommand]"); } - name + usage.trim().to_string() } pub(crate) fn merge(&mut self, other: Self) { if !other.name.is_empty() { @@ -187,20 +246,29 @@ impl SpecCommand { if other.help.is_some() { self.help = other.help; } - if other.long_help.is_some() { - self.long_help = other.long_help; + if other.help_long.is_some() { + self.help_long = other.help_long; + } + if other.help_md.is_some() { + self.help_md = other.help_md; } if other.before_help.is_some() { self.before_help = other.before_help; } - if other.before_long_help.is_some() { - self.before_long_help = other.before_long_help; + if other.before_help_long.is_some() { + self.before_help_long = other.before_help_long; + } + if other.before_help_md.is_some() { + self.before_help_md = other.before_help_md; } if other.after_help.is_some() { self.after_help = other.after_help; } - if other.after_long_help.is_some() { - self.after_long_help = other.after_long_help; + if other.after_help_long.is_some() { + self.after_help_long = other.after_help_long; + } + if other.after_help_md.is_some() { + self.after_help_md = other.after_help_md; } if !other.args.is_empty() { self.args = other.args; @@ -227,6 +295,15 @@ impl SpecCommand { } } + pub fn all_subcommands(&self) -> Vec<&SpecCommand> { + let mut cmds = vec![]; + for cmd in self.subcommands.values() { + cmds.push(cmd); + cmds.extend(cmd.all_subcommands()); + } + cmds + } + pub fn find_subcommand(&self, name: &str) -> Option<&SpecCommand> { let sl = self.subcommand_lookup.get_or_init(|| { let mut map = HashMap::new(); @@ -287,32 +364,50 @@ impl From<&SpecCommand> for KdlNode { node.entries_mut() .push(KdlEntry::new_prop("help", help.clone())); } - if let Some(help) = &cmd.long_help { + if let Some(help) = &cmd.help_long { let children = node.children_mut().get_or_insert_with(KdlDocument::new); let mut node = KdlNode::new("long_help"); node.insert(0, KdlValue::RawString(help.clone())); children.nodes_mut().push(node); } + if let Some(help) = &cmd.help_md { + let children = node.children_mut().get_or_insert_with(KdlDocument::new); + let mut node = KdlNode::new("help_md"); + node.insert(0, KdlValue::RawString(help.clone())); + children.nodes_mut().push(node); + } if let Some(help) = &cmd.before_help { node.entries_mut() .push(KdlEntry::new_prop("before_help", help.clone())); } - if let Some(help) = &cmd.before_long_help { + if let Some(help) = &cmd.before_help_long { let children = node.children_mut().get_or_insert_with(KdlDocument::new); let mut node = KdlNode::new("before_long_help"); node.insert(0, KdlValue::RawString(help.clone())); children.nodes_mut().push(node); } + if let Some(help) = &cmd.before_help_md { + let children = node.children_mut().get_or_insert_with(KdlDocument::new); + let mut node = KdlNode::new("before_help_md"); + node.insert(0, KdlValue::RawString(help.clone())); + children.nodes_mut().push(node); + } if let Some(help) = &cmd.after_help { node.entries_mut() .push(KdlEntry::new_prop("after_help", help.clone())); } - if let Some(help) = &cmd.after_long_help { + if let Some(help) = &cmd.after_help_long { let children = node.children_mut().get_or_insert_with(KdlDocument::new); let mut node = KdlNode::new("after_long_help"); node.insert(0, KdlValue::RawString(help.clone())); children.nodes_mut().push(node); } + if let Some(help) = &cmd.after_help_md { + let children = node.children_mut().get_or_insert_with(KdlDocument::new); + let mut node = KdlNode::new("after_help_md"); + node.insert(0, KdlValue::RawString(help.clone())); + children.nodes_mut().push(node); + } for flag in &cmd.flags { let children = node.children_mut().get_or_insert_with(KdlDocument::new); children.nodes_mut().push(flag.into()); @@ -340,11 +435,11 @@ impl From<&clap::Command> for SpecCommand { name: cmd.get_name().to_string(), hide: cmd.is_hide_set(), help: cmd.get_about().map(|s| s.to_string()), - long_help: cmd.get_long_about().map(|s| s.to_string()), + help_long: cmd.get_long_about().map(|s| s.to_string()), before_help: cmd.get_before_help().map(|s| s.to_string()), - before_long_help: cmd.get_before_long_help().map(|s| s.to_string()), + before_help_long: cmd.get_before_long_help().map(|s| s.to_string()), after_help: cmd.get_after_help().map(|s| s.to_string()), - after_long_help: cmd.get_after_long_help().map(|s| s.to_string()), + after_help_long: cmd.get_after_long_help().map(|s| s.to_string()), ..Default::default() }; for alias in cmd.get_visible_aliases() { diff --git a/lib/src/spec/config.rs b/lib/src/spec/config.rs index fa9c112..c4dd4af 100644 --- a/lib/src/spec/config.rs +++ b/lib/src/spec/config.rs @@ -117,6 +117,7 @@ impl From<&SpecConfig> for KdlNode { #[cfg(test)] mod tests { use crate::Spec; + use insta::assert_snapshot; #[test] fn test_config_defaults() { diff --git a/lib/src/spec/flag.rs b/lib/src/spec/flag.rs index 8c53c77..efdf8d6 100644 --- a/lib/src/spec/flag.rs +++ b/lib/src/spec/flag.rs @@ -1,23 +1,24 @@ -use std::fmt::Display; -use std::hash::Hash; -use std::str::FromStr; - use itertools::Itertools; use kdl::{KdlDocument, KdlEntry, KdlNode}; use serde::Serialize; +use std::fmt::Display; +use std::hash::Hash; +use std::str::FromStr; use crate::error::UsageErr::InvalidFlag; use crate::error::{Result, UsageErr}; use crate::spec::context::ParsingContext; use crate::spec::helpers::NodeHelper; -use crate::{SpecArg, SpecChoices}; +use crate::{string, SpecArg, SpecChoices}; #[derive(Debug, Default, Clone, Serialize)] pub struct SpecFlag { pub name: String, pub usage: String, pub help: Option, - pub long_help: Option, + pub help_long: Option, + pub help_md: Option, + pub help_first_line: Option, pub short: Vec, pub long: Vec, pub required: bool, @@ -37,7 +38,9 @@ impl SpecFlag { for (k, v) in node.props() { match k { "help" => flag.help = Some(v.ensure_string()?), - "long_help" => flag.long_help = Some(v.ensure_string()?), + "long_help" => flag.help_long = Some(v.ensure_string()?), + "help_long" => flag.help_long = Some(v.ensure_string()?), + "help_md" => flag.help_md = Some(v.ensure_string()?), "required" => flag.required = v.ensure_bool()?, "var" => flag.var = v.ensure_bool()?, "hide" => flag.hide = v.ensure_bool()?, @@ -59,7 +62,9 @@ impl SpecFlag { match child.name() { "arg" => flag.arg = Some(SpecArg::parse(ctx, &child)?), "help" => flag.help = Some(child.arg(0)?.ensure_string()?), - "long_help" => flag.long_help = Some(child.arg(0)?.ensure_string()?), + "long_help" => flag.help_long = Some(child.arg(0)?.ensure_string()?), + "help_long" => flag.help_long = Some(child.arg(0)?.ensure_string()?), + "help_md" => flag.help_md = Some(child.arg(0)?.ensure_string()?), "required" => flag.required = child.arg(0)?.ensure_bool()?, "var" => flag.var = child.arg(0)?.ensure_bool()?, "hide" => flag.hide = child.arg(0)?.ensure_bool()?, @@ -88,6 +93,7 @@ impl SpecFlag { } } flag.usage = flag.usage(); + flag.help_first_line = flag.help.as_ref().map(|s| string::first_line(s)); Ok(flag) } pub fn usage(&self) -> String { @@ -121,18 +127,24 @@ impl From<&SpecFlag> for KdlNode { .iter() .map(|c| format!("-{c}")) .chain(flag.long.iter().map(|s| format!("--{s}"))) - .collect::>() + .collect_vec() .join(" "); node.push(KdlEntry::new(name)); if let Some(desc) = &flag.help { node.push(KdlEntry::new_prop("help", desc.clone())); } - if let Some(desc) = &flag.long_help { + if let Some(desc) = &flag.help_long { let children = node.children_mut().get_or_insert_with(KdlDocument::new); let mut node = KdlNode::new("long_help"); node.entries_mut().push(KdlEntry::new(desc.clone())); children.nodes_mut().push(node); } + if let Some(desc) = &flag.help_md { + let children = node.children_mut().get_or_insert_with(KdlDocument::new); + let mut node = KdlNode::new("help_md"); + node.entries_mut().push(KdlEntry::new(desc.clone())); + children.nodes_mut().push(node); + } if flag.required { node.push(KdlEntry::new_prop("required", true)); } @@ -212,7 +224,8 @@ impl From<&clap::Arg> for SpecFlag { fn from(c: &clap::Arg) -> Self { let required = c.is_required_set(); let help = c.get_help().map(|s| s.to_string()); - let long_help = c.get_long_help().map(|s| s.to_string()); + let help_long = c.get_long_help().map(|s| s.to_string()); + let help_first_line = help.as_ref().map(|s| string::first_line(s)); let hide = c.is_hide_set(); let var = matches!( c.get_action(), @@ -248,7 +261,9 @@ impl From<&clap::Arg> for SpecFlag { long, required, help, - long_help, + help_long, + help_md: None, + help_first_line, var, hide, global: c.is_global_set(), @@ -333,6 +348,7 @@ fn get_name_from_short_and_long(short: &[char], long: &[String]) -> Option, pub about: Option, - pub long_about: Option, + pub about_long: Option, + pub about_md: Option, } impl Spec { @@ -89,11 +91,18 @@ impl Spec { for node in kdl.nodes().iter().map(|n| NodeHelper::new(ctx, n)) { match node.name() { "name" => schema.name = node.arg(0)?.ensure_string()?, - "bin" => schema.bin = node.arg(0)?.ensure_string()?, + "bin" => { + schema.bin = node.arg(0)?.ensure_string()?; + if schema.name.is_empty() { + schema.name.clone_from(&schema.bin); + } + } "version" => schema.version = Some(node.arg(0)?.ensure_string()?), "author" => schema.author = Some(node.arg(0)?.ensure_string()?), "about" => schema.about = Some(node.arg(0)?.ensure_string()?), - "long_about" => schema.long_about = Some(node.arg(0)?.ensure_string()?), + "long_about" => schema.about_long = Some(node.arg(0)?.ensure_string()?), + "about_long" => schema.about_long = Some(node.arg(0)?.ensure_string()?), + "about_md" => schema.about_md = Some(node.arg(0)?.ensure_string()?), "usage" => schema.usage = node.arg(0)?.ensure_string()?, "arg" => schema.cmd.args.push(SpecArg::parse(ctx, &node)?), "flag" => schema.cmd.flags.push(SpecFlag::parse(ctx, &node)?), @@ -125,6 +134,7 @@ impl Spec { k => bail_parse!(ctx, *node.node.name().span(), "unsupported spec key {k}"), } } + schema.cmd.name = schema.name.clone(); set_subcommand_ancestors(&mut schema.cmd, &[]); Ok(schema) } @@ -142,8 +152,11 @@ impl Spec { if other.about.is_some() { self.about = other.about; } - if other.long_about.is_some() { - self.long_about = other.long_about; + if other.about_long.is_some() { + self.about_long = other.about_long; + } + if other.about_md.is_some() { + self.about_md = other.about_md; } if !other.config.is_empty() { self.config.merge(&other.config); @@ -231,7 +244,7 @@ impl Display for Spec { node.push(KdlEntry::new(about.clone())); nodes.push(node); } - if let Some(long_about) = &self.long_about { + if let Some(long_about) = &self.about_long { let mut node = KdlNode::new("long_about"); node.push(KdlEntry::new(KdlValue::RawString(long_about.clone()))); nodes.push(node); @@ -274,7 +287,7 @@ impl From<&clap::Command> for Spec { cmd: cmd.into(), version: cmd.get_version().map(|v| v.to_string()), about: cmd.get_about().map(|a| a.to_string()), - long_about: cmd.get_long_about().map(|a| a.to_string()), + about_long: cmd.get_long_about().map(|a| a.to_string()), usage: cmd.clone().render_usage().to_string(), ..Default::default() } @@ -284,6 +297,7 @@ impl From<&clap::Command> for Spec { #[cfg(test)] mod tests { use super::*; + use insta::assert_snapshot; #[test] fn test_display() { diff --git a/lib/src/string.rs b/lib/src/string.rs new file mode 100644 index 0000000..f24510a --- /dev/null +++ b/lib/src/string.rs @@ -0,0 +1,5 @@ +pub use std::string::*; + +pub(crate) fn first_line(s: &str) -> String { + s.lines().next().unwrap_or_default().to_string() +} diff --git a/lib/src/test.rs b/lib/src/test.rs index 9435e10..ccff265 100644 --- a/lib/src/test.rs +++ b/lib/src/test.rs @@ -1,6 +1,47 @@ -// use crate::env; -// -// #[ctor::ctor] -// fn init() { -// env::set_var("USAGE_BIN", "usage"); -// } +use crate::Spec; +use once_cell::sync::Lazy; + +#[macro_export] +macro_rules! spec { + { $spec:literal } => { + $spec.parse::<$crate::spec::Spec>() + }; +} + +pub static SPEC_KITCHEN_SINK: Lazy = Lazy::new(|| { + spec! {r#" +bin "mycli" +arg "arg1" help="arg1 description" +arg "arg2" help="arg2 description" default="default value" { + choices "choice1" "choice2" "choice3" +} +arg "arg3" help="arg3 description" required=true long_help="arg3 long description" +arg "argrest" var=true + +flag "--flag1" help="flag1 description" +flag "--flag2" help="flag2 description" long_help="flag2 long description" +flag "--flag3" help="flag3 description" negate="--no-flag3" + +flag "--shell " { + choices "bash" "zsh" "fish" +} + +cmd "plugin" { + cmd "install" { + arg "plugin" + arg "version" + flag "-g --global" + flag "-d --dir " + flag "-f --force" negate="--no-force" + } +} + +complete "plugin" run="echo \"plugin-1\nplugin-2\nplugin-3\"" +"#} + .unwrap() +}); + +#[test] +fn test_parse() { + assert_eq!(SPEC_KITCHEN_SINK.name, "mycli"); +}