aboutsummaryrefslogtreecommitdiffstats
path: root/spec/scripts/gen_abnf.py
diff options
context:
space:
mode:
authorAhmed <git@gumx.cc>2026-06-14 01:49:48 +0300
committerAhmed <git@gumx.cc>2026-06-14 01:49:48 +0300
commit41f8d3025f041aaab98fcc3511cb6ec2198f76a7 (patch)
treec147d5a547c3e2d7b49546157f324cbd80e88c48 /spec/scripts/gen_abnf.py
init: vibed
Diffstat (limited to 'spec/scripts/gen_abnf.py')
-rw-r--r--spec/scripts/gen_abnf.py265
1 files changed, 265 insertions, 0 deletions
diff --git a/spec/scripts/gen_abnf.py b/spec/scripts/gen_abnf.py
new file mode 100644
index 0000000..9569195
--- /dev/null
+++ b/spec/scripts/gen_abnf.py
@@ -0,0 +1,265 @@
+#!/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]} <registry.json> <output.abnf>", 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()