#!/usr/bin/env python3 """Generate docs/driver/config.md from the runtime-config tables in rc.cc. The driver's recognized runtime config options live in two tables in src/base/internal/rc.cc: * INT_FLAGS[] -- integer options (name, default, min, max, category, prose) * STR_FLAGS[] -- simple string options (name, required, category, prose) Those tables are the single source of truth for both the parser AND this document, so the docs cannot drift from the driver as long as this script is re-run after editing them. Usage: python3 docs/gen_config_docs.py # regenerate docs/driver/config.md python3 docs/gen_config_docs.py --check # exit 1 if the doc is stale (CI) A handful of options with irregular parsing (the external ports, external commands, the global include file, and the default fail message) are not in the tables; they are documented from the hand-maintained SPECIAL_OPTIONS section below. When you add or change one of those in rc.cc, update SPECIAL_OPTIONS too. """ import argparse import os import sys HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) RC_CC = os.path.join(ROOT, "src", "base", "internal", "rc.cc") OPTIONS_H = os.path.join(ROOT, "src", "base", "internal", "options_internal.h") OUTPUT = os.path.join(HERE, "driver", "config.md") # Order in which categories are emitted. Categories found in the tables but not # listed here are appended at the end (with a warning) so nothing is dropped. CATEGORY_ORDER = [ "Identity & Network", "Directory Structure", "Core Files", "Logging", "Error Handling", "Timing & Lifecycle", "Limits", "Hash Tables", "Reset Behavior", "Language Behavior", "Type Checking", "Player I/O", "Diagnostics", "Performance", "Protocol Support", "Security", ] # Built-in integer constants the table defaults may reference. BUILTIN_CONSTS = { "INT_MAX": 2147483647, "UINT8_MAX": 255, } # --------------------------------------------------------------------------- # # Minimal C++ initializer parsing # --------------------------------------------------------------------------- # def _extract_array_body(text, decl): """Return the text between the outermost braces of ` = { ... }`.""" marker = decl + "[] = {" start = text.find(marker) if start < 0: raise SystemExit(f"could not find `{marker}` in {RC_CC}") i = start + len(marker) depth, in_str, esc = 1, False, False body = [] while i < len(text): ch = text[i] if in_str: body.append(ch) if esc: esc = False elif ch == "\\": esc = True elif ch == '"': in_str = False elif ch == '"': in_str = True body.append(ch) elif ch == "{": depth += 1 body.append(ch) elif ch == "}": depth -= 1 if depth == 0: return "".join(body) body.append(ch) else: body.append(ch) i += 1 raise SystemExit(f"unterminated `{decl}` table in {RC_CC}") def _split_top_level_braces(body): """Yield the inner text of each top-level `{ ... }` group in `body`.""" depth, in_str, esc, cur = 0, False, False, [] for ch in body: if in_str: cur.append(ch) if esc: esc = False elif ch == "\\": esc = True elif ch == '"': in_str = False continue if ch == '"': in_str = True cur.append(ch) continue if ch == "{": depth += 1 if depth == 1: cur = [] continue if ch == "}": depth -= 1 if depth == 0: yield "".join(cur) continue if depth >= 1: cur.append(ch) def _split_fields(entry): """Split one initializer's comma-separated fields (quote-aware).""" fields, cur, in_str, esc = [], [], False, False for ch in entry: if in_str: cur.append(ch) if esc: esc = False elif ch == "\\": esc = True elif ch == '"': in_str = False continue if ch == '"': in_str = True cur.append(ch) continue if ch == ",": fields.append("".join(cur).strip()) cur = [] continue cur.append(ch) tail = "".join(cur).strip() if tail: fields.append(tail) return fields def _unquote(field): field = field.strip() if field.startswith('"') and field.endswith('"'): field = field[1:-1] return field.replace('\\"', '"').replace("\\\\", "\\") # --------------------------------------------------------------------------- # # Default-value resolution # --------------------------------------------------------------------------- # def _load_cfg_consts(): consts = dict(BUILTIN_CONSTS) with open(OPTIONS_H, encoding="utf-8") as fh: for line in fh: parts = line.split() if len(parts) >= 3 and parts[0] == "#define" and parts[2].lstrip("-").isdigit(): consts[parts[1]] = int(parts[2]) return consts def _resolve_int(expr, consts): """Evaluate a simple integer expression (literals, shifts, named consts).""" expr = expr.strip() try: # Restricted: no builtins, names limited to resolved config constants. return int(eval(expr, {"__builtins__": {}}, consts)) # noqa: S307 except Exception: return None # --------------------------------------------------------------------------- # # Option model # --------------------------------------------------------------------------- # REQUIRED_LABELS = { "kMustHave": "required", "kOptional": "optional", "kWarnMissing": "recommended", } def parse_options(text, consts): options = [] for entry in _split_top_level_braces(_extract_array_body(text, "INT_FLAGS")): f = _split_fields(entry) if len(f) != 7: raise SystemExit(f"INT_FLAGS entry has {len(f)} fields, expected 7:\n {entry}") key, _pos, default, low, high, category, desc = f options.append({ "key": _unquote(key), "type": "int", "default": _resolve_int(default, consts), "default_src": default.strip(), "min": _resolve_int(low, consts), "max": _resolve_int(high, consts), "category": _unquote(category), "description": _unquote(desc), "required": None, }) for entry in _split_top_level_braces(_extract_array_body(text, "STR_FLAGS")): f = _split_fields(entry) if len(f) != 6: raise SystemExit(f"STR_FLAGS entry has {len(f)} fields, expected 6:\n {entry}") key, _pos, required, _tag, category, desc = f options.append({ "key": _unquote(key), "type": "string", "default": None, "default_src": None, "min": None, "max": None, "category": _unquote(category), "description": _unquote(desc), "required": REQUIRED_LABELS.get(required.strip(), required.strip()), }) return options # --------------------------------------------------------------------------- # # Rendering # --------------------------------------------------------------------------- # HEADER = """\ --- layout: doc title: driver / config --- # Driver Configuration File FluffOS reads a runtime configuration file at startup to configure the driver. The file is passed as the first argument to the `driver` executable. ```bash ./driver path/to/config.cfg ``` ## File Format - Lines beginning with `#` are comments; blank lines are ignored. - One setting per line, in the form `setting name : value`. - Most mudlib paths are relative to the mudlib directory; exceptions are noted per option (e.g. `mudlib directory` is an absolute OS path, and `log directory` is a filesystem path relative to the driver's working directory). - Integer options out of range are reset to their default with a warning. For extended commentary and example values, see the annotated `src/Config.example` in the source tree. The tables below are generated directly from the driver, so they always match the options it actually recognizes. """ FOOTER = """\ ## See Also - [driver](../cli/driver) - Driver command-line options - [get_config](../efun/internals/get_config) - Query a config value at runtime - [set_config](../efun/internals/set_config) - Modify a config value at runtime ## Reference Files - `src/base/internal/rc.cc` - The `INT_FLAGS[]` / `STR_FLAGS[]` tables (source of truth) - `src/Config.example` - Annotated example configuration - `src/include/runtime_config.h` - Config slot constants """ # Options parsed with irregular/dynamic handling in rc.cc, documented by hand. # Keep in sync with read_config() in src/base/internal/rc.cc. SPECIAL_OPTIONS = """\ ## Ports and Connections The listening ports are configured with numbered `external_port_N` entries (N = 1 to 5). Each names a protocol and a port number: ``` external_port_1 : telnet 4000 external_port_2 : binary 4001 external_port_3 : websocket 8080 ``` Recognized protocols are `telnet`, `binary`, `ascii`, `MUD`, and `websocket`. | Setting | Description | |---------|-------------| | `external_port_N` | Protocol and port for listener N, e.g. `telnet 4000`. | | `external_port_N_tls` | Enable TLS on listener N: `cert=path/to/cert.pem key=path/to/key.pem`. | | `websocket http dir` | Directory (under `src/www`) of static files served to web clients; required when a `websocket` port is defined. | | `port number` | Legacy single telnet port; equivalent to defining `external_port_1 : telnet `. | A `websocket` port requires `websocket http dir` to be set. With TLS, point `cert=`/`key=` at a PEM certificate and key, for example: ``` external_port_1 : telnet 4443 external_port_1_tls : cert=etc/cert.pem key=etc/key.pem ``` ## External Commands When the driver is built with `PACKAGE_EXTERNAL`, external programs callable via `external_start()` are declared with numbered entries: | Setting | Description | |---------|-------------| | `external_cmd_N` | Command line for external slot N (1-based). | ## Other Options | Setting | Description | |---------|-------------| | `global include file` | Header automatically `#include`d in every compiled object, e.g. `"/include/globals.h"` or ``. Quotes are added if omitted. | | `default fail message` | Message used when an action returns 0 and no `notify_fail()` was set. Defaults to `What?`. | ## Obsolete Options These settings are no longer used and should be removed. The driver prints a warning if any of these appear: `address server ip`, `address server port`, `reserved size`, `fd6 kind`, `fd6 port`, `binary directory`, `swap file`. These are accepted for backwards compatibility but silently ignored: `maximum users`, `compiler stack size`. """ def _range_note(opt): low, high = opt["min"], opt["max"] if low is None or high is None: return "" if low == 0 and high == BUILTIN_CONSTS["INT_MAX"]: return "" if high == BUILTIN_CONSTS["INT_MAX"]: return f" _(min {low})_" if low == 0: return f" _(max {high})_" return f" _(range {low}-{high})_" def _description_cell(opt): desc = opt["description"] if opt["type"] == "string" and opt["required"] in ("required", "recommended"): desc = f"**{opt['required'].capitalize()}.** {desc}" desc += _range_note(opt) return desc.replace("|", "\\|") def _default_cell(opt): if opt["type"] != "int": return "—" if opt["default"] is None: return f"`{opt['default_src']}`" return str(opt["default"]) def render(options): by_category = {} for opt in options: by_category.setdefault(opt["category"], []).append(opt) ordered = [c for c in CATEGORY_ORDER if c in by_category] extra = [c for c in by_category if c not in CATEGORY_ORDER] if extra: print(f"warning: categories not in CATEGORY_ORDER: {', '.join(sorted(extra))}", file=sys.stderr) ordered += sorted(extra) parts = [HEADER, "## Options\n"] for category in ordered: parts.append(f"### {category}\n") parts.append("| Setting | Type | Default | Description |") parts.append("|---------|------|---------|-------------|") for opt in by_category[category]: parts.append( f"| `{opt['key']}` | {opt['type']} | {_default_cell(opt)} | {_description_cell(opt)} |" ) parts.append("") parts.append(SPECIAL_OPTIONS) parts.append(FOOTER) return "\n".join(parts).rstrip() + "\n" # --------------------------------------------------------------------------- # # Entry point # --------------------------------------------------------------------------- # def main(): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--check", action="store_true", help="exit non-zero if docs/driver/config.md is out of date") args = ap.parse_args() with open(RC_CC, encoding="utf-8") as fh: rc_text = fh.read() consts = _load_cfg_consts() options = parse_options(rc_text, consts) generated = render(options) if args.check: try: with open(OUTPUT, encoding="utf-8") as fh: current = fh.read() except FileNotFoundError: current = None if current != generated: print("docs/driver/config.md is out of date.", file=sys.stderr) print("Regenerate it with: python3 docs/gen_config_docs.py", file=sys.stderr) return 1 print("docs/driver/config.md is up to date.") return 0 with open(OUTPUT, "w", encoding="utf-8") as fh: fh.write(generated) print(f"wrote {os.path.relpath(OUTPUT, ROOT)} ({len(options)} table options)") return 0 if __name__ == "__main__": sys.exit(main())