Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@ manpages, and SDKs — never a runtime dependency of somebody else's program.
the conversion happens where the struct is built, and a value that will not convert
reports the text and the type's own message. `Error` grew one boxed variant and lost
`Copy`, and stayed 40 bytes, so nothing on the hot path grew.
- [x] **Enumerated values** — `#[derive(usage::ValueEnum)]` on an enum of bare variants
gives the words a value may be, and a field says `value_enum` to use them. mise has
nine of these. The list is declared once, on the type: the spec, the help, the
completions and the check that rejects a wrong word all read it from there, so none of
them can drift from the type the way a second list on the field would.
- [ ] **Values that are not valid UTF-8** — a word reaches a field through
`from_utf8_lossy`, so a `PathBuf` holding a non-UTF-8 path gets replacement
characters instead of bytes. The partial should hold `OsString` and let `build`
Expand Down
11 changes: 11 additions & 0 deletions argv/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,17 @@ fn quoted(value: &str) -> String {
/// generated parse needs to name the type that accumulates a subcommand's values
/// while parsing, and cannot know which module the derive put it in — an
/// associated type is how it names it regardless.
/// A type whose values are a fixed set of words.
///
/// What a CLI calls an enum: `--shell bash`. The words are what the spec lists as
/// `choices`, so declaring them once on the type keeps help, completions and the check that
/// rejects a wrong value reading from the same place — rather than a list in an attribute
/// that has to be kept in step with the type by hand.
pub trait ValueEnum: Sized {
/// Every word this type accepts, in the order it declared them.
const CHOICES: &'static [&'static str];
}

pub trait CommandArgs: Sized {
/// Values collected so far. Partly-filled by construction, since a parse can
/// stop early.
Expand Down
74 changes: 74 additions & 0 deletions conformance/tests/typed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use std::ffi::OsStr;
use std::path::PathBuf;
use std::str::FromStr;

use usage::Spec as LibSpec;
use usage_argv::Error;
use usage_derive::Cli;

Expand Down Expand Up @@ -248,3 +249,76 @@ fn a_conversion_failure_on_a_subcommand_names_the_field() {
Ok(_) => panic!("300 does not fit in a u8"),
}
}

/// The words a value may be, declared once on the type.
///
/// mise has nine of these. What matters is that the list reaches the spec — so help and
/// completions offer it — without being written a second time on the field.
#[derive(Debug, PartialEq, usage_derive::ValueEnum)]
enum Interpreter {
Bash,
Zsh,
Fish,
/// Not `power-shell`, which is what the variant name would have given
#[usage(name = "pwsh")]
PowerShell,
}

/// A CLI with an enumerated value
#[derive(Cli)]
#[usage(bin = "enumerated")]
struct Enumerated {
/// Which shell
#[usage(short = 's', long, value_enum)]
shell: Option<Interpreter>,
/// Shells to generate for
#[usage(long, var, value_enum)]
also: Vec<Interpreter>,
}

#[test]
fn a_word_becomes_the_variant_it_names() {
let a = argv(["-s", "zsh", "--also", "bash", "--also", "pwsh"]);
let e = Enumerated::parse_from(&a).expect("should parse");
assert_eq!(e.shell, Some(Interpreter::Zsh));
assert_eq!(e.also, [Interpreter::Bash, Interpreter::PowerShell]);
}

#[test]
fn the_words_reach_the_spec_from_the_type() {
// The point of `value_enum`: the list is declared once, on the type, and the spec has
// it — so `usage g markdown` and the completions offer the same words the parse accepts.
let spec: LibSpec = Enumerated::to_kdl().parse().expect("valid spec");
let shell = spec.cmd.flags.iter().find(|f| f.name == "shell").unwrap();
let choices = shell
.arg
.as_ref()
.and_then(|a| a.choices.as_ref())
.expect("--shell should declare choices");
assert_eq!(choices.choices, ["bash", "zsh", "fish", "pwsh"]);
}

#[test]
fn a_wrong_word_lists_what_was_expected() {
// An `InvalidChoice` carrying the list, rather than a conversion error about a type the
// user never named.
let a = argv(["--shell", "csh"]);
match Enumerated::parse_from(&a) {
Err(Error::InvalidChoice { name, choices }) => {
assert_eq!(name, "shell");
assert_eq!(choices, ["bash", "zsh", "fish", "pwsh"]);
}
Err(other) => panic!("wrong error: {other:?}"),
Ok(_) => panic!("`csh` is not one of the words"),
}
}

#[test]
fn the_conversion_stands_on_its_own() {
// Whoever converts one by hand gets a message with the words in it, since the check
// above is the parser's and not the type's.
use std::str::FromStr;
assert_eq!(Interpreter::from_str("fish"), Ok(Interpreter::Fish));
let err = Interpreter::from_str("csh").expect_err("not a shell");
assert!(err.contains("bash, zsh, fish, pwsh"), "{err}");
}
72 changes: 67 additions & 5 deletions derive/src/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
use proc_macro2::TokenStream;
use quote::{format_ident, quote};

use crate::model::{rendered_path, Cli, Field, Kind, Shape, Subcommands};
use crate::model::{rendered_path, Cli, Field, Kind, Shape, Subcommands, ValueEnum};

