Coverage for src/footman/_toolhelp.py: 94%
337 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:38 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:38 +0000
1"""Read a tool's own `--help` and turn it into a `ToolSpec`.
3The structural extractors in `_toolspec` are better when they apply — click
4and argparse hand over their parameters as data. But most of the tools a
5task actually calls are Rust, Go or Node binaries with no Python parser to
6introspect: ruff and uv (clap), docker (cobra), cspell and markdownlint
7(commander), git (its own). For those, the tool's `--help` is the only
8description it offers, and it is far more regular than it looks.
10Every one of those help formats prints an option as a line that starts with
11a dash, followed by help text that is either on the same line past a run of
12spaces, or on the lines below indented deeper:
14 clap --fix
15 Apply fixes to resolve lint violations. Use `--no-fix`
16 to disable
18 optparse -d DIR, --directory=DIR
19 Write the output files to DIR.
21 cobra -f, --file stringArray Compose configuration files
23So one parser reads them all: find the lines that start an option, split the
24flag spellings from the prose, and glue on the continuation lines. What
25differs between the families is only how they spell a *default* and a
26*negation*, and those are small dialects on top (`[default: 3]`,
27`(default true)`, "Use `--no-fix` to disable").
29Nothing here runs at task time. The extractor runs when a maintainer
30regenerates the stubs, and `fm footman tools audit` compares what it finds
31against what is checked in.
32"""
34from __future__ import annotations
36import os
37import re
38import shutil
39import subprocess
40from collections.abc import Sequence
41from dataclasses import replace
43from footman._toolspec import Option, ToolSpec, Verb
45# An option block opens with a dash at the start of the line's content. The
46# indent is captured because it decides what counts as a continuation line;
47# it also absorbs a leading `- ` bullet — markdownlint-cli2 (and other
48# minimist/meow tools) print options as a bulleted list, `- --fix updates …`,
49# where the flag itself is what follows the bullet.
50_OPTION = re.compile(r"^(?P<indent> *(?:- )?)(?P<body>-{1,2}[A-Za-z0-9?].*)$")
52# `Options:`, `OPTIONS`, `Flags:`, `Rule selection:` — every family prints
53# some variant. The colon (or the shouting) is what makes it a heading: a
54# tool's one-line description is also short, unindented and capitalised.
55_SECTION = re.compile(r"^(?P<title>[A-Za-z][A-Za-z /-]*):$|^(?P<caps>[A-Z][A-Z /-]+)$")
56# Sections that hold something other than options.
57_NOT_OPTIONS = re.compile(r"command|example|usage|argument|see also|environment")
59# One spelling inside a flag block: `--select <RULE>`, `--directory=DIR`,
60# `-j N`, `--fix`. It runs on the flag column only — `_blocks` has already
61# split the prose off at the two-space gap — so the compound go-types are
62# named explicitly (`stringArray` repeats where `string` does not), and a
63# bare lowercase word left in the column is read as a value placeholder
64# (gh's `--assignee login`), not as the first word of the description.
65# Compound names first: alternation is ordered, so a leading `string`
66# would match `stringArray` and stop, losing the fact that it repeats.
67_GO_TYPES = (
68 r"(?:stringToString|stringArray|stringSlice|intSlice|uintSlice|boolSlice"
69 r"|ipSlice|bytesBase64|bytesHex|duration|float32|float64"
70 r"|int8|int16|int32|int64|uint8|uint16|uint32|uint64"
71 r"|string|int|uint|bool|ip)"
72)
73# The flag and any attached optional-value placeholder, shared by both forms.
74_FLAG = (
75 # A dot is allowed only *inside* the name (`--foo.bar`), never trailing:
76 # clap prints a repeatable flag as `--verbose...`, and a greedy `.` would
77 # swallow the ellipsis into the name (`verbose...` → keyword `verbose___`).
78 r"(?P<flag>--?(?:\[no-\])?[A-Za-z0-9](?:[A-Za-z0-9_-]|\.(?=[A-Za-z0-9]))*)"
79 # git glues an optional-value placeholder to the flag with no space:
80 # `--gpg-sign[=<key-id>]`, `--untracked-files[=<mode>]`. Read as one
81 # attached token so the option isn't mistaken for a bare switch.
82 r"(?P<attached>\[=[^\]]*\])?"
83)
84# The value placeholder every dialect agrees on: `<x>`, `[x]`, an UPPERCASE
85# metavar, or a cobra go-type (`stringArray`).
86_META = (
87 r"\[?<[^>]+>(?:\.\.\.)?\]?|\[[^\]]+\]|[A-Z][A-Z0-9_.,|]*(?:\.\.\.)?"
88 rf"|{_GO_TYPES}"
89)
90# cobra and gh also name a value with a bare lowercase word — `--assignee
91# login`, `--base branch`, `--memory bytes`. Only trusted in `--help` text,
92# where `_blocks` has split the prose off at the two-space gap: a man page's
93# description is a paragraph, and "the `--patch` option." there would read
94# "option" as `--patch`'s value.
95_META_BARE = r"|[a-z][A-Za-z0-9._-]*"
96_SPELLING = re.compile(_FLAG + rf"(?:[= ](?P<meta>{_META}{_META_BARE}))?")
97_SPELLING_STRICT = re.compile(_FLAG + rf"(?:[= ](?P<meta>{_META}))?")
99# The dialects of "this is the default".
100_DEFAULT = re.compile(
101 r"\[default: (?P<clap>[^\]]*)\]|\(default:? (?P<other>[^)]*)\)", re.IGNORECASE
102)
103# clap and cobra both print the closed set of values they accept — inline
104# when they are short, and as a bulleted list when each one has its own
105# gloss. Both forms mean the same thing to a stub.
106_CHOICES = re.compile(r"\[possible values: (?P<values>[^\]]+)\]")
107_POSSIBLE = re.compile(r"Possible values:\s*(?P<body>.*)$", re.IGNORECASE)
108_BULLET = re.compile(r"(?:^|\s)- (?P<name>[A-Za-z0-9][A-Za-z0-9_.-]*):")
109# The negation stated in prose, which is the only place some tools say it.
110_PROSE_NEGATION = re.compile(
111 r"(?:use|pass) [`'\"]?(?P<flag>--no-[A-Za-z0-9-]+|--[A-Za-z0-9-]+)[`'\"]?"
112 r"[^.]{0,40}?(?:to disable|to turn (?:it|this) off)",
113 re.IGNORECASE,
114)
115# git's own dialect: `--[no-]quiet` is both spellings on one line.
116_INLINE_NEGATION = re.compile(r"^--\[no-\](?P<name>.+)$")
117_REPEATABLE = re.compile(
118 r"(?:may|can) be (?:used|repeated|specified|passed|given)"
119 r"(?: multiple times| more than once| repeatedly)?",
120 re.IGNORECASE,
121)
124def _sections(text: str) -> dict[str, list[str]]:
125 """Split help output into `{section title: lines}`.
127 Sections matter for two reasons: subcommands live in one of them, and
128 an option's *section* is how a tool marks its global flags.
129 """
130 out: dict[str, list[str]] = {"": []}
131 title = ""
132 for line in text.splitlines():
133 if not line[:1].isspace() and line.strip():
134 match = _SECTION.match(line.strip())
135 if match and not line.strip().startswith("-"):
136 title = (match["title"] or match["caps"]).strip().lower()
137 out.setdefault(title, [])
138 continue
139 out[title].append(line)
140 return out
143def _blocks(lines: Sequence[str]) -> list[tuple[str, str]]:
144 """Yield `(spellings, help)` for each option in *lines*.
146 The boundary between two options is the *help column*, not the flag
147 column. Flags themselves sit at more than one indent — clap prints
148 ` -w, --watch` but ` --fix-only`, aligning long flags past the
149 short-flag column — so "indented deeper than the last flag" would read
150 the second one as prose belonging to the first. Help text is always
151 indented deeper still, so a dash at less than the help column opens a
152 new option and anything at or past it is that option's prose.
153 """
154 blocks: list[tuple[str, str]] = []
155 pending: tuple[str, list[str]] | None = None
156 flag_indent = 0
157 help_indent = 0 # 0 until the block's prose reveals the column
158 for line in lines:
159 match = _OPTION.match(line.rstrip())
160 indent = len(match["indent"]) if match else len(line) - len(line.lstrip())
161 opens = indent < help_indent if help_indent else indent <= flag_indent
162 if match and (pending is None or opens):
163 if pending is not None:
164 blocks.append((pending[0], " ".join(pending[1]).strip()))
165 flag_indent = indent
166 body = match["body"]
167 head, _, tail = body.partition(" ")
168 # Python's `--help` separates the flag column from the description
169 # with a ` : ` gutter (`-b : issue warnings`, `-c cmd : program
170 # passed in`), not the double-space gutter every other dialect
171 # uses. Re-split on it so the colon doesn't leak into the help text,
172 # and — when the columns touch and the double-space split found
173 # nothing — so the metavar and description aren't lost outright.
174 if not tail.strip() and " : " in head:
175 head, _, tail = body.partition(" : ")
176 elif tail.lstrip().startswith(":"):
177 tail = tail.lstrip()[1:]
178 # Learn the help column from same-line help too, not only from a
179 # continuation: cobra prints `-d, --detach` at one indent and
180 # ` --tail string` at a deeper one, and without the column
181 # the deeper flag reads as prose belonging to the shallower one.
182 help_indent = (
183 indent + len(head) + 2 + len(tail) - len(tail.lstrip())
184 if tail.strip()
185 else 0
186 )
187 pending = (head.strip(), [tail.strip()] if tail.strip() else [])
188 elif pending is not None:
189 stripped = line.strip()
190 if not stripped:
191 continue # a blank line inside a block is just formatting
192 if indent <= flag_indent:
193 blocks.append((pending[0], " ".join(pending[1]).strip()))
194 pending = None
195 continue
196 help_indent = help_indent or indent
197 pending[1].append(stripped)
198 if pending is not None:
199 blocks.append((pending[0], " ".join(pending[1]).strip()))
200 return blocks
203def _spellings(head: str, *, strict: bool = False) -> tuple[list[str], str, bool]:
204 """The flags in an option's left column, its placeholder, and whether
205 the value is optional (a `[=…]` glued to the flag).
207 *strict* drops the bare-lowercase metavar, for a man page whose prose
208 refers to flags mid-sentence: `--patch` there must not read the next
209 word as its value.
210 """
211 flags: list[str] = []
212 meta = ""
213 optional = False
214 pattern = _SPELLING_STRICT if strict else _SPELLING
215 for match in pattern.finditer(head):
216 flags.append(match["flag"])
217 meta = meta or (match["meta"] or "")
218 optional = optional or bool(match["attached"])
219 return flags, meta, optional
222# A manual's prose is Unicode-typeset (curly quotes, dashes, ellipsis).
223# The stub is source that must stay ASCII-clean (ruff RUF002), so fold them.
224_TYPOGRAPHY = str.maketrans(
225 {
226 "\u2019": "'", # right single quote
227 "\u2018": "'", # left single quote
228 "\u201c": '"', # left double quote
229 "\u201d": '"', # right double quote
230 "\u2013": "-", # en dash
231 "\u2014": "-", # em dash
232 "\u2026": "...", # ellipsis
233 "\u00a0": " ", # no-break space
234 }
235) # fmt: skip
236# Abbreviations whose period does not end a sentence.
237_ABBREV = (" e.g", " i.e", " etc", " vs", " cf", " al", " no")
240def _clean(text: str) -> str:
241 """The tool's prose, as one clean first sentence.
243 A `--help` line is already a sentence; a manual entry is paragraphs, so
244 keep only the first — the summary a completion popup can show — folding
245 the manual's typographic punctuation to ASCII on the way.
246 """
247 text = _DEFAULT.sub("", text)
248 text = _CHOICES.sub("", text)
249 text = _POSSIBLE.sub("", text)
250 text = re.sub(r"\[env: [^\]]*\]", "", text)
251 text = re.sub(r"\s+", " ", text).translate(_TYPOGRAPHY).strip(" .")
252 return _first_sentence(text)
255def _first_sentence(text: str) -> str:
256 """Up to the first sentence-ending period, skipping `e.g.`/`i.e.`."""
257 for match in re.finditer(r"\. ", text):
258 head = text[: match.start()]
259 if not head.endswith(_ABBREV):
260 return head
261 return text
264def _parse_default(text: str) -> str:
265 match = _DEFAULT.search(text)
266 if not match:
267 return ""
268 return (match["clap"] or match["other"] or "").strip().strip("\"'")
271def _option(
272 head: str, help_text: str, *, strict: bool = False, shorts: str = "only"
273) -> Option | None:
274 """One `Option` from one parsed block, or None if it isn't one.
276 *shorts* is the short-option policy: `"none"` never keys on a short,
277 `"only"` (default) keys on a short *when it is the option's only spelling*
278 (python's `-m`, git's `-C`), and `"all"` also keys on a short that has a
279 long — `_short_alias` adds the extra keyword for that mode.
280 """
281 flags, meta, optional = _spellings(head, strict=strict)
282 longs = [f for f in flags if f.startswith("--")]
283 if not longs:
284 # Go's stdlib `flag` spells even long options with one dash (`-color`,
285 # `-no_gitignore`); read a multi-char single-dash flag as the keyword
286 # when there's no `--` form.
287 longs = [f for f in flags if len(f) > 2 and not f.startswith("--")]
288 if not longs and shorts != "none" and not strict:
289 # A short-only option (python's `-m`, `-c`, `-O`): the single char is
290 # the keyword — the bridge turns `m="build"` into `-m build`. Help text
291 # only (a man page's prose is too noisy to trust), and only a letter
292 # that forms a valid keyword (`-0` can't).
293 longs = [f for f in flags if len(f) == 2 and f[1:].isidentifier()][:1]
294 if not longs:
295 return None # nothing spellable
296 inline = _INLINE_NEGATION.match(longs[0])
297 stem = inline["name"] if inline else longs[0].lstrip("-")
298 name = stem.replace("-", "_").replace(".", "_")
299 default = _parse_default(help_text)
300 choices = _choices(help_text)
301 # An optional-value option (`--gpg-sign[=<key-id>]`) is neither a plain
302 # switch nor a required-value option: it works bare *and* with a value.
303 is_flag = not meta and not optional
304 repeatable = bool(
305 meta.endswith(("...", "...]", "Array", "Slice", "ToString"))
306 or _REPEATABLE.search(help_text)
307 )
308 negation = f"--no-{stem}" if inline else ""
309 prose = _PROSE_NEGATION.search(help_text)
310 if not negation and is_flag and prose and prose["flag"] != longs[0]:
311 negation = prose["flag"]
312 return Option(
313 name=name,
314 flags=tuple(sorted((_spell(f, stem) for f in flags), key=len, reverse=True)),
315 negation=negation,
316 help=_clean(help_text),
317 type_name=_kind(is_flag, repeatable, choices, optional),
318 default=_coerce_default(default, is_flag),
319 choices=choices,
320 )
323# `--help` and `--version` are on every tool and belong to no task: the
324# bridge would happily emit them, but a stub that suggests them is noise.
325_NOISE = frozenset({"help", "version"})
328def _choices(text: str) -> tuple[str, ...]:
329 """The values a tool says it accepts, from whichever form it printed."""
330 inline = _CHOICES.search(text)
331 if inline:
332 return _values(inline["values"])
333 listed = _POSSIBLE.search(text)
334 if listed:
335 return tuple(m["name"] for m in _BULLET.finditer(listed["body"]))
336 return ()
339def _spell(flag: str, stem: str) -> str:
340 """`--[no-]quiet` is how git *prints* it; `--quiet` is what it takes."""
341 return f"--{stem}" if _INLINE_NEGATION.match(flag) else flag
344def _values(text: str) -> tuple[str, ...]:
345 """The closed set a tool prints, as the stub's `Literal` members."""
346 return tuple(v.strip() for v in text.split(",") if v.strip())
349def _kind(
350 is_flag: bool, repeatable: bool, choices: tuple[str, ...], optional: bool = False
351) -> str:
352 if is_flag:
353 return "bool"
354 if optional:
355 return "optvalue" # a switch that also accepts a value
356 if choices:
357 return "choice[]" if repeatable else "choice"
358 return "list[str]" if repeatable else "str"
361def _coerce_default(text: str, is_flag: bool) -> object:
362 if not text:
363 return None
364 if is_flag or text in {"true", "false"}: 364 ↛ 365line 364 didn't jump to line 365 because the condition on line 364 was never true
365 return text == "true"
366 return text
369def _pair_negations(options: list[Option]) -> list[Option]:
370 """Fold `--no-x` entries into `x`, and drop them as options of their own.
372 Every family that supports negation prints both spellings, so the pair
373 is right there in the help — `--fix` and `--no-fix`, `--clean` and
374 `--dirty` (that one only says so in prose). Folding them means `off`
375 knows the tool's real spelling and the stub stays one keyword per
376 concept, the way the tool's own docs read.
377 """
378 by_name = {o.name: o for o in options}
379 folded: list[Option] = []
380 negated: set[str] = set()
381 for option in options:
382 if not option.name.startswith("no_"):
383 continue
384 positive = by_name.get(option.name.removeprefix("no_"))
385 if positive is not None and positive.type_name == "bool":
386 negated.add(option.name)
387 by_name[positive.name] = _with_negation(positive, option.flags[0])
388 for option in options:
389 if option.name in negated:
390 continue
391 folded.append(by_name[option.name])
392 return folded
395def _with_negation(option: Option, negation: str) -> Option:
396 if option.negation: 396 ↛ 397line 396 didn't jump to line 397 because the condition on line 396 was never true
397 return option
398 return Option(
399 name=option.name,
400 flags=option.flags,
401 negation=negation,
402 help=option.help,
403 type_name=option.type_name,
404 default=option.default,
405 choices=option.choices,
406 )
409def parse_help(
410 text: str, *, name: str = "", man: bool = False, shorts: str = "only"
411) -> Verb:
412 """One verb's options, from its `--help` output or (with `man`) manual.
414 The option grammar is the same either way — the man page states a flag
415 and its help exactly as `--help` does. Only the positional shape reads
416 from a different place: a `usage:` line normally, the `SYNOPSIS` forms
417 for a manual.
418 """
419 sections = _sections(text)
420 options: list[Option] = []
421 seen: set[str] = set()
422 for title, lines in sections.items():
423 if _NOT_OPTIONS.search(title):
424 continue # `Commands:`, `Examples:` — dashes there aren't flags
425 for head, help_text in _blocks(lines):
426 option = _option(head, help_text, strict=man, shorts=shorts)
427 if (
428 option is not None
429 and option.name not in _NOISE
430 and option.name not in seen
431 ):
432 seen.add(option.name)
433 options.append(option)
434 if not options:
435 # Go's `flag` prints its options under `Usage of <prog>:` — a section
436 # `_NOT_OPTIONS` skips. Nothing parsed anywhere else, so scan every
437 # section, including that one. Guarded on emptiness, so a tool that
438 # parses normally never reaches here and can't regress.
439 for _title, lines in sections.items():
440 for head, help_text in _blocks(lines):
441 option = _option(head, help_text, strict=man, shorts=shorts)
442 if option is not None and option.name not in _NOISE: 442 ↛ 440line 442 didn't jump to line 440 because the condition on line 442 was always true
443 seen.add(option.name)
444 options.append(option)
445 options = list({o.name: o for o in options}.values())
446 if shorts == "all":
447 options = _with_short_aliases(options)
448 positional, lead = _synopsis_shape(text, name) if man else _usage_shape(text)
449 return Verb(
450 name=name,
451 help=_summary(text),
452 options=tuple(sorted(_pair_negations(options), key=lambda o: o.name)),
453 positional=positional,
454 lead=lead,
455 wraps=_wraps(text),
456 )
459def _with_short_aliases(options: list[Option]) -> list[Option]:
460 """For `shorts="all"`: add a keyword for a short that *also* has a long,
461 so `-m, --message` answers to both `message` and `m`. The long-keyed
462 option stays; the alias is an extra entry keyed on the single char."""
463 out = list(options)
464 seen = {o.name for o in options}
465 for option in options:
466 for flag in option.flags:
467 char = flag[1:]
468 if len(flag) == 2 and char.isidentifier() and char not in seen:
469 seen.add(char)
470 out.append(replace(option, name=char))
471 return out
474# A metavar that stands for a *wrapped* command's argv: `uv run [COMMAND]`,
475# `docker exec … COMMAND [ARG...]`.
476_WRAP_METAVAR = frozenset({"command", "cmd", "args", "arg", "argv"})
479def _wraps(text: str) -> bool:
480 """Whether the verb forwards everything after its own args to a child.
482 Signalled by a trailing command/argv metavar or coverage's literal
483 "program options" — the mark of `uv run`, `docker exec`, `coverage run`.
484 """
485 usage = _usage_line(text)
486 if "program option" in usage.lower():
487 return True
488 for token in _top_level_positionals(usage):
489 base = re.split(r"[\[:]", token.strip("[]<>"))[0].lower()
490 if base in _WRAP_METAVAR:
491 return True
492 return False
495# The base of a positional metavar, before any `[:TAG]` / `<...>` suffix:
496# `IMAGE`, `NAME` from `NAME[:TAG|@DIGEST]`, `repo` from `<repo>`.
497_METAVAR = re.compile(r"^<?[A-Za-z][A-Za-z0-9_-]*>?$")
500def _is_option_token(token: str) -> bool:
501 """A usage token that is an option, a separator, or the `[OPTIONS]` slot
502 — not a positional argument."""
503 bare = token.strip("[]<>").lower()
504 return not bare or bare in {"--", "|", "options", "flags"} or bare.startswith("-")
507def _top_level_positionals(usage: str) -> list[str]:
508 """The positional tokens at bracket depth 0.
510 A usage grammar nests option groups in brackets — `[--reason <string>]`,
511 `[--separate-git-dir <git-dir>]` — and whitespace-splitting scatters
512 their *values* into loose tokens (`<string>]`) that look like bare
513 positionals. Tracking depth keeps those out: only a token that starts
514 while no bracket is open can be a real argument.
515 """
516 positional: list[str] = []
517 depth = 0
518 for token in usage.split():
519 if depth == 0 and not _is_option_token(token):
520 positional.append(token)
521 depth = max(0, depth + token.count("[") - token.count("]"))
522 return positional
525def _usage_shape(text: str) -> tuple[str, str]:
526 """`(positional, lead)` from a verb's `usage:` line.
528 Two confident answers, everything else `"any"`:
530 * `"none"` when the argument section is *only* options — mkdocs build's
531 `[OPTIONS]`. A positional there is a type error.
532 * `"required"` when a single clean metavar leads — `docker run IMAGE …`,
533 `git clone <repo> …`. The stub makes it positional-only.
535 Ambiguity stays `"any"`, because a wrong answer *forbids a valid call*.
536 An option woven into an alternation (`<PACKAGES|--requirements …>`), a
537 bracketed-optional or variadic first argument, an unfamiliar token — all
538 fall through, so a real command is never rejected.
539 """
540 return _grammar_shape(_usage_line(text))
543def _grammar_shape(grammar: str) -> tuple[str, str]:
544 """`(positional, lead)` from one argument grammar (no program name)."""
545 if not grammar:
546 return "any", ""
547 positional = _top_level_positionals(grammar)
548 if not positional:
549 return "none", ""
550 first = positional[0]
551 if any("--" in token for token in positional):
552 return "any", "" # a `<X|--flag>` alternation — packages OR a flag
553 if first.startswith("[") or "..." in first:
554 return "any", "" # optional or variadic leading argument
555 base = re.split(r"[\[:]", first.strip("[]<>"))[0]
556 if not base or base[-1:].isdigit() or not _METAVAR.match(base):
557 return "any", "" # numbered (`path1`) or unrecognised — don't constrain
558 return "required", base.replace("-", "_").lower()
561def _synopsis_shape(text: str, verb: str) -> tuple[str, str]:
562 """`(positional, lead)` from a man page's `SYNOPSIS`.
564 git's manual states each verb as one or more complete forms. A verb
565 with a *single* form has one grammar to read (`git clone … <repository>
566 [<directory>]` → required); a verb with several — `git checkout` lists,
567 detaches, creates, restores — has no single shape, so it stays `"any"`.
568 Counting the forms is just counting the lines that restate `git <verb>`;
569 the wrapped continuations don't.
570 """
571 match = re.search(
572 r"(?ms)^SYNOPSIS[ \t]*\n(?P<body>.*?)\n(?:[A-Z][A-Z ]+\n|\Z)", text
573 )
574 if not match: 574 ↛ 575line 574 didn't jump to line 575 because the condition on line 574 was never true
575 return "any", ""
576 body = match["body"]
577 prog = f"git {verb}"
578 forms = re.findall(rf"(?m)^[ \t]*{re.escape(prog)}\b", body)
579 if len(forms) != 1:
580 return "any", "" # multi-form (or unrecognised) — don't constrain
581 grammar = " ".join(body.split()).split(prog, 1)[1]
582 return _grammar_shape(grammar)
585def _usage_line(text: str) -> str:
586 """The `usage:` line, minus the program name, joined if it wraps.
588 A wrapped usage (git's spans several indented lines) is stitched back
589 together; the program name and any leading subcommands are dropped so
590 only the argument grammar remains.
591 """
592 lines = text.splitlines()
593 for i, line in enumerate(lines):
594 if not line.lower().lstrip().startswith("usage"):
595 continue
596 collected = [line]
597 for cont in lines[i + 1 :]:
598 if not cont.strip() or not cont[:1].isspace():
599 break
600 # git prints alternative forms as ` or: git branch …`. Only
601 # the first form is parsed — stitching the alternatives together
602 # would merge incompatible grammars into nonsense.
603 if cont.lstrip().lower().startswith("or:"):
604 break
605 collected.append(cont)
606 joined = " ".join(part.strip() for part in collected)
607 after = re.sub(r"(?i)^usage:?\s*", "", joined)
608 # Drop the program + verbs: everything up to the first bracket or
609 # metavar-looking token is the command path, not an argument.
610 tokens = after.split()
611 rest = []
612 seen_arg = False
613 for token in tokens:
614 if not seen_arg and (token.startswith(("[", "<")) or token.isupper()):
615 seen_arg = True
616 if seen_arg:
617 rest.append(token)
618 return " ".join(rest)
619 return ""
622def _summary(text: str) -> str:
623 """A tool's one-line self-description: its help's first prose line."""
624 if re.match(r"^Usage of \S", text):
625 return "" # Go's `flag` opens with `Usage of <prog>:` and has no summary
626 for line in text.splitlines(): 626 ↛ 641line 626 didn't jump to line 641 because the loop on line 626 didn't complete
627 stripped = line.strip()
628 if not stripped:
629 continue
630 if stripped.lower().startswith("usage"):
631 continue
632 if (
633 stripped.startswith("-")
634 or _SECTION.match(stripped)
635 or stripped.endswith(":")
636 ):
637 # Reached the options/sections with no summary in between — a tool
638 # like python opens straight into `Options …:`, so it has none.
639 return ""
640 return stripped
641 return ""
644def subcommands(text: str) -> dict[str, str]:
645 """`{name: summary}` from the `Commands:` section of a tool's help."""
646 found: dict[str, str] = {}
647 for title, lines in _sections(text).items():
648 if not re.search(r"command|subcommand", title):
649 continue
650 for line in lines:
651 match = re.match(
652 r"^\s+(?P<name>[a-z][a-z0-9-]*)(?:,\s*[a-z0-9-]+)*"
653 r"(?:\s{2,}(?P<help>.*))?$",
654 line.rstrip(),
655 )
656 if match: 656 ↛ 650line 656 didn't jump to line 650 because the condition on line 656 was always true
657 found.setdefault(match["name"], (match["help"] or "").strip())
658 return found
661def run_help(
662 argv: list[str], *, flag: str = "--help", man: bool = False, timeout: float = 30.0
663) -> str:
664 """`<tool> ... --help`, as text. Empty when the tool isn't installed.
666 `argv[0]` is the executable to run — a bare name resolved on `PATH`, or the
667 absolute path a caller already resolved (`from_help(..., binary=…)`).
669 Help goes to stdout for every tool footman curates, but a few print
670 usage to stderr on older versions, so both are read.
672 `man` reads the manual instead — `git help <verb>` — for a tool whose
673 terse `-h` omits most of its flags (git's `-h` shows about half). It
674 runs only at stub-generation time, never at task time, so its heavier
675 footprint (a rendered man page) costs a user nothing.
676 """
677 if shutil.which(argv[0]) is None:
678 return ""
679 if man: 679 ↛ 680line 679 didn't jump to line 680 because the condition on line 679 was never true
680 return _run_man(argv, timeout)
681 try:
682 done = subprocess.run(
683 [*argv, flag],
684 capture_output=True,
685 text=True,
686 timeout=timeout,
687 env=_wide_env(),
688 )
689 except (OSError, subprocess.SubprocessError):
690 return ""
691 return done.stdout if len(done.stdout) > len(done.stderr) else done.stderr
694# Man renders bold/underline as `c\x08c` / `_\x08c` overstrike; dropping the
695# char-then-backspace pair leaves clean text, no `col` binary needed.
696_OVERSTRIKE = re.compile(r".\x08")
699def _run_man(argv: list[str], timeout: float) -> str:
700 """`<tool> help <verb>`, de-overstruck — the manual as plain text."""
702 env = {
703 **os.environ,
704 "GIT_PAGER": "cat",
705 "PAGER": "cat",
706 "MANPAGER": "cat",
707 "MAN_KEEP_FORMATTING": "",
708 "COLUMNS": "200",
709 }
710 try:
711 done = subprocess.run(
712 [argv[0], "help", *argv[1:]],
713 capture_output=True,
714 text=True,
715 timeout=timeout,
716 env=env,
717 )
718 except (OSError, subprocess.SubprocessError):
719 return ""
720 return _OVERSTRIKE.sub("", done.stdout)
723def _wide_env() -> dict[str, str]:
724 """A wide terminal, so help text wraps as little as possible.
726 Every family honours one of these; a narrow wrap costs nothing but
727 re-joined prose, and a wide one keeps `[default: …]` on the line it
728 belongs to.
729 """
731 return {**os.environ, "COLUMNS": "200", "TERM": "dumb", "NO_COLOR": "1"}
734def from_help(
735 name: str,
736 *,
737 binary: str | None = None,
738 verbs: tuple[str, ...] = (),
739 version: str = "",
740 in_process: bool = False,
741 flag: str = "--help",
742 man: bool = False,
743 shorts: str = "only",
744) -> ToolSpec:
745 """A `ToolSpec` for *name* by asking the installed binary.
747 *binary* is the executable to run (the caller may have resolved it, e.g. to
748 a Homebrew keg); it defaults to *name*, resolved on `PATH`. The tool's own
749 verb names still ride as `name`/verbs in each argv, only the executable
750 differs.
752 Each verb costs one `<tool> <verb> --help` (or `<tool> help <verb>`
753 with `man`); the root call supplies the tool's summary and its global
754 options (verb `""`). With `man`, per-verb manuals are read but the root
755 stays on `--help`, which is where a tool prints its verb list.
756 """
757 cmd = binary or name
758 root = run_help([cmd], flag=flag)
759 if not root:
760 return ToolSpec(name=name, version=version)
761 root_verb = parse_help(root, name="", shorts=shorts)
762 if verbs: 762 ↛ 768line 762 didn't jump to line 768 because the condition on line 762 was always true
763 # A multi-command tool's bare usage line (`docker [OPTIONS] COMMAND`)
764 # describes the subcommand slot, not arguments to `docker` itself —
765 # so `tools.docker(...)` must not be constrained by it. Only a
766 # single-command tool's root verb carries a real positional shape.
767 root_verb = replace(root_verb, positional="any", lead="", wraps=False)
768 if man: 768 ↛ 772line 768 didn't jump to line 772 because the condition on line 768 was never true
769 # The terse root help (`git -h`) lists subcommands, not globals; the
770 # tool's own manual (`git help git`) lists the options that must
771 # precede the verb — what `.opts()` binds. Read them from there.
772 manual = run_help([cmd, name], man=True)
773 if manual:
774 root_verb = replace(
775 root_verb, options=parse_help(manual, man=True, shorts=shorts).options
776 )
777 parsed = [root_verb]
778 for verb in verbs:
779 text = run_help([cmd, *verb.split(".")], flag=flag, man=man)
780 if text: 780 ↛ 778line 780 didn't jump to line 778 because the condition on line 780 was always true
781 # `git rev-parse` is spelled `tools.git.rev_parse(...)`: the
782 # bridge turns the underscore back into a dash when it calls.
783 parsed.append(
784 parse_help(text, name=verb.replace("-", "_"), man=man, shorts=shorts)
785 )
786 return ToolSpec(
787 name=name,
788 help=_summary(root),
789 version=version,
790 verbs=tuple(parsed),
791 in_process=in_process,
792 )