Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ once it reaches a published 0.1.0 release.

## [Unreleased]

### Fixed

- [S-167] An S-167 node's own USAGE line no longer doubles its name in front of text that already named the tool and the subcommand (`mandible lldb-server` into `gdbserver`).
- [S-096] A `--` end-of-options row whose value is a bare multi-word phrase keeps every word instead of the first (`mandible lldb-server gdbserver`'s `-- program args`).
- [S-167] A node's own short-prefix alias now renders as an `alias:` line in the detail pane (`mandible lldb-server gdbserver` shows `alias: g`).

## [0.8.0] - 2026-09-14

### Changed
Expand Down
90 changes: 90 additions & 0 deletions corpus/lldb-server/18.1.3/expected.snap
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,99 @@ subcommands:
- name: gdbserver
aliases:
- g
description: 'OVERVIEW: lldb-server'
usage:
- 'USAGE: lldb-server g[dbserver] [options] [[host]:port] [[--] program args...]'
flags:
- spellings:
- --fd
value_name: <fd>
value_kind: Required
group: 'CONNECTION:'
description: Communicate over the given file descriptor.
provenance:
sources:
- help-text
- spellings:
- --named-pipe
value_name: <name>
value_kind: Required
group: 'CONNECTION:'
description: Write port lldb-server will listen on to the given named pipe.
provenance:
sources:
- help-text
- spellings:
- --pipe
value_name: <fd>
value_kind: Required
group: 'CONNECTION:'
description: Write port lldb-server will listen on to the given file descriptor.
provenance:
sources:
- help-text
- spellings:
- --reverse-connect
group: 'CONNECTION:'
description: Connect to the client instead of passively waiting for a connection. In this case [host]:port denotes the remote address to connect to.
provenance:
sources:
- help-text
- spellings:
- --help
group: 'GENERAL OPTIONS:'
description: Prints out the usage information for lldb-server.
provenance:
sources:
- help-text
- spellings:
- --log-channels
value_name: <channel1 categories...:channel2 categories...>
value_kind: Required
group: 'GENERAL OPTIONS:'
description: Channels to log. A colon-separated list of entries. Each entry starts with a channel followed by a space-separated list of categories.
provenance:
sources:
- help-text
- spellings:
- --log-file
value_name: <file>
value_kind: Required
group: 'GENERAL OPTIONS:'
description: Destination file to log to. If empty, log to stderr.
provenance:
sources:
- help-text
- spellings:
- --setsid
group: 'GENERAL OPTIONS:'
description: Run lldb-server in a new session.
provenance:
sources:
- help-text
- spellings:
- --attach
value_name: <pid-or-name>
value_kind: Required
group: 'TARGET SELECTION:'
description: Attach to the process given by a (numeric) process id or a name.
provenance:
sources:
- help-text
- spellings:
- --
value_name: program args
value_kind: Required
group: 'TARGET SELECTION:'
description: Launch program for debugging.
provenance:
sources:
- help-text
provenance:
sources:
- help-text
confidence: 0.45
children_filled: true
invocation_attested: true
abbrev_probe_attested: true
- name: platform
Expand Down
32 changes: 32 additions & 0 deletions corpus/lldb-server/18.1.3/gdbserver.help.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
OVERVIEW: lldb-server

USAGE: lldb-server g[dbserver] [options] [[host]:port] [[--] program args...]

CONNECTION:
--fd <fd> Communicate over the given file descriptor.
--named-pipe <name> Write port lldb-server will listen on to the given named pipe.
--pipe <fd> Write port lldb-server will listen on to the given file descriptor.
--reverse-connect Connect to the client instead of passively waiting for a connection. In this case [host]:port denotes the remote address to connect to.

GENERAL OPTIONS:
--help Prints out the usage information for lldb-server.
--log-channels <channel1 categories...:channel2 categories...>
Channels to log. A colon-separated list of entries. Each entry starts with a channel followed by a space-separated list of categories.
--log-file <file> Destination file to log to. If empty, log to stderr.
--setsid Run lldb-server in a new session.

TARGET SELECTION:
--attach <pid-or-name> Attach to the process given by a (numeric) process id or a name.
-- program args Launch program for debugging.

DESCRIPTION
lldb-server connects to the LLDB client, which drives the debugging session.
If no connection options are given, the [host]:port argument must be present
and will denote the address that lldb-server will listen on. [host] defaults
to "localhost" if empty. Port can be zero, in which case the port number will
be chosen dynamically and written to destinations given by --named-pipe and
--pipe arguments.

If no target is selected at startup, lldb-server can be directed by the LLDB
client to launch or attach to a process.

16 changes: 16 additions & 0 deletions corpus/lldb-server/18.1.3/meta.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@
# flags; this frozen fixture captures only the root parse, with no
# subprocess, so it cannot show that fill. The help text sits entirely on
# stderr; stdout is empty.
#
# The second capture is the `gdbserver` node's own document (stdout this
# time, stderr empty). It pins two repairs: its USAGE line no longer
# double-names the node (`gdbserver lldb-server g[dbserver] ...`, S-167's
# own render fix in `mandible-tui`'s `usage_form`), and its `-- program
# args` row recovers as the end-of-options marker `--` with value
# `program args`, never a fabricated flag `--program` (S-096's own
# widened value capture in `mandible-extract`'s `grammar.rs`).

[bless]
provenance = "agent"
Expand All @@ -24,6 +32,11 @@ stdout = "help.txt"
stderr = "help.stderr.txt"
exit_code = 0

[[capture]]
argv = ["lldb-server", "gdbserver", "--help"]
stdout = "gdbserver.help.txt"
exit_code = 0

[contract]
expected_framework = "generic"
min_subcommands = 3
Expand All @@ -32,3 +45,6 @@ min_subcommands = 3
version = "version"
gdbserver = "gdbserver"
platform = "platform"

[contract.must_contain_flags_by_path]
gdbserver = ["--fd", "--named-pipe", "--pipe", "--reverse-connect", "--help", "--log-channels", "--log-file", "--setsid", "--attach", "--"]
25 changes: 22 additions & 3 deletions docs/shapes.md
Original file line number Diff line number Diff line change
Expand Up @@ -1592,15 +1592,22 @@ entry's `tools` field and nothing else. It does not get a new entry.
- id: S-096
- looks like: |
-- Only file names after this
- tools: vim.basic, nvim
- tools: vim.basic, nvim, lldb-server, lldb-server-18
- handling: Fixed. `parse_flag_spec`'s `try_bare_sigil` reads a
bare `--` fragment as spelling `--` (`Dashes::None`, so it renders
verbatim). Only a real terminator (nothing left, or whitespace/an alias
separator — `cargo fmt`'s synopsis fragment `-- <rustfmt_options>...`)
may follow the marker; glued onto more name-shaped text (`objdump`'s
`--[section-]headers` optional-bracket-prefix convention) it is left
alone, so the marker is never fabricated out of an unrelated long name's
own unread tail.
own unread tail. Revised: the value that follows the marker used to
truncate at its first space, dropping every later word — `lldb-server
gdbserver`'s `-- program args` kept `program` and silently lost `args`
(AGENTS.md §3.9). A row whose only spelling so far is the bare marker,
followed by a bare word run (never an already-structured `<value>`/
`[value]` spec `try_value` already reads correctly), now takes the
whole remaining row as one value; `cargo fmt`'s own angle-bracket case
is untouched.
- fleet: `end-of-options-marker` (`xtask/src/end_of_options_marker.rs`) fell
from 26 tool(s)/26 finding(s) to 0/0 in a full-PATH sweep, 2026-09-03
(`step3-sweepdiff-plus-prefixed-option.txt`): 0 losses. Ratchet-gated at
Expand Down Expand Up @@ -3571,7 +3578,19 @@ entry's `tools` field and nothing else. It does not get a new entry.
nothing else changed, which is why design §7 Tier B rule 7's existence
oracle needed a narrow amendment (§16): `gdbserver` is not a contiguous
substring of the raw text, only `g[dbserver]` is, so the oracle now also
attests a subcommand name reached this way.
attests a subcommand name reached this way. Revised twice more. The
probed child's own USAGE line used to gain a second, redundant copy of
the node's name in front of text that already named both the tool and
the subcommand (`gdbserver lldb-server g[dbserver] [options] ...`):
`mandible-tui`'s `usage_form::word_names_node` now also recognizes a
usage line's own bracket-abbreviated leading word as naming the node,
through `mandible_core::reconstruct_abbrev_word` (moved there from
`mandible-extract` so the renderer can reach it without a real
dependency on that crate), so the existing S-108 substitution path
replaces `g[dbserver]` with `gdbserver` in place instead of prepending.
And the row's own short prefix, kept as `CommandNode::aliases` per the
ruling above, rendered nowhere: the detail pane now prints an `alias:`
line under the node's summary when `aliases` is non-empty.
- fleet: `usage-optional-word-table`
(`xtask/src/usage_optional_word_table.rs`) named 10 tools/17 findings as a
raw shape before the round. What moved on a full-`PATH` sweep of 2323
Expand Down
4 changes: 3 additions & 1 deletion mandible-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ pub use entity::{is_literal_choice_value, Choice, Dashes, Entity, EntityKind, Sp
pub use merge::{
merge_entity_lists, merge_nodes, merge_subcommand_lists, pair_aliases, MergeError,
};
pub use node::{is_command_name_shaped, CommandNode, Confession, Example, ValueKind};
pub use node::{
is_command_name_shaped, reconstruct_abbrev_word, CommandNode, Confession, Example, ValueKind,
};
pub use noderef::{resolve, resolve_flag, resolve_mut, FlagKey, NodeRef};
pub use provenance::{Authority, Axis, ManFormat, Provenance, Source};
pub use snapshot::{
Expand Down
28 changes: 28 additions & 0 deletions mandible-core/src/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,34 @@ pub fn is_command_name_shaped(s: &str) -> bool {
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-'))
}

/// `token` read back to the whole command word it names when `token` is
/// spelled with S-167's own optional-abbreviation bracket — one bracket
/// group opened right after a single leading lowercase letter, holding
/// nothing but lowercase letters, closing at the token's own end
/// (`lldb-server`'s `g[dbserver]` reads back to `gdbserver`). `None` for
/// any other shape. Lives here, not in `mandible-extract`, because
/// `mandible-tui`'s usage-line renderer needs the same read-back
/// (docs/shapes.md S-167, S-151's render path) and only depends on
/// `mandible-core`/`mandible-search` as ordinary dependencies —
/// `mandible-extract` is a dev-dependency there, tests only.
pub fn reconstruct_abbrev_word(token: &str) -> Option<String> {
let mut chars = token.chars();
let lead = chars.next()?;
if !lead.is_ascii_lowercase() {
return None;
}
let rest = &token[lead.len_utf8()..];
let inner = rest.strip_prefix('[')?.strip_suffix(']')?;
if inner.is_empty() || inner.contains(['[', ']']) {
return None;
}
if !inner.chars().all(|c| c.is_ascii_lowercase()) {
return None;
}
let whole = format!("{lead}{inner}");
is_command_name_shaped(&whole).then_some(whole)
}

impl CommandNode {
/// A minimal, empty node with the given name and provenance. Useful as
/// a starting point for tiers and for tests.
Expand Down
47 changes: 47 additions & 0 deletions mandible-extract/src/help_text/grammar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,21 @@ pub fn parse_flag_spec(input: &str) -> FlagSpec {
return spec;
}

// S-096's own marker takes its whole remaining row as one value:
// `lldb-server`'s `-- program args` names not one word but the
// phrase `program args`, since everything past `--` is what it is
// saying gets passed through untouched. Only when the marker is
// the row's one and only spelling so far and what follows is a
// bare word run, never an already-structured `<value>`/`[value]`
// spec `try_value` already reads correctly (`cargo fmt`'s own
// `-- <rustfmt_options>...`).
if is_bare_end_of_options_marker(&spec) && !rest.starts_with(['<', '[', '=']) {
spec.value_name = Some(rest.trim_end().to_string());
spec.value_kind = ValueKind::Required;
spec.fully_consumed = true;
return spec;
}

// Whatever remains is treated as a value spec: `=VALUE`, ` VALUE`,
// `[=VALUE]`, `[VALUE]`, or a bare `<value>`/`VALUE` token.
let Some((value_name, kind, tail)) = try_value(rest) else {
Expand Down Expand Up @@ -763,6 +778,15 @@ fn try_bare_sigil(input: &str) -> Option<(Spelling, &str)> {
None
}

/// True when `spec`'s only spelling so far is the bare end-of-options
/// marker itself (S-096), never a flag that merely happens to be spelled
/// `--` on some other row this grammar cannot produce. Gates the
/// whole-rest-of-row value capture right above this function's own
/// caller.
fn is_bare_end_of_options_marker(spec: &FlagSpec) -> bool {
matches!(spec.spellings.as_slice(), [s] if s.dashes == Dashes::None && s.name == "--")
}

/// Strips a leading `[no-]`/`[no]` prefix, if present. Recognized
/// structurally (content exactly `no`/`no-`), never by tool name. See
/// docs/shapes.md S-077.
Expand Down Expand Up @@ -2755,4 +2779,27 @@ mod tests {
assert_eq!(spec.spellings[0].name, "--");
assert_eq!(spec.value_name.as_deref(), Some("<rustfmt_options>"));
}

#[test]
fn a_dashdash_row_with_a_multi_word_bare_value_keeps_every_word() {
// `lldb-server gdbserver`'s real row: "-- program args", where
// "program args" is a phrase, not a placeholder ending at the
// first space. Before this test the second word was dropped
// silently (AGENTS.md §3.9). See docs/shapes.md S-096.
let spec = parse_flag_spec("-- program args");
assert_eq!(spec.spellings.len(), 1);
assert_eq!(spec.spellings[0].name, "--");
assert_eq!(spec.value_name.as_deref(), Some("program args"));
assert!(spec.fully_consumed);
}

#[test]
fn a_dashdash_rows_angle_bracket_value_is_unaffected_by_the_multi_word_capture() {
// The multi-word capture above must never widen past the shape
// `try_value` already reads correctly: an angle-bracketed
// placeholder still stops there, `...` and all handled the way it
// always was.
let spec = parse_flag_spec("-- <rustfmt_options>...");
assert_eq!(spec.value_name.as_deref(), Some("<rustfmt_options>"));
}
}
Loading
Loading