Skip to content

Commit a4c1314

Browse files
jdxclaude
andcommitted
fix(cli): pair flags by name first, and let a value hint reach fig
Three from CodeRabbit's review of #1171: - **A moved spelling could claim a flag whose name still matched.** Pairing was one pass, so `--bar` migrating from the flag named `foo` to the flag named `bar` let `foo` claim it by spelling first; the old `bar` then matched the same flag by name and was compared to it a second time, while its own `--baz` was never reported as removed. Pairing is now two passes — every name match claims its flag, then what is left is offered to moved spellings — so one new flag is compared exactly once. - **`display_order` was documented as a metadata finding and never compared.** It is compared now, for commands, flags and arguments, rather than the documentation being trimmed to match: it is a declaration somebody made deliberately, and "moves from 10 to declaration order in help" is worth a line. - **`diff`'s two arguments offered no filesystem completion in the generated Fig spec.** They are spec files, so they now say so with `value_hint = ValueHint::FilePath`. That alone was not enough, and the reason was a gap in the Fig generator rather than in the declaration: it inferred templates from an argument's *name* (`out_file` gets paths because it contains "file"), applied only the root spec's `complete` nodes so a `complete` inside a `cmd` block never reached an argument at all, and turned a typed completer into a generator whose script was the empty string. A typed completer now becomes the template Fig has a name for, and each command's completers are applied at its own level. Regenerating changes two lines of `cli/assets/fig.ts` and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent b83afeb commit a4c1314

5 files changed

Lines changed: 220 additions & 23 deletions

File tree

‎cli/assets/fig.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,10 +191,12 @@ const completionSpec: Fig.Spec = {
191191
name: "old",
192192
description:
193193
'The spec as it was, typically the released one, use "-" to read from stdin',
194+
template: "filepaths",
194195
},
195196
{
196197
name: "new",
197198
description: 'The spec as it is now, use "-" to read from stdin',
199+
template: "filepaths",
198200
},
199201
],
200202
},

‎cli/src/cli/diff.rs‎

