#!/usr/bin/env python3 """Generate UGI_GRAMMAR_v0.abnf from ugi_registry_v0.json.""" import json import os import sys def load_registry(path): with open(path, "r") as f: return json.load(f) def hex_char(c): """Return ABNF hex notation for a character.""" return f"%x{ord(c):02X}" def hex_range(start, end): """Return ABNF hex range.""" return f"%x{ord(start):02X}-{ord(end):02X}" def collect_sub_ids(f): """Collect all sub-ID codes from a field.""" ids = [] if "sub_ids" in f: ids.extend(f["sub_ids"].keys()) if "sub_id_groups" in f: for group in f["sub_id_groups"].values(): if "sub_ids" in group: ids.extend(group["sub_ids"].keys()) return ids def gen_sub_id_rule(name, sub_ids): """Generate ABNF rule matching any of the given sub-ID strings.""" # Quote each as case-insensitive string parts = [] for sid in sorted(set(sub_ids)): # ABNF quoted strings are case-insensitive by default parts.append(f'"{sid}"') return f"{name} = " + " / ".join(parts) def main(): if len(sys.argv) != 3: print(f"Usage: {sys.argv[0]} ", file=sys.stderr) sys.exit(1) registry_path, output_path = sys.argv[1], sys.argv[2] reg = load_registry(registry_path) fields = reg["fields"] lines = [] lines.append("; UGI Grammar v0: RFC 5234 ABNF") lines.append("; Generated from ugi_registry_v0.json") lines.append(";") lines.append("; Compatible with abnf.dev/abnf2svg") lines.append("") # ── Top-level ── lines.append("; === Top-level ===") lines.append("") lines.append('ugi-string = "ugi:" version "@" handle ":" field-list') lines.append("") lines.append('version = 1*DIGIT ["/" 1*DIGIT]') lines.append("") lines.append("; handle: alphanumeric, hyphens, underscores, dots") lines.append("handle = 1*(ALPHA / DIGIT / %x2D / %x5F / %x2E)") lines.append("") lines.append('field-list = field *("," field)') lines.append("") # ── Field dispatch ── lines.append("; === Field dispatch ===") lines.append("") # Build field alternatives field_rules = [] for code in sorted(fields.keys()): field_rules.append(f"field-{code}") lines.append("field = " + " / ".join(field_rules)) lines.append("") # ── Common rules ── lines.append("; === Common rules ===") lines.append("") lines.append("ODIGIT = %x30-37") lines.append("") lines.append('paid = "$"') lines.append("") lines.append('aspire = "+" ODIGIT') lines.append("") lines.append("paid-aspire = paid [aspire]") lines.append("") lines.append("modifier = paid-aspire / aspire") lines.append("") lines.append('alternative = "/" ODIGIT') lines.append("") lines.append('; custom sub-ID: ~ followed by 2+ alpha chars') lines.append('custom-sub = "~" 2*ALPHA') lines.append("") lines.append("; sub-ID: 1-4 alpha chars") lines.append("sub-id = 1*4ALPHA") lines.append("") # ── Per-field rules ── lines.append("; === Per-field rules ===") lines.append("") for code in sorted(fields.keys()): f = fields[code] ftype = f["type"] mods = f.get("modifiers", {}) has_paid = mods.get("paid", False) has_aspire = mods.get("aspire", False) has_alt = mods.get("alternative", False) hex_code = hex_char(code) lines.append(f"; {code}: {f['name']} ({ftype})") if ftype == "direct": # Build modifier part mod_parts = [] if has_alt: mod_parts.append("alternative") if has_paid and has_aspire: mod_parts.append("modifier") elif has_paid: mod_parts.append("paid") elif has_aspire: mod_parts.append("aspire") if mod_parts: mod_str = " [" + " / ".join(mod_parts) + "]" else: mod_str = "" lines.append(f"field-{code} = {hex_code} ODIGIT{mod_str}") elif ftype == "multi": # Entry: sub-id + digit + optional modifier mod_parts = [] if has_paid and has_aspire: mod_parts.append("modifier") elif has_paid: mod_parts.append("paid") elif has_aspire: mod_parts.append("aspire") if mod_parts: mod_str = " [" + " / ".join(mod_parts) + "]" else: mod_str = "" entry_rule = f"field-{code}-entry" if code == "l": # l uses 2-char ISO 639-1 codes instead of generic sub-ids lines.append( f"{entry_rule} = 2ALPHA ODIGIT{mod_str}" ) else: lines.append( f"{entry_rule} = (sub-id / custom-sub) ODIGIT{mod_str}" ) lines.append( f"field-{code} = {hex_code} 1*{entry_rule}" ) elif ftype == "single": mod_parts = [] if has_paid and has_aspire: mod_parts.append("modifier") elif has_paid: mod_parts.append("paid") elif has_aspire: mod_parts.append("aspire") if mod_parts: mod_str = " [" + " / ".join(mod_parts) + "]" else: mod_str = "" lines.append( f"field-{code} = {hex_code} (sub-id / custom-sub) ODIGIT{mod_str}" ) elif ftype == "special": if code == "g": # g: domains separated by /, $ after domain, ~ for custom, no digits lines.append(f'g-domain = 2*4ALPHA ["$"]') lines.append(f'g-custom = "~" 2*ALPHA') lines.append( f'field-g = {hex_code} (g-domain / g-custom) *("/" (g-domain / g-custom))' ) elif code == "b": # b: digit/digit, optional aspire on each aspire_str = " [aspire]" if has_aspire else "" lines.append( f'field-{code} = {hex_code} ODIGIT{aspire_str} "/" ODIGIT{aspire_str}' ) elif code == "v": lines.append( f'field-{code} = {hex_code} ODIGIT "/" ODIGIT' ) else: # Fallback lines.append(f"field-{code} = {hex_code} 1*(ALPHA / DIGIT / %x24 / %x2B / %x2F / %x7E)") lines.append("") # ── Block format ── lines.append("; === Block format ===") lines.append("") lines.append( 'ugi-block = block-header CRLF block-first-line CRLF ' '1*(block-field-line CRLF) block-footer' ) lines.append("") lines.append('block-header = "------- BEGIN UGI BLOCK -------"') lines.append('block-footer = "-------- END UGI BLOCK --------"') lines.append("") lines.append( 'block-first-line = "v:" version SP "@" handle [SP block-geek-field]' ) lines.append("") lines.append('; In block format, G field uses uppercase and appears on first line') lines.append( 'block-geek-field = "G" (g-domain / g-custom) *("/" (g-domain / g-custom))' ) lines.append("") lines.append( 'block-field-line = block-field *(SP block-field)' ) lines.append("") lines.append( "; Block fields use uppercase code prefix repeated per sub-ID" ) lines.append( 'block-field = ALPHA 1*(ALPHA / DIGIT / %x24 / %x2B / %x2F / %x7E)' ) lines.append("") # ── Core rules ── lines.append("; === Core rules (RFC 5234) ===") lines.append("") lines.append("ALPHA = %x41-5A / %x61-7A") lines.append("DIGIT = %x30-39") lines.append("SP = %x20") lines.append("CRLF = %x0D.0A / %x0A") lines.append("") output = "\n".join(lines) os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) with open(output_path, "w") as f: f.write(output) print(f"Generated {output_path}") if __name__ == "__main__": main()