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
100 changes: 79 additions & 21 deletions argv/src/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1106,6 +1106,9 @@ fn short_sections(
.filter(|(flag, _)| !flag.hide_short_help)
.collect();
let mut sections = Sections::default();
// The narrow page wraps too. Its descriptions used to run off the end of the terminal,
// which the wide page has never done — and `-h` is the form most people type.
let width = terminal_width(meta);
let out = &mut sections.about;

// Text the command puts above everything else, and below it. The short form has only the
Expand Down Expand Up @@ -1144,7 +1147,7 @@ fn short_sections(
&mut sections.commands,
&path[1.min(path.len())..],
meta,
terminal_width(meta),
width,
false,
);
}
Expand Down Expand Up @@ -1196,18 +1199,9 @@ fn short_sections(
);
return;
}
match a.help.filter(|h| !h.trim().is_empty()) {
Some(help) => {
let _ = write!(out, " {usage:<arg_col$} {help}");
}
None => {
let _ = write!(out, " {usage}");
}
}
let environment =
inline_environment_notes(a.hide_env, a.env_fallback, a.deprecated_env);
annotations(
out,
let notes = inline_annotations(
if a.hide_possible_values {
&[]
} else {
Expand All @@ -1218,6 +1212,14 @@ fn short_sections(
if a.hide_default_value { &[] } else { a.default },
None,
);
entry(
out,
&usage,
with_annotations(a.help, notes).as_deref(),
arg_col,
width,
false,
);
},
);
// One column over *both* lists, so the two sections read as one table with a rule through
Expand Down Expand Up @@ -1249,19 +1251,10 @@ fn short_sections(
flag_notes(out, f, 4);
return;
}
match f.help.filter(|h| !h.trim().is_empty()) {
Some(help) => {
let _ = write!(out, " {usage:<flag_col$} {help}");
}
None => {
let _ = write!(out, " {usage}");
}
}
let deprecation =
deprecation_label(f.deprecated, f.deprecated_warn_at, f.deprecated_remove_at);
let environment = inline_environment_notes(f.hide_env, f.env_fallback, f.deprecated_env);
annotations(
out,
let notes = inline_annotations(
if f.hide_possible_values {
&[]
} else {
Expand All @@ -1272,6 +1265,14 @@ fn short_sections(
if f.hide_default_value { &[] } else { f.default },
deprecation.as_deref(),
);
entry(
out,
&usage,
with_annotations(f.help, notes).as_deref(),
flag_col,
width,
false,
);
};
split_groups_section(
SectionSink {
Expand Down Expand Up @@ -1791,6 +1792,63 @@ fn annotations(
out.push('\n');
}

/// The same annotations as one string, for an entry that carries them in its text.
///
/// [`annotations`] writes them straight out, which the flattened sections still want. The
/// narrow layout cannot: its text has to be complete before it is wrapped.
fn inline_annotations(
choices: &[&str],
env: Option<&str>,
environment: Option<&str>,
default: &[&str],
suffix: Option<&str>,
) -> Option<String> {
let mut out = String::new();
let mut push = |part: &str| {
if !out.is_empty() {
out.push(' ');
}
out.push_str(part);
};
if !choices.is_empty() {
push(&format!("[{}]", choices.join(", ")));
}
if let Some(env) = env {
push(&format!("[env: {env}]"));
}
if let Some(environment) = environment {
push(environment);
}
if !default.is_empty() {
push(&format!("(default: {})", default.join(", ")));
}
if let Some(suffix) = suffix {
push(suffix);
}
(!out.is_empty()).then_some(out)
}

/// A narrow entry's description with its annotations joined on.
///
/// The wide layout gives each annotation a line of its own; the narrow one has no room for
/// that, so they ride along with the description — and they have to be joined *before* it is
/// wrapped, or an entry with a long description keeps its `[env: …]` out past the column the
/// wrapping was supposed to bring the text back into.
///
/// A description with nothing to add to it is borrowed rather than copied, which is most of
/// them.
fn with_annotations<'a>(
help: Option<&'a str>,
annotations: Option<String>,
) -> Option<Cow<'a, str>> {
match (summarize(help), annotations) {
(Some(help), None) => Some(Cow::Borrowed(help)),
(None, Some(annotations)) => Some(Cow::Owned(annotations)),
(Some(help), Some(annotations)) => Some(Cow::Owned(format!("{help} {annotations}"))),
(None, None) => None,
}
}

/// How a usage line writes a flag: its first long form, or its short if that is all it has.
///
/// Shared with the diagnostics for the same reason as [`arg_usage`], and gated with them: under
Expand Down
13 changes: 10 additions & 3 deletions conformance/tests/arg_group.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,16 @@ fn case_insensitive_group_values_match_relationships_the_same_way_they_parse() {
fn a_value_carrying_group_member_reaches_help_and_the_spec() {
let help =
help::render(ValuedGroup::spec(), ValuedGroup::spec().root.cmd, false).expect("a page");
assert!(help.contains("--migrate <SOURCE>"), "{help}");
assert!(help.contains("[prettier, biome]"), "{help}");
assert!(help.contains("--stdin-filepath <STDIN_FILEPATH>"), "{help}");
// The narrow page wraps, so an annotation can be split across two lines. What this test is
// about is that the choices reach the page at all, so it reads the page with the layout
// collapsed rather than pinning where the break happens to fall.
let flattened = help.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(flattened.contains("--migrate <SOURCE>"), "{help}");
assert!(flattened.contains("[prettier, biome]"), "{help}");
assert!(
flattened.contains("--stdin-filepath <STDIN_FILEPATH>"),
"{help}"
);

let kdl = ValuedGroup::to_kdl();
assert!(
Expand Down
32 changes: 28 additions & 4 deletions conformance/tests/combinations_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,18 @@ fn ordered_environment_names_use_first_set_value() {
.expect("root page");
let fallback_notes = " [env fallback: USAGE_ENV_FALLBACK_A] [env fallback: USAGE_ENV_FALLBACK_B] [deprecated env: USAGE_ENV_DEPRECATED]";
let ordered_notes = format!("{fallback_notes} (default: fallback)");
assert!(typed_help.contains(&ordered_notes), "{typed_help}");
// Read with the layout collapsed: the narrow page wraps, so a run of notes this long is
// split across lines. What is under test is that they arrive in this order, not where the
// break falls.
assert!(
flattened(&typed_help).contains(&ordered_notes),
"{typed_help}"
);
let reference_help = usage::docs::cli::render_help(&spec, &spec.cmd, false);
assert!(reference_help.contains(&ordered_notes), "{reference_help}");
assert!(
flattened(&reference_help).contains(&ordered_notes),
"{reference_help}"
);

unsafe { std::env::remove_var("USAGE_ENV_DEPRECATED") };
}
Expand Down Expand Up @@ -200,9 +209,18 @@ fn positional_environment_names_round_trip_and_use_first_set_value() {
.expect("root page");
let fallback_notes = " [env fallback: USAGE_ARG_ENV_FALLBACK_A] [env fallback: USAGE_ARG_ENV_FALLBACK_B] [deprecated env: USAGE_ARG_ENV_DEPRECATED]";
let ordered_notes = format!("{fallback_notes} (default: fallback)");
assert!(typed_help.contains(&ordered_notes), "{typed_help}");
// Read with the layout collapsed: the narrow page wraps, so a run of notes this long is
// split across lines. What is under test is that they arrive in this order, not where the
// break falls.
assert!(
flattened(&typed_help).contains(&ordered_notes),
"{typed_help}"
);
let reference_help = usage::docs::cli::render_help(&spec, &spec.cmd, false);
assert!(reference_help.contains(&ordered_notes), "{reference_help}");
assert!(
flattened(&reference_help).contains(&ordered_notes),
"{reference_help}"
);

for name in [
"USAGE_ARG_ENV_FALLBACK_A",
Expand All @@ -228,3 +246,9 @@ fn flattened_next_line_help_indents_environment_notes() {
assert!(page.contains(note), "{page}");
}
}

/// A page with its wrapping taken back out, for a test about what it says rather than how it
/// is laid out.
fn flattened(page: &str) -> String {
page.split_whitespace().collect::<Vec<_>>().join(" ")
}
6 changes: 5 additions & 1 deletion conformance/tests/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,12 @@ fn deprecation_metadata_survives_the_typed_spec() {
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();
// A flag with no description carries its label in the description column, so what
// separates the two is the column rather than a single space. Read with the layout
// collapsed: the label reaching the flag's row is the point, not the width of the gap.
let flattened = page.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(
page.contains("--old [deprecated: use --new; warns at 6.2; removed at 7.0]"),
flattened.contains("--old [deprecated: use --new; warns at 6.2; removed at 7.0]"),
"{page}"
);

Expand Down
81 changes: 60 additions & 21 deletions go/argv/page.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,12 +149,7 @@ func ShortHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) s
longAnnotations(w, h, true)
return
}
if help := helpText(h); help != "" {
w.WriteString(" " + pad(usage, argCol) + " " + help)
} else {
w.WriteString(" " + usage)
}
annotations(w, h, true)
entry(w, usage, withAnnotations(shortSummary(h), inlineAnnotations(h, true, false)), argCol, false)
})

own, inherited := ownAndGlobal(chain, help)
Expand All @@ -174,7 +169,7 @@ func ShortHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) s
flagCol = n
}
}
entry := func(w *strings.Builder, f shownFlag) {
flagEntry := func(w *strings.Builder, f shownFlag) {
h := help.Lookup(f.key)
if f.supplied != "" {
// A flag the parser supplies has no table entry; its help is fixed.
Expand All @@ -185,12 +180,7 @@ func ShortHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) s
}
return
}
if text := f.suppliedHelp; text != "" {
w.WriteString(" " + pad(f.usage, flagCol) + " " + text)
} else {
w.WriteString(" " + f.usage)
}
w.WriteString("\n")
entry(w, f.usage, f.suppliedHelp, flagCol, false)
return
}
if nextLineHelp {
Expand All @@ -201,12 +191,7 @@ func ShortHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) s
longAnnotations(w, h, true)
return
}
if text := helpText(h); text != "" {
w.WriteString(" " + pad(f.usage, flagCol) + " " + text)
} else {
w.WriteString(" " + f.usage)
}
annotations(w, h, true)
entry(w, f.usage, withAnnotations(shortSummary(h), inlineAnnotations(h, true, true)), flagCol, false)
}
groupsSection(&sections.flags, &sections.ungroupedFlags, &sections.groupedFlags, "Flags", len(own),
func(i int) string {
Expand All @@ -215,13 +200,13 @@ func ShortHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) s
}
return headingOf(help, own[i].key)
},
func(w *strings.Builder, i int) { entry(w, own[i]) })
func(w *strings.Builder, i int) { flagEntry(w, own[i]) })
// After the command's own, and under a heading that says where they came
// from: a global belongs to the program, not to this command, and a reader
// should be able to see that.
groupsSection(&sections.flags, &sections.ungroupedFlags, &sections.groupedFlags, "Global flags", len(inherited),
func(int) string { return "" },
func(w *strings.Builder, i int) { entry(w, inherited[i]) })
func(w *strings.Builder, i int) { flagEntry(w, inherited[i]) })
if meta != nil && meta.FlattenHelp {
flatCommandsShort(&sections.flattened, path[min(1, len(path)):], cmd, help, nextLineHelp)
}
Expand Down Expand Up @@ -539,6 +524,60 @@ func annotations(out *strings.Builder, h *Help, withDefault bool) {
out.WriteString("\n")
}