Lines changed: 155 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,11 @@ use usage::spec::unknown_flags::UnknownFlags;
4747
#[usage(effect = "read", verbatim_doc_comment)]
4848
pub struct Diff {
4949
/// The spec as it was, typically the released one, use "-" to read from stdin
50+
#[usage(value_hint = usage_rs::ValueHint::FilePath)]
5051
old: PathBuf,
5152

5253
/// The spec as it is now, use "-" to read from stdin
54+
#[usage(value_hint = usage_rs::ValueHint::FilePath)]
5355
new: PathBuf,
5456

5557
/// Output format
@@ -542,54 +544,95 @@ fn diff_command_props(old: &SpecCommand, new: &SpecCommand, path: &str, c: &mut
542544
{
543545
c.metadata("help-changed", path, "help text changed".to_string());
544546
}
547+
548+
diff_display_order(old.display_order, new.display_order, path, "command", c);
545549
}
546550

551+
/// Pair the two flag lists up, then report.
552+
///
553+
/// Two passes, because the two rules are not equals. A matching internal name is the
554+
/// primary pairing and claims its flag first; only then may a flag whose spellings
555+
/// moved onto a differently-named declaration claim what is left. Interleaving them let
556+
/// a spelling take a flag whose name still matched some later old flag, which then
557+
/// paired with it a second time and reported one comparison twice while losing another
558+
/// flag's removal entirely.
547559
fn diff_flags(old: &SpecCommand, new: &SpecCommand, path: &str, c: &mut Changes) {
548-
// Which new flags an old one has already been paired with, so a rename is not also
549-
// reported as an addition.
550-
let mut paired: Vec<bool> = vec![false; new.flags.len()];
560+
/// What became of one old flag.
561+
enum Pairing {
562+
/// Paired by internal name, which is what the derive and the KDL both key on.
563+
Named(usize),
564+
/// Paired by a shared spelling. The name is not something a caller can type, so
565+
/// this is a rename rather than a removal — reported, and then compared in full,
566+
/// so a spelling that really did disappear is still reported.
567+
Renamed(usize),
568+
Gone,
569+
}
570+
571+
let mut claimed: Vec<bool> = vec![false; new.flags.len()];
572+
let mut pairings: Vec<Pairing> = Vec::with_capacity(old.flags.len());
551573

552574
for was in &old.flags {
553-
// By internal name first, which is what the derive and the KDL both key on.
554-
if let Some(position) = new.flags.iter().position(|f| f.name == was.name) {
555-
paired[position] = true;
556-
diff_flag(was, &new.flags[position], path, c);
575+
match new
576+
.flags
577+
.iter()
578+
.enumerate()
579+
.find(|(position, f)| !claimed[*position] && f.name == was.name)
580+
.map(|(position, _)| position)
581+
{
582+
Some(position) => {
583+
claimed[position] = true;
584+
pairings.push(Pairing::Named(position));
585+
}
586+
None => pairings.push(Pairing::Gone),
587+
}
588+
}
589+
590+
for (index, was) in old.flags.iter().enumerate() {
591+
if !matches!(pairings[index], Pairing::Gone) {
557592
continue;
558593
}
559-
// Then by spelling: the name is not something a caller can type, so a flag whose
560-
// spellings moved onto a differently-named declaration was renamed rather than
561-
// removed. Compared in full afterwards, so a spelling that really did disappear
562-
// is still reported.
563594
let spellings = flag_spellings(was);
564-
let renamed = new
595+
if let Some(position) = new
565596
.flags
566597
.iter()
567598
.enumerate()
568599
.find(|(position, f)| {
569-
!paired[*position] && flag_spellings(f).iter().any(|s| spellings.contains(s))
600+
!claimed[*position] && flag_spellings(f).iter().any(|s| spellings.contains(s))
570601
})
571-
.map(|(position, _)| position);
572-
match renamed {
573-
Some(position) => {
574-
paired[position] = true;
575-
let now = &new.flags[position];
602+
.map(|(position, _)| position)
603+
{
604+
claimed[position] = true;
605+
pairings[index] = Pairing::Renamed(position);
606+
}
607+
}
608+
609+
// Reported in the old spec's declaration order, whichever pass did the pairing.
610+
for (was, pairing) in old.flags.iter().zip(&pairings) {
611+
match pairing {
612+
Pairing::Named(position) => diff_flag(was, &new.flags[*position], path, c),
613+
Pairing::Renamed(position) => {
614+
let now = &new.flags[*position];
576615
c.metadata(
577616
"flag-renamed",
578617
path,
579618
format!("flag '{}' was renamed to '{}'", was.name, now.name),
580619
);
581620
diff_flag(was, now, path, c);
582621
}
583-
_ => c.breaking(
622+
Pairing::Gone => c.breaking(
584623
"flag-removed",
585624
path,
586-
format!("flag '{}' ({}) was removed", was.name, spellings.join(", ")),
625+
format!(
626+
"flag '{}' ({}) was removed",
627+
was.name,
628+
flag_spellings(was).join(", ")
629+
),
587630
),
588631
}
589632
}
590633

591634
for (position, now) in new.flags.iter().enumerate() {
592-
if paired[position] {
635+
if claimed[position] {
593636
continue;
594637
}
595638
let spellings = flag_spellings(now);
@@ -968,6 +1011,8 @@ fn diff_flag(old: &SpecFlag, new: &SpecFlag, path: &str, c: &mut Changes) {
9681011
{
9691012
c.metadata("help-changed", path, format!("{subject} help text changed"));
9701013
}
1014+
1015+
diff_display_order(old.display_order, new.display_order, path, &subject, c);
9711016
}
9721017

9731018
fn diff_args(old: &[SpecArg], new: &[SpecArg], path: &str, c: &mut Changes) {
@@ -1218,6 +1263,8 @@ fn diff_arg(old: &SpecArg, new: &SpecArg, path: &str, subject: &str, c: &mut Cha
12181263
{
12191264
c.metadata("help-changed", path, format!("{subject} help text changed"));
12201265
}
1266+
1267+
diff_display_order(old.display_order, new.display_order, path, subject, c);
12211268
}
12221269

12231270
fn diff_choices(
@@ -1879,6 +1926,34 @@ fn diff_unknown_flags(
18791926
}
18801927
}
18811928

1929+
/// Where a flag, argument or command sits in help output.
1930+
///
1931+
/// Presentation only — nothing about parsing moves — but it is a declaration somebody
1932+
/// made deliberately, and the documentation lists it among the things a metadata finding
1933+
/// covers, so it is compared rather than promised.
1934+
fn diff_display_order(
1935+
old: Option<usize>,
1936+
new: Option<usize>,
1937+
path: &str,
1938+
subject: &str,
1939+
c: &mut Changes,
1940+
) {
1941+
if old == new {
1942+
return;
1943+
}
1944+
let describe =
1945+
|order: Option<usize>| order.map_or("declaration order".to_string(), |o| o.to_string());
1946+
c.metadata(
1947+
"display-order-changed",
1948+
path,
1949+
format!(
1950+
"{subject} moves from {} to {} in help",
1951+
describe(old),
1952+
describe(new)
1953+
),
1954+
);
1955+
}
1956+
18821957
/// How many values are required. Flags carry this as well as arguments do, and a flag
18831958
/// that needs two values where it needed one rejects a command line that used to work.
18841959
fn diff_var_min(
@@ -2719,4 +2794,63 @@ cmd "add" help="install" {
27192794
assert_eq!(removed.location, "ex install");
27202795
assert_eq!(removed.category, Category::Breaking);
27212796
}
2797+
2798+
#[test]
2799+
fn a_name_match_claims_its_flag_before_a_moved_spelling_can() {
2800+
// `--bar` moves from the flag named `foo` to the flag named `bar`, whose own
2801+
// `--baz` goes away. Pairing in one pass let `foo` claim the new `bar` by
2802+
// spelling, after which the old `bar` matched the same flag by name and was
2803+
// compared to it a second time — so `--baz` was never reported as removed.
2804+
let old = r#"
2805+
name "ex"
2806+
bin "ex"
2807+
flag "foo: --bar" help="one"
2808+
flag "bar: --baz" help="two"
2809+
"#;
2810+
let new = r#"
2811+
name "ex"
2812+
bin "ex"
2813+
flag "bar: --bar" help="two"
2814+
"#;
2815+
let found = changes(old, new);
2816+
// One comparison per new flag: `bar` pairs with `bar` by name, and `foo` is gone.
2817+
let removed: Vec<&str> = found
2818+
.iter()
2819+
.filter(|c| c.code == "flag-removed")
2820+
.map(|c| c.message.as_str())
2821+
.collect();
2822+
assert_eq!(removed, ["flag 'foo' (--bar) was removed"]);
2823+
assert_eq!(
2824+
find(&found, "flag-spelling-removed").message,
2825+
"flag '--bar' no longer answers to '--baz'"
2826+
);
2827+
assert!(!found.iter().any(|c| c.code == "flag-renamed"), "{found:?}");
2828+
}
2829+
2830+
#[test]
2831+
fn display_order_is_compared_because_the_docs_say_it_is() {
2832+
let old = r#"
2833+
name "ex"
2834+
bin "ex"
2835+
flag "--verbose" help="verbose" display_order=10
2836+
"#;
2837+
let new = r#"
2838+
name "ex"
2839+
bin "ex"
2840+
flag "--verbose" help="verbose" display_order=20
2841+
"#;
2842+
let found = changes(old, new);
2843+
let moved = find(&found, "display-order-changed");
2844+
assert_eq!(moved.category, Category::Metadata);
2845+
assert_eq!(
2846+
moved.message,
2847+
"flag '--verbose' moves from 10 to 20 in help"
2848+
);
2849+
// And dropping it entirely says what it falls back to.
2850+
let none = "name \"ex\"\nbin \"ex\"\nflag \"--verbose\" help=\"verbose\"\n";
2851+
assert_eq!(
2852+
find(&changes(old, none), "display-order-changed").message,
2853+
"flag '--verbose' moves from 10 to declaration order in help"
2854+
);
2855+
}
27222856
}

‎cli/src/cli/generate/fig.rs‎

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,19 @@ impl FigGenerator {
178178
}
179179
}
180180

181+
/// The Fig template for a portable `complete … type=`, where there is one.
182+
///
183+
/// `executable` is filesystem completion in Fig's vocabulary too; the narrower kinds
184+
/// (`command`, `user`, `host`, `none`) have no template and are left alone rather than
185+
/// approximated with the wrong one.
186+
fn template_for_type(type_: &str) -> Option<&'static str> {
187+
match type_ {
188+
"path" | "file" | "executable" => Some("filepaths"),
189+
"dir" => Some("folders"),
190+
_ => None,
191+
}
192+
}
193+
181194
impl FigArg {
182195
fn get_template(name: &str) -> Option<String> {
183196
name.to_lowercase()
@@ -234,10 +247,25 @@ impl FigArg {
234247
pub fn update_from_complete(&mut self, spec: SpecComplete) {
235248
let name = spec.name;
236249

250+
// A typed completer is a policy Fig has its own name for, so it becomes a
251+
// template rather than a generator. `get_template` above guesses from the
252+
// argument's name — `out_file` gets paths because it contains "file" — which was
253+
// all there was before `type=` existed.
254+
if let Some(template) = spec.type_.as_deref().and_then(template_for_type) {
255+
self.template = Some(template.to_string());
256+
return;
257+
}
258+
259+
// Only a completer with something to run becomes a generator. The rest — `none`,
260+
// and the kinds a shell answers for itself — have no command to post-process, and
261+
// building one anyway emitted a generator whose script was the empty string.
262+
let Some(run) = spec.run else {
263+
return;
264+
};
237265
self.generators = self.generators.clone().or_else(|| {
238266
Some(FigGenerator {
239267
type_: GeneratorType::Complete,
240-
post_process: spec.run.unwrap_or("".to_string()),
268+
post_process: run,
241269
template_str: format!("${name}$"),
242270
})
243271
})
@@ -305,6 +333,18 @@ impl FigCommand {
305333
[subcmds, vec![self.clone()]].concat()
306334
}
307335

336+
/// This command's own arguments — its positionals and its flags' values — without
337+
/// descending into subcommands.
338+
///
339+
/// What a `complete` node inside a `cmd` block is about. [`Self::get_args`] gathers
340+
/// the whole subtree, which is right for the root spec's completers because those are
341+
/// inherited, and wrong for a command's own.
342+
pub fn get_own_args(&mut self) -> Vec<&mut FigArg> {
343+
let mut own: Vec<&mut FigArg> = self.options.iter_mut().map(|o| o.get_args()).concat();
344+
own.extend(self.args.iter_mut());
345+
own
346+
}
347+
308348
pub fn get_args(&mut self) -> Vec<&mut FigArg> {
309349
let opt_args = self.options.iter_mut().map(|o| o.get_args()).concat();
310350
let sub_args = self.subcommands.iter_mut().map(|c| c.get_args()).concat();
@@ -318,6 +358,15 @@ impl FigCommand {
318358
}
319359

320360
pub fn parse_from_spec(cmd: &SpecCommand) -> Option<Self> {
361+
let mut command = Self::parse_declarations(cmd)?;
362+
// Each command's own completers, at its own level. Only the root spec's `complete`
363+
// nodes used to reach any argument, so a `complete` inside a `cmd` block — which
364+
// is where a typed value hint on a subcommand's argument lands — was dropped.
365+
Fig::fill_args_complete(command.get_own_args(), cmd.complete.clone());
366+
Some(command)
367+
}
368+
369+
fn parse_declarations(cmd: &SpecCommand) -> Option<Self> {
321370
(!cmd.hide).then(|| Self {
322371
name: FigCommand::get_names(cmd),
323372
description: cmd.help.clone(),

‎cli/usage.usage.kdl‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ cmd diff help="Compare two usage specs and report what changed about the interfa
5353
flag --exit-zero help="Exit 0 even when there are breaking changes"
5454
arg <OLD> help="The spec as it was, typically the released one, use \"-\" to read from stdin"
5555
arg <NEW> help="The spec as it is now, use \"-\" to read from stdin"
56+
complete old type=path
57+
complete new type=path
5658
}
5759
cmd exec help="Execute a script, parsing args and exposing them as environment variables" unknown_flags=value {
5860
alias x

‎docs/cli/reference/commands.json‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,17 @@
249249
"name": "diff",
250250
"aliases": [],
251251
"hidden_aliases": [],
252-
"examples": []
252+
"examples": [],
253+
"complete": {
254+
"old": {
255+
"name": "old",
256+
"type_": "path"
257+
},
258+
"new": {
259+
"name": "new",
260+
"type_": "path"
261+
}
262+
}
253263
},
254264
"exec": {
255265
"full_cmd": ["exec"],

0 commit comments

Comments
 (0)