use std::collections::HashMap;
use std::fmt::Write;
use super::parser::{Grammar, GretElement, GretType};
pub fn serialize(grammar: &Grammar) -> String {
let id_to_name: HashMap<u32, String> = grammar
.symbol_ids
.iter()
.map(|(name, id)| (*id, name.clone()))
.collect();
let mut out = String::with_capacity(256);
for (i, rule) in grammar.rules.iter().enumerate() {
let rule_id = i as u32;
if rule.is_empty() {
continue;
}
write_rule(&mut out, rule_id, rule, &id_to_name);
}
out
}
fn write_rule(
out: &mut String,
rule_id: u32,
rule: &[GretElement],
id_to_name: &HashMap<u32, String>,
) {
if rule.last().map(|e| e.ty) != Some(GretType::End) {
return;
}
let name = id_to_name
.get(&rule_id)
.map(|s| s.as_str())
.unwrap_or("<anonymous>");
let _ = write!(out, "{} ::= ", name);
let n = rule.len() - 1; let mut i = 0;
let mut alt_is_empty = true;
while i < n {
let elem = rule[i];
match elem.ty {
GretType::End => {
return;
}
GretType::Alt => {
if alt_is_empty {
let _ = write!(out, "\"\" ");
}
let _ = write!(out, "| ");
alt_is_empty = true;
}
GretType::RuleRef => {
let ref_name = id_to_name
.get(&elem.value)
.map(|s| s.as_str())
.unwrap_or("<unknown>");
let _ = write!(out, "{} ", ref_name);
alt_is_empty = false;
}
GretType::Char => {
let _ = write!(out, "[");
write_char(out, elem.value, true);
alt_is_empty = false;
}
GretType::CharNot => {
let _ = write!(out, "[^");
write_char(out, elem.value, true);
alt_is_empty = false;
}
GretType::CharRngUpper => {
let _ = write!(out, "-");
write_char(out, elem.value, true);
}
GretType::CharAlt => {
write_char(out, elem.value, true);
}
GretType::CharAny => {
let _ = write!(out, ".");
alt_is_empty = false;
}
}
if elem.ty.is_char_element() {
let next_ty = rule[i + 1].ty;
let inside_class_continues =
matches!(next_ty, GretType::CharAlt | GretType::CharRngUpper);
if !inside_class_continues {
if elem.ty != GretType::CharAny {
let _ = write!(out, "] ");
}
}
}
i += 1;
}
if alt_is_empty {
let _ = write!(out, "\"\" ");
}
out.push('\n');
}
fn write_char(out: &mut String, cp: u32, in_class: bool) {
match cp {
0x09 => out.push_str(r"\t"),
0x0A => out.push_str(r"\n"),
0x0D => out.push_str(r"\r"),
0x5C => out.push_str(r"\\"),
0x5B if in_class => out.push_str(r"\["),
0x5D if in_class => out.push_str(r"\]"),
0x2D if in_class => out.push_str(r"\x2D"),
0x22 => {
if in_class {
out.push('"');
} else {
out.push_str(r#"\""#);
}
}
0x5E if in_class => out.push_str(r"\x5E"),
0x20..=0x7E => out.push(cp as u8 as char),
0x00..=0x1F | 0x7F => {
let _ = write!(out, r"\x{:02X}", cp);
}
0x80..=0xFFFF => {
let _ = write!(out, r"\u{:04X}", cp);
}
_ => {
let _ = write!(out, r"\U{:08X}", cp);
}
}
}
pub fn rename_rules<F>(grammar: &Grammar, mut f: F) -> Grammar
where
F: FnMut(&str) -> String,
{
let mut new_symbol_ids: HashMap<String, u32> = HashMap::with_capacity(grammar.symbol_ids.len());
for (name, id) in &grammar.symbol_ids {
let new_name = f(name);
new_symbol_ids.insert(new_name, *id);
}
Grammar {
rules: grammar.rules.clone(),
symbol_ids: new_symbol_ids,
}
}
#[cfg(test)]
mod tests {
use super::super::parser::{parse, GretElement, GretType};
use super::*;
fn ast_eq(a: &Grammar, b: &Grammar) -> bool {
if a.rules.len() != b.rules.len() {
return false;
}
if a.symbol_ids.len() != b.symbol_ids.len() {
return false;
}
for name in a.symbol_ids.keys() {
if !b.symbol_ids.contains_key(name) {
return false;
}
}
let a_id_to_name: std::collections::HashMap<u32, &str> = a
.symbol_ids
.iter()
.map(|(n, id)| (*id, n.as_str()))
.collect();
let b_id_to_name: std::collections::HashMap<u32, &str> = b
.symbol_ids
.iter()
.map(|(n, id)| (*id, n.as_str()))
.collect();
for (name, &a_id) in &a.symbol_ids {
let b_id = b.symbol_ids[name];
let ra = &a.rules[a_id as usize];
let rb = &b.rules[b_id as usize];
if ra.len() != rb.len() {
return false;
}
for (ea, eb) in ra.iter().zip(rb.iter()) {
if ea.ty != eb.ty {
return false;
}
match ea.ty {
GretType::RuleRef => {
let na = a_id_to_name.get(&ea.value).copied().unwrap_or("?");
let nb = b_id_to_name.get(&eb.value).copied().unwrap_or("?");
if na != nb {
return false;
}
}
_ => {
if ea.value != eb.value {
return false;
}
}
}
}
}
true
}
fn roundtrip(src: &str) -> Grammar {
let g1 = parse(src).expect("first parse");
let serialized = serialize(&g1);
let g2 = parse(&serialized).unwrap_or_else(|e| {
panic!(
"round-trip serialize → parse failed: {}\nserialized text was:\n{}",
e, serialized
)
});
assert!(
ast_eq(&g1, &g2),
"AST identity broken on round-trip\noriginal:\n{:?}\nserialized:\n{}\nreparsed:\n{:?}",
g1,
serialized,
g2
);
g2
}
#[test]
fn round_trip_simple_literal() {
roundtrip("root ::= \"hello\"\n");
}
#[test]
fn parser_round_trip_simple_grammar() {
roundtrip("root ::= \"hi\"\n");
}
#[test]
fn parser_round_trip_grammar_with_negated_char_class() {
roundtrip("root ::= [^<\\\\]\n");
}
#[test]
fn parser_round_trip_grammar_with_quoted_literal_escapes() {
roundtrip("root ::= \"<|tool_call>\"\n");
}
#[test]
fn parser_round_trip_grammar_with_alternations_and_groups() {
roundtrip("root ::= a | b ( c d )\na ::= \"a\"\nb ::= \"b\"\nc ::= \"c\"\nd ::= \"d\"\n");
}
#[test]
fn round_trip_alternation() {
roundtrip("root ::= \"a\" | \"b\" | \"c\"\n");
}
#[test]
fn round_trip_char_class_range() {
roundtrip("root ::= [a-z]\n");
}
#[test]
fn round_trip_negated_char_class() {
roundtrip("root ::= [^<\\\\]\n");
}
#[test]
fn round_trip_negated_with_quote_and_backslash() {
roundtrip("root ::= [^\"\\\\]\n");
}
#[test]
fn round_trip_char_class_multi_alt() {
roundtrip("root ::= [abc]\n");
}
#[test]
fn round_trip_char_class_range_plus_alt() {
roundtrip("root ::= [a-zA-Z0-9]\n");
}
#[test]
fn round_trip_quoted_literal_with_escapes() {
roundtrip("root ::= \"a\\\\b\\\"c\\nd\\te\"\n");
}
#[test]
fn round_trip_rule_reference() {
roundtrip("root ::= ws \"x\" ws\nws ::= \" \"?\n");
}
#[test]
fn round_trip_repetition_star() {
roundtrip("root ::= \"a\"*\n");
}
#[test]
fn round_trip_repetition_plus() {
roundtrip("root ::= \"a\"+\n");
}
#[test]
fn round_trip_grouping() {
roundtrip("root ::= ( \"x\" \"y\" ) | \"z\"\n");
}
#[test]
fn round_trip_any_char_dot() {
roundtrip("root ::= .\n");
}
#[test]
fn round_trip_utf8_literal() {
roundtrip("root ::= \"α\"\n");
}
#[test]
fn round_trip_supplementary_plane() {
roundtrip("root ::= \"😀\"\n");
}
#[test]
fn round_trip_json_grammar_fixture() {
let src = std::fs::read_to_string("/opt/llama.cpp/grammars/json.gbnf")
.expect("json.gbnf fixture present");
roundtrip(&src);
}
#[test]
fn round_trip_arithmetic_grammar_fixture() {
let src = std::fs::read_to_string("/opt/llama.cpp/grammars/arithmetic.gbnf")
.expect("arithmetic.gbnf fixture present");
roundtrip(&src);
}
#[test]
fn round_trip_list_grammar_fixture() {
let src = std::fs::read_to_string("/opt/llama.cpp/grammars/list.gbnf")
.expect("list.gbnf fixture present");
roundtrip(&src);
}
#[test]
fn negated_char_class_semantics_preserved_after_round_trip() {
use super::super::parser::parse;
use super::super::sampler::GrammarRuntime;
let src = "root ::= [^<\\\\]+\n";
let g1 = parse(src).expect("first parse");
let serialized = serialize(&g1);
let g2 = parse(&serialized).expect("re-parse");
let rid = g2.rule_id("root").expect("root rule exists");
let mut rt = GrammarRuntime::new(g2, rid).expect("runtime init");
let alive = rt.accept_bytes(b"<");
assert!(
!alive,
"negated char class `[^<\\\\]` was corrupted during round-trip: \
a `<` byte was ACCEPTED, but the grammar should REJECT it"
);
}
#[test]
fn rename_rules_preserves_rule_bodies() {
let src = "root ::= ws \"x\"\nws ::= \" \"?\n";
let g = parse(src).expect("parse");
let renamed = rename_rules(&g, |n| format!("fn-7-{}", n));
assert_eq!(renamed.rules, g.rules);
assert!(renamed.symbol_ids.contains_key("fn-7-root"));
assert!(renamed.symbol_ids.contains_key("fn-7-ws"));
assert!(!renamed.symbol_ids.contains_key("root"));
assert!(!renamed.symbol_ids.contains_key("ws"));
assert_eq!(renamed.rule_id("fn-7-root"), g.rule_id("root"));
assert_eq!(renamed.rule_id("fn-7-ws"), g.rule_id("ws"));
}
#[test]
fn rename_rules_round_trip_serialize_parse() {
let src = "root ::= ws \"x\"\nws ::= \" \"?\n";
let g = parse(src).expect("parse");
let renamed = rename_rules(&g, |n| format!("fn-3-{}", n));
let text = serialize(&renamed);
let reparsed = parse(&text).expect("re-parse renamed");
assert!(reparsed.symbol_ids.contains_key("fn-3-root"));
assert!(reparsed.symbol_ids.contains_key("fn-3-ws"));
for name in renamed.symbol_ids.keys() {
let rid_a = renamed.rule_id(name).expect("renamed has name");
let rid_b = reparsed.rule_id(name).expect("reparsed has name");
assert_eq!(
renamed.rules[rid_a as usize], reparsed.rules[rid_b as usize],
"rule body for {} differs after round trip",
name
);
}
}
#[test]
fn empty_grammar_serializes_to_empty_string() {
let g = Grammar {
rules: Vec::new(),
symbol_ids: HashMap::new(),
};
assert_eq!(serialize(&g), "");
}
#[test]
fn write_char_emits_hex_for_control() {
let mut s = String::new();
write_char(&mut s, 0x07, true); assert_eq!(s, r"\x07");
}
#[test]
fn write_char_emits_unicode_escape_for_bmp() {
let mut s = String::new();
write_char(&mut s, 0x03B1, true); assert_eq!(s, r"\u03B1");
}
#[test]
fn write_char_emits_long_unicode_for_supplementary() {
let mut s = String::new();
write_char(&mut s, 0x1F600, true); assert_eq!(s, r"\U0001F600");
}
#[test]
fn write_char_escapes_backslash() {
let mut s = String::new();
write_char(&mut s, 0x5C, true);
assert_eq!(s, r"\\");
}
#[test]
fn write_char_escapes_close_bracket_in_class() {
let mut s = String::new();
write_char(&mut s, 0x5D, true);
assert_eq!(s, r"\]");
}
#[test]
fn round_trip_zero_or_one_repetition() {
roundtrip("root ::= \"a\"?\n");
}
#[test]
fn round_trip_brace_min_max_repetition() {
roundtrip("root ::= \"a\"{0,2}\n");
}
#[test]
fn round_trip_gemma4_str_char_rule() {
let src = "gemma4-str-char ::= [^<\\\\] | [\\\\] [^\\x00-\\x1F]\n\
root ::= gemma4-str-char\n";
let g1 = parse(src).expect("first parse");
let serialized = serialize(&g1);
let g2 = parse(&serialized).expect("re-parse");
let id1 = g1.rule_id("gemma4-str-char").expect("rule exists");
let id2 = g2
.rule_id("gemma4-str-char")
.expect("rule exists post-roundtrip");
assert_eq!(
g1.rules[id1 as usize], g2.rules[id2 as usize],
"gemma4-str-char rule body differs after round trip"
);
}
#[test]
fn round_trip_negated_class_with_range() {
roundtrip("root ::= [^\\x00-\\x1F]\n");
}
#[test]
fn round_trip_adjacent_char_classes() {
roundtrip("root ::= [a-z] \"x\" [A-Z]\n");
}
#[test]
fn round_trip_class_with_escaped_close_bracket() {
let src = "root ::= [\\]a]\n";
let g1 = parse(src).expect("first parse");
let serialized = serialize(&g1);
let g2 = parse(&serialized).expect("re-parse");
assert!(
ast_eq(&g1, &g2),
"escaped close-bracket in class did not round-trip:\n serialized: {}",
serialized
);
}
#[test]
fn parser_round_trip_char_any_after_quoted_literal() {
roundtrip("root ::= \"a\" .\n");
}
#[test]
fn parser_round_trip_char_any_after_class() {
roundtrip("root ::= [a] .\n");
}
#[test]
fn parser_round_trip_char_any_after_negated_class() {
roundtrip("root ::= [^a] .\n");
}
#[allow(dead_code)]
fn _silence_unused_import_warnings() {
let _ = GretElement::new(GretType::End, 0);
}
}