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

1"""Read a tool's own `--help` and turn it into a `ToolSpec`. 

2 

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. 

9 

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: 

13 

14 clap --fix 

15 Apply fixes to resolve lint violations. Use `--no-fix` 

16 to disable 

17 

18 optparse -d DIR, --directory=DIR 

19 Write the output files to DIR. 

20 

21 cobra -f, --file stringArray Compose configuration files 

22 

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"). 

28 

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""" 

33 

34from __future__ import annotations 

35 

36import os 

37import re 

38import shutil 

39import subprocess 

40from collections.abc import Sequence 

41from dataclasses import replace 

42 

43from footman._toolspec import Option, ToolSpec, Verb 

44 

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?].*)$") 

51 

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") 

58 

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}))?") 

98 

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) 

122 

123 

124def _sections(text: str) -> dict[str, list[str]]: 

125 """Split help output into `{section title: lines}`. 

126 

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 

141 

142 

143def _blocks(lines: Sequence[str]) -> list[tuple[str, str]]: 

144 """Yield `(spellings, help)` for each option in *lines*. 

145 

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 

201 

202 

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). 

206 

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 

220 

221 

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") 

238 

239 

240def _clean(text: str) -> str: 

241 """The tool's prose, as one clean first sentence. 

242 

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) 

253 

254 

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 

262 

263 

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("\"'") 

269 

270 

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. 

275 

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 ) 

321 

322 

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"}) 

326 

327 

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 () 

337 

338 

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 

342 

343 

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()) 

347 

348 

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" 

359 

360 

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 

367 

368 

369def _pair_negations(options: list[Option]) -> list[Option]: 

370 """Fold `--no-x` entries into `x`, and drop them as options of their own. 

371 

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 

393 

394 

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 ) 

407 

408 

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. 

413 

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 ) 

457 

458 

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 

472 

473 

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"}) 

477 

478 

479def _wraps(text: str) -> bool: 

480 """Whether the verb forwards everything after its own args to a child. 

481 

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 

493 

494 

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_-]*>?$") 

498 

499 

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("-") 

505 

506 

507def _top_level_positionals(usage: str) -> list[str]: 

508 """The positional tokens at bracket depth 0. 

509 

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 

523 

524 

525def _usage_shape(text: str) -> tuple[str, str]: 

526 """`(positional, lead)` from a verb's `usage:` line. 

527 

528 Two confident answers, everything else `"any"`: 

529 

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. 

534 

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)) 

541 

542 

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() 

559 

560 

561def _synopsis_shape(text: str, verb: str) -> tuple[str, str]: 

562 """`(positional, lead)` from a man page's `SYNOPSIS`. 

563 

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) 

583 

584 

585def _usage_line(text: str) -> str: 

586 """The `usage:` line, minus the program name, joined if it wraps. 

587 

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 "" 

620 

621 

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 "" 

642 

643 

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 

659 

660 

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. 

665 

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=…)`). 

668 

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. 

671 

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 

692 

693 

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") 

697 

698 

699def _run_man(argv: list[str], timeout: float) -> str: 

700 """`<tool> help <verb>`, de-overstruck — the manual as plain text.""" 

701 

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) 

721 

722 

723def _wide_env() -> dict[str, str]: 

724 """A wide terminal, so help text wraps as little as possible. 

725 

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 """ 

730 

731 return {**os.environ, "COLUMNS": "200", "TERM": "dumb", "NO_COLOR": "1"} 

732 

733 

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. 

746 

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. 

751 

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 )