mirror of
https://github.com/shadowsocks/shadowsocks-libev.git
synced 2026-09-26 10:53:58 +00:00
152 lines
6.6 KiB
Python
152 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Check native Doxygen CLI snippets against source getopt declarations.
|
|
|
|
This is validation only. Doxygen reads source snippets and renders the manuals;
|
|
no generated documentation or intermediate markup is written by this script.
|
|
"""
|
|
|
|
import argparse
|
|
from pathlib import Path
|
|
import re
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
PROGRAMS = ("local", "server", "tunnel", "redir", "manager", "nat")
|
|
C_COMMENT = re.compile(r'/\*.*?\*/|//[^\n]*', re.S)
|
|
|
|
|
|
def short_options(spec):
|
|
result = {}
|
|
spec = spec.lstrip(':+-')
|
|
while spec:
|
|
match = re.match(r'([A-Za-z0-9])(:?)', spec)
|
|
if not match:
|
|
raise ValueError(f"Unsupported getopt string: {spec}")
|
|
flag, argument = match.groups()
|
|
if '-' + flag in result:
|
|
raise ValueError(f"Duplicate short option: {flag}")
|
|
result['-' + flag] = bool(argument)
|
|
spec = spec[match.end():]
|
|
return result
|
|
|
|
|
|
def options(source, shell=False):
|
|
result = {}
|
|
if shell:
|
|
specs = re.findall(r'^while getopts "([^"]+)" \w+; do$', source, re.M)
|
|
if len(specs) != 1:
|
|
raise ValueError("Expected one literal shell getopts declaration")
|
|
else:
|
|
source = C_COMMENT.sub('', source)
|
|
specs = re.findall(r'getopt_long\(argc,\s*argv,\s*"([^"]+)"\s*,\s*long_options,\s*NULL\)', source)
|
|
if not specs or len(specs) != len(re.findall(r'\bgetopt_long\s*\(', source)):
|
|
raise ValueError("Expected literal getopt_long declarations")
|
|
for spec in specs:
|
|
for flag, argument in short_options(spec).items():
|
|
if flag in result and result[flag] != argument:
|
|
raise ValueError(f"Inconsistent argument across platform variants: {flag}")
|
|
result[flag] = argument
|
|
if shell:
|
|
return result
|
|
tables = re.findall(r'static struct option long_options\[\]\s*=\s*\{(.*?)\};', source, re.S)
|
|
if len(tables) != 1:
|
|
raise ValueError("Expected one long_options table")
|
|
table = re.sub(r'^\s*#.*$', '', tables[0], flags=re.M)
|
|
entry = re.compile(r'\{\s*"([a-z0-9-]+)"\s*,\s*(no_argument|required_argument)\s*,\s*NULL\s*,\s*(?:GETOPT_VAL_[A-Z0-9_]+|\x27[A-Za-z0-9]\x27)\s*\}\s*,', re.S)
|
|
for match in entry.finditer(table):
|
|
flag, argument = match.groups()
|
|
if '--' + flag in result:
|
|
raise ValueError(f"Duplicate long option: {flag}")
|
|
result['--' + flag] = argument == 'required_argument'
|
|
remainder = entry.sub('', table)
|
|
if not re.fullmatch(r'\s*\{\s*NULL\s*,\s*0\s*,\s*NULL\s*,\s*0\s*\}\s*', remainder):
|
|
raise ValueError(f"Unsupported long_options entry: {remainder.strip()}")
|
|
return result
|
|
|
|
|
|
|
|
def snippets(source):
|
|
result = {}
|
|
for match in re.finditer(r'(?:/\*|//) \[([\w-]+)\]\n(.*?)\n\[\1\](?: \*/)?', source, re.S):
|
|
name, body = match.groups()
|
|
if name in result:
|
|
raise ValueError(f"Duplicate snippet: {name}")
|
|
result[name] = body
|
|
return result
|
|
|
|
|
|
def documented_options(source_name, sources):
|
|
inventory = snippets(sources[source_name]).get('cli-options')
|
|
if not inventory:
|
|
raise ValueError(f"{source_name}: missing cli-options snippet")
|
|
result = {}
|
|
for line in inventory.splitlines():
|
|
match = re.fullmatch(r'\\snippet\{doc\} ([\w.-]+) ([\w-]+)', line)
|
|
if not match:
|
|
raise ValueError(f"Invalid option snippet inclusion: {line}")
|
|
filename, identifier = match.groups()
|
|
body = snippets(sources.get(filename, '')).get(identifier)
|
|
if not body:
|
|
raise ValueError(f"Missing snippet: {filename} {identifier}")
|
|
heading, _, description = body.partition('\n')
|
|
term = re.fullmatch(r'\\par `(-{1,2}[A-Za-z0-9][A-Za-z0-9-]*)(?: (<[^>]+>))?`', heading)
|
|
if not term or not description.strip():
|
|
raise ValueError(f"Invalid option documentation: {filename} {identifier}")
|
|
flag, argument = term.groups()
|
|
if flag in result:
|
|
raise ValueError(f"Duplicate documented option: {flag}")
|
|
result[flag] = bool(argument)
|
|
return result
|
|
|
|
|
|
def check(root=ROOT, rendered=None):
|
|
sources = {path.name: path.read_text(encoding='utf-8')
|
|
for path in (root / 'src').glob('*.c')}
|
|
sources['ss-nat'] = (root / 'src/ss-nat').read_text(encoding='utf-8')
|
|
for module in PROGRAMS:
|
|
program = 'ss-' + module
|
|
filename = 'ss-nat' if module == 'nat' else module + '.c'
|
|
declared = options(sources[filename], shell=module == 'nat')
|
|
documented = documented_options(filename, sources)
|
|
missing = declared.keys() - documented.keys()
|
|
extra = documented.keys() - declared.keys()
|
|
if missing or extra:
|
|
raise ValueError(f"{program}: missing documentation {sorted(missing)}; stale documentation {sorted(extra)}")
|
|
for flag in declared:
|
|
if declared[flag] != documented[flag]:
|
|
raise ValueError(f"{program}: argument mismatch for {flag}")
|
|
page = (root / f'doc/{program}.md').read_text(encoding='utf-8')
|
|
if page.count(f'\\snippet{{doc}} {filename} cli-options') != 1:
|
|
raise ValueError(f"{program}: manual must include its source cli-options exactly once")
|
|
if rendered:
|
|
man = (rendered / 'man' / (program + '.1')).read_text(encoding='utf-8')
|
|
man = re.sub(r'\\f[A-Z]', '', man).replace('\\-', '-')
|
|
for flag in declared:
|
|
if not re.search(r'(?<![\w-])' + re.escape(flag) + r'(?![\w-])', man):
|
|
raise ValueError(f"{program}: missing rendered flag {flag}")
|
|
html = (rendered / 'html' / (program + '.html')).read_text(encoding='utf-8')
|
|
if 'OPTIONS' not in html or 'SYNOPSIS' not in html:
|
|
raise ValueError(f"{program}: incomplete HTML output")
|
|
if rendered:
|
|
man = (rendered / 'man/shadowsocks-c.8').read_text(encoding='utf-8')
|
|
if not re.search(r'^\.TH .* 8 ', man, re.M):
|
|
raise ValueError('Overview manual must use section 8')
|
|
alias = (rendered / 'man/shadowsocks-libev.8').read_text(encoding='utf-8')
|
|
if alias != '.so man8/shadowsocks-c.8\n':
|
|
raise ValueError('Missing legacy manual alias')
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--root', type=Path, default=ROOT)
|
|
parser.add_argument('--rendered', type=Path, help='also check a Doxygen build directory')
|
|
args = parser.parse_args()
|
|
try:
|
|
check(args.root, args.rendered)
|
|
except (ValueError, OSError) as error:
|
|
parser.exit(1, f'{error}\n')
|
|
print('Doxygen CLI documentation matches the source parsers.')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|