diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ccbda46..e6f9cbae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ once it reaches a published 0.1.0 release. - `xtask sweep-diff` now reports subcommand-count gains and losses per tool, separately from flags, so an invented subcommand row no longer passes as a clean sweep. - `xtask detector calibrate` now prints `NOT EVALUABLE` instead of `DOES NOT PASS` for a detector whose family has zero labelled members in the audit seed, so a legitimate gap in the sample is no longer reported as a failing detector. - [S-149] A bare-word block opening on a centered ALL-CAPS group label no longer ends before reading a single real row beneath it (`mandible fail2ban-client`). +- [S-173] A description's own `* NAME - VALUE` bullet list no longer splits at its inline dash into two lines per bullet (`mandible rustc`'s `--emit`). +- [S-174] A value spec with one bracket nested inside another keeps its outer group now even when more of it follows the inner pair's close, generalizing S-119 (`mandible fzf`'s `--listen`, `cpio`, `journalctl`, `lsusb`, `node`/`nodejs`, `rustc`'s `-l`). +- [S-133] `icupkg`'s `-tl or --type l`/`-tb or --type b`/`-te or --type e` rows fold into one `-t`/`--type` flag with `choices`, each keeping its own description (`mandible icupkg`). - [S-103, S-104] A command table's short-alias-comma rows (`i, install`) and the descriptions wrapping off their neighbors no longer drop the whole table or invent commands from wrapped text (`mandible pnpm`). - [S-117] A single-dash long option with an uppercase flag letter and a spaced value keeps its full name and value now, instead of truncating to its first letter with the value swallowed (`mandible g++`'s `-Xassembler`, `-Xpreprocessor`, `-Xlinker`). - [S-130] A docopt bracket row's own trailing choice list no longer repeats as the flag's value name too, so `mandible pvdisplay`'s `--configreport` and `--driverloaded` show their choices once instead of twice. diff --git a/corpus/fzf/0.44.1/expected.snap b/corpus/fzf/0.44.1/expected.snap index 474fda41..2cd4c0e8 100644 --- a/corpus/fzf/0.44.1/expected.snap +++ b/corpus/fzf/0.44.1/expected.snap @@ -530,7 +530,7 @@ flags: - help-text - spellings: - --listen - value_name: '[ADDR:' + value_name: '[[ADDR:]PORT]' value_kind: Optional group: Scripting description: Start HTTP server to receive actions (POST /) (To allow remote process execution, use --listen-unsafe) diff --git a/corpus/fzf/0.44.1/meta.toml b/corpus/fzf/0.44.1/meta.toml index 3b68f9cc..48cfe16f 100644 --- a/corpus/fzf/0.44.1/meta.toml +++ b/corpus/fzf/0.44.1/meta.toml @@ -36,7 +36,16 @@ must_contain_flags = [ "--extended", "--multi", "--height", "--history", "--preview", "--query", "+i", "+s", "--scheme", ] + [contract.must_flag_group] "+i" = "Search" "+s" = "Search" "--scheme" = "Search" + +# [S-174, round 11] `--listen[=[ADDR:]PORT]`'s own value spec: a bracket +# nested inside another with more of the outer group after the inner pair +# closes. The bracket matcher used to stop at the inner close, dropping +# the rest and the closing bracket (`value_name` came out `[ADDR:`, missing +# `]PORT]`). Locks in the whole spec so a regression here fails loudly. +[contract.must_value_name] +"--listen" = "[[ADDR:]PORT]" diff --git a/corpus/icupkg/74.2/meta.toml b/corpus/icupkg/74.2/meta.toml index 25dccc82..ae68bbdf 100644 --- a/corpus/icupkg/74.2/meta.toml +++ b/corpus/icupkg/74.2/meta.toml @@ -144,4 +144,4 @@ must_not_contain_flags = ["--auto"] [xfail] broken = true -reason = "the row `-c or --copyright include the ICU copyright notice` joins its two spellings with the word or and puts the description one space after the long spelling, so -c takes the literal word or as a value and --copyright reaches nothing. The value-carrying form of the same row shape is fixed; this value-free form with a one-space description column is not (docs/shapes.md S-134: or-joined-alias-single-space-gap reads 4 tools / 4 findings fleet-wide, below the five-tool bar; ships nothing). The -tl/-tb/-te rows are one option with three literal choice values, each keeping its own description, never folded into one flag's choices (docs/shapes.md S-133: choice-value-rows-unfolded reads 8 tools / 12 findings fleet-wide, but hand review finds only icupkg is this shape, the rest being different, already-tracked shapes; true count 1, below the five-tool bar; ships nothing)" +reason = "the row `-c or --copyright include the ICU copyright notice` joins its two spellings with the word or and puts the description one space after the long spelling, so -c takes the literal word or as a value and --copyright reaches nothing. The value-carrying form of the same row shape is fixed; this value-free form with a one-space description column is not (docs/shapes.md S-134: or-joined-alias-single-space-gap reads 4 tools / 4 findings fleet-wide, below the five-tool bar; ships nothing). [round 11] The -tl/-tb/-te rows (docs/shapes.md S-133) are now folded into one -t/--type flag with choices l/b/e, each keeping its own description, gated on the raw `-tl or --type l` literal so S-134's shape (above) is never touched; fleet count for S-133 stayed 1 tool, below the bar, but the fixture's own contract.must_attach_choices assertion now passes (gated exception, S-133 fixed for icupkg specifically). This fixture stays [xfail] solely for the still-open -c/--copyright defect above; issue #142 stays open." diff --git a/corpus/rustc/1.97.1/expected.snap b/corpus/rustc/1.97.1/expected.snap index 10a085f2..368fc0ee 100644 --- a/corpus/rustc/1.97.1/expected.snap +++ b/corpus/rustc/1.97.1/expected.snap @@ -41,7 +41,7 @@ flags: - help-text - spellings: - -l - value_name: [: + value_name: '[[:]=]' value_kind: Optional description: 'Link the generated crate(s) to the specified native library NAME. The optional KIND can be one of (default: dylib). Optional comma separated MODIFIERS may be specified each with a prefix of either ''+'' to enable or ''-'' to disable.' provenance: diff --git a/docs/shapes.md b/docs/shapes.md index 891c0307..47ada3c2 100644 --- a/docs/shapes.md +++ b/docs/shapes.md @@ -2407,13 +2407,44 @@ entry's `tools` field and nothing else. It does not get a new entry. -tb or --type b output for big-endian/ASCII charset family -te or --type e output for big-endian/EBCDIC charset family - tools: icupkg -- handling: Open defect. `icupkg`'s three rows are one flag, `-t, --type`, with three - literal choice values, each keeping its own description. The tree - carries them as three separate entities, all spelled `-t`/`--type`, - differing only in `value_name` and description, never folded into one - flag's `choices`. `docs/shapes.md` S-102 already records a prototype - fold that moved only `icupkg` on a full-`PATH` sweep, below the - five-tool bar. +- handling: Fixed (gated exception, round 11). `icupkg`'s three rows fold into one + flag, `-t`/`--type`, with three literal choices, each keeping its own + description (`help_text::sections::or_choice_fold::fold_or_joined_choice_rows`). + Gated on the raw row's own literal text — the short spelling's glued + tail equal to the long spelling's own value literal (`-tl` / `--type + l`) — never on entity shape alone, so an unrelated same-spelling repeat + (S-134's fused `pod2man` regression, round 10's issue #142 attempt) is + never folded; `pod2man`'s `--lquote`/`--rquote` stay separate. Below + the five-tool bar, shipped only because `corpus/icupkg/74.2`'s own + `must_attach_choices` assertion now passes with a zero-loss sweep and + the nine named controls held byte-identical. The fixture stays + `[xfail]` for S-134's separate, still-open `-c`/`--copyright` defect; + issue #142 stays open. + + A full-`PATH` sweep of icupkg alone reads its own flag count as + `19 -> 17 (-2)`, which lands on the loss side of a sweep-diff by number + alone — and is not a real loss. The two entities that disappear are the + three unfolded `-t`/`--type` rows collapsing to one: three rows in, one + row out, net `-2`. Nothing they carried is dropped — each row's own + `l`/`b`/`e` value and its own description survive as that one flag's + three `choices`, description-for-description, and the trailing + "The output type defaults to the input type." sentence (originally + folded onto the third row's own description by an unrelated + continuation-join rule) rides along on the `e` choice's description. + Read the count as three-rows-become-one, never as two flags vanishing. + + Fencing gap, recorded rather than worked around: with the fold disabled, + `cargo run -p xtask -- corpus` reports `icupkg/74.2` as + `xfail (as expected)`, not `FAIL` — `must_attach_choices["-t"]` does fail + once disabled, but the fixture stays `[xfail]` for the separate, + still-open `-c`/`--copyright` defect (S-134), so strict xfail has no + clean-fixture transition to demand and the corpus gate stays green + either way. A regression in this fold is therefore invisible to + `cargo run -p xtask -- corpus`; the only fence that would catch one is + `or_choice_fold.rs`'s own unit test, + `icupkgs_three_type_rows_fold_into_one_flag_with_choices`. This is not + changed this round — no new xfail-independent assertion is added, and + the fixture's layout stays as-is. - fleet: `choice-value-rows-unfolded` (`xtask/src/detector/choice_value_rows_unfolded.rs`) reads 8 tools / 12 findings on a full-`PATH` sweep of 2319 tools, 2026-09-06, requiring @@ -2427,8 +2458,8 @@ entry's `tools` field and nothing else. It does not get a new entry. `pkg-config`-style ambiguity S-120's own handling notes; `-q`/`-qq` and `-t`/`-tt` are S-035's repeated-letter verbosity levels. Folding any of those into `choices` would misrepresent them. True count for this - family alone: 1 tool, below the five-tool bar. Ship nothing; the - fixture stays `[xfail]`. + family alone: 1 tool, below the five-tool bar, fixed anyway as a gated + exception. ### S-134: value-free `or`-joined alias, one-space description gap @@ -3777,3 +3808,91 @@ entry's `tools` field and nothing else. It does not get a new entry. below the five-tool bar. Shipped as a gated exception (docs/design.md §16): both fixtures promote out of a prior unfixed state, `cargo xtask corpus` is zero-loss, and every self-check holds, both directions. + +### S-173: a description's own `* NAME - VALUE` bullet split at its inline dash + +- id: S-173 +- looks like: | + Each TYPE has the default FILE name: + * asm - CRATE_NAME.s + * llvm-bc - CRATE_NAME.bc +- tools: rustc +- handling: Fixed (round 11, TUI render layer, not the parser: the description + field itself was already whole — `mandible-extract` joins these lines + correctly). `wrap_description`'s enumerator-break heuristic + (`mandible-tui/src/render/detail_pane/wrap.rs`) treats a bare `-` word as + opening a fresh enumerated item unconditionally, so the ` - ` inside each + bullet's own `* asm - CRATE_NAME.s` opened a second, spurious break, + splitting `* asm` from `- CRATE_NAME.s`. `opens_enumerated_item` now + refuses a bare `-` sitting two words after a bare `*` opener — that dash + is the bullet's own inline separator, not a new item. These are prose + bullets, never choices; nothing about `Entity::choices` changes. +- fleet: A broadened raw-text sweep of the frozen `audit/queue-captures` set + (2301 tools) for a description line opening `[*+-]` or a digit bullet + followed by ` - ` inside its own text finds this shape on 1 tool, rustc, + 9 findings (`* asm`, `* llvm-bc`, `* dep-info`, `* link`, `* llvm-ir`, + `* metadata`, `* mir`, `* obj`, `* thin-link-bitcode`); `-`/`+`/digit-led + variants add 0 tools. Below the five-tool bar; shipped anyway as a small, + self-contained render-layer generalization with its own unit tests + (`mandible-tui/src/render/detail_pane/wrap.rs`'s `tests` module), verified + not to disturb the genuine `N ->` (`sg_luns`) or real dash-bullet + (`fail2ban-client`) shapes the same heuristic serves. + + Both the defect and the fix live entirely in `mandible-tui`'s render + layer, not in `mandible-extract`'s parser: the description field a + fixture snapshots is already whole (`xtask corpus --show` confirms this + directly). A full-`PATH` sweep, which measures the extracted tree, is + structurally blind to this family — a sweep-diff can show zero change + here and that is expected, not evidence of nothing shipped. The named + controls (screen-level, post-render output) and the unit tests above are + this family's actual regression fence, not a sweep. + +### S-174: a value spec's nested bracket pair with trailing text before the outer close + +- id: S-174 +- looks like: | + --listen[=[ADDR:]PORT] + -l [[:]=][:] +- tools: cpio, fzf, journalctl, lsusb, node, nodejs, rustc +- handling: Fixed (round 11), CLEARS THE FIVE-TOOL BAR, generalizing S-119. + S-119's own bracket matcher requires the outer group to close immediately + after the inner pair's own close; each tool above carries more of the + outer group *after* the inner pair closes before the true outer close, so + S-119's matcher refused them and the naive first-`]` reader truncated the + value. `nested_bracket_content_general` + (`mandible-extract/src/help_text/grammar.rs`) matches by bracket depth + instead of anchoring to the inner close, still refusing a second nested + pair or a second level of nesting — the same exclusions S-119 documents + (`fzf-tmux`'s `[WIDTH[%][,HEIGHT[%]]]` stays out of scope). Distinct id + from S-158 (branch `r10/option-values`, not present on `main`): that entry + records the same first-nesting-level defect S-119 already fixed on + `main`; whoever merges both branches should fold S-158's entry into + S-119/S-174 rather than keep three ids for two defects. + + Not a complete recovery everywhere — only the outer bracket group is + recovered, so a spec with real content *after* that group's own close + still loses that trailing part. Per tool: `node`/`nodejs` (`--inspect`, + `--inspect-brk`, `--inspect-wait`) and `journalctl` (`-n`/`--lines`) and + `fzf` (`--listen`) come out COMPLETE, nothing left over after the outer + group. `cpio` (`-I`/`-O`) keeps `FILE-NAME` unrendered after + `[[USER@]HOST:]`; `lsusb` (`-s`) keeps `[devnum]` after `[[bus]:]`; `rustc` + (`-l`) keeps `[:]` after `[[:]=]`. Fixing + the trailing part too is a further, separate rule. +- fleet: `nested-bracket-value-general` (the depth-tracked matcher itself; no + separate xtask detector module this round) moved 7 tools on a full-`PATH` + sweep of 2323 tools, zero losses, 2026-09-13 (`r11/p1value.txt`, + `r11/p1value.diff.txt`): `cpio` `-I`/`-O` (`[USER@` -> `[[USER@]HOST:]`, + raw `-I [[USER@]HOST:]FILE-NAME`), `fzf` `--listen` (`[[ADDR:]` -> + `[[ADDR:]PORT]`), `journalctl` `-n`/`--lines` (`[+` -> `[[+]INTEGER]`), + `lsusb` `-s` (`[bus` -> `[[bus]:]`, raw `-s [[bus]:][devnum]`), `node` and + `nodejs` `--inspect`/`--inspect-brk`/`--inspect-wait` (`[host:` -> + `[[host:]port]`, raw `--inspect[=[host:]port]`), `rustc` `-l` + (`[:` -> `[[:]=]`). Clears the + five-tool admission bar; not a gated exception. `corpus/fzf/0.44.1`'s own + `must_value_name["--listen"]` assertion passes (`[[ADDR:]PORT]`), a + corpus run over all 155 fixtures is zero-loss (0 failed), and the nine + named controls (`git`, `gcc`, `aarch64-linux-gnu-g++-13`, `ar`, `pnpm`, + `systemctl`, `tar`, `find`, `docker`) held byte-identical. The other six + tools have no fixture yet; rustc's own row is verified by a direct unit + test (`grammar.rs`'s + `a_nested_bracket_value_after_a_space_keeps_the_whole_outer_group`). diff --git a/mandible-extract/src/help_text/grammar.rs b/mandible-extract/src/help_text/grammar.rs index 3ac6548b..c5dfc1c2 100644 --- a/mandible-extract/src/help_text/grammar.rs +++ b/mandible-extract/src/help_text/grammar.rs @@ -829,6 +829,43 @@ fn take_glued_bracket_group(s: &str) -> Option<(&str, &str)> { (close > 0).then(|| (&s[..close + 2], &rest[close + 1..])) } +/// [S-174] Generalizes [`nested_bracket_content`] (S-119) to the same +/// one-nested-pair shape when the outer group carries more text *after* +/// the inner pair closes, before its own close (`fzf`'s +/// `--listen[=[ADDR:]PORT]`, rustc's `-l +/// [[:]=]...`'s own `[[:]=]`). +/// Depth-tracked rather than anchored to the inner close, but still +/// refuses a second nested pair or a second level of nesting — the exact +/// exclusions S-119 already documents (`fzf-tmux`'s +/// `[WIDTH[%][,HEIGHT[%]]]`). Returns the content up to (not including) +/// the matching outer `]`. +fn nested_bracket_content_general(s: &str) -> Option<&str> { + let mut depth = 1i32; + let mut inner_pairs = 0usize; + for (i, c) in s.char_indices() { + match c { + '[' => { + depth += 1; + if depth > 2 { + return None; + } + inner_pairs += 1; + if inner_pairs > 1 { + return None; + } + } + ']' => { + depth -= 1; + if depth == 0 { + return (inner_pairs == 1).then(|| &s[..i]); + } + } + _ => {} + } + } + None +} + /// A value spec following the flag token(s): `=VALUE`, ` VALUE`, /// `[=VALUE]`, `[VALUE]`, ``, or a bare uppercase-ish word. Returns /// `(value_name, kind, rest)`. @@ -919,6 +956,15 @@ fn try_value(input: &str) -> Option<(String, ValueKind, &str)> { .expect("nested_bracket_content only returns a prefix a `]` follows"); return Some((format!("[{content}]"), ValueKind::Optional, after_outer)); } + // [S-174] The same one-nested-pair shape, but with more of the + // outer group after the inner pair closes (`--listen[=[ADDR:]PORT]`). + if let Some(content) = nested_bracket_content_general(s) { + let after_inner = &s[content.len()..]; + let after_outer = after_inner + .strip_prefix(']') + .expect("nested_bracket_content_general only returns a prefix a `]` follows"); + return Some((format!("[{content}]"), ValueKind::Optional, after_outer)); + } let name = value_inside_brackets(&mut s).ok()?; close_bracket(&mut s).ok()?; // A bracket group whose every `|`-separated member is itself an @@ -1642,6 +1688,39 @@ mod tests { } } + /// `fzf`'s own `--listen[=[ADDR:]PORT]` row, byte-exact + /// (`audit/queue-captures/fzf/0.stdout`). The nested pair (`[ADDR:]`) + /// closes with more of the outer group (`PORT`) still to come before + /// the outer close — S-119's own matcher stops at the inner close and + /// drops the rest; S-174 generalizes it by depth instead. See + /// docs/shapes.md S-174. + #[test] + fn a_nested_bracket_value_with_trailing_text_keeps_the_whole_spec() { + let spec = parse_flag_spec("--listen[=[ADDR:]PORT]"); + assert_eq!(spec.long(), Some("listen")); + // The leading `=` is stripped the same way a plain `[=VALUE]` + // spec already strips it (`parses_optional_bracketed_value` + // above) — this rule only extends how far the bracket matcher + // reads, not that convention. + assert_eq!(spec.value_name.as_deref(), Some("[[ADDR:]PORT]")); + assert_eq!(spec.value_kind, ValueKind::Optional); + assert!(spec.fully_consumed); + } + + /// `rustc`'s own `-l [[:]=][:]` row + /// (`audit/queue-captures/rustc/0.stdout`): the same S-174 shape, one + /// space before the bracket rather than glued. The outer group + /// (`[[:]=]`) survives whole; the trailing + /// `[:]` is a second, later spec fragment and out of + /// this rule's scope. + #[test] + fn a_nested_bracket_value_after_a_space_keeps_the_whole_outer_group() { + let spec = parse_flag_spec("-l [[:]=][:]"); + assert_eq!(spec.short(), Some('l')); + assert_eq!(spec.value_name.as_deref(), Some("[[:]=]")); + assert_eq!(spec.value_kind, ValueKind::Optional); + } + /// `xxd`'s own `-s [+][-]seek` row, byte-exact. Neither bracket names /// a value, so nothing is folded and the flag keeps what it had. See /// docs/shapes.md S-097. diff --git a/mandible-extract/src/help_text/sections/flag_rows.rs b/mandible-extract/src/help_text/sections/flag_rows.rs index 71024098..7bf2169d 100644 --- a/mandible-extract/src/help_text/sections/flag_rows.rs +++ b/mandible-extract/src/help_text/sections/flag_rows.rs @@ -763,7 +763,7 @@ pub(super) fn choice_description_sub_row(trimmed: &str) -> Option<(&str, &str)> /// A bare identifier: letters/digits, `_`/`-`, first character /// alphanumeric — the shape a choice value is written in (`r`, `pvseg`, /// `shell-escape`), never punctuation alone. See docs/shapes.md S-120. -fn is_choice_token(token: &str) -> bool { +pub(super) fn is_choice_token(token: &str) -> bool { let mut chars = token.chars(); chars.next().is_some_and(|c| { c.is_ascii_alphanumeric() diff --git a/mandible-extract/src/help_text/sections/mod.rs b/mandible-extract/src/help_text/sections/mod.rs index 25dca258..e7377b1b 100644 --- a/mandible-extract/src/help_text/sections/mod.rs +++ b/mandible-extract/src/help_text/sections/mod.rs @@ -39,6 +39,7 @@ mod flag_rows; mod heading; mod layout; mod multiword; +mod or_choice_fold; mod preamble; mod repair; mod scan; @@ -57,6 +58,7 @@ use flag_rows::*; pub use heading::*; pub use layout::*; use multiword::*; +use or_choice_fold::fold_or_joined_choice_rows; use preamble::*; use repair::*; use scan::*; @@ -2213,6 +2215,9 @@ fn parse_body( // §7's row grammar) — `-help`'s row only qualifies once the repair // above has turned it into a single-dash spelling. See S-007. result.flags = recover_anchored_values(std::mem::take(&mut result.flags), raw); + // [S-133] `-tl or --type l`-style rows: fold into one flag with + // `choices`, gated on the raw ` or ` row's own literal text. + fold_or_joined_choice_rows(raw, &mut result.flags); // A `+word` row's own value column (S-163) is borrowed onto its // `-word` sibling when the ordinary repair above could not recover a // bare, unbracketed value (Xvfb's own `+extension name` / diff --git a/mandible-extract/src/help_text/sections/or_choice_fold.rs b/mandible-extract/src/help_text/sections/or_choice_fold.rs new file mode 100644 index 00000000..cd6139a0 --- /dev/null +++ b/mandible-extract/src/help_text/sections/or_choice_fold.rs @@ -0,0 +1,144 @@ +//! [S-133] `icupkg`'s `-tl or --type l`, `-tb or --type b`, `-te or +//! --type e` rows: one option, three literal choice values, each keeping +//! its own description, gated on the raw ` or ` row's own literal text so +//! an unrelated same-spelling repeat (S-134's fused `pod2man` regression) +//! is never folded. See docs/shapes.md S-133. + +use super::is_choice_token; +use mandible_core::{Choice, Entity}; + +/// True when `e` is one row of the unfolded shape this fold repairs: both +/// a short and a long spelling, a bare choice-shaped `value_name`, its +/// own description, and no `choices` of its own yet. +fn is_unfolded_or_choice_row(e: &Entity) -> bool { + e.short().is_some() + && e.long().is_some() + && e.choices.is_empty() + && e.description.is_some() + && e.value_name.as_deref().is_some_and(is_choice_token) +} + +/// The literal row text this fold requires, verbatim in `raw`: the short +/// spelling glued directly to the value, the word ` or `, the long +/// spelling, then the same value again spelled out — `-tl or --type l`. +/// Checking this against the tool's own bytes (not just the already- +/// parsed entity shape) is what keeps this rule from over-generalizing +/// onto a same-spelling repeat that isn't this shape at all. +fn glued_or_choice_row_in_raw(raw: &str, e: &Entity, value: &str) -> bool { + let (Some(short), Some(long)) = (e.short(), e.long()) else { + return false; + }; + let expected = format!("-{short}{value} or --{long} {value}"); + raw.contains(&expected) +} + +/// Fold every run of 2+ consecutive [`is_unfolded_or_choice_row`] entities +/// sharing one spelling into a single flag carrying `choices`, each with +/// its own description — but only when every member's own raw row +/// verifies via [`glued_or_choice_row_in_raw`]. A run that fails the raw +/// check (or is shorter than 2) is left exactly as parsed. +pub(super) fn fold_or_joined_choice_rows(raw: &str, flags: &mut Vec) { + let mut i = 0; + while i < flags.len() { + if !is_unfolded_or_choice_row(&flags[i]) { + i += 1; + continue; + } + let mut j = i + 1; + while j < flags.len() + && is_unfolded_or_choice_row(&flags[j]) + && flags[j].spellings == flags[i].spellings + { + j += 1; + } + let run_verified = j - i >= 2 + && (i..j).all(|k| { + let value = flags[k].value_name.clone().unwrap_or_default(); + glued_or_choice_row_in_raw(raw, &flags[k], &value) + }); + if run_verified { + let mut folded = flags[i].clone(); + folded.value_name = None; + folded.value_kind = mandible_core::ValueKind::Required; + folded.choices = flags[i..j] + .iter() + .map(|e| Choice { + name: e.value_name.clone().unwrap_or_default(), + description: e.description.clone(), + }) + .collect(); + folded.description = None; + flags.splice(i..j, std::iter::once(folded)); + i += 1; + } else { + i = j.max(i + 1); + } + } +} + +#[cfg(test)] +mod tests { + use super::super::parse; + + /// icupkg's own bytes, byte-exact (`corpus/icupkg/74.2/help.txt`). + const ICUPKG_TYPE_ROWS: &str = + "\t-tl or --type l output for little-endian/ASCII charset family\n\ + \t-tb or --type b output for big-endian/ASCII charset family\n\ + \t-te or --type e output for big-endian/EBCDIC charset family\n\ + \t The output type defaults to the input type.\n"; + + #[test] + fn icupkgs_three_type_rows_fold_into_one_flag_with_choices() { + let parsed = parse(ICUPKG_TYPE_ROWS); + let type_flags: Vec<_> = parsed + .flags + .iter() + .filter(|e| e.long() == Some("type")) + .collect(); + assert_eq!( + type_flags.len(), + 1, + "expected one folded -t/--type flag, got {type_flags:?}" + ); + let choices: Vec<&str> = type_flags[0] + .choices + .iter() + .map(|c| c.name.as_str()) + .collect(); + assert_eq!(choices, vec!["l", "b", "e"]); + assert_eq!( + type_flags[0] + .choices + .iter() + .map(|c| c.description.as_ref().map(|d| d.as_str())) + .collect::>(), + vec![ + Some("output for little-endian/ASCII charset family"), + Some("output for big-endian/ASCII charset family"), + Some( + "output for big-endian/EBCDIC charset family The output type defaults to \ + the input type." + ), + ] + ); + } + + /// pod2man's own two rows (`corpus/pod2man/5.01/help.txt`): two + /// *different* long spellings, never folded even though each is a + /// same-shaped value-carrying row — the round-10 regression this rule + /// must not repeat. + #[test] + fn pod2mans_lquote_and_rquote_stay_separate() { + let help = " --lquote quote\n Sets the string used as a left quote.\n\ + --rquote quote\n Sets the string used as a right quote.\n"; + let parsed = parse(help); + assert!( + parsed.flags.iter().any(|e| e.long() == Some("lquote")), + "expected --lquote to survive on its own" + ); + assert!( + parsed.flags.iter().any(|e| e.long() == Some("rquote")), + "expected --rquote to survive on its own" + ); + } +} diff --git a/mandible-tui/src/render/detail_pane/wrap.rs b/mandible-tui/src/render/detail_pane/wrap.rs index d3b10ec1..f5bc0da2 100644 --- a/mandible-tui/src/render/detail_pane/wrap.rs +++ b/mandible-tui/src/render/detail_pane/wrap.rs @@ -207,9 +207,19 @@ fn word_offsets(text: &str) -> Vec<(usize, &str)> { /// True when `word` (at position `i` in `words`) opens an enumerated item: /// a token shaped `N ->`, `N:`, a bare `-`, or a bare `*`, where `N` is /// [`is_enumerator_key`]-shaped. +/// +/// [S-173] A bare `-` two words after a bare `*` opener (`* asm - +/// CRATE_NAME.s`, rustc's own `--emit` bullet list) is that bullet's own +/// inline separator, not a fresh item — without this exclusion the dash +/// opened a second, spurious break inside the very bullet the `*` had +/// just opened, splitting `* asm` from `- CRATE_NAME.s` onto their own +/// lines. See docs/shapes.md S-173. fn opens_enumerated_item(words: &[(usize, &str)], i: usize) -> bool { let word = words[i].1; - matches!(word, "-" | "*") + if word == "-" { + return !(i >= 2 && words[i - 2].1 == "*"); + } + word == "*" || (is_enumerator_key(word) && words.get(i + 1).is_some_and(|(_, w)| *w == "->")) || (word.len() > 1 && word.ends_with(':') && is_enumerator_key(&word[..word.len() - 1])) } @@ -290,3 +300,45 @@ pub(super) fn break_overlong_word(word: &str, width: usize) -> Vec { } chunks } + +#[cfg(test)] +mod tests { + use super::*; + + /// rustc's own `--emit` description, byte-exact + /// (`audit/queue-captures/rustc/0.stdout`): each `* NAME - VALUE` + /// bullet must wrap as one segment, never split at its own inline + /// dash. See docs/shapes.md S-173. + #[test] + fn a_bullet_list_with_inline_dashes_keeps_each_bullet_on_one_segment() { + let text = "Each TYPE has the default FILE name: * asm - CRATE_NAME.s \ + * llvm-bc - CRATE_NAME.bc * dep-info - CRATE_NAME.d"; + let wrapped = wrap_description(text, 200); + assert!( + wrapped.iter().any(|l| l == "* asm - CRATE_NAME.s"), + "expected one whole bullet line, got {wrapped:?}" + ); + assert!( + !wrapped.iter().any(|l| l == "* asm"), + "the bullet must not be split from its own dash, got {wrapped:?}" + ); + } + + /// The genuine enumerator shape this rule must not disturb: + /// `sg_luns`'s own `N -> ...` range list still breaks before each `N`. + #[test] + fn a_numbered_arrow_list_still_breaks_before_each_number() { + let text = "0 -> 8192 bytes 1 -> 16384 bytes 2 -> 32768 bytes"; + let breaks = enumerator_breaks(text); + assert!(!breaks.is_empty(), "expected the arrow list to still break"); + } + + /// A lone `-` with nothing shaped like a `*` two words back is still a + /// real dash-bullet opener. + #[test] + fn a_bare_dash_with_no_star_two_back_still_opens_an_item() { + let words = word_offsets("first - second - third"); + assert!(opens_enumerated_item(&words, 1)); + assert!(opens_enumerated_item(&words, 3)); + } +}