diff --git a/crates/plotnik-lib/src/infer/tyton.rs b/crates/plotnik-lib/src/infer/tyton.rs index a5184131..7bfa1f42 100644 --- a/crates/plotnik-lib/src/infer/tyton.rs +++ b/crates/plotnik-lib/src/infer/tyton.rs @@ -1,6 +1,7 @@ //! Tyton: Types Testing Object Notation //! //! A compact DSL for constructing `TypeTable` test fixtures. +//! Supports both parsing (text → TypeTable) and emitting (TypeTable → text). //! //! # Design //! @@ -48,10 +49,12 @@ //! Stmts = Stmt* //! ``` +use std::fmt::Write; + +use indexmap::IndexMap; use logos::Logos; use super::{TypeKey, TypeTable, TypeValue}; -use indexmap::IndexMap; #[derive(Logos, Debug, Clone, PartialEq)] #[logos(skip r"[ \t\n\r]+")] @@ -427,3 +430,87 @@ pub fn parse(input: &str) -> Result, ParseError> { let mut parser = Parser::new(input)?; parser.parse_all() } + +/// Emit TypeTable as tyton notation. +pub fn emit(table: &TypeTable<'_>) -> String { + let mut out = String::new(); + + for (key, value) in table.iter() { + if is_builtin(key) { + continue; + } + if !out.is_empty() { + out.push('\n'); + } + emit_key(&mut out, key); + out.push_str(" = "); + emit_value(&mut out, value); + } + + out +} + +fn is_builtin(key: &TypeKey<'_>) -> bool { + matches!(key, TypeKey::Node | TypeKey::String | TypeKey::Unit) +} + +fn emit_key(out: &mut String, key: &TypeKey<'_>) { + match key { + TypeKey::Node => out.push_str("Node"), + TypeKey::String => out.push_str("string"), + TypeKey::Unit => out.push_str("()"), + TypeKey::Named(name) => out.push_str(name), + TypeKey::Synthetic(segments) => { + out.push('<'); + for (i, seg) in segments.iter().enumerate() { + if i > 0 { + out.push(' '); + } + out.push_str(seg); + } + out.push('>'); + } + } +} + +fn emit_value(out: &mut String, value: &TypeValue<'_>) { + match value { + TypeValue::Node => out.push_str("Node"), + TypeValue::String => out.push_str("string"), + TypeValue::Unit => out.push_str("()"), + TypeValue::Struct(fields) => { + out.push_str("{ "); + for (i, (field, key)) in fields.iter().enumerate() { + if i > 0 { + out.push(' '); + } + emit_key(out, key); + write!(out, " @{}", field).unwrap(); + } + out.push_str(" }"); + } + TypeValue::TaggedUnion(variants) => { + out.push_str("[ "); + for (i, (tag, key)) in variants.iter().enumerate() { + if i > 0 { + out.push(' '); + } + write!(out, "{}: ", tag).unwrap(); + emit_key(out, key); + } + out.push_str(" ]"); + } + TypeValue::Optional(key) => { + emit_key(out, key); + out.push('?'); + } + TypeValue::List(key) => { + emit_key(out, key); + out.push('*'); + } + TypeValue::NonEmptyList(key) => { + emit_key(out, key); + out.push('+'); + } + } +} diff --git a/crates/plotnik-lib/src/infer/tyton_tests.rs b/crates/plotnik-lib/src/infer/tyton_tests.rs index 3687a09c..3b796a29 100644 --- a/crates/plotnik-lib/src/infer/tyton_tests.rs +++ b/crates/plotnik-lib/src/infer/tyton_tests.rs @@ -1,4 +1,4 @@ -use super::tyton::parse; +use super::tyton::{emit, parse}; use indoc::indoc; fn dump_table(input: &str) -> String { @@ -405,3 +405,115 @@ fn error_invalid_type_value() { let input = "Foo = @bar"; insta::assert_snapshot!(dump_table(input), @"ERROR: expected type value at 6..7"); } + +// === emit tests === + +#[test] +fn emit_empty() { + let table = parse("").unwrap(); + insta::assert_snapshot!(emit(&table), @""); +} + +#[test] +fn emit_struct_simple() { + let table = parse("Foo = { Node @name }").unwrap(); + insta::assert_snapshot!(emit(&table), @"Foo = { Node @name }"); +} + +#[test] +fn emit_struct_multiple_fields() { + let table = parse("Func = { string @name Node @body Node @params }").unwrap(); + insta::assert_snapshot!(emit(&table), @"Func = { string @name Node @body Node @params }"); +} + +#[test] +fn emit_struct_empty() { + let table = parse("Empty = {}").unwrap(); + insta::assert_snapshot!(emit(&table), @"Empty = { }"); +} + +#[test] +fn emit_tagged_union() { + let table = parse("Stmt = [ Assign: AssignStmt Call: CallStmt ]").unwrap(); + insta::assert_snapshot!(emit(&table), @"Stmt = [ Assign: AssignStmt Call: CallStmt ]"); +} + +#[test] +fn emit_optional() { + let table = parse("MaybeNode = Node?").unwrap(); + insta::assert_snapshot!(emit(&table), @"MaybeNode = Node?"); +} + +#[test] +fn emit_list() { + let table = parse("Nodes = Node*").unwrap(); + insta::assert_snapshot!(emit(&table), @"Nodes = Node*"); +} + +#[test] +fn emit_non_empty_list() { + let table = parse("Nodes = Node+").unwrap(); + insta::assert_snapshot!(emit(&table), @"Nodes = Node+"); +} + +#[test] +fn emit_synthetic_key() { + let table = parse(" = { Node @value }").unwrap(); + insta::assert_snapshot!(emit(&table), @" = { Node @value }"); +} + +#[test] +fn emit_synthetic_in_wrapper() { + let table = parse("Wrapper = ?").unwrap(); + insta::assert_snapshot!(emit(&table), @"Wrapper = ?"); +} + +#[test] +fn emit_bare_builtins() { + let input = indoc! {r#" + AliasNode = Node + AliasString = string + AliasUnit = () + "#}; + let table = parse(input).unwrap(); + insta::assert_snapshot!(emit(&table), @r" + AliasNode = Node + AliasString = string + AliasUnit = () + "); +} + +#[test] +fn emit_multiple_definitions() { + let input = indoc! {r#" + AssignStmt = { Node @target Node @value } + CallStmt = { Node @func Node @args } + Stmt = [ Assign: AssignStmt Call: CallStmt ] + Stmts = Stmt* + "#}; + let table = parse(input).unwrap(); + insta::assert_snapshot!(emit(&table), @r" + AssignStmt = { Node @target Node @value } + CallStmt = { Node @func Node @args } + Stmt = [ Assign: AssignStmt Call: CallStmt ] + Stmts = Stmt* + "); +} + +#[test] +fn emit_roundtrip() { + let input = indoc! {r#" + FuncInfo = { string @name Node @body } + Param = { string @name string @type_annotation } + Params = Param* + FuncDecl = { FuncInfo @info Params @params } + Stmt = [ Func: FuncDecl Expr: Node ] + MaybeStmt = Stmt? + "#}; + + let table1 = parse(input).unwrap(); + let emitted = emit(&table1); + let table2 = parse(&emitted).unwrap(); + + assert_eq!(table1.types, table2.types); +}