Skip to main content

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 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        assert_eq!(rules.len(), 151);
427        assert!(rules.iter().any(|r| r == "http_handler"));
428        assert!(rules.iter().any(|r| r == "_type_ref"));
429        // The two trivial wrappers the display layer collapses are excluded.
430        assert!(!rules.iter().any(|r| r == "_base_type"));
431        assert!(!rules.iter().any(|r| r == "pred_atom"));
432        // Unparseable JSON yields no rules rather than panicking.
433        assert!(embeddable_rules("not json").is_empty());
434    }
435
436    #[test]
437    fn render_production_includes_display_head() {
438        let g = grammar_json();
439        assert_eq!(
440            render_production(&g, "match_arm").unwrap(),
441            "match_arm ::= (pattern | refined_pattern) (\"if\" expression)? \"=>\" expression \",\"?"
442        );
443    }
444
445    #[test]
446    fn display_name_collapses_and_strips() {
447        let g = grammar_json();
448        // Trivial wrapper `_base_type ::= base_type` collapses to its target.
449        assert_eq!(display_name(&g, "_base_type").unwrap(), "base_type");
450        // Helper rules strip the leading underscore.
451        assert_eq!(display_name(&g, "_expression").unwrap(), "expression");
452        assert_eq!(display_name(&g, "_type_ref").unwrap(), "type_ref");
453        // An ordinary rule is unchanged.
454        assert_eq!(display_name(&g, "http_handler").unwrap(), "http_handler");
455    }
456
457    #[test]
458    fn render_rule_unknown_rule_errors() {
459        let g = grammar_json();
460        assert_eq!(
461            render_rule(&g, "no_such_rule"),
462            Err(GrammarError::UnknownRule("no_such_rule".to_string()))
463        );
464    }
465
466    #[test]
467    fn render_rule_invalid_json_errors() {
468        assert!(matches!(
469            render_rule("not json", "match_arm"),
470            Err(GrammarError::Parse(_))
471        ));
472    }
473
474    #[test]
475    fn override_keys_are_real_rules() {
476        let g = grammar_json();
477        for (key, _) in OVERRIDES {
478            assert!(
479                display_name(&g, key).is_ok(),
480                "override key `{key}` is not a top-level rule"
481            );
482        }
483    }
484
485    /// The display transform must not map two displayed productions to the same
486    /// name — that would make the reference ambiguous. Trivial wrappers are
487    /// collapsed and so excluded.
488    #[test]
489    fn display_names_are_unique() {
490        let g = grammar_json();
491        let grammar: Value = serde_json::from_str(&g).unwrap();
492        let rules = rules(&grammar);
493        let mut seen: HashMap<String, String> = HashMap::new();
494        for name in rules.keys() {
495            if trivial_wrapper_target(rules, name).is_some() {
496                continue;
497            }
498            let disp = display_name_in(rules, name);
499            if let Some(prev) = seen.insert(disp.clone(), name.clone()) {
500                panic!("display name `{disp}` for `{name}` collides with `{prev}`");
501            }
502        }
503    }
504
505    /// Pins the two renderers to one implementation: every displayed rule's
506    /// production line must appear verbatim in the appendix, and the appendix
507    /// has exactly one production per non-wrapper rule (wrappers are collapsed,
508    /// nothing is duplicated).
509    #[test]
510    fn every_displayed_rule_matches_the_appendix() {
511        let g = grammar_json();
512        let appendix = render_appendix(&g);
513        let grammar: Value = serde_json::from_str(&g).unwrap();
514        let rules = rules(&grammar);
515
516        let mut displayed = 0;
517        for name in rules.keys() {
518            if trivial_wrapper_target(rules, name).is_some() {
519                continue;
520            }
521            displayed += 1;
522            let line = render_production(&g, name).unwrap();
523            assert!(
524                appendix.contains(&line),
525                "production for `{name}` not found in appendix:\n{line}"
526            );
527        }
528
529        // One `::=` per displayed rule — collapsed wrappers added no lines and
530        // no production is duplicated. (No grammar token contains `::=`.)
531        assert_eq!(appendix.matches("::=").count(), displayed);
532    }
533}