import argparse
import os
import re
import subprocess
import sys
START = "<!-- forge-help:start (generated by .github/scripts/readme-cli.py --write; do not edit) -->"
END = "<!-- forge-help:end -->"
def run_help(forge, *args):
env = {k: v for k, v in os.environ.items() if k not in ("CLICOLOR_FORCE", "CLICOLOR")}
env["NO_COLOR"] = "1"
out = subprocess.run([forge, *args, "--help"], capture_output=True, text=True, check=True,
env=env).stdout
return "\n".join(line.rstrip() for line in out.splitlines()).strip("\n")
def help_block(forge):
return f"{START}\n```text\n$ forge --help\n{run_help(forge)}\n```\n{END}"
def commands(top_help):
names, inside = set(), False
for line in top_help.splitlines():
if line.strip() == "Commands:":
inside = True
continue
if inside:
m = re.match(r"^ ([a-z][a-z0-9-]*)\s", line + " ")
if m:
names.add(m.group(1))
elif line and not line.startswith(" "):
inside = False
return names
def flags(help_text):
return set(re.findall(r"(?<![\w-])(--[a-z0-9][a-z0-9-]*|-[a-zA-Z])\b", help_text))
def code_lines(readme):
open_fence, scanned = False, False
for n, line in enumerate(readme.splitlines(), 1):
fence = re.match(r"^\s*```(\w*)", line)
if fence:
open_fence = not open_fence
scanned = open_fence and fence.group(1) in ("", "bash", "sh", "shell", "console")
continue
if scanned:
yield n, line
def check_commands(readme, forge, top_help):
errors, cmds, global_flags, cache = [], commands(top_help), flags(top_help), {}
for n, line in code_lines(readme):
text = re.sub(r"^\s*\$\s*", "", line.split("#", 1)[0]).strip()
for words in invocations(text):
errors += check_invocation(n, words, forge, cmds, global_flags, cache)
return errors
def invocations(text):
for segment in re.split(r"\s*(?:&&|\|\||;|\|)\s*", text):
words = segment.split()
for i, w in enumerate(words):
if w == "forge" and (i == 0 or words[i - 1] == "--") and len(words) > i + 1:
yield words[i:]
break
def check_invocation(n, words, forge, cmds, global_flags, cache):
errors = []
cmd = words[1]
if cmd.startswith("-"):
return errors
if cmd not in cmds:
errors.append(f"README.md:{n}: `forge {cmd}` is not a forge command (forge --help)")
return errors
if cmd not in cache:
cache[cmd] = flags(run_help(forge, cmd))
for w in words[2:]:
flag = w.split("=", 1)[0]
if re.fullmatch(r"--?[A-Za-z0-9][A-Za-z0-9-]*", flag) and flag not in cache[cmd] | global_flags:
errors.append(f"README.md:{n}: `forge {cmd} {flag}` is not listed by `forge {cmd} --help`")
return errors
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--forge", required=True)
ap.add_argument("--readme", default="README.md")
ap.add_argument("--write", action="store_true")
a = ap.parse_args()
with open(a.readme, encoding="utf-8", newline="") as f:
readme = f.read()
pattern = re.compile(re.escape(START) + r".*?" + re.escape(END), re.S)
found = pattern.findall(readme)
want = help_block(a.forge)
errors = []
if len(found) != 1:
errors.append(f"{a.readme}: want exactly one forge-help block, found {len(found)}")
elif a.write:
readme = pattern.sub(lambda _: want, readme)
with open(a.readme, "w", encoding="utf-8", newline="") as f:
f.write(readme)
print(f"readme-cli: wrote the forge --help block in {a.readme}")
elif found[0] != want:
errors.append(f"{a.readme}: the forge-help block differs from `forge --help`; run "
".github/scripts/readme-cli.py --forge <forge> --write")
errors += check_commands(readme, a.forge, run_help(a.forge))
for e in errors:
print(f"::error::{e}")
print(f"readme-cli: {'FAIL' if errors else 'ok'} ({len(errors)} problem(s))")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())