// inlineAnnotations is the same annotations as one string, for an entry that carries
// them in its text rather than writing them out. The narrow layout needs its text
// complete before it is wrapped.
func inlineAnnotations(h *Help, withDefault, withDeprecation bool) string {
if h == nil {
return ""
}
parts := []string{}
if !h.HidePossibleValues && len(h.Choices) > 0 {
parts = append(parts, "["+strings.Join(h.Choices, ", ")+"]")
}
if !h.HideEnv && h.Env != "" {
parts = append(parts, "[env: "+h.Env+"]")
}
if !h.HideEnv {
for _, env := range h.EnvFallback {
parts = append(parts, "[env fallback: "+env+"]")
}
for _, env := range h.DeprecatedEnv {
parts = append(parts, "[deprecated env: "+env+"]")
}
}
if withDefault && !h.HideDefaultValue && len(h.Default) > 0 {
parts = append(parts, "(default: "+strings.Join(h.Default, ", ")+")")
}
// Last, as it is everywhere else a row carries one.
if withDeprecation {
if label := deprecationLabel(h); label != "" {
parts = append(parts, label)
}
}
return strings.Join(parts, " ")
}

// shortSummary is the description a narrow entry shows, with nothing appended.
func shortSummary(h *Help) string {
if h == nil {
return ""
}
return trimEnd(h.Short)
}

// withAnnotations joins a description to its annotations. Either may be empty.
func withAnnotations(help, annotations string) string {
switch {
case help == "":
return annotations
case annotations == "":
return help
default:
return help + " " + annotations
}
}

func helpText(h *Help) string {
if h == nil {
return ""
Expand Down
Loading