From 84af1ba44abb76f432088588e501170c5f4dfe3f Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:25:05 +0000 Subject: [PATCH] feat(spec): add deprecation milestones --- .github/workflows/perf-pr.yml | 50 +++-- PLAN.md | 15 +- argv/src/complete.rs | 55 ++++- argv/src/help.rs | 199 +++++++++++++++++- argv/src/spec.rs | 41 ++++ conformance/src/tables.rs | 6 + conformance/tests/metadata.rs | 62 ++++++ derive/src/codegen.rs | 40 ++++ derive/src/model.rs | 64 +++++- go/argv/complete.go | 2 +- go/argv/help.go | 4 + go/argv/page.go | 89 +++++--- go/argv/page_long.go | 12 ++ go/argv/page_test.go | 60 +++++- go/internal/spec/spec.go | 38 ++-- lib/src/docs/cli/mod.rs | 26 ++- .../cli/templates/spec_template_long.tera | 18 +- .../cli/templates/spec_template_short.tera | 15 +- lib/src/docs/manpage/renderer.rs | 51 +++++ .../markdown/templates/cmd_template.md.tera | 3 + .../markdown/templates/flag_template.md.tera | 4 + lib/src/docs/models.rs | 14 ++ lib/src/go/mod.rs | 18 ++ lib/src/spec/builder.rs | 20 ++ lib/src/spec/cmd.rs | 32 +++ lib/src/spec/flag.rs | 22 ++ lib/src/spec/mod.rs | 22 ++ 27 files changed, 898 insertions(+), 84 deletions(-) diff --git a/.github/workflows/perf-pr.yml b/.github/workflows/perf-pr.yml index 42d4e45b4..4717a96a0 100644 --- a/.github/workflows/perf-pr.yml +++ b/.github/workflows/perf-pr.yml @@ -9,9 +9,9 @@ name: perf-pr # person who can still do something about it. # # Nothing here writes to refs/notes/tak. A pull request's measurements are -# recorded locally, used for the comparison, and discarded with the runner. Only -# main contributes to the history — a branch's numbers are not the trunk's, and -# a series that mixes them cannot be read. +# base and pull-request measurements are recorded locally, used for the comparison, +# and discarded with the runner. Only main contributes to the persistent history — +# a branch's numbers are not the trunk's, and a series that mixes them cannot be read. on: pull_request: @@ -74,6 +74,38 @@ jobs: } valgrind --version + - name: Find the base commit + id: base + env: + # The generic context resolved to main for a reopened stacked PR even + # though the event and PR both named the stack parent. Read the + # authoritative pull-request payload directly. + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + git fetch --quiet origin "$BASE_REF" + # The merge base, not the branch tip: comparing against a moving + # target attributes other people's commits to this pull request. + base=$(git merge-base "origin/$BASE_REF" HEAD) + echo "sha=$base" >> "$GITHUB_OUTPUT" + echo "comparing against $base on $BASE_REF" + + # Stack-parent commits are intentionally absent from the persistent tak history. Measure + # the exact merge base in a linked worktree so the comparison below has a same-runner + # baseline instead of passing with "nothing was compared". Git notes are shared between + # linked worktrees, but both measurements remain local to this disposable runner. + - name: Measure the base commit + env: + BASE_SHA: ${{ steps.base.outputs.sha }} + MISE_TRUSTED_CONFIG_PATHS: /tmp/usage-perf-base + run: | + base_worktree=/tmp/usage-perf-base + git worktree add --detach "$base_worktree" "$BASE_SHA" + trap 'git worktree remove --force "$base_worktree"' EXIT + ( + cd "$base_worktree" + mise run perf:record + ) + # `--record` writes a git note locally and nothing more. There is no # `tak push` in this workflow and there should never be one. - name: Measure this pull request @@ -87,18 +119,6 @@ jobs: - name: Measure the shadow against clap, argh and bpaf run: ./tasks/perf-shadow.sh /tmp/shadow-report.md || true - - name: Find the base commit - id: base - env: - BASE_REF: ${{ github.base_ref }} - run: | - git fetch --quiet origin "$BASE_REF" - # The merge base, not the branch tip: comparing against a moving - # target attributes other people's commits to this pull request. - base=$(git merge-base "origin/$BASE_REF" HEAD) - echo "sha=$base" >> "$GITHUB_OUTPUT" - echo "comparing against $base on $BASE_REF" - # Deliberately not `continue-on-error`. That would hide a compare that # died for an unrelated reason behind a green check. The exit code is # captured, the comment is posted either way, and a later step re-raises diff --git a/PLAN.md b/PLAN.md index 81d48f792..bae86c37d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1319,13 +1319,14 @@ above are where it lands. obvious first user. It is now portable through KDL, the typed derive, usage-lib and generated Go help. clap#4589 asks for prose under a heading — Deno wants per-section doc links — which is the same node with one more field. -- [ ] **Deprecation and stability metadata on flags and commands** (clap#3321) — - `deprecated`, with warn/remove versions, already exists in the - config-prop vocabulary; the same on flags and commands would flow into - help, docs and completions from one declaration. Explicit full-name - deprecated env aliases (clap#5447) fit the same slot — full names, so - they stay greppable — and clap#5925's ordered fallback across several - env names is the same declaration read in order. +- [x] **Deprecation and stability metadata on flags and commands** (clap#3321) — + `deprecated`, with warn/remove versions, is portable through KDL, typed + Rust, generated Go, help, docs and completions from one declaration. +- [ ] **Ordered environment fallbacks and deprecated aliases** — explicit + full-name deprecated env aliases (clap#5447) stay greppable, while + clap#5925's fallback across several env names preserves declaration + order. This is parser behavior rather than command/flag presentation, so + it remains separate from the deprecation metadata above. - [ ] **A group as an enum in the derive** (clap#2621, 102 votes — tied for clap's most-requested) — mutually exclusive flags declared as enum variants, lowering to the `group`/`conflicts` vocabulary the spec already diff --git a/argv/src/complete.rs b/argv/src/complete.rs index 5327ca8a4..41bc58c19 100644 --- a/argv/src/complete.rs +++ b/argv/src/complete.rs @@ -1142,7 +1142,12 @@ fn subcommands<'a>(meta: &'a CommandMeta<'a>, token: &str) -> Vec> if name.starts_with(token) { out.push(Candidate { value: (*name).to_string(), - description: sub.about.map(::std::borrow::Cow::Borrowed), + description: deprecated_description( + sub.about, + sub.deprecated, + sub.deprecated_warn_at, + sub.deprecated_remove_at, + ), }); } } @@ -1158,7 +1163,14 @@ fn long_flags<'a>(spec: &'a Spec<'a>, position: &Position<'_>, token: &str) -> V if meta.is_some_and(|m| m.hide) { continue; } - let description = meta.and_then(|m| m.help); + let description = meta.and_then(|m| { + deprecated_description( + m.help, + m.deprecated, + m.deprecated_warn_at, + m.deprecated_remove_at, + ) + }); for long in flag.longs { if meta.is_some_and(|m| m.hidden_longs.contains(long)) { continue; @@ -1167,7 +1179,7 @@ fn long_flags<'a>(spec: &'a Spec<'a>, position: &Position<'_>, token: &str) -> V if value.starts_with(token) { out.push(Candidate { value, - description: description.map(::std::borrow::Cow::Borrowed), + description: description.clone(), }); } } @@ -1181,7 +1193,7 @@ fn long_flags<'a>(spec: &'a Spec<'a>, position: &Position<'_>, token: &str) -> V if value.starts_with(token) { out.push(Candidate { value, - description: description.map(::std::borrow::Cow::Borrowed), + description: description.clone(), }); } } @@ -1213,7 +1225,14 @@ fn short_flags<'a>(spec: &'a Spec<'a>, position: &Position<'_>, token: &str) -> if asked_about { out.push(Candidate { value: format!("-{}", short as char), - description: meta.and_then(|m| m.help).map(::std::borrow::Cow::Borrowed), + description: meta.and_then(|m| { + deprecated_description( + m.help, + m.deprecated, + m.deprecated_warn_at, + m.deprecated_remove_at, + ) + }), }); } } @@ -1221,6 +1240,32 @@ fn short_flags<'a>(spec: &'a Spec<'a>, position: &Position<'_>, token: &str) -> out } +fn deprecated_description<'a>( + base: Option<&'a str>, + message: Option<&'a str>, + warn_at: Option<&'a str>, + remove_at: Option<&'a str>, +) -> Option> { + if message.is_none() && warn_at.is_none() && remove_at.is_none() { + return base.map(std::borrow::Cow::Borrowed); + } + let mut parts = Vec::new(); + if let Some(message) = message { + parts.push(message.to_string()); + } + if let Some(at) = warn_at { + parts.push(format!("warns at {at}")); + } + if let Some(at) = remove_at { + parts.push(format!("removed at {at}")); + } + let label = format!("[deprecated: {}]", parts.join("; ")); + Some(std::borrow::Cow::Owned(match base { + Some(base) if !base.is_empty() => format!("{base} {label}"), + _ => label, + })) +} + /// What a positional accepts here — its choices, or the separator that has to come first. /// /// An argument declared `double_dash = "required"` is not fillable until a `--` has been diff --git a/argv/src/help.rs b/argv/src/help.rs index a93ee1b81..a0ef88189 100644 --- a/argv/src/help.rs +++ b/argv/src/help.rs @@ -662,6 +662,7 @@ pub fn short_help(spec: &Spec<'_>, path: &[&str], chain: &[&CommandMeta<'_>]) -> // description is written here, so one already in the text doubles it. let _ = writeln!(out, "{}\n", about.trim_end()); } + command_deprecation(&mut out, meta, 0); usage_section(&mut out, spec, path, meta); // The path without the binary, which is what a listed subcommand shows: usage-lib prints @@ -727,6 +728,7 @@ pub fn short_help(spec: &Spec<'_>, path: &[&str], chain: &[&CommandMeta<'_>]) -> }, if a.hide_env { None } else { a.env }, if a.hide_default_value { &[] } else { a.default }, + None, ); }, ); @@ -754,6 +756,7 @@ pub fn short_help(spec: &Spec<'_>, path: &[&str], chain: &[&CommandMeta<'_>]) -> if f.hide_env { None } else { f.env }, if f.hide_default_value { &[] } else { f.default }, ); + flag_deprecation(out, f, 4); return; } match f.help.filter(|h| !h.trim().is_empty()) { @@ -764,6 +767,8 @@ pub fn short_help(spec: &Spec<'_>, path: &[&str], chain: &[&CommandMeta<'_>]) -> let _ = write!(out, " {usage}"); } } + let deprecation = + deprecation_label(f.deprecated, f.deprecated_warn_at, f.deprecated_remove_at); annotations( out, if f.hide_possible_values { @@ -773,6 +778,7 @@ pub fn short_help(spec: &Spec<'_>, path: &[&str], chain: &[&CommandMeta<'_>]) -> }, if f.hide_env { None } else { f.env }, if f.hide_default_value { &[] } else { f.default }, + deprecation.as_deref(), ); }; groups_section( @@ -876,6 +882,13 @@ fn commands_section(out: &mut String, path: &[&str], meta: &CommandMeta<'_>) { // layouts, as usage-lib does before choosing a layout. let _ = write!(out, " {}", about.trim_end()); } + if let Some(label) = deprecation_label( + sub.deprecated, + sub.deprecated_warn_at, + sub.deprecated_remove_at, + ) { + let _ = write!(out, " {label}"); + } out.push('\n'); } if heading.is_none() && !meta.cmd.disable_help_subcommand { @@ -904,6 +917,7 @@ fn flat_commands_short(out: &mut String, path: &[&str], meta: &CommandMeta<'_>) if let Some(about) = sub.about.filter(|about| !about.trim().is_empty()) { let _ = writeln!(out, "{}", about.trim_end()); } + command_deprecation(out, sub, 0); let mut args: Vec<_> = sub .args @@ -952,20 +966,43 @@ fn flat_commands_short(out: &mut String, path: &[&str], meta: &CommandMeta<'_>) } else { arg.default }, + None, ); } for flag in flags { let usage = column_usage(flag); - if let Some(help) = flag.help.filter(|help| !help.trim().is_empty()) { - if meta.next_line_help { - let _ = writeln!(out, " {usage}"); + if meta.next_line_help { + let _ = writeln!(out, " {usage}"); + if let Some(help) = flag.help.filter(|help| !help.trim().is_empty()) { write_indented(out, help, 4); - } else { - let _ = write!(out, " {usage:) } else { flag.default }, + deprecation.as_deref(), ); } if sub.flatten_help { @@ -1051,7 +1089,13 @@ fn command_help_section<'a>(sub: &'a CommandMeta<'a>, default_title: &str) -> Op } /// The bracketed notes after an entry's help: choices, environment, default. -fn annotations(out: &mut String, choices: &[&str], env: Option<&str>, default: &[&str]) { +fn annotations( + out: &mut String, + choices: &[&str], + env: Option<&str>, + default: &[&str], + suffix: Option<&str>, +) { if !choices.is_empty() { let _ = write!(out, " [{}]", choices.join(", ")); } @@ -1061,6 +1105,9 @@ fn annotations(out: &mut String, choices: &[&str], env: Option<&str>, default: & if !default.is_empty() { let _ = write!(out, " (default: {})", default.join(", ")); } + if let Some(suffix) = suffix { + let _ = write!(out, " {suffix}"); + } out.push('\n'); } @@ -1249,6 +1296,7 @@ pub fn long_help(spec: &Spec<'_>, path: &[&str], chain: &[&CommandMeta<'_>]) -> // description is written here, so one already in the text doubles it. let _ = writeln!(out, "{}\n", about.trim_end()); } + command_deprecation(&mut out, meta, 0); usage_section(&mut out, spec, path, meta); if !meta.flatten_help { @@ -1329,6 +1377,7 @@ pub fn long_help(spec: &Spec<'_>, path: &[&str], chain: &[&CommandMeta<'_>]) -> if f.hide_env { None } else { f.env }, if f.hide_default_value { &[] } else { f.default }, ); + flag_deprecation(out, f, 4); }, ); // After the command's own, and under a heading that says where they came from: `--config` @@ -1353,6 +1402,7 @@ pub fn long_help(spec: &Spec<'_>, path: &[&str], chain: &[&CommandMeta<'_>]) -> if f.hide_env { None } else { f.env }, if f.hide_default_value { &[] } else { f.default }, ); + flag_deprecation(out, f, 4); }, ); if meta.flatten_help { @@ -1496,6 +1546,47 @@ fn long_annotations(out: &mut String, choices: &[&str], env: Option<&str>, defau } } +fn deprecation_label( + message: Option<&str>, + warn_at: Option<&str>, + remove_at: Option<&str>, +) -> Option { + if message.is_none() && warn_at.is_none() && remove_at.is_none() { + return None; + } + let mut parts = Vec::new(); + if let Some(message) = message { + parts.push(message.to_string()); + } + if let Some(at) = warn_at { + parts.push(format!("warns at {at}")); + } + if let Some(at) = remove_at { + parts.push(format!("removed at {at}")); + } + Some(format!("[deprecated: {}]", parts.join("; "))) +} + +fn command_deprecation(out: &mut String, meta: &CommandMeta<'_>, indent: usize) { + if let Some(label) = deprecation_label( + meta.deprecated, + meta.deprecated_warn_at, + meta.deprecated_remove_at, + ) { + let _ = writeln!(out, "{}{label}", " ".repeat(indent)); + } +} + +fn flag_deprecation(out: &mut String, meta: &FlagMeta<'_>, indent: usize) { + if let Some(label) = deprecation_label( + meta.deprecated, + meta.deprecated_warn_at, + meta.deprecated_remove_at, + ) { + let _ = writeln!(out, "{}{label}", " ".repeat(indent)); + } +} + /// The commands list, with each command's help beneath its usage. fn long_commands_section(out: &mut String, path: &[&str], meta: &CommandMeta<'_>) { let mut visible: Vec<&&CommandMeta<'_>> = meta.subcommands.iter().filter(|c| !c.hide).collect(); @@ -1552,6 +1643,7 @@ fn long_commands_section(out: &mut String, path: &[&str], meta: &CommandMeta<'_> // the middle of the list. write_indented(out, about.trim_end(), 4); } + command_deprecation(out, sub, 4); // A blank line between entries, which the wider layout can afford and which keeps a // multi-line description from running into the next command's name. out.push('\n'); @@ -1579,6 +1671,7 @@ fn flat_commands_long(out: &mut String, path: &[&str], meta: &CommandMeta<'_>, w { let _ = writeln!(out, "{}", about.trim_end()); } + command_deprecation(out, sub, 0); let mut args: Vec<_> = sub .args @@ -1649,6 +1742,7 @@ fn flat_commands_long(out: &mut String, path: &[&str], meta: &CommandMeta<'_>, w flag.default }, ); + flag_deprecation(out, flag, 4); } if sub.flatten_help { flat_commands_long(out, &sub_path, sub, width); @@ -2097,9 +2191,96 @@ pub fn render_at_styled( #[cfg(test)] mod style_tests { - use super::{commands_section, styled_help, Style}; - use crate::spec::CommandMeta; - use crate::Command; + use super::{commands_section, flat_commands_short, styled_help, Style}; + use crate::spec::{CommandMeta, FlagMeta}; + use crate::{Command, Flag}; + + #[test] + fn flattened_next_line_deprecation_follows_help_without_a_blank_row() { + let flag = Flag { + name: "old", + longs: &["old"], + ..Flag::BOOL + }; + let flag_meta = FlagMeta { + flag: &flag, + help: Some("Use the old mode"), + deprecated: Some("use --new"), + ..FlagMeta::EMPTY + }; + let sub_cmd = Command { + name: "run", + ..Command::EMPTY + }; + let sub_meta = CommandMeta { + cmd: &sub_cmd, + flags: &[flag_meta], + ..CommandMeta::EMPTY + }; + let subcommands = [&sub_meta]; + let root_meta = CommandMeta { + next_line_help: true, + subcommands: &subcommands, + ..CommandMeta::EMPTY + }; + let mut page = String::new(); + + flat_commands_short(&mut page, &["tool"], &root_meta); + + assert!( + page.contains(" Use the old mode\n [deprecated: use --new]"), + "{page}" + ); + assert!(!page.contains("Use the old mode\n\n [deprecated")); + } + + #[test] + fn flattened_next_line_flags_without_help_still_end_their_usage_rows() { + let old = Flag { + name: "old", + longs: &["old"], + ..Flag::BOOL + }; + let new = Flag { + name: "new", + longs: &["new"], + ..Flag::BOOL + }; + let flags = [ + FlagMeta { + flag: &old, + deprecated: Some("use --new"), + ..FlagMeta::EMPTY + }, + FlagMeta { + flag: &new, + ..FlagMeta::EMPTY + }, + ]; + let sub_cmd = Command { + name: "run", + ..Command::EMPTY + }; + let sub_meta = CommandMeta { + cmd: &sub_cmd, + flags: &flags, + ..CommandMeta::EMPTY + }; + let subcommands = [&sub_meta]; + let root_meta = CommandMeta { + next_line_help: true, + subcommands: &subcommands, + ..CommandMeta::EMPTY + }; + let mut page = String::new(); + + flat_commands_short(&mut page, &["tool"], &root_meta); + + assert!(page.contains("--old\n"), "{page}"); + assert!(page.contains("[deprecated: use --new]\n"), "{page}"); + assert!(page.contains("--new\n"), "{page}"); + assert!(!page.contains("--old [deprecated"), "{page}"); + } #[test] fn short_command_rows_trim_trailing_help_whitespace() { diff --git a/argv/src/spec.rs b/argv/src/spec.rs index 31d166cab..0a4b2edf8 100644 --- a/argv/src/spec.rs +++ b/argv/src/spec.rs @@ -706,6 +706,10 @@ pub struct CommandMeta<'a> { pub cmd: &'a Command<'a>, pub about: Option<&'a str>, pub long_about: Option<&'a str>, + /// Why this command is deprecated, plus optional release milestones. + pub deprecated: Option<&'a str>, + pub deprecated_warn_at: Option<&'a str>, + pub deprecated_remove_at: Option<&'a str>, /// Aliases that work but are not shown in help or completions. Everything in /// `cmd.aliases` and not here is visible. pub hidden_aliases: &'a [&'a str], @@ -780,6 +784,9 @@ impl CommandMeta<'_> { cmd: &Command::EMPTY, about: None, long_about: None, + deprecated: None, + deprecated_warn_at: None, + deprecated_remove_at: None, hidden_aliases: &[], hide: false, help_heading: None, @@ -821,6 +828,10 @@ pub struct FlagMeta<'a> { pub help: Option<&'a str>, /// Long help, shown by `--help`. pub long_help: Option<&'a str>, + /// Why this flag is deprecated, plus optional release milestones. + pub deprecated: Option<&'a str>, + pub deprecated_warn_at: Option<&'a str>, + pub deprecated_remove_at: Option<&'a str>, /// The placeholder for the flag's value, such as `n` in `--jobs `. pub value_name: Option<&'a str>, /// Ordered placeholders for one fixed-arity occurrence. @@ -928,6 +939,9 @@ impl FlagMeta<'_> { hidden_longs: &[], help: None, long_help: None, + deprecated: None, + deprecated_warn_at: None, + deprecated_remove_at: None, value_name: None, value_names: &[], env: None, @@ -1205,6 +1219,15 @@ impl Spec<'_> { if let Some(long_about) = self.long_about.or(self.root.long_about) { prop(out, "long_about", long_about)?; } + if let Some(message) = self.root.deprecated { + prop(out, "deprecated", message)?; + } + if let Some(at) = self.root.deprecated_warn_at { + prop(out, "deprecated_warn_at", at)?; + } + if let Some(at) = self.root.deprecated_remove_at { + prop(out, "deprecated_remove_at", at)?; + } if let Some(usage) = self.usage { prop(out, "usage", usage)?; } @@ -1478,6 +1501,15 @@ fn write_command<'a>( if let Some(help) = meta.about { write!(out, " help={}", quoted(help))?; } + if let Some(deprecated) = meta.deprecated { + write!(out, " deprecated={}", quoted(deprecated))?; + } + if let Some(at) = meta.deprecated_warn_at { + write!(out, " deprecated_warn_at={}", quoted(at))?; + } + if let Some(at) = meta.deprecated_remove_at { + write!(out, " deprecated_remove_at={}", quoted(at))?; + } if meta.hide { out.push_str(" hide=#true"); } @@ -1693,6 +1725,15 @@ fn write_flag(out: &mut String, meta: &FlagMeta<'_>, depth: usize) -> core::fmt: if let Some(help) = meta.help { write!(out, " help={}", quoted(help))?; } + if let Some(deprecated) = meta.deprecated { + write!(out, " deprecated={}", quoted(deprecated))?; + } + if let Some(at) = meta.deprecated_warn_at { + write!(out, " deprecated_warn_at={}", quoted(at))?; + } + if let Some(at) = meta.deprecated_remove_at { + write!(out, " deprecated_remove_at={}", quoted(at))?; + } if meta.required { out.push_str(" required=#true"); } diff --git a/conformance/src/tables.rs b/conformance/src/tables.rs index 7a6f31437..440996153 100644 --- a/conformance/src/tables.rs +++ b/conformance/src/tables.rs @@ -138,6 +138,9 @@ pub fn build( cmd: table, about: opt(&cmd.help), long_about: opt(&cmd.help_long), + deprecated: opt(&cmd.deprecated), + deprecated_warn_at: opt(&cmd.deprecated_warn_at), + deprecated_remove_at: opt(&cmd.deprecated_remove_at), hidden_aliases: Box::leak( cmd.hidden_aliases .iter() @@ -350,6 +353,9 @@ fn flag_meta( hidden_longs: strs(&f.hidden_aliases), help: opt(&f.help), long_help: opt(&f.help_long), + deprecated: opt(&f.deprecated), + deprecated_warn_at: opt(&f.deprecated_warn_at), + deprecated_remove_at: opt(&f.deprecated_remove_at), value_name: arg.map(|a| leak(&a.name)), value_names: arg.map_or(&[], |a| strs(&a.value_names)), // The value's own bracket bit, which is not the flag's — usage-lib renders a flag from diff --git a/conformance/tests/metadata.rs b/conformance/tests/metadata.rs index 62d124c99..549a699de 100644 --- a/conformance/tests/metadata.rs +++ b/conformance/tests/metadata.rs @@ -12,6 +12,68 @@ use usage::Spec as LibSpec; use usage_derive::{Args, Cli, Subcommands}; +#[derive(Args)] +#[usage(deprecated = "use inspect", deprecated_remove_at = "7.0")] +struct Old {} + +#[derive(Subcommands)] +enum DeprecatedCommands { + #[usage(deprecated = "use show", deprecated_warn_at = "6.1")] + Old(Old), +} + +#[derive(Cli)] +#[usage(bin = "deprecated", deprecated = "use the replacement")] +#[allow(dead_code)] +struct DeprecatedCli { + #[usage( + long, + deprecated = "use --new", + deprecated_warn_at = "6.2", + deprecated_remove_at = "7.0" + )] + old: bool, + #[usage(subcommand)] + command: Option, +} + +#[test] +fn deprecation_metadata_survives_the_typed_spec() { + let spec: LibSpec = DeprecatedCli::to_kdl().parse().expect("valid spec"); + assert_eq!(spec.cmd.deprecated.as_deref(), Some("use the replacement")); + let flag = spec + .cmd + .flags + .iter() + .find(|flag| flag.name == "old") + .unwrap(); + assert_eq!(flag.deprecated.as_deref(), Some("use --new")); + assert_eq!(flag.deprecated_warn_at.as_deref(), Some("6.2")); + assert_eq!(flag.deprecated_remove_at.as_deref(), Some("7.0")); + let command = spec.cmd.subcommands.get("old").expect("old command"); + assert_eq!(command.deprecated.as_deref(), Some("use show")); + assert_eq!(command.deprecated_warn_at.as_deref(), Some("6.1")); + assert_eq!(command.deprecated_remove_at.as_deref(), Some("7.0")); +} + +#[test] +fn deprecation_renders_inline_and_in_flattened_command_sections() { + let spec = DeprecatedCli::spec(); + let page = usage_argv::help::render(spec, spec.root.cmd, false).unwrap(); + assert!( + page.contains("--old [deprecated: use --new; warns at 6.2; removed at 7.0]"), + "{page}" + ); + + let mut portable: LibSpec = DeprecatedCli::to_kdl().parse().unwrap(); + portable.cmd.flatten_help = true; + let page = usage::docs::cli::render_help(&portable, &portable.cmd, false); + assert!( + page.contains("old:\n[deprecated: use show; warns at 6.1; removed at 7.0]"), + "{page}" + ); +} + /// A flag reachable only by its short form, whose value still needs a name. #[derive(Cli)] #[usage(bin = "shortonly")] diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index 51aa2e9ad..d5c2b03c9 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -216,6 +216,9 @@ pub fn emit(cli: &Cli) -> TokenStream { .as_ref() .map(|value| option_expr(Some(value))) .unwrap_or_else(|| option_str(cli.long_about.as_deref())); + let deprecated = option_str(cli.deprecated.as_deref()); + let deprecated_warn_at = option_str(cli.deprecated_warn_at.as_deref()); + let deprecated_remove_at = option_str(cli.deprecated_remove_at.as_deref()); // The same wiring a nested command uses: the root differs only in how it is // entered, so it does not get its own copy. @@ -507,6 +510,9 @@ pub fn emit(cli: &Cli) -> TokenStream { cmd: &ROOT, about: #about, long_about: #long_about, + deprecated: #deprecated, + deprecated_warn_at: #deprecated_warn_at, + deprecated_remove_at: #deprecated_remove_at, restart_token: #restart_token, subcommand_required: #subcommand_required, subcommand_help_heading: #subcommand_help_heading, @@ -1323,6 +1329,9 @@ fn flag_meta(cli: &Cli, i: usize, field: &Field, owner: &syn::Ident) -> TokenStr let env = option_str(field.env.as_deref()); let help_heading = option_str(field.help_heading.as_deref()); let display_order = option_usize(field.display_order); + let deprecated = option_str(field.deprecated.as_deref()); + let deprecated_warn_at = option_str(field.deprecated_warn_at.as_deref()); + let deprecated_remove_at = option_str(field.deprecated_remove_at.as_deref()); let value_name = option_str(field.value_name.as_deref()); let value_names = &field.value_names; let complete_type = option_str(field.complete_type.as_deref()); @@ -1442,6 +1451,9 @@ fn flag_meta(cli: &Cli, i: usize, field: &Field, owner: &syn::Ident) -> TokenStr display_order: #display_order, help: #help, long_help: #long_help, + deprecated: #deprecated, + deprecated_warn_at: #deprecated_warn_at, + deprecated_remove_at: #deprecated_remove_at, env: #env, default: #default, help_heading: #help_heading, @@ -3714,6 +3726,9 @@ pub fn emit_args(cli: &Cli) -> TokenStream { .as_ref() .map(|value| option_expr(Some(value))) .unwrap_or_else(|| option_str(cli.long_about.as_deref())); + let deprecated = option_str(cli.deprecated.as_deref()); + let deprecated_warn_at = option_str(cli.deprecated_warn_at.as_deref()); + let deprecated_remove_at = option_str(cli.deprecated_remove_at.as_deref()); let partial = partial_struct(cli); let argument_lookup = argument_lookup_functions(cli); let defaults = partial_defaults(cli); @@ -3800,6 +3815,9 @@ pub fn emit_args(cli: &Cli) -> TokenStream { effect: #effect, about: #about, long_about: #long_about, + deprecated: #deprecated, + deprecated_warn_at: #deprecated_warn_at, + deprecated_remove_at: #deprecated_remove_at, hidden_aliases: &[#(#hidden_aliases),*], restart_token: #restart_token, subcommand_required: #subcommand_required, @@ -4204,6 +4222,25 @@ pub fn emit_subcommands(subs: &Subcommands) -> TokenStream { Some(long) => option_expr(Some(long)), None => quote!(<#ty as usage_argv::spec::CommandArgs>::META.long_about), }; + let deprecated = v + .deprecated + .as_deref() + .map(|value| option_str(Some(value))) + .unwrap_or_else(|| quote!(<#ty as usage_argv::spec::CommandArgs>::META.deprecated)); + let deprecated_warn_at = v + .deprecated_warn_at + .as_deref() + .map(|value| option_str(Some(value))) + .unwrap_or_else( + || quote!(<#ty as usage_argv::spec::CommandArgs>::META.deprecated_warn_at), + ); + let deprecated_remove_at = v + .deprecated_remove_at + .as_deref() + .map(|value| option_str(Some(value))) + .unwrap_or_else( + || quote!(<#ty as usage_argv::spec::CommandArgs>::META.deprecated_remove_at), + ); let before_help = v .before_help .as_ref() @@ -4250,6 +4287,9 @@ pub fn emit_subcommands(subs: &Subcommands) -> TokenStream { cmd: &#cmd, about: #about, long_about: #long_about, + deprecated: #deprecated, + deprecated_warn_at: #deprecated_warn_at, + deprecated_remove_at: #deprecated_remove_at, before_help: #before_help, before_long_help: #before_long_help, after_help: #after_help, diff --git a/derive/src/model.rs b/derive/src/model.rs index d1ad9f6d2..f24b9d1b0 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -95,6 +95,10 @@ pub struct Cli { /// From the struct's doc comment: first paragraph, and the whole thing. pub about: Option, pub long_about: Option, + /// Why this command is deprecated, plus optional release milestones. + pub deprecated: Option, + pub deprecated_warn_at: Option, + pub deprecated_remove_at: Option, /// Package metadata expressions whose results must be usable as `&'static str`. /// /// Keeping their tokens lets the Cargo-provided `env!("CARGO_PKG_…")` values remain the @@ -199,6 +203,10 @@ pub struct Field { pub value_enum: bool, pub help: Option, pub long_help: Option, + /// Why this flag is deprecated, plus optional release milestones. + pub deprecated: Option, + pub deprecated_warn_at: Option, + pub deprecated_remove_at: Option, pub env: Option, /// The setting this flag sets, when the CLI resolves configuration. /// @@ -629,6 +637,9 @@ impl Cli { runtime_long_version: None, about: None, long_about: None, + deprecated: None, + deprecated_warn_at: None, + deprecated_remove_at: None, author: None, license: None, repository: None, @@ -789,6 +800,9 @@ impl Cli { // declared. "about" => cli.about_attr = Some(metadata_expr(&meta)?), "long_about" => cli.long_about_attr = Some(metadata_expr(&meta)?), + "deprecated" => cli.deprecated = Some(string_value(&meta)?), + "deprecated_warn_at" => cli.deprecated_warn_at = Some(string_value(&meta)?), + "deprecated_remove_at" => cli.deprecated_remove_at = Some(string_value(&meta)?), "author" => cli.author = Some(metadata_expr(&meta)?), "license" => cli.license = Some(metadata_expr(&meta)?), "repository" => cli.repository = Some(metadata_expr(&meta)?), @@ -855,7 +869,7 @@ impl Cli { path, format!( "unknown option `{other}` on a struct; usage::Cli takes \ - `name`, `name_spec`, `bin`, `bin_spec`, `version`, `version_spec`, `long_version`, `long_version_spec`, `author`, `license`, `repository`, `usage`, `verbatim_doc_comment`, `unknown_flags`, \ + `name`, `name_spec`, `bin`, `bin_spec`, `version`, `version_spec`, `long_version`, `long_version_spec`, `author`, `license`, `repository`, `usage`, `deprecated`, `deprecated_warn_at`, `deprecated_remove_at`, `verbatim_doc_comment`, `unknown_flags`, \ `default_subcommand`, `multicall`, `no_binary_name`, `arg_required_else_help`, `disable_help_flag`, `disable_help_subcommand`, `disable_version_flag`, `dont_delimit_trailing_values`, `args_override_self`, `subcommand_negates_reqs`, `args_conflicts_with_subcommands`, `subcommand_precedence_over_arg`, `allow_missing_positional`, \ `next_help_heading`, `subcommand_help_heading`, `next_line_help`, `flatten_help`, `term_width`, `max_term_width`, \ `subcommand_value_name`, `restart_token`, `mount` and \ @@ -1577,6 +1591,9 @@ impl Field { optional_value_type: false, help: None, long_help: None, + deprecated: None, + deprecated_warn_at: None, + deprecated_remove_at: None, env: None, setting: None, default: Vec::new(), @@ -1707,6 +1724,9 @@ impl Field { optional_value_type: false, help: None, long_help: None, + deprecated: None, + deprecated_warn_at: None, + deprecated_remove_at: None, env: None, setting: None, default: Vec::new(), @@ -1831,6 +1851,9 @@ impl Field { optional_value_type: false, help: None, long_help: None, + deprecated: None, + deprecated_warn_at: None, + deprecated_remove_at: None, env: None, setting: None, default: Vec::new(), @@ -1939,6 +1962,9 @@ impl Field { let mut required_collection = false; let mut help_attr: Option = None; let mut long_help_attr: Option = None; + let mut deprecated = None; + let mut deprecated_warn_at = None; + let mut deprecated_remove_at = None; let mut verbatim_doc_comment = false; let mut hide = false; let mut hide_default_value = false; @@ -2195,6 +2221,9 @@ impl Field { // help whose breaks are meant literally has to be given directly. "help" => help_attr = Some(string_value(&meta)?), "long_help" => long_help_attr = Some(string_value(&meta)?), + "deprecated" => deprecated = Some(string_value(&meta)?), + "deprecated_warn_at" => deprecated_warn_at = Some(string_value(&meta)?), + "deprecated_remove_at" => deprecated_remove_at = Some(string_value(&meta)?), "verbatim_doc_comment" => verbatim_doc_comment = flag_value(&meta)?, "required" => required_collection = flag_value(&meta)?, "double_dash" => { @@ -2222,7 +2251,7 @@ impl Field { format!( "unknown option `{other}`; a field takes `name`, `id`, `long`, \ `short`, `negate`, `global`, `var`, `variadic`, \ - `count`, `action`, `hide`, `hide_default_value`, `hide_env`, `hide_env_values`, \ + `count`, `action`, `hide`, `hide_default_value`, `hide_env`, `hide_env_values`, `deprecated`, `deprecated_warn_at`, `deprecated_remove_at`, \ `hide_possible_values`, `hide_short_help`, `hide_long_help`, \ `arg`, `env`, `default`, `default_value_t`, `choices`, `validate`, \ `validate_error`, \ @@ -3079,6 +3108,17 @@ impl Field { value_name = Some(shout(&name)); } + if !matches!(kind, Kind::Flag { .. }) + && (deprecated.is_some() + || deprecated_warn_at.is_some() + || deprecated_remove_at.is_some()) + { + return Err(syn::Error::new_spanned( + field, + "deprecation metadata belongs on a flag or command, not a positional argument", + )); + } + Ok(Field { ident, ty: field.ty.clone(), @@ -3091,6 +3131,9 @@ impl Field { optional_value_type, help, long_help, + deprecated, + deprecated_warn_at, + deprecated_remove_at, env, setting, default, @@ -4016,6 +4059,9 @@ pub struct Variant { pub hidden_aliases: Vec, pub help: Option, pub long_help: Option, + pub deprecated: Option, + pub deprecated_warn_at: Option, + pub deprecated_remove_at: Option, pub before_help: Option, pub before_long_help: Option, pub after_help: Option, @@ -4136,6 +4182,9 @@ impl Variant { let mut external = false; let mut help_attr: Option = None; let mut long_help_attr: Option = None; + let mut deprecated = None; + let mut deprecated_warn_at = None; + let mut deprecated_remove_at = None; let mut before_help = None; let mut before_long_help = None; let mut after_help = None; @@ -4164,6 +4213,9 @@ impl Variant { // breaks matter is declared instead. "help" => help_attr = Some(metadata_expr(&meta)?), "long_help" => long_help_attr = Some(metadata_expr(&meta)?), + "deprecated" => deprecated = Some(string_value(&meta)?), + "deprecated_warn_at" => deprecated_warn_at = Some(string_value(&meta)?), + "deprecated_remove_at" => deprecated_remove_at = Some(string_value(&meta)?), "before_help" => before_help = Some(metadata_expr(&meta)?), "before_long_help" => before_long_help = Some(metadata_expr(&meta)?), "after_help" => after_help = Some(metadata_expr(&meta)?), @@ -4175,7 +4227,7 @@ impl Variant { format!( "unknown option `{other}` on a variant; a subcommand \ variant takes `name`, `alias`, `alias_hidden`, `help_heading`, `display_order`, \ - `external_subcommand`, `help`, `long_help`, `before_help`, \ + `external_subcommand`, `help`, `long_help`, `deprecated`, `deprecated_warn_at`, `deprecated_remove_at`, `before_help`, \ `before_long_help`, `after_help`, `after_long_help`, and `verbatim_doc_comment` here, \ and its description comes from the doc comment" ), @@ -4293,6 +4345,9 @@ impl Variant { hidden_aliases, help: None, long_help: None, + deprecated: None, + deprecated_warn_at: None, + deprecated_remove_at: None, before_help: None, before_long_help: None, after_help: None, @@ -4359,6 +4414,9 @@ impl Variant { hidden_aliases, help, long_help, + deprecated, + deprecated_warn_at, + deprecated_remove_at, before_help, before_long_help, after_help, diff --git a/go/argv/complete.go b/go/argv/complete.go index da29b3b9b..446bb8276 100644 --- a/go/argv/complete.go +++ b/go/argv/complete.go @@ -338,7 +338,7 @@ func describe(key uint64, help HelpTable) string { // renderer's job — see oneLine — because it is the line-based protocols // that need it, and collapsing there keeps both halves of a two-line // description instead of dropping the second. - return h.Short + return helpText(h) } return "" } diff --git a/go/argv/help.go b/go/argv/help.go index 5a43ab695..0c7864891 100644 --- a/go/argv/help.go +++ b/go/argv/help.go @@ -61,6 +61,10 @@ type Help struct { // Short is the one-line help, and Long the fuller text `--help` prefers. Short string Long string + // Deprecated is the migration message, with optional warn/remove milestones. + Deprecated string + DeprecatedWarnAt string + DeprecatedRemoveAt string // Heading groups an entry into a section of the page. Presentational only. Heading string // DisplayOrderSet distinguishes an explicit zero from declaration order. diff --git a/go/argv/page.go b/go/argv/page.go index ed81f0142..f39dd17d8 100644 --- a/go/argv/page.go +++ b/go/argv/page.go @@ -98,6 +98,9 @@ func ShortHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) s if about := trimEnd(about); about != "" { out.WriteString(about + "\n\n") } + if label := deprecationLabel(meta); label != "" { + out.WriteString(label + "\n\n") + } for i, line := range usageLines(path, cmd, help) { if i == 0 { @@ -182,7 +185,7 @@ func ShortHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) s } if nextLineHelp { w.WriteString(" " + f.usage + "\n") - if text := helpText(h); text != "" { + if text := metaField(h, func(x *Help) string { return x.Short }); text != "" { writeIndented(w, text, 4) } longAnnotations(w, h, true) @@ -295,15 +298,20 @@ func commandsSection(out *strings.Builder, path []string, cmd *Command, help Hel if len(h.VisibleAliases) > 0 { out.WriteString(" [aliases: " + strings.Join(h.VisibleAliases, ", ") + "]") } - if h.Short != "" { - if nextLineHelp { - out.WriteString("\n") + if nextLineHelp { + out.WriteString("\n") + if strings.TrimSpace(h.Short) != "" { writeIndented(out, trimEnd(h.Short), 4) - continue } + if label := deprecationLabel(h); label != "" { + writeIndented(out, label, 4) + } + continue + } + if text := helpText(h); text != "" { // The row owns its terminating newline. Trim the description in both // layouts, as usage-lib does before selecting a layout. - out.WriteString(" " + trimEnd(h.Short)) + out.WriteString(" " + trimEnd(text)) } } out.WriteString("\n") @@ -328,8 +336,11 @@ func flatCommandsShort(out *strings.Builder, path []string, cmd *Command, help H } subPath := append(append([]string{}, path...), sub.Name) out.WriteString("\n" + strings.Join(subPath, " ") + ":\n") - if h != nil && strings.TrimSpace(h.Short) != "" { - out.WriteString(trimEnd(h.Short) + "\n") + if text := metaField(h, func(x *Help) string { return x.Short }); strings.TrimSpace(text) != "" { + out.WriteString(trimEnd(text) + "\n") + } + if label := deprecationLabel(h); label != "" { + out.WriteString(label + "\n") } args := visibleArgs(sub, help, false) @@ -351,32 +362,38 @@ func flatCommandsShort(out *strings.Builder, path []string, cmd *Command, help H for _, a := range args { ah := help.Lookup(a.Key) usage := argUsage(a, ah) - if text := helpText(ah); text != "" { - if nextLine { - out.WriteString(" " + usage + "\n") + if nextLine { + out.WriteString(" " + usage + "\n") + if text := metaField(ah, func(x *Help) string { return x.Short }); text != "" { writeIndented(out, text, 4) - } else { - out.WriteString(" " + pad(usage, argCol) + " " + text) } + longAnnotations(out, ah, true) } else { - out.WriteString(" " + usage) + if text := helpText(ah); text != "" { + out.WriteString(" " + pad(usage, argCol) + " " + text) + } else { + out.WriteString(" " + usage) + } + annotations(out, ah, true) } - annotations(out, ah, true) } for _, f := range flags { fh := help.Lookup(f.Key) usage := columnUsage(f, allShown(f), help) - if text := helpText(fh); text != "" { - if nextLine { - out.WriteString(" " + usage + "\n") + if nextLine { + out.WriteString(" " + usage + "\n") + if text := metaField(fh, func(x *Help) string { return x.Short }); text != "" { writeIndented(out, text, 4) - } else { - out.WriteString(" " + pad(usage, flagCol) + " " + text) } + longAnnotations(out, fh, true) } else { - out.WriteString(" " + usage) + if text := helpText(fh); text != "" { + out.WriteString(" " + pad(usage, flagCol) + " " + text) + } else { + out.WriteString(" " + usage) + } + annotations(out, fh, true) } - annotations(out, fh, true) } if h != nil && h.FlattenHelp { flatCommandsShort(out, subPath, sub, help, h.NextLineHelp) @@ -473,10 +490,34 @@ func annotations(out *strings.Builder, h *Help, withDefault bool) { } func helpText(h *Help) string { - if h == nil || strings.TrimSpace(h.Short) == "" { + if h == nil { + return "" + } + label := deprecationLabel(h) + if strings.TrimSpace(h.Short) == "" { + return label + } + if label == "" { + return h.Short + } + return h.Short + " " + label +} + +func deprecationLabel(h *Help) string { + if h == nil || (h.Deprecated == "" && h.DeprecatedWarnAt == "" && h.DeprecatedRemoveAt == "") { return "" } - return h.Short + parts := []string{} + if h.Deprecated != "" { + parts = append(parts, h.Deprecated) + } + if h.DeprecatedWarnAt != "" { + parts = append(parts, "warns at "+h.DeprecatedWarnAt) + } + if h.DeprecatedRemoveAt != "" { + parts = append(parts, "removed at "+h.DeprecatedRemoveAt) + } + return "[deprecated: " + strings.Join(parts, "; ") + "]" } func headingOf(help HelpTable, key uint64) string { diff --git a/go/argv/page_long.go b/go/argv/page_long.go index 20e2a86ab..81803af5e 100644 --- a/go/argv/page_long.go +++ b/go/argv/page_long.go @@ -63,6 +63,9 @@ func LongHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) st if about := trimEnd(about); about != "" { out.WriteString(about + "\n\n") } + if label := deprecationLabel(meta); label != "" { + out.WriteString(label + "\n\n") + } for i, line := range usageLines(path, cmd, help) { if i == 0 { @@ -225,6 +228,9 @@ func longCommandsSection(out *strings.Builder, path []string, cmd *Command, help if about := trimEnd(firstOf(h.Long, h.Short)); about != "" { writeIndented(out, about, 4) } + if label := deprecationLabel(h); label != "" { + writeIndented(out, label, 4) + } } // A blank line between entries, which the wider layout can afford and // which keeps a multi-line description from running into the next name. @@ -250,6 +256,9 @@ func flatCommandsLong(out *strings.Builder, path []string, cmd *Command, help He metaField(h, func(x *Help) string { return x.Short })); strings.TrimSpace(about) != "" { out.WriteString(trimEnd(about) + "\n") } + if label := deprecationLabel(h); label != "" { + out.WriteString(label + "\n") + } args := visibleArgs(sub, help, true) flags := make([]*Flag, 0, len(sub.Flags)) @@ -332,6 +341,9 @@ func longAnnotations(out *strings.Builder, h *Help, withDefault bool) { if withDefault && !h.HideDefaultValue && len(h.Default) > 0 { out.WriteString(" (default: " + strings.Join(h.Default, ", ") + ")\n") } + if label := deprecationLabel(h); label != "" { + out.WriteString(" " + label + "\n") + } } // writeIndented writes text with every line indented, leaving blank lines blank — diff --git a/go/argv/page_test.go b/go/argv/page_test.go index 0c8df6789..85773e4ed 100644 --- a/go/argv/page_test.go +++ b/go/argv/page_test.go @@ -139,6 +139,44 @@ func TestSubcommandPresentation(t *testing.T) { } } +func TestCommandDeprecationAppearsInListingsAndFlattenedHelp(t *testing.T) { + sub := &Command{Name: "old", Key: 2} + root := &Command{Name: "ex", Key: 1, Subcommands: []*Command{sub}} + help := HelpTable{ + {Key: 1}, + {Key: 2, Short: "old command", DeprecatedWarnAt: "6.1"}, + } + for _, page := range []string{ + ShortHelp(HelpSpec{Bin: "ex"}, []string{"ex"}, []*Command{root}, help), + LongHelp(HelpSpec{Bin: "ex"}, []string{"ex"}, []*Command{root}, help), + } { + if !strings.Contains(page, "[deprecated: warns at 6.1]") { + t.Fatalf("command listing omitted deprecation:\n%s", page) + } + } + + help[0].NextLineHelp = true + nextLinePage := ShortHelp(HelpSpec{Bin: "ex"}, []string{"ex"}, []*Command{root}, help) + if !strings.Contains(nextLinePage, "old\n old command\n [deprecated: warns at 6.1]") { + t.Fatalf("next-line command listing glued deprecation to its description:\n%s", nextLinePage) + } + help[0].NextLineHelp = false + + help[0].FlattenHelp = true + shortPage := ShortHelp(HelpSpec{Bin: "ex"}, []string{"ex"}, []*Command{root}, help) + if !strings.Contains(shortPage, "old command\n[deprecated: warns at 6.1]") { + t.Fatalf("flattened command glued deprecation to its description:\n%s", shortPage) + } + for _, page := range []string{ + shortPage, + LongHelp(HelpSpec{Bin: "ex"}, []string{"ex"}, []*Command{root}, help), + } { + if !strings.Contains(page, "old command") || !strings.Contains(page, "[deprecated: warns at 6.1]") { + t.Fatalf("flattened command omitted deprecation:\n%s", page) + } + } +} + func TestSubcommandHelpHeadings(t *testing.T) { run := &Command{Name: "run", Key: 2} clean := &Command{Name: "clean", Key: 3} @@ -214,7 +252,7 @@ func TestNextLineHelp(t *testing.T) { help := HelpTable{ {Key: 1, NextLineHelp: true}, {Key: 2, Short: "Input file"}, - {Key: 3, Short: "Enable verbose output"}, + {Key: 3, Short: "Enable verbose output", Deprecated: "use --log-level"}, {Key: 4, Short: "Run it\n"}, {Key: 5, Env: "MODE", Default: []string{"fast"}, Choices: []string{"fast", "slow"}}, } @@ -227,6 +265,9 @@ func TestNextLineHelp(t *testing.T) { shortPage, LongHelp(HelpSpec{Bin: "ex"}, []string{"ex"}, []*Command{root}, help), } { + if count := strings.Count(page, "[deprecated: use --log-level]"); count != 1 { + t.Fatalf("deprecation should appear once, got %d:\n%s", count, page) + } for _, want := range []string{ " [input]\n Input file", " --verbose\n Enable verbose output", @@ -251,7 +292,7 @@ func TestFlattenHelp(t *testing.T) { {Key: 1, FlattenHelp: true}, {Key: 2, Short: "Run it", FlattenHelp: true, NextLineHelp: true}, {Key: 3, Short: "Task name", Demanded: true}, - {Key: 4, Short: "Only show changes"}, + {Key: 4, Short: "Only show changes", Deprecated: "use --mode"}, {Key: 5, Short: "Nested operation"}, {Key: 6, Short: "Deep option"}, } @@ -281,6 +322,21 @@ func TestFlattenHelp(t *testing.T) { } } +func TestFlattenedNextLineHelpKeepsDeprecationSeparate(t *testing.T) { + flag := &Flag{Name: "old", Key: 3, Longs: []string{"old"}} + sub := &Command{Name: "run", Key: 2, Flags: []*Flag{flag}} + root := &Command{Name: "ex", Key: 1, Subcommands: []*Command{sub}} + help := HelpTable{ + {Key: 1, FlattenHelp: true, NextLineHelp: true}, + {Key: 2, Short: "Run it"}, + {Key: 3, Short: "Old mode", Deprecated: "use --new"}, + } + page := ShortHelp(HelpSpec{Bin: "ex"}, []string{"ex"}, []*Command{root}, help) + if !strings.Contains(page, "--old\n Old mode\n [deprecated: use --new]") { + t.Fatalf("flattened next-line help glued deprecation to its description:\n%s", page) + } +} + // A description that ends in a break adds no blank line. // // clap's `long_about` often ends with one — a `///` block whose last line is diff --git a/go/internal/spec/spec.go b/go/internal/spec/spec.go index 6b52e5be1..b205ae3d7 100644 --- a/go/internal/spec/spec.go +++ b/go/internal/spec/spec.go @@ -87,6 +87,9 @@ type Cmd struct { Hide bool `json:"hide"` Help string `json:"help"` HelpLong string `json:"help_long"` + Deprecated string `json:"deprecated"` + DeprecatedWarnAt string `json:"deprecated_warn_at"` + DeprecatedRemoveAt string `json:"deprecated_remove_at"` HelpHeading string `json:"help_heading"` DisplayOrder *uint32 `json:"display_order"` Usage string `json:"usage"` @@ -233,6 +236,9 @@ type Flag struct { Help string `json:"help"` HelpFirstLine string `json:"help_first_line"` HelpLong string `json:"help_long"` + Deprecated string `json:"deprecated"` + DeprecatedWarnAt string `json:"deprecated_warn_at"` + DeprecatedRemoveAt string `json:"deprecated_remove_at"` HelpHeading string `json:"help_heading"` // The four that name another flag. They arrive as written, dashes included. Conflicts []string `json:"conflicts"` @@ -679,7 +685,10 @@ func (b *builder) command(c *Cmd, inherited argv.UnknownFlags) *argv.Command { // a command: the page renderer falls back for itself, so carrying the same // string twice would only put it in the table twice. What matters is that // the two producers of this table agree — see TestTheTwoProducersAgree. - Long: c.HelpLong, + Long: c.HelpLong, + Deprecated: c.Deprecated, + DeprecatedWarnAt: c.DeprecatedWarnAt, + DeprecatedRemoveAt: c.DeprecatedRemoveAt, // Visible only: the parse table merges the hidden ones in beside these, // because binding does not care which is which. A spec may declare the same // alias twice, once hidden, and hiding wins — usage-lib reports it in both @@ -947,18 +956,21 @@ func (b *builder) flag(f *Flag, strictDuplicates bool) *argv.Flag { HideLongHelp: f.HideLongHelp, // Required *and* undefaulted: a required flag with a default is one the // user never has to type, so the line brackets it. - Demanded: f.Required && len(f.Default) == 0, - Repeatable: f.Var, - ValueName: valueName, - ValueNames: valueNames(f.Arg), - ValueArity: exactArity(f.Arg), - ValueDemanded: valueDemanded, - Short: first(f.Help, f.HelpFirstLine), - Long: first(f.HelpLong, f.Help), - Heading: f.HelpHeading, - Choices: f.choices(), - Env: f.Env, - Default: f.defaults(), + Demanded: f.Required && len(f.Default) == 0, + Repeatable: f.Var, + ValueName: valueName, + ValueNames: valueNames(f.Arg), + ValueArity: exactArity(f.Arg), + ValueDemanded: valueDemanded, + Short: first(f.Help, f.HelpFirstLine), + Long: first(f.HelpLong, f.Help), + Deprecated: f.Deprecated, + DeprecatedWarnAt: f.DeprecatedWarnAt, + DeprecatedRemoveAt: f.DeprecatedRemoveAt, + Heading: f.HelpHeading, + Choices: f.choices(), + Env: f.Env, + Default: f.defaults(), }) b.record(out.Key, argv.Meta{ Name: f.Name, diff --git a/lib/src/docs/cli/mod.rs b/lib/src/docs/cli/mod.rs index c6a9eb67f..a45129ada 100644 --- a/lib/src/docs/cli/mod.rs +++ b/lib/src/docs/cli/mod.rs @@ -926,24 +926,44 @@ flag "--verbose" help="Enable verbose output" fn test_render_help_with_deprecated_command() { let spec = crate::spec! { r#" bin "testcli" -cmd "old-cmd" help="Do something" deprecated="use new-cmd instead" +flag "--old" help="Old switch" deprecated="use --new" deprecated_warn_at="6.1" deprecated_remove_at="7.0" +cmd "old-cmd" help="Do something" deprecated="use new-cmd instead" deprecated_warn_at="6.2" deprecated_remove_at="7.0" cmd "new-cmd" help="Do something better" "# } .unwrap(); assert_snapshot!(render_help(&spec, &spec.cmd, false), @" - Usage: testcli + Usage: testcli [--old] Commands: new-cmd Do something better - old-cmd [deprecated: use new-cmd instead] Do something + old-cmd [deprecated: use new-cmd instead; warns at 6.2; removed at 7.0] Do something help Print this message or the help of the given subcommand(s) Flags: + --old Old switch [deprecated: use --new; warns at 6.1; removed at 7.0] -h, --help Print help "); } + #[test] + fn deprecation_milestones_do_not_need_a_message() { + let spec = crate::spec! { r#" +bin "testcli" +flag "--old" help="Old switch" deprecated_remove_at="7.0" +cmd "old-cmd" help="Do something" deprecated_warn_at="6.2" + "# } + .unwrap(); + + let page = render_help(&spec, &spec.cmd, false); + assert!( + page.contains("old-cmd [deprecated: warns at 6.2]"), + "{page}" + ); + assert!(page.contains("[deprecated: removed at 7.0]"), "{page}"); + assert!(!page.contains("[deprecated:;"), "{page}"); + } + #[test] fn test_render_help_with_subcommand_presentation() { let spec = crate::spec! { r#" diff --git a/lib/src/docs/cli/templates/spec_template_long.tera b/lib/src/docs/cli/templates/spec_template_long.tera index 66554aa6c..9daa2e4a1 100644 --- a/lib/src/docs/cli/templates/spec_template_long.tera +++ b/lib/src/docs/cli/templates/spec_template_long.tera @@ -22,6 +22,10 @@ {% elif cmd.help %}{{ cmd.help }} {% endif -%} +{%- endif -%} +{%- if cmd.deprecated or cmd.deprecated_warn_at or cmd.deprecated_remove_at %} +[deprecated:{% if cmd.deprecated %} {{ cmd.deprecated }}{% endif %}{% if cmd.deprecated_warn_at %}{% if cmd.deprecated %};{% endif %} warns at {{ cmd.deprecated_warn_at }}{% endif %}{% if cmd.deprecated_remove_at %}{% if cmd.deprecated or cmd.deprecated_warn_at %};{% endif %} removed at {{ cmd.deprecated_remove_at }}{% endif %}] + {%- endif -%} {%- if cmd.flatten_help and cmd.flattened_usage %} {%- for usage in cmd.flattened_usage %} @@ -37,7 +41,7 @@ Usage: {{ (spec.bin ~ " " ~ cmd.usage) | trim }} {{ group.heading | default(value=cmd.subcommand_help_heading | default(value="Commands")) }}: {%- for cmd in group.items %} {{ cmd.usage | trim }} -{%- if cmd.deprecated %} [deprecated: {{ cmd.deprecated }}]{%- endif %} +{%- if cmd.deprecated or cmd.deprecated_warn_at or cmd.deprecated_remove_at %} [deprecated:{% if cmd.deprecated %} {{ cmd.deprecated }}{% endif %}{% if cmd.deprecated_warn_at %}{% if cmd.deprecated %};{% endif %} warns at {{ cmd.deprecated_warn_at }}{% endif %}{% if cmd.deprecated_remove_at %}{% if cmd.deprecated or cmd.deprecated_warn_at %};{% endif %} removed at {{ cmd.deprecated_remove_at }}{% endif %}]{%- endif %} {%- if cmd.aliases %} [aliases: {{ cmd.aliases | join(sep=", ") }}]{% endif %} {%- set help = cmd.help_long | default(value=cmd.help | default(value='')) %} {%- if help %} @@ -124,6 +128,9 @@ Usage: {{ (spec.bin ~ " " ~ cmd.usage) | trim }} {%- if not flag.hide_default_value and flag.default %} (default: {{ flag.default | join(sep=", ") }}) {%- endif %} +{%- if flag.deprecated or flag.deprecated_warn_at or flag.deprecated_remove_at %} + [deprecated:{% if flag.deprecated %} {{ flag.deprecated }}{% endif %}{% if flag.deprecated_warn_at %}{% if flag.deprecated %};{% endif %} warns at {{ flag.deprecated_warn_at }}{% endif %}{% if flag.deprecated_remove_at %}{% if flag.deprecated or flag.deprecated_warn_at %};{% endif %} removed at {{ flag.deprecated_remove_at }}{% endif %}] +{%- endif %} {%- endfor %} {%- endfor %} @@ -163,6 +170,9 @@ Global flags: {%- if not flag.hide_default_value and flag.default %} (default: {{ flag.default | join(sep=", ") }}) {%- endif %} +{%- if flag.deprecated or flag.deprecated_warn_at or flag.deprecated_remove_at %} + [deprecated:{% if flag.deprecated %} {{ flag.deprecated }}{% endif %}{% if flag.deprecated_warn_at %}{% if flag.deprecated %};{% endif %} warns at {{ flag.deprecated_warn_at }}{% endif %}{% if flag.deprecated_remove_at %}{% if flag.deprecated or flag.deprecated_warn_at %};{% endif %} removed at {{ flag.deprecated_remove_at }}{% endif %}] +{%- endif %} {%- endfor %} {%- endif %} @@ -174,6 +184,9 @@ Global flags: {%- if about %} {{ about | trim }} {%- endif %} +{%- if sub.deprecated or sub.deprecated_warn_at or sub.deprecated_remove_at %} +[deprecated:{% if sub.deprecated %} {{ sub.deprecated }}{% endif %}{% if sub.deprecated_warn_at %}{% if sub.deprecated %};{% endif %} warns at {{ sub.deprecated_warn_at }}{% endif %}{% if sub.deprecated_remove_at %}{% if sub.deprecated or sub.deprecated_warn_at %};{% endif %} removed at {{ sub.deprecated_remove_at }}{% endif %}] +{%- endif %} {%- for group in sub.arg_groups %} {%- for arg in group.items %} {%- if sub.flattened_next_line_help %} @@ -220,6 +233,9 @@ Global flags: {%- if not flag.hide_default_value and flag.default %} (default: {{ flag.default | join(sep=", ") }}) {%- endif %} +{%- if flag.deprecated or flag.deprecated_warn_at or flag.deprecated_remove_at %} + [deprecated:{% if flag.deprecated %} {{ flag.deprecated }}{% endif %}{% if flag.deprecated_warn_at %}{% if flag.deprecated %};{% endif %} warns at {{ flag.deprecated_warn_at }}{% endif %}{% if flag.deprecated_remove_at %}{% if flag.deprecated or flag.deprecated_warn_at %};{% endif %} removed at {{ flag.deprecated_remove_at }}{% endif %}] +{%- endif %} {%- endfor %} {%- endfor %} {%- endfor %} diff --git a/lib/src/docs/cli/templates/spec_template_short.tera b/lib/src/docs/cli/templates/spec_template_short.tera index 3b6f78c15..04850602c 100644 --- a/lib/src/docs/cli/templates/spec_template_short.tera +++ b/lib/src/docs/cli/templates/spec_template_short.tera @@ -14,6 +14,10 @@ {%- if cmd.help %}{{ cmd.help }} {% endif -%} +{%- endif -%} +{%- if cmd.deprecated or cmd.deprecated_warn_at or cmd.deprecated_remove_at %} +[deprecated:{% if cmd.deprecated %} {{ cmd.deprecated }}{% endif %}{% if cmd.deprecated_warn_at %}{% if cmd.deprecated %};{% endif %} warns at {{ cmd.deprecated_warn_at }}{% endif %}{% if cmd.deprecated_remove_at %}{% if cmd.deprecated or cmd.deprecated_warn_at %};{% endif %} removed at {{ cmd.deprecated_remove_at }}{% endif %}] + {%- endif -%} {%- if cmd.flatten_help and cmd.flattened_usage %} {%- for usage in cmd.flattened_usage %} @@ -30,7 +34,7 @@ Usage: {{ (spec.bin ~ " " ~ cmd.usage) | trim }} {{ group.heading | default(value=cmd.subcommand_help_heading | default(value="Commands")) }}: {%- for cmd in group.items %} {{ cmd.usage | trim }} -{%- if cmd.deprecated %} [deprecated: {{ cmd.deprecated }}]{%- endif %} +{%- if cmd.deprecated or cmd.deprecated_warn_at or cmd.deprecated_remove_at %} [deprecated:{% if cmd.deprecated %} {{ cmd.deprecated }}{% endif %}{% if cmd.deprecated_warn_at %}{% if cmd.deprecated %};{% endif %} warns at {{ cmd.deprecated_warn_at }}{% endif %}{% if cmd.deprecated_remove_at %}{% if cmd.deprecated or cmd.deprecated_warn_at %};{% endif %} removed at {{ cmd.deprecated_remove_at }}{% endif %}]{%- endif %} {%- if cmd.aliases %} [aliases: {{ cmd.aliases | join(sep=", ") }}]{% endif %} {%- if cmd.help %}{% if next_line_help %} {{ cmd.help | indent(width=4) }}{% else %} {{ cmd.help }}{% endif %}{%- endif %} @@ -87,6 +91,8 @@ Usage: {{ (spec.bin ~ " " ~ cmd.usage) | trim }} {%- if not flag.hide_env and flag.env %} [env: {{ flag.env }}]{%- endif %} {%- if not flag.hide_default_value and flag.default %} (default: {{ flag.default | join(sep=", ") }}){%- endif %} {%- endif %} +{%- if flag.deprecated or flag.deprecated_warn_at or flag.deprecated_remove_at %}{% if cmd.next_line_help %} + {% else %} {% endif %}[deprecated:{% if flag.deprecated %} {{ flag.deprecated }}{% endif %}{% if flag.deprecated_warn_at %}{% if flag.deprecated %};{% endif %} warns at {{ flag.deprecated_warn_at }}{% endif %}{% if flag.deprecated_remove_at %}{% if flag.deprecated or flag.deprecated_warn_at %};{% endif %} removed at {{ flag.deprecated_remove_at }}{% endif %}]{%- endif %} {%- endfor %} {%- endfor %} @@ -111,6 +117,8 @@ Global flags: {%- if not flag.hide_env and flag.env %} [env: {{ flag.env }}]{%- endif %} {%- if not flag.hide_default_value and flag.default %} (default: {{ flag.default | join(sep=", ") }}){%- endif %} {%- endif %} +{%- if flag.deprecated or flag.deprecated_warn_at or flag.deprecated_remove_at %}{% if cmd.next_line_help %} + {% else %} {% endif %}[deprecated:{% if flag.deprecated %} {{ flag.deprecated }}{% endif %}{% if flag.deprecated_warn_at %}{% if flag.deprecated %};{% endif %} warns at {{ flag.deprecated_warn_at }}{% endif %}{% if flag.deprecated_remove_at %}{% if flag.deprecated or flag.deprecated_warn_at %};{% endif %} removed at {{ flag.deprecated_remove_at }}{% endif %}]{%- endif %} {%- endfor %} {%- endif %} @@ -121,6 +129,9 @@ Global flags: {%- if sub.help %} {{ sub.help | trim }} {%- endif %} +{%- if sub.deprecated or sub.deprecated_warn_at or sub.deprecated_remove_at %} +[deprecated:{% if sub.deprecated %} {{ sub.deprecated }}{% endif %}{% if sub.deprecated_warn_at %}{% if sub.deprecated %};{% endif %} warns at {{ sub.deprecated_warn_at }}{% endif %}{% if sub.deprecated_remove_at %}{% if sub.deprecated or sub.deprecated_warn_at %};{% endif %} removed at {{ sub.deprecated_remove_at }}{% endif %}] +{%- endif %} {%- for group in sub.arg_groups %} {%- for arg in group.items %} {% if arg.help %}{% if sub.flattened_next_line_help %}{{ arg.usage | trim }} @@ -161,6 +172,8 @@ Global flags: {%- if not flag.hide_env and flag.env %} [env: {{ flag.env }}]{%- endif %} {%- if not flag.hide_default_value and flag.default %} (default: {{ flag.default | join(sep=", ") }}){%- endif %} {%- endif %} +{%- if flag.deprecated or flag.deprecated_warn_at or flag.deprecated_remove_at %}{% if sub.flattened_next_line_help %} + {% else %} {% endif %}[deprecated:{% if flag.deprecated %} {{ flag.deprecated }}{% endif %}{% if flag.deprecated_warn_at %}{% if flag.deprecated %};{% endif %} warns at {{ flag.deprecated_warn_at }}{% endif %}{% if flag.deprecated_remove_at %}{% if flag.deprecated or flag.deprecated_warn_at %};{% endif %} removed at {{ flag.deprecated_remove_at }}{% endif %}]{%- endif %} {%- endfor %} {%- endfor %} {%- endfor %} diff --git a/lib/src/docs/manpage/renderer.rs b/lib/src/docs/manpage/renderer.rs index 96cb3348e..e488f113a 100644 --- a/lib/src/docs/manpage/renderer.rs +++ b/lib/src/docs/manpage/renderer.rs @@ -276,6 +276,14 @@ impl ManpageRenderer { roff.control("PP", [] as [&str; 0]); } } + if let Some(notice) = deprecation_notice( + self.spec.cmd.deprecated.as_deref(), + self.spec.cmd.deprecated_warn_at.as_deref(), + self.spec.cmd.deprecated_remove_at.as_deref(), + ) { + roff.text([italic(notice)]); + roff.control("PP", [] as [&str; 0]); + } } fn render_command(&self, roff: &mut Roff, cmd: &SpecCommand, is_root: bool) { @@ -361,6 +369,13 @@ impl ManpageRenderer { if let Some(help) = &flag.help_long.as_ref().or(flag.help.as_ref()) { roff.text([roman(help.as_str())]); } + if let Some(notice) = deprecation_notice( + flag.deprecated.as_deref(), + flag.deprecated_warn_at.as_deref(), + flag.deprecated_remove_at.as_deref(), + ) { + roff.text([italic(notice)]); + } // Default value if !flag.default.is_empty() { @@ -446,6 +461,14 @@ impl ManpageRenderer { roff.text([roman(help.as_str())]); roff.control("PP", [] as [&str; 0]); } + if let Some(notice) = deprecation_notice( + subcmd.deprecated.as_deref(), + subcmd.deprecated_warn_at.as_deref(), + subcmd.deprecated_remove_at.as_deref(), + ) { + roff.text([italic(notice)]); + roff.control("PP", [] as [&str; 0]); + } // Synopsis let synopsis = self.build_synopsis(subcmd, &full_name); @@ -514,6 +537,13 @@ impl ManpageRenderer { let first_line = help.lines().next().unwrap_or(""); roff.text([roman(first_line)]); } + if let Some(notice) = deprecation_notice( + cmd.deprecated.as_deref(), + cmd.deprecated_warn_at.as_deref(), + cmd.deprecated_remove_at.as_deref(), + ) { + roff.text([italic(notice)]); + } // Show aliases if any if !cmd.aliases.is_empty() { @@ -525,6 +555,27 @@ impl ManpageRenderer { } } +fn deprecation_notice( + message: Option<&str>, + warn_at: Option<&str>, + remove_at: Option<&str>, +) -> Option { + if message.is_none() && warn_at.is_none() && remove_at.is_none() { + return None; + } + let mut parts = Vec::new(); + if let Some(message) = message { + parts.push(message.to_string()); + } + if let Some(at) = warn_at { + parts.push(format!("warns at {at}")); + } + if let Some(at) = remove_at { + parts.push(format!("removed at {at}")); + } + Some(format!("Deprecated: {}", parts.join("; "))) +} + #[cfg(test)] mod tests { use super::*; diff --git a/lib/src/docs/markdown/templates/cmd_template.md.tera b/lib/src/docs/markdown/templates/cmd_template.md.tera index 1442f3be1..59172db69 100644 --- a/lib/src/docs/markdown/templates/cmd_template.md.tera +++ b/lib/src/docs/markdown/templates/cmd_template.md.tera @@ -9,6 +9,9 @@ {%- endif %} - **Usage**: `{{ (spec.bin ~ " " ~ cmd.usage) | trim }}` +{%- if cmd.deprecated or cmd.deprecated_warn_at or cmd.deprecated_remove_at %} +- **Deprecated**:{% if cmd.deprecated %} {{ cmd.deprecated }}{% if cmd.deprecated_warn_at or cmd.deprecated_remove_at %};{% endif %}{% endif %}{% if cmd.deprecated_warn_at %} Warns at {{ cmd.deprecated_warn_at }}.{% endif %}{% if cmd.deprecated_remove_at %} Removed at {{ cmd.deprecated_remove_at }}.{% endif %} +{%- endif %} {%- if cmd.aliases %} - **Aliases**: `{{ cmd.aliases | join(sep="`, `") }}` {%- endif %} diff --git a/lib/src/docs/markdown/templates/flag_template.md.tera b/lib/src/docs/markdown/templates/flag_template.md.tera index 886086033..33095321f 100644 --- a/lib/src/docs/markdown/templates/flag_template.md.tera +++ b/lib/src/docs/markdown/templates/flag_template.md.tera @@ -2,6 +2,10 @@ **Effect**: {% if flag.effect == "read" %}read-only{% elif flag.effect == "destructive" %}destructive — may delete or irreversibly overwrite{% else %}modifies state{% endif %} {%- endif %} +{%- if flag.deprecated or flag.deprecated_warn_at or flag.deprecated_remove_at %} + +**Deprecated:**{% if flag.deprecated %} {{ flag.deprecated }}{% if flag.deprecated_warn_at or flag.deprecated_remove_at %};{% endif %}{% endif %}{% if flag.deprecated_warn_at %} Warns at {{ flag.deprecated_warn_at }}.{% endif %}{% if flag.deprecated_remove_at %} Removed at {{ flag.deprecated_remove_at }}.{% endif %} +{%- endif %} {%- if flag.help_md %} {{ flag.help_md | escape_md }} diff --git a/lib/src/docs/models.rs b/lib/src/docs/models.rs index 7cb7d5687..fec727b2e 100644 --- a/lib/src/docs/models.rs +++ b/lib/src/docs/models.rs @@ -47,6 +47,8 @@ pub struct SpecCommand { pub arg_groups: Vec>, // pub mounts: Vec, pub deprecated: Option, + pub deprecated_warn_at: Option, + pub deprecated_remove_at: Option, pub effect: Option, pub hide: bool, pub help_heading: Option, @@ -87,6 +89,8 @@ pub struct SpecCommand { pub struct HelpCommand { pub usage: String, pub deprecated: Option, + pub deprecated_warn_at: Option, + pub deprecated_remove_at: Option, pub aliases: Vec, pub help: Option, pub help_long: Option, @@ -98,6 +102,8 @@ impl From<&SpecCommand> for HelpCommand { Self { usage: cmd.usage.clone(), deprecated: cmd.deprecated.clone(), + deprecated_warn_at: cmd.deprecated_warn_at.clone(), + deprecated_remove_at: cmd.deprecated_remove_at.clone(), aliases: cmd.aliases.clone(), help: cmd.help.clone(), help_long: cmd.help_long.clone(), @@ -120,6 +126,8 @@ pub struct SpecFlag { pub long: Vec, pub required: bool, pub deprecated: Option, + pub deprecated_warn_at: Option, + pub deprecated_remove_at: Option, pub var: bool, pub var_min: Option, pub var_max: Option, @@ -545,6 +553,8 @@ impl From<&crate::SpecCommand> for SpecCommand { usage, subcommands, deprecated, + deprecated_warn_at, + deprecated_remove_at, effect, hide, help_heading, @@ -671,6 +681,8 @@ impl From<&crate::SpecCommand> for SpecCommand { args, flags, deprecated: deprecated.clone(), + deprecated_warn_at: deprecated_warn_at.clone(), + deprecated_remove_at: deprecated_remove_at.clone(), effect: *effect, hide: *hide, help_heading: help_heading.clone(), @@ -831,6 +843,8 @@ impl From<&crate::SpecFlag> for SpecFlag { .collect(), required: flag.required, deprecated: flag.deprecated.clone(), + deprecated_warn_at: flag.deprecated_warn_at.clone(), + deprecated_remove_at: flag.deprecated_remove_at.clone(), var: flag.var, var_min: flag.var_min, var_max: flag.var_max, diff --git a/lib/src/go/mod.rs b/lib/src/go/mod.rs index 7ac674916..9c21afc8f 100644 --- a/lib/src/go/mod.rs +++ b/lib/src/go/mod.rs @@ -1015,6 +1015,15 @@ fn command_help(e: &Emitted) -> String { if let Some(long) = &e.cmd.help_long { fields.push(format!("Long: {}", go_string(long))); } + if let Some(message) = &e.cmd.deprecated { + fields.push(format!("Deprecated: {}", go_string(message))); + } + if let Some(at) = &e.cmd.deprecated_warn_at { + fields.push(format!("DeprecatedWarnAt: {}", go_string(at))); + } + if let Some(at) = &e.cmd.deprecated_remove_at { + fields.push(format!("DeprecatedRemoveAt: {}", go_string(at))); + } if let Some(heading) = &e.cmd.subcommand_help_heading { fields.push(format!("SubcommandHelpHeading: {}", go_string(heading))); } @@ -1085,6 +1094,15 @@ fn command_help(e: &Emitted) -> String { fn flag_help(flag: &SpecFlag, named: &Named) -> String { let mut fields = vec![format!("Key: {}", named.key)]; + if let Some(message) = &flag.deprecated { + fields.push(format!("Deprecated: {}", go_string(message))); + } + if let Some(at) = &flag.deprecated_warn_at { + fields.push(format!("DeprecatedWarnAt: {}", go_string(at))); + } + if let Some(at) = &flag.deprecated_remove_at { + fields.push(format!("DeprecatedRemoveAt: {}", go_string(at))); + } if flag.hide { fields.push("Hide: true".to_string()); } diff --git a/lib/src/spec/builder.rs b/lib/src/spec/builder.rs index 71f162d46..744bed561 100644 --- a/lib/src/spec/builder.rs +++ b/lib/src/spec/builder.rs @@ -423,6 +423,16 @@ impl SpecFlagBuilder { self } + pub fn deprecated_warn_at(mut self, version: impl Into) -> Self { + self.inner.deprecated_warn_at = Some(version.into()); + self + } + + pub fn deprecated_remove_at(mut self, version: impl Into) -> Self { + self.inner.deprecated_remove_at = Some(version.into()); + self + } + /// Set the rendered usage string. `build` derives this when unset. pub fn usage(mut self, usage: impl Into) -> Self { self.inner.usage = usage.into(); @@ -937,6 +947,16 @@ impl SpecCommandBuilder { self } + pub fn deprecated_warn_at(mut self, version: impl Into) -> Self { + self.inner.deprecated_warn_at = Some(version.into()); + self + } + + pub fn deprecated_remove_at(mut self, version: impl Into) -> Self { + self.inner.deprecated_remove_at = Some(version.into()); + self + } + /// Set restart token for resetting argument parsing /// e.g., `mise run lint ::: test ::: check` with restart_token=":::" pub fn restart_token(mut self, token: impl Into) -> Self { diff --git a/lib/src/spec/cmd.rs b/lib/src/spec/cmd.rs index 39db5cca0..b14ecb1ed 100644 --- a/lib/src/spec/cmd.rs +++ b/lib/src/spec/cmd.rs @@ -58,6 +58,12 @@ pub struct SpecCommand { /// Deprecation message if this command is deprecated #[serde(skip_serializing_if = "Option::is_none")] pub deprecated: Option, + /// Version at which consumers should begin warning about this command. + #[serde(skip_serializing_if = "Option::is_none")] + pub deprecated_warn_at: Option, + /// Version at which consumers expect this command to be removed. + #[serde(skip_serializing_if = "Option::is_none")] + pub deprecated_remove_at: Option, /// What running this command does to the world: read, write or destructive. /// Not inherited by subcommands. #[serde(skip_serializing_if = "Option::is_none")] @@ -214,6 +220,8 @@ impl Default for SpecCommand { mounts: vec![], groups: vec![], deprecated: None, + deprecated_warn_at: None, + deprecated_remove_at: None, effect: None, unknown_flags: None, hide: false, @@ -401,6 +409,8 @@ impl SpecCommand { None => Some(v.ensure_string()?), } } + "deprecated_warn_at" => cmd.deprecated_warn_at = Some(v.ensure_string()?), + "deprecated_remove_at" => cmd.deprecated_remove_at = Some(v.ensure_string()?), k => bail_parse!(ctx, v.entry.span(), "unsupported cmd prop {k}"), } } @@ -576,6 +586,12 @@ impl SpecCommand { None => Some(child.arg(0)?.ensure_string()?), } } + "deprecated_warn_at" => { + cmd.deprecated_warn_at = Some(child.arg(0)?.ensure_string()?) + } + "deprecated_remove_at" => { + cmd.deprecated_remove_at = Some(child.arg(0)?.ensure_string()?) + } "complete" => { let complete = SpecComplete::parse(ctx, &child)?; cmd.complete.insert(complete.name.clone(), complete); @@ -697,6 +713,8 @@ impl SpecCommand { subcommands, complete, deprecated, + deprecated_warn_at, + deprecated_remove_at, effect, unknown_flags, // Recomputed from the merged command, never carried over. @@ -805,6 +823,12 @@ impl SpecCommand { if deprecated.is_some() { self.deprecated = deprecated; } + if deprecated_warn_at.is_some() { + self.deprecated_warn_at = deprecated_warn_at; + } + if deprecated_remove_at.is_some() { + self.deprecated_remove_at = deprecated_remove_at; + } if restart_token.is_some() { self.restart_token = restart_token; } @@ -931,6 +955,8 @@ impl From<&SpecCommand> for KdlNode { after_help_long, after_help_md, deprecated, + deprecated_warn_at, + deprecated_remove_at, effect, flags, args, @@ -1094,6 +1120,12 @@ impl From<&SpecCommand> for KdlNode { node.entries_mut() .push(string_entry(Some("deprecated"), deprecated)); } + if let Some(at) = deprecated_warn_at { + node.push(string_entry(Some("deprecated_warn_at"), at)); + } + if let Some(at) = deprecated_remove_at { + node.push(string_entry(Some("deprecated_remove_at"), at)); + } if let Some(effect) = effect { node.entries_mut() .push(string_entry(Some("effect"), effect.as_str())); diff --git a/lib/src/spec/flag.rs b/lib/src/spec/flag.rs index 337ca87e5..dfb31d4d6 100644 --- a/lib/src/spec/flag.rs +++ b/lib/src/spec/flag.rs @@ -154,6 +154,12 @@ pub struct SpecFlag { /// Deprecation message if this flag is deprecated #[serde(skip_serializing_if = "Option::is_none")] pub deprecated: Option, + /// Version at which consumers should begin warning about this flag. + #[serde(skip_serializing_if = "Option::is_none")] + pub deprecated_warn_at: Option, + /// Version at which consumers expect this flag to be removed. + #[serde(skip_serializing_if = "Option::is_none")] + pub deprecated_remove_at: Option, /// Whether this flag can be specified multiple times #[serde(skip_serializing_if = "is_false")] pub var: bool, @@ -338,6 +344,8 @@ impl SpecFlag { None => Some(v.ensure_string()?), } } + "deprecated_warn_at" => flag.deprecated_warn_at = Some(v.ensure_string()?), + "deprecated_remove_at" => flag.deprecated_remove_at = Some(v.ensure_string()?), "global" => flag.global = v.ensure_bool()?, "count" => flag.count = v.ensure_bool()?, "action" => { @@ -465,6 +473,12 @@ impl SpecFlag { _ => Some(child.arg(0)?.ensure_string()?), } } + "deprecated_warn_at" => { + flag.deprecated_warn_at = Some(child.arg(0)?.ensure_string()?) + } + "deprecated_remove_at" => { + flag.deprecated_remove_at = Some(child.arg(0)?.ensure_string()?) + } "global" => flag.global = child.arg(0)?.ensure_bool()?, "count" => flag.count = child.arg(0)?.ensure_bool()?, "action" => { @@ -1007,6 +1021,12 @@ impl From<&SpecFlag> for KdlNode { if let Some(deprecated) = &flag.deprecated { node.push(string_entry(Some("deprecated"), deprecated)); } + if let Some(at) = &flag.deprecated_warn_at { + node.push(string_entry(Some("deprecated_warn_at"), at)); + } + if let Some(at) = &flag.deprecated_remove_at { + node.push(string_entry(Some("deprecated_remove_at"), at)); + } // Serialize default values if !flag.default.is_empty() { if flag.default.len() == 1 { @@ -1211,6 +1231,8 @@ impl From<&clap::Arg> for SpecFlag { required_if_eq_all: vec![], required_unless: vec![], required_unless_all: vec![], + deprecated_warn_at: None, + deprecated_remove_at: None, conflicts: vec![], // clap 4.6 has `Arg::requires` and its variants as setters with no getter, so // there is nothing to read here however the `Arg` was built. Left empty rather diff --git a/lib/src/spec/mod.rs b/lib/src/spec/mod.rs index b2b9ce39f..74c7678f2 100644 --- a/lib/src/spec/mod.rs +++ b/lib/src/spec/mod.rs @@ -331,6 +331,13 @@ impl Spec { "allow_missing_positional" => { schema.cmd.allow_missing_positional = node.arg(0)?.ensure_bool()?; } + "deprecated" => schema.cmd.deprecated = Some(node.arg(0)?.ensure_string()?), + "deprecated_warn_at" => { + schema.cmd.deprecated_warn_at = Some(node.arg(0)?.ensure_string()?); + } + "deprecated_remove_at" => { + schema.cmd.deprecated_remove_at = Some(node.arg(0)?.ensure_string()?); + } "subcommand_required" => { schema.cmd.subcommand_required = node.arg(0)?.ensure_bool()?; } @@ -686,6 +693,21 @@ impl Display for Spec { node.push(true); nodes.push(node); } + if let Some(message) = &self.cmd.deprecated { + let mut node = KdlNode::new("deprecated"); + node.push(string_entry(None, message)); + nodes.push(node); + } + if let Some(at) = &self.cmd.deprecated_warn_at { + let mut node = KdlNode::new("deprecated_warn_at"); + node.push(string_entry(None, at)); + nodes.push(node); + } + if let Some(at) = &self.cmd.deprecated_remove_at { + let mut node = KdlNode::new("deprecated_remove_at"); + node.push(string_entry(None, at)); + nodes.push(node); + } if self.cmd.subcommand_required && !self.cmd.subcommands.is_empty() { let mut node = KdlNode::new("subcommand_required"); node.push(true);