pub fn emit(cli: &Cli) -> TokenStream {
let ident = &cli.ident;
Expand Down Expand Up @@ -406,6 +406,12 @@ fn arg_meta(i: usize, field: &Field) -> TokenStream {

/// A field's declared choices, as the metadata holds them.
fn choices_tokens(field: &Field) -> TokenStream {
// From the type when the field says `value_enum`, so the spec, the help and the check
// all read the list the type declares rather than a copy of it.
if let (true, Some(ty)) = (field.value_enum, field.value_ty.as_ref()) {
let ty = in_module(ty);
return quote!(<#ty as ::usage_argv::spec::ValueEnum>::CHOICES);
}
let choices = &field.choices;
quote!(&[#(#choices),*])
}
Expand Down Expand Up @@ -1482,12 +1488,24 @@ fn post_binding(cli: &Cli) -> TokenStream {
});

let choice_checks = cli.fields.iter().filter_map(|f| {
if f.choices.is_empty() {
if f.choices.is_empty() && !f.value_enum {
return None;
}
let ident = &f.ident;
let name = &f.name;
let choices = &f.choices;
// A `value_enum`'s words live on the type. Checking against them here rather than
// letting the conversion fail is what makes a wrong word an `InvalidChoice` that
// lists what was expected, instead of a message about a type the user did not name.
let choices: TokenStream = match (f.value_enum, f.value_ty.as_ref()) {
(true, Some(ty)) => {
let ty = in_module(ty);
quote!(<#ty as ::usage_argv::spec::ValueEnum>::CHOICES)
}
_ => {
let list = &f.choices;
quote!(&[#(#list),*])
}
};
let values = match f.shape {
Shape::Optional => quote!(partial.#ident.iter()),
Shape::Required => quote!(::std::iter::once(&partial.#ident)),
Expand All @@ -1497,11 +1515,11 @@ fn post_binding(cli: &Cli) -> TokenStream {
};
Some(quote! {
for value in #values {
if ![#(#choices),*].contains(&value.as_str()) {
if !#choices.contains(&value.as_str()) {
return ::std::result::Result::Err(
::usage_argv::Error::InvalidChoice {
name: #name,
choices: &[#(#choices),*],
choices: #choices,
},
);
}
Expand Down Expand Up @@ -1663,6 +1681,50 @@ fn post_binding(cli: &Cli) -> TokenStream {
}
}

/// The word list and the conversion for a value enum.
///
/// Two impls and nothing else: the words as a `const` the spec can read, and the `FromStr`
/// that every typed field already goes through. Deliberately not a bespoke path — a value
/// enum is a type whose values happen to be listed, so it converts the way any other type
/// does, and the check that rejects a wrong word is the same `choices` check as a
/// hand-written list.
pub fn emit_value_enum(value_enum: &ValueEnum) -> TokenStream {
let ident = &value_enum.ident;
let words: Vec<&String> = value_enum.variants.iter().map(|(_, name)| name).collect();
let arms = value_enum
.variants
.iter()
.map(|(variant, name)| quote!(#name => ::std::result::Result::Ok(#ident::#variant),));
// Listed in the message because a wrong word is the common mistake, and the words are
// right here. The `choices` check usually reports this first, with the same list; this
// is what a caller sees who converts one by hand.
let expected = words
.iter()
.map(|w| w.as_str())
.collect::<::std::vec::Vec<_>>()
.join(", ");

quote! {
impl ::usage_argv::spec::ValueEnum for #ident {
const CHOICES: &'static [&'static str] = &[#(#words),*];
}

impl ::std::str::FromStr for #ident {
type Err = ::std::string::String;

fn from_str(value: &str) -> ::std::result::Result<Self, Self::Err> {
match value {
#(#arms)*
other => ::std::result::Result::Err(::std::format!(
"`{other}` is not one of: {}",
#expected
)),
}
}
}
}
}

#[cfg(test)]
mod in_module_tests {
use super::in_module;
Expand Down
32 changes: 32 additions & 0 deletions derive/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@
//! | `help_heading = "x"` | the section to list this under in help output |
//! | `hide` | keep it out of help and completions |
//! | `double_dash = "required"` | a positional only fillable after `--` |
//! | `value_enum` | the words come from the field's type, which derives [`ValueEnum`] |
//! | `arg` | force a field to be positional |
//! | `overrides = "--other"` | a flag this one displaces, the last given winning |
//! | `conflicts = "--other"` | a flag this one cannot be given with |
Expand Down Expand Up @@ -247,3 +248,34 @@ pub fn derive_subcommands(input: TokenStream) -> TokenStream {
Err(e) => e.to_compile_error().into(),
}
}

/// Compile an enum into the words one value may be.
///
/// What a CLI calls an enum — `--shell bash` — and what the spec calls `choices`. The
/// variant's name in kebab-case is the word, unless `name` says otherwise:
///
/// ```ignore
/// #[derive(usage::ValueEnum)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Documented derive path is unavailable

When a downstream user copies #[derive(usage::ValueEnum)], the usage crate has no such export, causing compilation to fail; the proc macro is currently exported from usage_derive.

Suggested change
/// #[derive(usage::ValueEnum)]
/// #[derive(usage_derive::ValueEnum)]

Knowledge Base Used: Compiled argv parsing and derives

Fix in Claude Code

/// enum Shell {
/// Bash,
/// Zsh,
/// #[usage(name = "pwsh")]
/// PowerShell,
/// }
/// ```
///
/// A variant cannot be `cfg`-ed out: the words are a `const` list, and a list with holes
/// in it would either offer a word nothing answers to or name a variant that is not there.
/// `cfg` the whole enum instead.
///
/// A field holding one says `value_enum`, which is what puts the words in the spec — so
/// help, completions and the check that rejects a wrong word all read the same list, and
/// none of them can drift from the type.
#[proc_macro_derive(ValueEnum, attributes(usage))]
pub fn derive_value_enum(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
match model::ValueEnum::from_input(&input) {
Ok(value_enum) => codegen::emit_value_enum(&value_enum).into(),
Err(e) => e.to_compile_error().into(),
}
}
Loading