#!/usr/bin/env python3 """Regenerate the file and symbol index at the end of docs/PROJECT_MAP.md. python3 tools/map.py rewrite the index (everything after the INDEX marker) python3 tools/map.py --check exit 1 if the index is out of date (nothing is written) The index lists every source file in build order with its line count, its header comment, and the top-level functions and variables it declares, so a name can be found without searching. Run it after adding, moving or renaming files or top-level functions. The hand-written part of the map above the marker is kept as it is. """ import json, pathlib, re, sys ROOT = pathlib.Path(__file__).resolve().parent.parent MAP = ROOT / 'docs' / 'PROJECT_MAP.md' MARK = '' def header(text): """The file's leading comment (// lines or one /* block */), as one line.""" out, block = [], False for line in text.split('\n'): s = line.strip() if block: done = s.endswith('*/') s = s.rstrip('/').rstrip('*').strip(' =') if s: out.append(s) if done: break continue if s in ("'use strict';", '') and not out: continue if s.startswith('/*'): block = not s.endswith('*/') s = s.lstrip('/').lstrip('*').rstrip('/').rstrip('*').strip(' =') if s: out.append(s) if not block: break elif s.startswith('//') and not s.startswith('// ----'): out.append(s[2:].strip()) else: break return re.sub(r'\s+', ' ', ' '.join(out)).strip() def symbols(text): funcs = re.findall(r'^(?:async\s+)?function\s+([A-Za-z0-9_$]+)', text, re.M) names = [] for m in re.finditer(r'^(?:const|let|var)\s+(.+)$', text, re.M): decl = m.group(1) # names at the top level of the declaration list (skip what is inside brackets) depth, cur, parts = 0, '', [] for ch in decl: if ch in '([{': depth += 1 elif ch in ')]}': depth -= 1 if ch == ',' and depth == 0: parts.append(cur); cur = '' else: cur += ch if ch == ';' and depth == 0: break parts.append(cur) for p in parts: n = re.match(r'\s*([A-Za-z_$][A-Za-z0-9_$]*)', p) if n and n.group(1) not in names: names.append(n.group(1)) return funcs, names def index(): man = json.loads((ROOT / 'src' / 'manifest.json').read_text()) lines = [MARK, '', '## Index of source files (build order)', ''] for f in man['sim'] + man['client']: text = (ROOT / 'src' / f).read_text() funcs, names = symbols(text) lines.append(f'### src/{f} ({text.count(chr(10))} lines)') h = header(text) if h: lines.append(h) if funcs: lines.append('- functions: ' + ', '.join(funcs)) if names: lines.append('- variables: ' + ', '.join(names)) lines.append('') lines += ['## Tests and tools', ''] for d in ['test', 'tools', 'tools/experiments', 'tools/scenes']: for p in sorted((ROOT / d).glob('*')): if p.suffix not in ('.js', '.py') : continue text = p.read_text() m = re.search(r'"""(.+?)(?:\n\n|""")', text, re.S) if p.suffix == '.py' else None h = re.sub(r'\s+', ' ', m.group(1)).strip() if m else header(text) lines.append(f'- `{d}/{p.name}`: {h}') return '\n'.join(lines).rstrip() + '\n' def main(): cur = MAP.read_text() if MAP.exists() else '# Project map\n\n' head = cur.split(MARK)[0].rstrip() + '\n\n' new = head + index() if '--check' in sys.argv: ok = new == cur print('index is up to date' if ok else 'index is out of date: run python3 tools/map.py') sys.exit(0 if ok else 1) MAP.parent.mkdir(exist_ok=True) MAP.write_text(new) print(f'wrote {MAP.relative_to(ROOT)}') if __name__ == '__main__': main()