Skip to main content

sim_shape/
grammar.rs

1//! Shape-to-grammar lowering and seed JSON Schema rendering.
2//!
3//! The public lowering returns a codec-neutral [`GrammarGraph`]. JSON Schema is
4//! kept here only as the seed renderer that needs no codec-specific terminals;
5//! codec-owned renderers can consume the same graph without reversing the
6//! dependency arrow.
7
8mod graph;
9
10use sim_kernel::{Error, Expr, Result, Symbol};
11
12use crate::{
13    AnyShape, ExactExprShape, ExprKind, ExprKindShape, FieldShape, ListShape, OneOfShape, Shape,
14    ShapeDefRef, ShapeDefs,
15};
16
17pub use graph::{
18    GrammarDialect, GrammarGraph, GrammarPosition, GrammarTarget, Production, ShapeGrammar,
19    TerminalAtom,
20};
21
22/// Lower a [`Shape`] into a codec-neutral production graph.
23pub fn shape_grammar_graph(shape: &dyn Shape) -> Result<GrammarGraph> {
24    let lowered = lower_shape(shape)?;
25    Ok(GrammarGraph {
26        root: lowered.production,
27        defs: lowered.defs,
28        diagnostics: Vec::new(),
29    })
30}
31
32/// Render `shape` as JSON Schema using the seed renderer.
33///
34/// JSON Schema is a concrete dialect, not the neutral grammar form. This helper
35/// exists for callers that already consume JSON Schema text while codec-owned
36/// renderers migrate to [`GrammarGraph`].
37pub fn shape_json_schema(shape: &dyn Shape) -> Result<String> {
38    let graph = shape_grammar_graph(shape)?;
39    render_json_graph(&graph)
40}
41
42/// Renders a neutral [`GrammarGraph`] into one concrete codec grammar surface.
43pub trait GrammarRenderer {
44    /// Codec symbol this renderer targets.
45    fn codec_symbol(&self) -> Symbol;
46
47    /// Concrete grammar dialect this renderer emits.
48    fn dialect(&self) -> GrammarDialect;
49
50    /// Renders `graph` for this codec at `position`.
51    fn render(&self, graph: &GrammarGraph, position: GrammarPosition) -> Result<String>;
52}
53
54/// Lowers `shape` and renders it through a supplied codec-owned renderer.
55pub fn shape_grammar(
56    shape: &dyn Shape,
57    target: GrammarTarget,
58    renderer: &dyn GrammarRenderer,
59) -> Result<ShapeGrammar> {
60    let renderer_codec = renderer.codec_symbol();
61    if renderer_codec != target.codec {
62        return Err(unsupported_shape(format!(
63            "grammar renderer targets codec {}, not {}",
64            renderer_codec, target.codec
65        )));
66    }
67    let renderer_dialect = renderer.dialect();
68    if renderer_dialect != target.dialect {
69        return Err(unsupported_shape(format!(
70            "grammar renderer targets dialect {:?}, not {:?}",
71            renderer_dialect, target.dialect
72        )));
73    }
74    let graph = shape_grammar_graph(shape)?;
75    let text = renderer.render(&graph, target.position)?;
76    let diagnostics = graph.diagnostics.clone();
77    Ok(ShapeGrammar {
78        target,
79        graph,
80        text,
81        diagnostics,
82    })
83}
84
85struct LoweredProduction {
86    production: Production,
87    defs: Vec<(Symbol, Production)>,
88}
89
90impl LoweredProduction {
91    fn new(production: Production) -> Self {
92        Self {
93            production,
94            defs: Vec::new(),
95        }
96    }
97}
98
99fn lower_shape(shape: &dyn Shape) -> Result<LoweredProduction> {
100    if shape.as_any().is::<AnyShape>() {
101        return Ok(LoweredProduction::new(Production::Alt(vec![
102            Production::Terminal(TerminalAtom::Any),
103        ])));
104    }
105    if let Some(kind) = shape.as_any().downcast_ref::<ExprKindShape>() {
106        return lower_expr_kind(kind.kind());
107    }
108    if let Some(defs) = shape.as_any().downcast_ref::<ShapeDefs>() {
109        let root = lower_shape(defs.root().as_ref())?;
110        let mut graph_defs = root.defs;
111        for (name, shape) in defs.defs() {
112            let lowered = lower_shape(shape.as_ref())?;
113            graph_defs.extend(lowered.defs);
114            graph_defs.push((name.clone(), lowered.production));
115        }
116        return Ok(LoweredProduction {
117            production: root.production,
118            defs: graph_defs,
119        });
120    }
121    if let Some(reference) = shape.as_any().downcast_ref::<ShapeDefRef>() {
122        return Ok(LoweredProduction::new(Production::Ref(
123            reference.name().clone(),
124        )));
125    }
126    if let Some(fields) = shape.as_any().downcast_ref::<FieldShape>() {
127        return lower_field_shape(fields);
128    }
129    if let Some(list) = shape.as_any().downcast_ref::<ListShape>() {
130        return lower_list_shape(list);
131    }
132    if let Some(one_of) = shape.as_any().downcast_ref::<OneOfShape>() {
133        let choices = one_of
134            .choices()
135            .iter()
136            .map(|choice| lower_shape(choice.as_ref()))
137            .collect::<Result<Vec<_>>>()?;
138        let mut defs = Vec::new();
139        let choices = choices
140            .into_iter()
141            .map(|choice| {
142                defs.extend(choice.defs);
143                choice.production
144            })
145            .collect();
146        return Ok(LoweredProduction {
147            production: Production::Alt(choices),
148            defs,
149        });
150    }
151    if let Some(exact) = shape.as_any().downcast_ref::<ExactExprShape>() {
152        return Ok(LoweredProduction::new(Production::Terminal(
153            TerminalAtom::Exact(exact.expected().clone()),
154        )));
155    }
156    Err(unsupported_shape(
157        "shape_grammar_graph does not support this shape",
158    ))
159}
160
161fn lower_expr_kind(kind: &ExprKind) -> Result<LoweredProduction> {
162    let atom = match kind {
163        ExprKind::Nil => TerminalAtom::Nil,
164        ExprKind::Bool => TerminalAtom::Bool,
165        ExprKind::Number => TerminalAtom::Number,
166        ExprKind::String => TerminalAtom::String,
167        ExprKind::List | ExprKind::Vector => TerminalAtom::List,
168        ExprKind::Map => TerminalAtom::Map,
169        ExprKind::Symbol => TerminalAtom::Symbol,
170        other => {
171            return Err(unsupported_shape(format!(
172                "shape_grammar_graph does not support expr-kind {}",
173                other.name()
174            )));
175        }
176    };
177    Ok(LoweredProduction::new(Production::Terminal(atom)))
178}
179
180fn lower_field_shape(shape: &FieldShape) -> Result<LoweredProduction> {
181    let mut defs = Vec::new();
182    let args = shape
183        .fields()
184        .iter()
185        .map(|field| {
186            let lowered = lower_shape(field.shape().as_ref())?;
187            defs.extend(lowered.defs);
188            Ok(Production::Seq(vec![
189                Production::Terminal(TerminalAtom::Exact(Expr::Symbol(field.name().clone()))),
190                lowered.production,
191            ]))
192        })
193        .collect::<Result<Vec<_>>>()?;
194    Ok(LoweredProduction {
195        production: Production::Call {
196            head: Box::new(Production::Terminal(TerminalAtom::Exact(Expr::Symbol(
197                shape
198                    .class_symbol()
199                    .cloned()
200                    .unwrap_or_else(|| Symbol::qualified("shape", "fields")),
201            )))),
202            args,
203        },
204        defs,
205    })
206}
207
208fn lower_list_shape(shape: &ListShape) -> Result<LoweredProduction> {
209    let mut defs = Vec::new();
210    let mut items = shape
211        .items()
212        .iter()
213        .map(|item| {
214            let lowered = lower_shape(item.as_ref())?;
215            defs.extend(lowered.defs);
216            Ok(lowered.production)
217        })
218        .collect::<Result<Vec<_>>>()?;
219    if let Some(rest) = shape.rest() {
220        let lowered = lower_shape(rest.as_ref())?;
221        defs.extend(lowered.defs);
222        items.push(Production::Repeat {
223            inner: Box::new(lowered.production),
224            at_least: 0,
225        });
226    }
227    Ok(LoweredProduction {
228        production: Production::Seq(items),
229        defs,
230    })
231}
232
233fn render_json_graph(graph: &GrammarGraph) -> Result<String> {
234    let root = render_json_schema(&graph.root)?;
235    if graph.defs.is_empty() {
236        return Ok(root);
237    }
238    let defs = graph
239        .defs
240        .iter()
241        .map(|(name, production)| {
242            Ok(format!(
243                "{}:{}",
244                json_string(&name.to_string()),
245                render_json_schema(production)?
246            ))
247        })
248        .collect::<Result<Vec<_>>>()?;
249    Ok(format!(
250        r#"{{"allOf":[{}],"$defs":{{{}}}}}"#,
251        root,
252        defs.join(",")
253    ))
254}
255
256fn render_json_schema(production: &Production) -> Result<String> {
257    match production {
258        Production::Terminal(atom) => render_json_terminal(atom),
259        Production::Seq(items) => render_json_seq(items),
260        Production::Alt(choices) => {
261            if choices.len() == 1
262                && matches!(
263                    choices.first(),
264                    Some(Production::Terminal(TerminalAtom::Any))
265                )
266            {
267                return Ok("true".to_owned());
268            }
269            let choices = choices
270                .iter()
271                .map(render_json_schema)
272                .collect::<Result<Vec<_>>>()?;
273            Ok(format!(r#"{{"anyOf":[{}]}}"#, choices.join(",")))
274        }
275        Production::Repeat { inner, at_least } => {
276            let min_items = (*at_least > 0).then(|| format!(r#","minItems":{at_least}"#));
277            Ok(format!(
278                r#"{{"type":"array","items":{}{}}}"#,
279                render_json_schema(inner)?,
280                min_items.unwrap_or_default(),
281            ))
282        }
283        Production::Call { head: _, args } => render_json_object(args),
284        Production::Ref(name) => Ok(format!(r##"{{"$ref":"#/$defs/{}"}}"##, name)),
285    }
286}
287
288fn render_json_terminal(atom: &TerminalAtom) -> Result<String> {
289    Ok(match atom {
290        TerminalAtom::Any => "true".to_owned(),
291        TerminalAtom::Nil => r#"{"type":"null"}"#.to_owned(),
292        TerminalAtom::Bool => r#"{"type":"boolean"}"#.to_owned(),
293        TerminalAtom::Number => r#"{"type":"number"}"#.to_owned(),
294        TerminalAtom::String => r#"{"type":"string"}"#.to_owned(),
295        TerminalAtom::List => r#"{"type":"array"}"#.to_owned(),
296        TerminalAtom::Map => r#"{"type":"object"}"#.to_owned(),
297        TerminalAtom::Symbol => r#"{"type":"string","description":"symbol"}"#.to_owned(),
298        TerminalAtom::Exact(expr) => format!(r#"{{"const":{}}}"#, json_expr(expr)?),
299    })
300}
301
302fn render_json_seq(items: &[Production]) -> Result<String> {
303    let (prefix, rest) = match items.split_last() {
304        Some((Production::Repeat { inner, at_least: 0 }, prefix)) => (prefix, Some(inner)),
305        _ => (items, None),
306    };
307    let prefix_items = prefix
308        .iter()
309        .map(render_json_schema)
310        .collect::<Result<Vec<_>>>()?;
311    let items = match rest {
312        Some(rest) => render_json_schema(rest)?,
313        None => "false".to_owned(),
314    };
315    let bounds = rest.is_none().then(|| {
316        format!(
317            r#","minItems":{},"maxItems":{}"#,
318            prefix.len(),
319            prefix.len()
320        )
321    });
322    Ok(format!(
323        r#"{{"type":"array","prefixItems":[{}],"items":{}{}}}"#,
324        prefix_items.join(","),
325        items,
326        bounds.unwrap_or_default(),
327    ))
328}
329
330fn render_json_object(args: &[Production]) -> Result<String> {
331    let mut properties = Vec::new();
332    let mut required = Vec::new();
333    for arg in args {
334        let Production::Seq(parts) = arg else {
335            return Err(unsupported_shape(
336                "shape_json_schema object field must lower to a sequence",
337            ));
338        };
339        let [
340            Production::Terminal(TerminalAtom::Exact(Expr::Symbol(name))),
341            value,
342        ] = parts.as_slice()
343        else {
344            return Err(unsupported_shape(
345                "shape_json_schema object field must start with a symbol name",
346            ));
347        };
348        properties.push(format!(
349            "{}:{}",
350            json_string(name.name.as_ref()),
351            render_json_schema(value)?
352        ));
353        required.push(json_string(name.name.as_ref()));
354    }
355    Ok(format!(
356        r#"{{"type":"object","properties":{{{}}},"required":[{}],"additionalProperties":false}}"#,
357        properties.join(","),
358        required.join(","),
359    ))
360}
361
362fn json_expr(expr: &Expr) -> Result<String> {
363    Ok(match expr {
364        Expr::Nil => "null".to_owned(),
365        Expr::Bool(value) => value.to_string(),
366        Expr::Number(number) => number.canonical.clone(),
367        Expr::String(text) => json_string(text),
368        Expr::Symbol(symbol) => json_string(&symbol.to_string()),
369        Expr::List(items) | Expr::Vector(items) => {
370            let items = items.iter().map(json_expr).collect::<Result<Vec<_>>>()?;
371            format!("[{}]", items.join(","))
372        }
373        Expr::Map(entries) => {
374            let entries = entries
375                .iter()
376                .map(|(key, value)| {
377                    let Expr::Symbol(symbol) = key else {
378                        return Err(unsupported_shape(
379                            "shape_json_schema exact map keys must be symbols",
380                        ));
381                    };
382                    Ok(format!(
383                        "{}:{}",
384                        json_string(symbol.name.as_ref()),
385                        json_expr(value)?
386                    ))
387                })
388                .collect::<Result<Vec<_>>>()?;
389            format!("{{{}}}", entries.join(","))
390        }
391        _ => {
392            return Err(unsupported_shape(
393                "shape_json_schema exact expr lowering only supports json-like forms",
394            ));
395        }
396    })
397}
398
399fn json_string(text: &str) -> String {
400    format!("{text:?}")
401}
402
403fn unsupported_shape(message: impl Into<String>) -> Error {
404    Error::Eval(message.into())
405}
406
407#[cfg(test)]
408mod tests {
409    use std::sync::Arc;
410
411    use sim_kernel::{Expr, NumberLiteral, Result, Symbol};
412
413    use super::{
414        GrammarDialect, GrammarPosition, GrammarRenderer, GrammarTarget, Production, TerminalAtom,
415        shape_grammar, shape_grammar_graph, shape_json_schema,
416    };
417    use crate::{
418        ExactExprShape, ExprKind, ExprKindShape, FieldShape, FieldSpec, ListShape,
419        NumberValueShape, OneOfShape,
420    };
421
422    #[test]
423    fn graph_lowers_non_trivial_object_shape() {
424        let graph = shape_grammar_graph(&record_shape()).unwrap();
425
426        let Production::Call { args, .. } = graph.root else {
427            panic!("field shape should lower to a call production");
428        };
429        assert_eq!(args.len(), 2);
430        assert!(graph.defs.is_empty());
431        assert!(graph.diagnostics.is_empty());
432    }
433
434    #[test]
435    fn json_schema_renderer_preserves_seed_object_output() {
436        let grammar = shape_json_schema(&record_shape()).unwrap();
437
438        assert!(grammar.contains(r#""type":"object""#));
439        assert!(grammar.contains(r#""name":{"type":"string"}"#));
440        assert!(grammar.contains(r#""versions":{"type":"array""#));
441        assert!(grammar.contains(r#""additionalProperties":false"#));
442    }
443
444    #[test]
445    fn exact_expr_lowers_to_exact_terminal_and_const_schema() {
446        let expr = Expr::Map(vec![
447            (
448                Expr::Symbol(Symbol::new("head")),
449                Expr::String("ok".to_owned()),
450            ),
451            (
452                Expr::Symbol(Symbol::new("count")),
453                Expr::Number(NumberLiteral {
454                    domain: Symbol::qualified("num", "int"),
455                    canonical: "3".to_owned(),
456                }),
457            ),
458        ]);
459        let graph = shape_grammar_graph(&ExactExprShape::new(expr)).unwrap();
460
461        assert!(matches!(
462            graph.root,
463            Production::Terminal(TerminalAtom::Exact(_))
464        ));
465        assert_eq!(
466            shape_json_schema(&ExactExprShape::new(Expr::Bool(true))).unwrap(),
467            r#"{"const":true}"#,
468        );
469    }
470
471    #[test]
472    fn one_of_lowers_to_alt_and_any_remains_permissive() {
473        let shape = OneOfShape::new(vec![
474            Arc::new(ExprKindShape::new(ExprKind::Number)),
475            Arc::new(ExprKindShape::new(ExprKind::String)),
476        ]);
477        let graph = shape_grammar_graph(&shape).unwrap();
478
479        assert!(matches!(graph.root, Production::Alt(_)));
480        assert_eq!(shape_json_schema(&crate::AnyShape).unwrap(), "true");
481    }
482
483    #[test]
484    fn shape_grammar_uses_supplied_renderer() {
485        let target = GrammarTarget {
486            codec: Symbol::qualified("codec", "test"),
487            dialect: GrammarDialect::SExpr,
488            position: GrammarPosition::Data,
489        };
490        let rendered = shape_grammar(
491            &record_shape(),
492            target.clone(),
493            &StubRenderer {
494                codec: target.codec.clone(),
495                dialect: target.dialect,
496            },
497        )
498        .unwrap();
499
500        assert_eq!(rendered.target, target);
501        assert!(rendered.text.contains("codec/test"));
502        assert!(rendered.text.contains("root=Call"));
503        assert!(rendered.diagnostics.is_empty());
504    }
505
506    #[test]
507    fn shape_grammar_fails_closed_on_renderer_mismatch() {
508        let target = GrammarTarget {
509            codec: Symbol::qualified("codec", "test"),
510            dialect: GrammarDialect::SExpr,
511            position: GrammarPosition::Data,
512        };
513        let wrong_codec = shape_grammar(
514            &record_shape(),
515            target.clone(),
516            &StubRenderer {
517                codec: Symbol::qualified("codec", "other"),
518                dialect: target.dialect,
519            },
520        )
521        .unwrap_err();
522        assert!(wrong_codec.to_string().contains("codec codec/other"));
523
524        let wrong_dialect = shape_grammar(
525            &record_shape(),
526            target.clone(),
527            &StubRenderer {
528                codec: target.codec.clone(),
529                dialect: GrammarDialect::JsonSchema,
530            },
531        )
532        .unwrap_err();
533        assert!(wrong_dialect.to_string().contains("dialect JsonSchema"));
534    }
535
536    #[test]
537    fn unsupported_shapes_fail_closed() {
538        let err = shape_grammar_graph(&NumberValueShape).unwrap_err();
539        assert!(err.to_string().contains("does not support this shape"));
540
541        let err = shape_grammar_graph(&ExprKindShape::new(ExprKind::Bytes)).unwrap_err();
542        assert!(err.to_string().contains("expr-kind bytes"));
543    }
544
545    fn record_shape() -> FieldShape {
546        FieldShape::anonymous(vec![
547            FieldSpec::required(
548                Symbol::new("name"),
549                Arc::new(ExprKindShape::new(ExprKind::String)),
550            ),
551            FieldSpec::required(
552                Symbol::new("versions"),
553                Arc::new(ListShape::new(vec![
554                    Arc::new(ExprKindShape::new(ExprKind::String)),
555                    Arc::new(ExprKindShape::new(ExprKind::String)),
556                ])),
557            ),
558        ])
559    }
560
561    struct StubRenderer {
562        codec: Symbol,
563        dialect: GrammarDialect,
564    }
565
566    impl GrammarRenderer for StubRenderer {
567        fn codec_symbol(&self) -> Symbol {
568            self.codec.clone()
569        }
570
571        fn dialect(&self) -> GrammarDialect {
572            self.dialect
573        }
574
575        fn render(&self, graph: &crate::GrammarGraph, position: GrammarPosition) -> Result<String> {
576            Ok(format!(
577                "codec={} dialect={:?} position={:?} root={}",
578                self.codec,
579                self.dialect,
580                position,
581                production_kind(&graph.root)
582            ))
583        }
584    }
585
586    fn production_kind(production: &Production) -> &'static str {
587        match production {
588            Production::Terminal(_) => "Terminal",
589            Production::Seq(_) => "Seq",
590            Production::Alt(_) => "Alt",
591            Production::Repeat { .. } => "Repeat",
592            Production::Call { .. } => "Call",
593            Production::Ref(_) => "Ref",
594        }
595    }
596}