aboutsummaryrefslogtreecommitdiffstats
path: root/spec/scripts/gen_ebnf.py
diff options
context:
space:
mode:
Diffstat (limited to 'spec/scripts/gen_ebnf.py')
-rw-r--r--spec/scripts/gen_ebnf.py215
1 files changed, 215 insertions, 0 deletions
diff --git a/spec/scripts/gen_ebnf.py b/spec/scripts/gen_ebnf.py
new file mode 100644
index 0000000..65824bd
--- /dev/null
+++ b/spec/scripts/gen_ebnf.py
@@ -0,0 +1,215 @@
+#!/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]} <registry.json> <output.ebnf>", 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()