bynk_grammar/lib.rs
1//! Render the `tree-sitter-bynk` grammar to EBNF.
2//!
3//! This crate is the single source of the grammar reference. It takes the
4//! compiled grammar JSON (`tree-sitter-bynk/src/grammar.json`) as input and is
5//! otherwise location-agnostic, so the same renderer feeds both the full
6//! appendix page ([`render_appendix`]) and the per-rule includes embedded in
7//! the curated reference page ([`render_production`] / [`render_rule`]). Because
8//! both come from one implementation, an embedded production cannot drift from
9//! the appendix.
10//!
11//! **Display names.** Grammar rule names are parser-internal (`_type_ref`,
12//! `_expression`, …). For the reference we render *readable* names via
13//! [`display_name`]: a trivial `_x ::= y` wrapper collapses to its target, an
14//! optional override applies, otherwise a single leading underscore is stripped.
15//! The transform is applied to both rule heads and the nonterminal references
16//! inside productions, so the whole reference reads as language, not internals.
17//!
18//! See `bynkc/tests/grammar_reference.rs` (the appendix generator) and
19//! `site/src/plugins/remark-bynk-directives.mjs` (the `{{#grammar <rule>}}`
20//! include directive the Book renders with).
21
22use std::error::Error;
23use std::fmt;
24
25use serde_json::{Map, Value};
26
27/// An error rendering a grammar production.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum GrammarError {
30 /// The grammar JSON could not be parsed.
31 Parse(String),
32 /// `name` is not a top-level rule in the grammar.
33 UnknownRule(String),
34}
35
36impl fmt::Display for GrammarError {
37 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38 match self {
39 GrammarError::Parse(e) => write!(f, "could not parse grammar JSON: {e}"),
40 GrammarError::UnknownRule(name) => {
41 write!(
42 f,
43 "unknown grammar rule `{name}` (not a top-level production)"
44 )
45 }
46 }
47 }
48}
49
50impl Error for GrammarError {}
51
52/// Display-name overrides for rules whose mechanical name reads badly. Each key
53/// must be a real top-level rule (checked in tests). Keep this tiny — most rules
54/// read fine after collapsing wrappers and stripping the leading underscore.
55const OVERRIDES: &[(&str, &str)] = &[];
56
57fn rules_of(grammar: &Value) -> Option<&Map<String, Value>> {
58 grammar.get("rules").and_then(Value::as_object)
59}
60
61/// If `name`'s body is a single `SYMBOL` (a trivial `_x ::= y` wrapper), return
62/// the target rule name. Such wrappers are collapsed: they never appear as their
63/// own production, and references to them render as the target's display name.
64fn trivial_wrapper_target<'a>(rules: &'a Map<String, Value>, name: &str) -> Option<&'a str> {
65 let body = rules.get(name)?;
66 if body.get("type").and_then(Value::as_str) == Some("SYMBOL") {
67 body.get("name").and_then(Value::as_str)
68 } else {
69 None
70 }
71}
72
73/// The readable display name of a grammar rule: collapse a trivial-wrapper chain
74/// to its target, apply any override, else strip a single leading underscore.
75fn display_name_in(rules: &Map<String, Value>, name: &str) -> String {
76 if let Some(target) = trivial_wrapper_target(rules, name) {
77 return display_name_in(rules, target);
78 }
79 if let Some((_, disp)) = OVERRIDES.iter().find(|(k, _)| *k == name) {
80 return (*disp).to_string();
81 }
82 name.strip_prefix('_').unwrap_or(name).to_string()
83}
84
85/// Render a grammar node to EBNF text plus a precedence level:
86/// 0 = choice (`a | b`), 1 = sequence (`a b`), 2 = atom / postfix (`x`, `(…)*`).
87/// Nonterminal (`SYMBOL`) references are rendered with their display name.
88fn render(rules: &Map<String, Value>, node: &Value) -> (String, u8) {
89 match node.get("type").and_then(Value::as_str).unwrap_or("") {
90 "SYMBOL" => (
91 display_name_in(rules, node["name"].as_str().unwrap_or("?")),
92 2,
93 ),
94 "STRING" => (format!("\"{}\"", node["value"].as_str().unwrap_or("")), 2),
95 "PATTERN" => (format!("/{}/", node["value"].as_str().unwrap_or("")), 2),
96 "BLANK" => ("ε".to_string(), 2),
97 // Wrappers that don't affect the surface grammar: render their content.
98 "PREC" | "PREC_LEFT" | "PREC_RIGHT" | "PREC_DYNAMIC" | "TOKEN" | "IMMEDIATE_TOKEN"
99 | "FIELD" | "ALIAS" => render(rules, &node["content"]),
100 "REPEAT" => (format!("{}*", wrap_atom(rules, &node["content"])), 2),
101 "REPEAT1" => (format!("{}+", wrap_atom(rules, &node["content"])), 2),
102 "SEQ" => {
103 let parts: Vec<String> = members(node).iter().map(|m| wrap(rules, m, 1)).collect();
104 (parts.join(" "), 1)
105 }
106 "CHOICE" => {
107 let all = members(node);
108 let has_blank = all
109 .iter()
110 .any(|m| m.get("type").and_then(Value::as_str) == Some("BLANK"));
111 let non_blank: Vec<&Value> = all
112 .iter()
113 .filter(|m| m.get("type").and_then(Value::as_str) != Some("BLANK"))
114 .collect();
115 if has_blank {
116 // An optional: `X?`.
117 if non_blank.len() == 1 {
118 (format!("{}?", wrap_atom(rules, non_blank[0])), 2)
119 } else {
120 let inner: Vec<String> = non_blank.iter().map(|m| render(rules, m).0).collect();
121 (format!("({})?", inner.join(" | ")), 2)
122 }
123 } else {
124 let inner: Vec<String> = non_blank.iter().map(|m| render(rules, m).0).collect();
125 (inner.join(" | "), 0)
126 }
127 }
128 other => (format!("/* {other} */"), 2),
129 }
130}
131
132fn members(node: &Value) -> Vec<Value> {
133 node["members"].as_array().cloned().unwrap_or_default()
134}
135
136/// Wrap so the result can be a postfix operand (`*`, `+`, `?`): needs an atom.
137fn wrap_atom(rules: &Map<String, Value>, node: &Value) -> String {
138 wrap(rules, node, 2)
139}
140
141/// Wrap `node`'s rendering in parens if its level is below `min`.
142fn wrap(rules: &Map<String, Value>, node: &Value, min: u8) -> String {
143 let (s, level) = render(rules, node);
144 if level < min { format!("({s})") } else { s }
145}
146
147fn render_extra(node: &Value) -> String {
148 match node.get("type").and_then(Value::as_str).unwrap_or("") {
149 "SYMBOL" => format!("`{}`", node["name"].as_str().unwrap_or("?")),
150 "PATTERN" => format!("`/{}/`", node["value"].as_str().unwrap_or("")),
151 "STRING" => format!("`\"{}\"`", node["value"].as_str().unwrap_or("")),
152 _ => "?".to_string(),
153 }
154}
155
156/// Render the complete grammar reference appendix
157/// (`site/src/content/docs/book/reference/grammar-appendix.md`): the
158/// generated-file header, the
159/// notation note, the full `ebnf` block of every production (display names,
160/// trivial wrappers collapsed), and the Tokens & trivia section.
161pub fn render_appendix(grammar_json: &str) -> String {
162 let grammar: Value = serde_json::from_str(grammar_json).expect("grammar.json parses");
163
164 let mut out = String::new();
165 out.push_str("# Complete grammar (appendix)\n\n");
166 out.push_str(
167 "<!-- GENERATED FILE — do not edit by hand.\n \
168 Source: tree-sitter-bynk/src/grammar.json, via bynkc/tests/grammar_reference.rs.\n \
169 Regenerate with: BYNK_BLESS=1 cargo test -p bynkc --test it grammar_reference:: -->\n\n",
170 );
171 out.push_str(
172 "The complete Bynk grammar, generated from the `tree-sitter-bynk` grammar. \
173 For the annotated, per-construct reference see [Syntax & grammar](grammar.md).\n\n",
174 );
175 out.push_str("**Notation.** ");
176 out.push_str(
177 "`\"x\"` a literal token · `/x/` a regular expression · `( … )?` optional · \
178 `( … )*` zero or more · `( … )+` one or more · `a | b` choice · `ε` empty. \
179 Rule names are the readable display names (a leading `_` denotes an \
180 internal helper rule; trivial wrappers are collapsed). `doc_block` is an \
181 external token — a `--- … ---` documentation block.\n\n",
182 );
183
184 out.push_str("```ebnf\n");
185 if let Some(rules) = rules_of(&grammar) {
186 for (name, body) in rules {
187 // Trivial wrappers are collapsed into their target.
188 if trivial_wrapper_target(rules, name).is_some() {
189 continue;
190 }
191 let (rendered, _) = render(rules, body);
192 out.push_str(&format!(
193 "{} ::= {rendered}\n",
194 display_name_in(rules, name)
195 ));
196 }
197 }
198 out.push_str("```\n\n");
199
200 out.push_str("## Tokens & trivia\n\n");
201 if let Some(word) = grammar.get("word").and_then(Value::as_str) {
202 out.push_str(&format!("- **Word token:** `{word}`\n"));
203 }
204 if let Some(extras) = grammar.get("extras").and_then(Value::as_array) {
205 let rendered: Vec<String> = extras.iter().map(render_extra).collect();
206 out.push_str(&format!(
207 "- **Ignored between tokens:** {}\n",
208 rendered.join(", ")
209 ));
210 }
211 if let Some(externals) = grammar.get("externals").and_then(Value::as_array) {
212 let rendered: Vec<String> = externals.iter().map(render_extra).collect();
213 out.push_str(&format!("- **External tokens:** {}\n", rendered.join(", ")));
214 }
215
216 out
217}
218
219/// Look up a rule body by name, erroring if the grammar is unparseable or the
220/// rule is not a top-level production.
221fn rule_body<'a>(grammar: &'a Value, name: &str) -> Result<&'a Value, GrammarError> {
222 grammar
223 .get("rules")
224 .and_then(Value::as_object)
225 .and_then(|rules| rules.get(name))
226 .ok_or_else(|| GrammarError::UnknownRule(name.to_string()))
227}
228
229/// Render a single production's right-hand side (display names applied), exactly
230/// as it appears after `<name> ::= ` in the appendix's EBNF block.
231///
232/// Errors if the grammar JSON cannot be parsed, or if `name` is not a top-level
233/// rule of the grammar.
234pub fn render_rule(grammar_json: &str, name: &str) -> Result<String, GrammarError> {
235 let grammar: Value =
236 serde_json::from_str(grammar_json).map_err(|e| GrammarError::Parse(e.to_string()))?;
237 let rules = grammar
238 .get("rules")
239 .and_then(Value::as_object)
240 .ok_or_else(|| GrammarError::UnknownRule(name.to_string()))?;
241 let body = rule_body(&grammar, name)?;
242 Ok(render(rules, body).0)
243}
244
245/// Render a complete production line, `<display name> ::= <rhs>`, as it appears
246/// in the appendix (no surrounding fence). This is what `{{#grammar <rule>}}`
247/// embeds.
248pub fn render_production(grammar_json: &str, name: &str) -> Result<String, GrammarError> {
249 let grammar: Value =
250 serde_json::from_str(grammar_json).map_err(|e| GrammarError::Parse(e.to_string()))?;
251 let rules = grammar
252 .get("rules")
253 .and_then(Value::as_object)
254 .ok_or_else(|| GrammarError::UnknownRule(name.to_string()))?;
255 let body = rule_body(&grammar, name)?;
256 Ok(format!(
257 "{} ::= {}",
258 display_name_in(rules, name),
259 render(rules, body).0
260 ))
261}
262
263/// Every top-level rule that should have exactly one `{{#grammar}}` entry in the
264/// annotated reference: all rules **except** the trivial wrappers the display
265/// layer collapses (so this can never disagree with what is rendered). Grammar
266/// rule order is preserved. Returns an empty vector if the JSON is unparseable.
267pub fn embeddable_rules(grammar_json: &str) -> Vec<String> {
268 let Ok(grammar) = serde_json::from_str::<Value>(grammar_json) else {
269 return Vec::new();
270 };
271 let Some(rules) = rules_of(&grammar) else {
272 return Vec::new();
273 };
274 rules
275 .keys()
276 .filter(|name| trivial_wrapper_target(rules, name.as_str()).is_none())
277 .cloned()
278 .collect()
279}
280
281/// The readable display name for a top-level rule. Errors if the grammar is
282/// unparseable or `name` is not a top-level rule.
283pub fn display_name(grammar_json: &str, name: &str) -> Result<String, GrammarError> {
284 let grammar: Value =
285 serde_json::from_str(grammar_json).map_err(|e| GrammarError::Parse(e.to_string()))?;
286 let rules = grammar
287 .get("rules")
288 .and_then(Value::as_object)
289 .ok_or_else(|| GrammarError::UnknownRule(name.to_string()))?;
290 if !rules.contains_key(name) {
291 return Err(GrammarError::UnknownRule(name.to_string()));
292 }
293 Ok(display_name_in(rules, name))
294}
295
296/// Render the grammar as the JSON document the documentation site consumes. The
297/// `{{#grammar}}` remark directive looks each rule up in `productions` (the same
298/// `<name> ::= <rhs>` line [`render_production`] embeds, over [`embeddable_rules`]),
299/// and the full-grammar page reads `appendix` ([`render_appendix`]). Because both
300/// come from the same renderer as the mdBook preprocessor, the site cannot drift
301/// from the book. Object keys preserve grammar order (serde_json `preserve_order`).
302///
303/// The committed artifact (`site/src/generated/grammar.json`) is drift-guarded by
304/// `bynk-grammar/tests/generated_grammar_json.rs`; regenerate with `BYNK_BLESS=1`.
305pub fn render_site_json(grammar_json: &str) -> String {
306 let mut productions = Map::new();
307 for rule in embeddable_rules(grammar_json) {
308 if let Ok(production) = render_production(grammar_json, &rule) {
309 productions.insert(rule, Value::String(production));
310 }
311 }
312 let mut doc = Map::new();
313 doc.insert(
314 "_generated".into(),
315 Value::String(
316 "GENERATED from tree-sitter-bynk/src/grammar.json. Do not edit by hand. \
317 Regenerate with: BYNK_BLESS=1 cargo test -p bynk-grammar --test generated_grammar_json"
318 .into(),
319 ),
320 );
321 doc.insert("productions".into(), Value::Object(productions));
322 doc.insert(
323 "appendix".into(),
324 Value::String(render_appendix(grammar_json)),
325 );
326 let mut out =
327 serde_json::to_string_pretty(&Value::Object(doc)).expect("serialise grammar JSON");
328 out.push('\n');
329 out
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335 use std::collections::HashMap;
336 use std::fs;
337 use std::path::PathBuf;
338
339 fn grammar_json() -> String {
340 let path =
341 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../tree-sitter-bynk/src/grammar.json");
342 fs::read_to_string(path).expect("read grammar.json")
343 }
344
345 fn rules(grammar: &Value) -> &Map<String, Value> {
346 grammar.get("rules").and_then(Value::as_object).unwrap()
347 }
348
349 #[test]
350 fn render_rule_uses_display_names() {
351 let g = grammar_json();
352 // `_pattern`/`_expression` render without their leading underscore.
353 // #472: a match arm's pattern is `pattern | refined_pattern` (the
354 // latter admitted only here, not through `_pattern` generally).
355 assert_eq!(
356 render_rule(&g, "match_arm").unwrap(),
357 "(pattern | refined_pattern) (\"if\" expression)? \"=>\" expression \",\"?"
358 );
359 // `http_method` is a plain choice, unchanged.
360 assert_eq!(
361 render_rule(&g, "http_method").unwrap(),
362 "\"GET\" | \"POST\" | \"PUT\" | \"PATCH\" | \"DELETE\""
363 );
364 // `http_handler` references `_type_ref` and `block` — display, not raw.
365 let http = render_rule(&g, "http_handler").unwrap();
366 assert!(http.contains("type_ref"), "{http}");
367 assert!(!http.contains("_type_ref"), "{http}");
368 assert!(http.contains("block"), "{http}");
369 }
370
371 #[test]
372 fn embeddable_rules_excludes_trivial_wrappers() {
373 let g = grammar_json();
374 let rules = embeddable_rules(&g);
375 // v0.17 added: adapter_decl, _adapter_body_item, binding_decl,
376 // binding_requirement. v0.20a added: function_type_ref, lambda_expr,
377 // lambda_param. v0.20b added: list_literal. v0.21 added:
378 // float_literal. v0.43 added: string_interpolation. v0.44 added:
379 // service_protocol. v0.45 added: actor_decl, scheme, by_clause. v0.51
380 // added: scheme_config, scheme_arg. v0.79 added: effect_send_stmt. v0.80
381 // added: invariant_decl. v0.81 added: store_field, store_kind,
382 // assign_stmt. v0.85 added: store_annotation, annotation_arg. v0.96
383 // removed: state_decl, commit_stmt (parity cutover, ADR 0123).
384 // v0.103 added: ws_open_handler, ws_close_handler (the `from websocket`
385 // lifecycle handlers; `on message` reuses queue_handler). v0.114 added:
386 // property_decl, for_all, for_all_binding (generative tests); mock_expr/
387 // mock_arg renamed to val_expr/val_arg (no count change). v0.115 added:
388 // requires_clause, ensures_clause (function contracts). v0.116 added:
389 // transition_decl (agent step invariants). v0.117 added: observation_expr,
390 // trace_expr (the observation surface; the call-count matcher is inlined).
391 // v0.118 (testing track slice 6) removed: mocks_decl, integration_decl,
392 // wires_decl, _integration_body_item (mocks/integration/wires retired) and
393 // added: provides_clause (the test-scope stub; the `as <tier>` clause and
394 // the stub right-hand side are inlined). Net -3. (#548 renamed
395 // provides_clause → stub_clause with the `stub` keyword — no count change.)
396 // v0.130 added:
397 // literal_pattern (literal match-arm patterns). v0.131 (ADR 0159) added:
398 // cors_policy, cors_field (the CORS service policy). Net +3. v0.141
399 // (ADR 0164) added: security_policy, security_field (the security-headers
400 // service policy). Net +2. v0.142 (ADR 0165) added: limits_policy,
401 // limits_field (the request-body-size service policy). Net +2. v0.145
402 // (ADR 0169) removed: positional_binding — a variant payload is now a
403 // full `_pattern` (recursion), so a positional binding is a payload-less
404 // `variant_pattern`, not a dedicated node. Net -1. v0.146 (ADR 0170)
405 // added: do_stmt (the `do e` effect statement). Net +1. v0.157 (ADR
406 // 0183) added: applied_type_ref (a user generic-type application
407 // `Name[Arg, …]`). Net +1.
408 // v0.184 (ADR 0205) added: call_site_actor (the test-body
409 // `by <Actor>(<identity>)` clause). Net +1.
410 // Slice C added: wire_expr (the `Wire(<String>)` raw system-tier
411 // argument). Net +1.
412 // #472 added: refined_pattern (`_ where <predicate>` match-arm
413 // patterns, admitted only at a match arm's top-level pattern). Net +1.
414 // #474 added: or_pattern (`p₁ | p₂`) and paren_pattern (transparent
415 // grouping around a pattern, e.g. after `is`). Net +2.
416 // message-bundles slice 1 (#859) added: messages_decl, message_entry
417 // (the `messages <tag> { "code" => "template" }` construct). Net +2.
418 // Events track slice 0/1 (spine #936) added: event_decl (`event Name =
419 // { fields }`), event_handler (`on event(...)`), event_pattern,
420 // event_pattern_field, event_pattern_value (the slice-1 subscription
421 // filter `from Events(E { field: value, .. })`; `service_protocol`
422 // itself was extended in place, no new rule for it). Net +5.
423 // Events track slice 4 (spine #936) added: schema_dispatch_clause
424 // (the `via schema(N)` envelope-version dispatch clause; nested
425 // inside `service_protocol`'s Events arm, extended in place). Net +1.
426 // #1706 added: faults_expr (the `expect <call> faults` fault claim).
427 // Net +1.
428 // #1888 added: doc_line (the `--|` one-line doc token, an extra).
429 // Net +1.
430 // #1883 (static assets, slice 1a) added: asset_decl. Net +1.
431 // #2010 added: let_else_stmt, let_else_match (`let <pattern> … else …`),
432 // and _let_pattern, _let_variant_pattern, _let_or_pattern (the patterns
433 // that may open one, so a bare name after `let` stays a binding). Net +5.
434 // #2137 added: advance_stmt (`advance Clock by <Duration>`). Net +1.
435 // #2134 added: env_clause, env_entry (a suite's or case's
436 // `env { NAME = "value" … }`). Net +2.
437 // #2203 removed: doc_line (the one-line doc is an external token now,
438 // scanned with the doc-block). Net -1.
439 // #2224 added: migrate_decl, migrate_drop (an agent's `migrate from N`
440 // step and its `drop` item). Net +2.
441 // #2248 added: removed_agent_decl (`removed agent Old`). Net +1.
442 // #2341 added: rejected_handler (`on rejected(why: …)`). Net +1.
443 // #2347 follow-up added: _contextual_identifier (`trace`/`advance` as
444 // ordinary identifiers outside their keyword shapes). Net +1.
445 // #2387 (embedded text, slice 1) added: embed_decl. Net +1.
446 assert_eq!(rules.len(), 167);
447 assert!(rules.iter().any(|r| r == "http_handler"));
448 assert!(rules.iter().any(|r| r == "_type_ref"));
449 // The two trivial wrappers the display layer collapses are excluded.
450 assert!(!rules.iter().any(|r| r == "_base_type"));
451 assert!(!rules.iter().any(|r| r == "pred_atom"));
452 // Unparseable JSON yields no rules rather than panicking.
453 assert!(embeddable_rules("not json").is_empty());
454 }
455
456 #[test]
457 fn render_production_includes_display_head() {
458 let g = grammar_json();
459 assert_eq!(
460 render_production(&g, "match_arm").unwrap(),
461 "match_arm ::= (pattern | refined_pattern) (\"if\" expression)? \"=>\" expression \",\"?"
462 );
463 }
464
465 #[test]
466 fn display_name_collapses_and_strips() {
467 let g = grammar_json();
468 // Trivial wrapper `_base_type ::= base_type` collapses to its target.
469 assert_eq!(display_name(&g, "_base_type").unwrap(), "base_type");
470 // Helper rules strip the leading underscore.
471 assert_eq!(display_name(&g, "_expression").unwrap(), "expression");
472 assert_eq!(display_name(&g, "_type_ref").unwrap(), "type_ref");
473 // An ordinary rule is unchanged.
474 assert_eq!(display_name(&g, "http_handler").unwrap(), "http_handler");
475 }
476
477 #[test]
478 fn render_rule_unknown_rule_errors() {
479 let g = grammar_json();
480 assert_eq!(
481 render_rule(&g, "no_such_rule"),
482 Err(GrammarError::UnknownRule("no_such_rule".to_string()))
483 );
484 }
485
486 #[test]
487 fn render_rule_invalid_json_errors() {
488 assert!(matches!(
489 render_rule("not json", "match_arm"),
490 Err(GrammarError::Parse(_))
491 ));
492 }
493
494 #[test]
495 fn override_keys_are_real_rules() {
496 let g = grammar_json();
497 for (key, _) in OVERRIDES {
498 assert!(
499 display_name(&g, key).is_ok(),
500 "override key `{key}` is not a top-level rule"
501 );
502 }
503 }
504
505 /// The display transform must not map two displayed productions to the same
506 /// name — that would make the reference ambiguous. Trivial wrappers are
507 /// collapsed and so excluded.
508 #[test]
509 fn display_names_are_unique() {
510 let g = grammar_json();
511 let grammar: Value = serde_json::from_str(&g).unwrap();
512 let rules = rules(&grammar);
513 let mut seen: HashMap<String, String> = HashMap::new();
514 for name in rules.keys() {
515 if trivial_wrapper_target(rules, name).is_some() {
516 continue;
517 }
518 let disp = display_name_in(rules, name);
519 if let Some(prev) = seen.insert(disp.clone(), name.clone()) {
520 panic!("display name `{disp}` for `{name}` collides with `{prev}`");
521 }
522 }
523 }
524
525 /// Pins the two renderers to one implementation: every displayed rule's
526 /// production line must appear verbatim in the appendix, and the appendix
527 /// has exactly one production per non-wrapper rule (wrappers are collapsed,
528 /// nothing is duplicated).
529 #[test]
530 fn every_displayed_rule_matches_the_appendix() {
531 let g = grammar_json();
532 let appendix = render_appendix(&g);
533 let grammar: Value = serde_json::from_str(&g).unwrap();
534 let rules = rules(&grammar);
535
536 let mut displayed = 0;
537 for name in rules.keys() {
538 if trivial_wrapper_target(rules, name).is_some() {
539 continue;
540 }
541 displayed += 1;
542 let line = render_production(&g, name).unwrap();
543 assert!(
544 appendix.contains(&line),
545 "production for `{name}` not found in appendix:\n{line}"
546 );
547 }
548
549 // One `::=` per displayed rule — collapsed wrappers added no lines and
550 // no production is duplicated. (No grammar token contains `::=`.)
551 assert_eq!(appendix.matches("::=").count(), displayed);
552 }
553}