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
12 changes: 6 additions & 6 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,11 @@ jobs:
# was checking it. It said 1.80 — set in 2024 when `LazyLock` landed and never revisited —
# and usage-argv had not built at 1.80 for a long time. A promise nobody checks is a guess.
#
# Two floors, because the crates genuinely differ: usage-argv, usage-derive and usage-config
# take no dependency on KDL and hold mise's own 1.91, which is the floor that matters for the
# fleet. usage-lib and everything that reads a spec through it need 1.95, because `kdl` says
# so. Each crate is checked at the version *it* declares rather than at one shared guess,
# which is how a lower floor stays real instead of aspirational.
# Two floors, because the crates genuinely differ: usage-argv, usage-derive, usage-config and
# usage-validation take no dependency on KDL and hold mise's own 1.91, which is the floor that
# matters for the fleet. usage-lib and everything that reads a spec through it need 1.95,
# because `kdl` says so. Each crate is checked at the version *it* declares rather than at one
# shared guess, which is how a lower floor stays real instead of aspirational.
#
# `cargo check` rather than `cargo test`: dev-dependencies are not part of what an adopter
# compiles, and holding them to the MSRV would pin the toolchain past what the library needs.
Expand All @@ -132,7 +132,7 @@ jobs:
matrix:
include:
- version: "1.91"
crates: usage-argv usage-derive usage-config usage-rs
crates: usage-argv usage-derive usage-config usage-validation usage-rs
- version: "1.95"
crates: usage-lib usage-config-build clap_usage usage-cli
steps:
Expand Down
117 changes: 117 additions & 0 deletions Cargo.lock

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

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ members = [
"config-build",
"derive",
"usage-rs",
"validation",
"clap_usage",
"cli",
"conformance",
Expand Down Expand Up @@ -41,8 +42,9 @@ usage-cli = { path = "./cli" }
usage-argv = { path = "./argv", version = "5.1.0" }
usage-config = { path = "./config", version = "5.1.0" }
usage-derive = { path = "./derive", version = "5.1.0" }
usage-lib = { path = "./lib", version = "5.1.0", features = ["clap"] }
usage-lib = { path = "./lib", version = "5.1.0", features = ["clap", "validation"] }
usage-rs = { path = "./usage-rs", version = "5.1.0" }
usage-validation = { path = "./validation", version = "5.1.0" }

[workspace.metadata.release]
allow-branch = ["main"]
20 changes: 11 additions & 9 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,9 +400,13 @@ Groups are the opposite case: `Command::get_groups`, `ArgGroup::get_args` and
on the command line and has no env value. clap 4 has the setter and no
getter, so the bridge cannot read it — same hole as `requires`. **Used
by:** mise `bin_paths` (`default_value_if("json", IsPresent, "true")`).
- [ ] **`value_parser`** — clap takes an arbitrary parser function and range
validators (`value_parser!(u16).range(1..=65535)`). We are `T: FromStr` and
nothing else, so there is no per-field validation and no bounded numeric.
- [x] **Portable value validation** — `validate="int(value) >= 1 && int(value) <=
65535"` is a declarative expr rule stored in KDL and enforced by usage-lib and
generated Rust and Go parsers. `validate_error` supplies the user-facing failure.
This covers clap's common range-validation use case without embedding a Rust
parser function in the spec. clap's arbitrary `value_parser` remains inherently
opaque to `clap_usage`, so an existing clap command must declare the equivalent
rule when moving to the typed usage rewrite.
- [ ] **Token-boundary controls** — `allow_negative_numbers`, `value_terminator`
and `dont_delimit_trailing_values`. `allow_hyphen_values` is the broader
answer to the first one, but accepting every dash-word is not equivalent to
Expand Down Expand Up @@ -720,12 +724,10 @@ looking at the clap surface, not only at the spec.
they force, and a binary that still answers `--help` / `--usage-spec` the
same way. That is the experiment that tells you whether the rest of the
fleet is a rewrite or a blocked rewrite.
- [ ] **The clap-only behaviour the fleet actually uses**, from the list above,
in the order it would change a command line rather than a compile.

| gap | who | what breaks without it |
| --------------------- | ------------------------------- | -------------------------------------------------------- |
| `value_parser` ranges | unknown until the typed rewrite | `FromStr` accepts out-of-range numbers clap would refuse |
- [x] **The clap-only validation behaviour the fleet actually uses.** Portable
`validate` expressions cover numeric ranges in the typed rewrite. Arbitrary clap
parser functions remain opaque to `clap_usage`, but they no longer require a
Rust-only extension to the spec: the rewrite declares the equivalent expr rule.

`external_subcommand` and `default_if` have landed: the parser, the derive, and
the corpus all say them. clap's bridge reads `allow_external_subcommands`;
Expand Down
28 changes: 28 additions & 0 deletions argv/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,10 @@ pub struct FlagMeta<'a> {
/// Canonical-to-alias pairs used when emitting a lossless spec.
pub choice_aliases: &'a [(&'a str, &'a str)],
pub ignore_case: bool,
/// Portable expr expression evaluated for each raw value.
pub validate: Option<&'a str>,
/// Message reported when validation returns false.
pub validate_error: Option<&'a str>,
pub required: bool,
/// Whether the flag's value may be left off, as in `--bump` or `--bump 5`.
///
Expand Down Expand Up @@ -667,6 +671,8 @@ impl FlagMeta<'_> {
choices: &[],
choice_aliases: &[],
ignore_case: false,
validate: None,
validate_error: None,
required: false,
value_optional: false,
hide: false,
Expand Down Expand Up @@ -720,6 +726,10 @@ pub struct ArgMeta<'a> {
/// Canonical-to-alias pairs used when emitting a lossless spec.
pub choice_aliases: &'a [(&'a str, &'a str)],
pub ignore_case: bool,
/// Portable expr expression evaluated for each raw value.
pub validate: Option<&'a str>,
/// Message reported when validation returns false.
pub validate_error: Option<&'a str>,
/// Whether the argument must be filled. The parser does not enforce this —
/// it is checked once the last token has been read — but the spec has to say
/// it, and help output has to show it.
Expand Down Expand Up @@ -751,6 +761,8 @@ impl ArgMeta<'_> {
choices: &[],
choice_aliases: &[],
ignore_case: false,
validate: None,
validate_error: None,
required: true,
hide: false,
var_min: None,
Expand Down Expand Up @@ -1312,6 +1324,14 @@ fn write_flag(out: &mut String, meta: &FlagMeta<'_>, depth: usize) -> core::fmt:
if meta.value_optional {
out.push_str(" required=#false");
}
if let Some(validate) = meta.validate {
write!(out, " validate={}", quoted(validate))?;
}
if meta.validate.is_some() {
if let Some(error) = meta.validate_error {
write!(out, " validate_error={}", quoted(error))?;
}
}
if meta.choices.is_empty() {
out.push('\n');
} else {
Expand Down Expand Up @@ -1381,6 +1401,14 @@ fn write_arg(out: &mut String, meta: &ArgMeta<'_>, depth: usize) -> core::fmt::R
if let Some(env) = meta.env {
write!(out, " env={}", quoted(env))?;
}
if let Some(validate) = meta.validate {
write!(out, " validate={}", quoted(validate))?;
}
if meta.validate.is_some() {
if let Some(error) = meta.validate_error {
write!(out, " validate_error={}", quoted(error))?;
}
}
write_single_default(out, meta.default)?;

let has_children =
Expand Down
1 change: 1 addition & 0 deletions conformance/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ usage-lib = { workspace = true }
[dev-dependencies]
insta = "1"
usage-derive = { workspace = true }
usage-validation = { workspace = true }

[[bin]]
name = "oracle"
Expand Down
4 changes: 4 additions & 0 deletions conformance/src/tables.rs
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,8 @@ fn flag_meta(
choices: visible_choices(choices),
choice_aliases: choice_aliases(choices),
ignore_case: choices.is_some_and(|c| c.ignore_case),
validate: arg.and_then(|a| a.validate.as_deref()).map(leak),
validate_error: arg.and_then(|a| a.validate_error.as_deref()).map(leak),
required: f.required,
hide: f.hide,
count: f.count,
Expand Down Expand Up @@ -376,6 +378,8 @@ fn arg_meta(
choices: visible_choices(choices),
choice_aliases: choice_aliases(choices),
ignore_case: choices.is_some_and(|c| c.ignore_case),
validate: a.validate.as_deref().map(leak),
validate_error: a.validate_error.as_deref().map(leak),
required: a.required,
hide: a.hide,
delimiter: a.delimiter,
Expand Down
24 changes: 24 additions & 0 deletions conformance/tests/validation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
use serde::Deserialize;

#[derive(Deserialize)]
struct Vector {
expression: String,
value: String,
valid: bool,
}

#[test]
fn rust_matches_the_portable_validation_vectors() {
let vectors: Vec<Vector> =
serde_json::from_str(include_str!("../validation.json")).expect("valid vectors");
assert!(!vectors.is_empty(), "validation fixture must not be empty");
for vector in vectors {
let actual = usage_validation::validate(&vector.expression, &vector.value)
.unwrap_or_else(|err| panic!("{} with {:?}: {err}", vector.expression, vector.value));
assert_eq!(
actual, vector.valid,
"{} with {:?}",
vector.expression, vector.value
);
}
}
Loading
Loading