#!/usr/bin/env python3 """Generate UGI_GRAMMAR_v0.ebnf 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 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: W3C EBNF */") lines.append("/* Generated from ugi_registry_v0.json */") lines.append("/* Compatible with bottlecaps.de/rr/ui */") lines.append("") # ── Top-level ── lines.append("/* === Top-level === */") lines.append("") lines.append("ugi_string ::= 'ugi:' version '@' handle ':' field_list") lines.append("") lines.append("version ::= digit+ ( '/' digit+ )?") lines.append("") lines.append("/* handle: alphanumeric, hyphens, underscores, dots */") lines.append("handle ::= ( alpha | digit | '-' | '_' | '.' )+") lines.append("") lines.append("field_list ::= field ( ',' field )*") lines.append("") # ── Field dispatch ── lines.append("/* === Field dispatch === */") lines.append("") field_alts = [f"field_{code}" for code in sorted(fields.keys())] # Break into multiple lines for readability lines.append("field ::= " + "\n | ".join(field_alts)) lines.append("") # ── Common rules ── lines.append("/* === Common rules === */") lines.append("") lines.append("odigit ::= [0-7]") 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 ::= '~' alpha alpha alpha*") lines.append("") lines.append("/* sub-ID: 1-4 alpha chars */") lines.append("sub_id ::= alpha alpha? alpha? alpha?") 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) lines.append(f"/* {code}: {f['name']} ({ftype}) */") if ftype == "direct": 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} ::= '{code}' odigit{mod_str}") elif ftype == "multi": 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 = "" if code == "l": # l uses 2-char ISO 639-1 codes instead of generic sub-ids lines.append( f"field_{code}_entry ::= alpha alpha odigit{mod_str}" ) else: lines.append( f"field_{code}_entry ::= ( sub_id | custom_sub ) odigit{mod_str}" ) lines.append( f"field_{code} ::= '{code}' field_{code}_entry+" ) 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} ::= '{code}' ( sub_id | custom_sub ) odigit{mod_str}" ) elif ftype == "special": if code == "g": lines.append("g_domain ::= alpha alpha alpha? alpha? paid?") lines.append("g_custom ::= '~' alpha alpha alpha*") lines.append( "field_g ::= 'g' ( g_domain | g_custom ) ( '/' ( g_domain | g_custom ) )*" ) elif code == "b": aspire_str = " aspire?" if has_aspire else "" lines.append( f"field_b ::= 'b' odigit{aspire_str} '/' odigit{aspire_str}" ) elif code == "v": lines.append("field_v ::= 'v' odigit '/' odigit") else: lines.append( f"field_{code} ::= '{code}' ( alpha | digit | '$' | '+' | '/' | '~' )+" ) lines.append("") # ── Block format ── lines.append("/* === Block format === */") lines.append("") lines.append( "ugi_block ::= block_header newline block_first_line newline " "block_field_line+ 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 ' @' handle ( ' ' block_geek_field )?" ) lines.append("") lines.append( "block_geek_field ::= 'G' ( g_domain | g_custom ) ( '/' ( g_domain | g_custom ) )*" ) lines.append("") lines.append("block_field_line ::= block_field ( ' ' block_field )* newline") lines.append("") lines.append( "block_field ::= alpha ( alpha | digit | '$' | '+' | '/' | '~' )+" ) lines.append("") # ── Terminals ── lines.append("/* === Terminals === */") lines.append("") lines.append("alpha ::= [a-zA-Z]") lines.append("digit ::= [0-9]") lines.append("newline ::= #xD #xA | #xA") 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()