diff --git a/crates/plotnik-lib/src/infer/emit/mod.rs b/crates/plotnik-lib/src/infer/emit/mod.rs new file mode 100644 index 00000000..2131fffe --- /dev/null +++ b/crates/plotnik-lib/src/infer/emit/mod.rs @@ -0,0 +1,14 @@ +//! Code emitters for inferred types. +//! +//! This module provides language-specific code generation from a `TypeTable`. + +pub mod rust; +pub mod typescript; + +#[cfg(test)] +mod rust_tests; +#[cfg(test)] +mod typescript_tests; + +pub use rust::{Indirection, RustEmitConfig, emit_rust}; +pub use typescript::{OptionalStyle, TypeScriptEmitConfig, emit_typescript}; diff --git a/crates/plotnik-lib/src/infer/emit/rust.rs b/crates/plotnik-lib/src/infer/emit/rust.rs new file mode 100644 index 00000000..6a9c8a8b --- /dev/null +++ b/crates/plotnik-lib/src/infer/emit/rust.rs @@ -0,0 +1,238 @@ +//! Rust code emitter for inferred types. +//! +//! Emits Rust struct and enum definitions from a `TypeTable`. + +use indexmap::IndexMap; + +use super::super::types::{TypeKey, TypeTable, TypeValue}; + +/// Configuration for Rust emission. +#[derive(Debug, Clone)] +pub struct RustEmitConfig { + /// Indirection type for cyclic references. + pub indirection: Indirection, + /// Whether to derive common traits. + pub derive_debug: bool, + pub derive_clone: bool, + pub derive_partial_eq: bool, +} + +/// How to handle cyclic type references. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Indirection { + Box, + Rc, + Arc, +} + +impl Default for RustEmitConfig { + fn default() -> Self { + Self { + indirection: Indirection::Box, + derive_debug: true, + derive_clone: true, + derive_partial_eq: false, + } + } +} + +/// Emit Rust code from a type table. +pub fn emit_rust(table: &TypeTable<'_>, config: &RustEmitConfig) -> String { + let mut output = String::new(); + let sorted = topological_sort(table); + + for key in sorted { + let Some(value) = table.get(&key) else { + continue; + }; + + // Skip built-in types + if matches!(key, TypeKey::Node | TypeKey::String | TypeKey::Unit) { + continue; + } + + let type_def = emit_type_def(&key, value, table, config); + if !type_def.is_empty() { + output.push_str(&type_def); + output.push_str("\n\n"); + } + } + + output.trim_end().to_string() +} + +fn emit_type_def( + key: &TypeKey<'_>, + value: &TypeValue<'_>, + table: &TypeTable<'_>, + config: &RustEmitConfig, +) -> String { + let name = key.to_pascal_case(); + + match value { + TypeValue::Node | TypeValue::String | TypeValue::Unit => String::new(), + + TypeValue::Struct(fields) => { + let mut out = emit_derives(config); + if fields.is_empty() { + out.push_str(&format!("pub struct {};", name)); + } else { + out.push_str(&format!("pub struct {} {{\n", name)); + for (field_name, field_type) in fields { + let type_str = emit_type_ref(field_type, table, config); + out.push_str(&format!(" pub {}: {},\n", field_name, type_str)); + } + out.push('}'); + } + out + } + + TypeValue::TaggedUnion(variants) => { + let mut out = emit_derives(config); + out.push_str(&format!("pub enum {} {{\n", name)); + for (variant_name, variant_key) in variants { + let fields = match table.get(variant_key) { + Some(TypeValue::Struct(f)) => Some(f), + Some(TypeValue::Unit) | None => None, + _ => None, + }; + match fields { + Some(f) if !f.is_empty() => { + out.push_str(&format!(" {} {{\n", variant_name)); + for (field_name, field_type) in f { + let type_str = emit_type_ref(field_type, table, config); + out.push_str(&format!(" {}: {},\n", field_name, type_str)); + } + out.push_str(" },\n"); + } + _ => { + out.push_str(&format!(" {},\n", variant_name)); + } + } + } + out.push('}'); + out + } + + TypeValue::Optional(_) | TypeValue::List(_) | TypeValue::NonEmptyList(_) => { + // Wrapper types become type aliases + let mut out = String::new(); + let inner_type = emit_type_ref(key, table, config); + out.push_str(&format!("pub type {} = {};", name, inner_type)); + out + } + } +} + +pub(crate) fn emit_type_ref( + key: &TypeKey<'_>, + table: &TypeTable<'_>, + config: &RustEmitConfig, +) -> String { + let is_cyclic = table.is_cyclic(key); + + let base = match table.get(key) { + Some(TypeValue::Node) => "Node".to_string(), + Some(TypeValue::String) => "String".to_string(), + Some(TypeValue::Unit) => "()".to_string(), + Some(TypeValue::Optional(inner)) => { + let inner_str = emit_type_ref(inner, table, config); + format!("Option<{}>", inner_str) + } + Some(TypeValue::List(inner)) => { + let inner_str = emit_type_ref(inner, table, config); + format!("Vec<{}>", inner_str) + } + Some(TypeValue::NonEmptyList(inner)) => { + let inner_str = emit_type_ref(inner, table, config); + format!("Vec<{}>", inner_str) + } + // Struct, TaggedUnion, or undefined forward reference - use pascal-cased name + Some(TypeValue::Struct(_)) | Some(TypeValue::TaggedUnion(_)) | None => key.to_pascal_case(), + }; + + if is_cyclic { + wrap_indirection(&base, config.indirection) + } else { + base + } +} + +pub(crate) fn wrap_indirection(type_str: &str, indirection: Indirection) -> String { + match indirection { + Indirection::Box => format!("Box<{}>", type_str), + Indirection::Rc => format!("Rc<{}>", type_str), + Indirection::Arc => format!("Arc<{}>", type_str), + } +} + +pub(crate) fn emit_derives(config: &RustEmitConfig) -> String { + let mut derives = Vec::new(); + if config.derive_debug { + derives.push("Debug"); + } + if config.derive_clone { + derives.push("Clone"); + } + if config.derive_partial_eq { + derives.push("PartialEq"); + } + + if derives.is_empty() { + String::new() + } else { + format!("#[derive({})]\n", derives.join(", ")) + } +} + +/// Topologically sort types so dependencies come before dependents. +pub(crate) fn topological_sort<'src>(table: &TypeTable<'src>) -> Vec> { + let mut result = Vec::new(); + let mut visited = IndexMap::new(); + + for key in table.types.keys() { + visit(key, table, &mut visited, &mut result); + } + + result +} + +fn visit<'src>( + key: &TypeKey<'src>, + table: &TypeTable<'src>, + visited: &mut IndexMap, bool>, + result: &mut Vec>, +) { + if visited.contains_key(key) { + return; + } + + visited.insert(key.clone(), true); + + let Some(value) = table.get(key) else { + visited.insert(key.clone(), false); + result.push(key.clone()); + return; + }; + + for dep in dependencies(value) { + visit(&dep, table, visited, result); + } + + visited.insert(key.clone(), false); + result.push(key.clone()); +} + +pub(crate) fn dependencies<'src>(value: &TypeValue<'src>) -> Vec> { + match value { + TypeValue::Node | TypeValue::String | TypeValue::Unit => vec![], + + TypeValue::Struct(fields) => fields.values().cloned().collect(), + + TypeValue::TaggedUnion(variants) => variants.values().cloned().collect(), + + TypeValue::Optional(inner) | TypeValue::List(inner) | TypeValue::NonEmptyList(inner) => { + vec![inner.clone()] + } + } +} diff --git a/crates/plotnik-lib/src/infer/emit/rust_tests.rs b/crates/plotnik-lib/src/infer/emit/rust_tests.rs new file mode 100644 index 00000000..89b4822f --- /dev/null +++ b/crates/plotnik-lib/src/infer/emit/rust_tests.rs @@ -0,0 +1,539 @@ +use super::rust::{Indirection, RustEmitConfig, emit_rust}; +use crate::infer::tyton::parse; +use indoc::indoc; + +fn emit(input: &str) -> String { + let table = parse(input).expect("tyton parse failed"); + emit_rust(&table, &RustEmitConfig::default()) +} + +fn emit_with_config(input: &str, config: &RustEmitConfig) -> String { + let table = parse(input).expect("tyton parse failed"); + emit_rust(&table, config) +} + +fn emit_cyclic(input: &str, cyclic_types: &[&str]) -> String { + let mut table = parse(input).expect("tyton parse failed"); + for name in cyclic_types { + table.mark_cyclic(crate::infer::TypeKey::Named(name)); + } + emit_rust(&table, &RustEmitConfig::default()) +} + +// --- Simple Structs --- + +#[test] +fn emit_struct_single_field() { + let input = "Foo = { Node @value }"; + insta::assert_snapshot!(emit(input), @r" + #[derive(Debug, Clone)] + pub struct Foo { + pub value: Node, + } + "); +} + +#[test] +fn emit_struct_multiple_fields() { + let input = "Func = { string @name Node @body Node @params }"; + insta::assert_snapshot!(emit(input), @r" + #[derive(Debug, Clone)] + pub struct Func { + pub name: String, + pub body: Node, + pub params: Node, + } + "); +} + +#[test] +fn emit_struct_empty() { + let input = "Empty = {}"; + insta::assert_snapshot!(emit(input), @r" + #[derive(Debug, Clone)] + pub struct Empty; + "); +} + +#[test] +fn emit_struct_with_unit_field() { + let input = "Wrapper = { () @marker }"; + insta::assert_snapshot!(emit(input), @r" + #[derive(Debug, Clone)] + pub struct Wrapper { + pub marker: (), + } + "); +} + +#[test] +fn emit_struct_nested_refs() { + let input = indoc! {r#" + Inner = { Node @value } + Outer = { Inner @inner string @label } + "#}; + insta::assert_snapshot!(emit(input), @r" + #[derive(Debug, Clone)] + pub struct Inner { + pub value: Node, + } + + #[derive(Debug, Clone)] + pub struct Outer { + pub inner: Inner, + pub label: String, + } + "); +} + +// --- Tagged Unions --- + +#[test] +fn emit_tagged_union_simple() { + let input = indoc! {r#" + AssignStmt = { Node @target Node @value } + CallStmt = { Node @func } + Stmt = [ Assign: AssignStmt Call: CallStmt ] + "#}; + insta::assert_snapshot!(emit(input), @r" + #[derive(Debug, Clone)] + pub struct AssignStmt { + pub target: Node, + pub value: Node, + } + + #[derive(Debug, Clone)] + pub struct CallStmt { + pub func: Node, + } + + #[derive(Debug, Clone)] + pub enum Stmt { + Assign { + target: Node, + value: Node, + }, + Call { + func: Node, + }, + } + "); +} + +#[test] +fn emit_tagged_union_with_empty_variant() { + let input = indoc! {r#" + ValueVariant = { Node @value } + Expr = [ Some: ValueVariant None: () ] + "#}; + insta::assert_snapshot!(emit(input), @r" + #[derive(Debug, Clone)] + pub struct ValueVariant { + pub value: Node, + } + + #[derive(Debug, Clone)] + pub enum Expr { + Some { + value: Node, + }, + None, + } + "); +} + +#[test] +fn emit_tagged_union_all_empty() { + let input = "Token = [ Comma: () Dot: () Semi: () ]"; + insta::assert_snapshot!(emit(input), @r" + #[derive(Debug, Clone)] + pub enum Token { + Comma, + Dot, + Semi, + } + "); +} + +#[test] +fn emit_tagged_union_with_builtins() { + let input = "Value = [ Text: string Code: Node Empty: () ]"; + insta::assert_snapshot!(emit(input), @r" + #[derive(Debug, Clone)] + pub enum Value { + Text, + Code, + Empty, + } + "); +} + +// --- Wrapper Types --- + +#[test] +fn emit_optional() { + let input = "MaybeNode = Node?"; + insta::assert_snapshot!(emit(input), @"pub type MaybeNode = Option;"); +} + +#[test] +fn emit_list() { + let input = "Nodes = Node*"; + insta::assert_snapshot!(emit(input), @"pub type Nodes = Vec;"); +} + +#[test] +fn emit_non_empty_list() { + let input = "Nodes = Node+"; + insta::assert_snapshot!(emit(input), @"pub type Nodes = Vec;"); +} + +#[test] +fn emit_optional_named() { + let input = indoc! {r#" + Stmt = { Node @value } + MaybeStmt = Stmt? + "#}; + insta::assert_snapshot!(emit(input), @r" + #[derive(Debug, Clone)] + pub struct Stmt { + pub value: Node, + } + + pub type MaybeStmt = Option; + "); +} + +#[test] +fn emit_list_named() { + let input = indoc! {r#" + Stmt = { Node @value } + Stmts = Stmt* + "#}; + insta::assert_snapshot!(emit(input), @r" + #[derive(Debug, Clone)] + pub struct Stmt { + pub value: Node, + } + + pub type Stmts = Vec; + "); +} + +#[test] +fn emit_nested_wrappers() { + let input = indoc! {r#" + Item = { Node @value } + Items = Item* + MaybeItems = Items? + "#}; + insta::assert_snapshot!(emit(input), @r" + #[derive(Debug, Clone)] + pub struct Item { + pub value: Node, + } + + pub type Items = Vec; + + pub type MaybeItems = Option>; + "); +} + +// --- Cyclic Types --- + +#[test] +fn emit_cyclic_box() { + let input = indoc! {r#" + TreeNode = { Node @value TreeNode @left TreeNode @right } + "#}; + insta::assert_snapshot!(emit_cyclic(input, &["TreeNode"]), @r" + #[derive(Debug, Clone)] + pub struct TreeNode { + pub value: Node, + pub left: Box, + pub right: Box, + } + "); +} + +#[test] +fn emit_cyclic_rc() { + let input = "TreeNode = { Node @value TreeNode @child }"; + let config = RustEmitConfig { + indirection: Indirection::Rc, + ..Default::default() + }; + let mut table = parse(input).expect("tyton parse failed"); + table.mark_cyclic(crate::infer::TypeKey::Named("TreeNode")); + insta::assert_snapshot!(emit_rust(&table, &config), @r" + #[derive(Debug, Clone)] + pub struct TreeNode { + pub value: Node, + pub child: Rc, + } + "); +} + +#[test] +fn emit_cyclic_arc() { + let input = "TreeNode = { Node @value TreeNode @child }"; + let config = RustEmitConfig { + indirection: Indirection::Arc, + ..Default::default() + }; + let mut table = parse(input).expect("tyton parse failed"); + table.mark_cyclic(crate::infer::TypeKey::Named("TreeNode")); + insta::assert_snapshot!(emit_rust(&table, &config), @r" + #[derive(Debug, Clone)] + pub struct TreeNode { + pub value: Node, + pub child: Arc, + } + "); +} + +// --- Config Variations --- + +#[test] +fn emit_no_derives() { + let input = "Foo = { Node @value }"; + let config = RustEmitConfig { + derive_debug: false, + derive_clone: false, + derive_partial_eq: false, + ..Default::default() + }; + insta::assert_snapshot!(emit_with_config(input, &config), @r" + pub struct Foo { + pub value: Node, + } + "); +} + +#[test] +fn emit_debug_only() { + let input = "Foo = { Node @value }"; + let config = RustEmitConfig { + derive_debug: true, + derive_clone: false, + derive_partial_eq: false, + ..Default::default() + }; + insta::assert_snapshot!(emit_with_config(input, &config), @r" + #[derive(Debug)] + pub struct Foo { + pub value: Node, + } + "); +} + +#[test] +fn emit_all_derives() { + let input = "Foo = { Node @value }"; + let config = RustEmitConfig { + derive_debug: true, + derive_clone: true, + derive_partial_eq: true, + ..Default::default() + }; + insta::assert_snapshot!(emit_with_config(input, &config), @r" + #[derive(Debug, Clone, PartialEq)] + pub struct Foo { + pub value: Node, + } + "); +} + +// --- Complex Scenarios --- + +#[test] +fn emit_complex_program() { + let input = indoc! {r#" + FuncInfo = { string @name Node @body } + Param = { string @name string @type_annotation } + Params = Param* + FuncDecl = { FuncInfo @info Params @params } + ExprStmt = { Node @expr } + Stmt = [ Func: FuncDecl Expr: ExprStmt ] + Program = { Stmt @statements } + "#}; + insta::assert_snapshot!(emit(input), @r" + #[derive(Debug, Clone)] + pub struct FuncInfo { + pub name: String, + pub body: Node, + } + + #[derive(Debug, Clone)] + pub struct Param { + pub name: String, + pub type_annotation: String, + } + + pub type Params = Vec; + + #[derive(Debug, Clone)] + pub struct FuncDecl { + pub info: FuncInfo, + pub params: Vec, + } + + #[derive(Debug, Clone)] + pub struct ExprStmt { + pub expr: Node, + } + + #[derive(Debug, Clone)] + pub enum Stmt { + Func { + info: FuncInfo, + params: Vec, + }, + Expr { + expr: Node, + }, + } + + #[derive(Debug, Clone)] + pub struct Program { + pub statements: Stmt, + } + "); +} + +#[test] +fn emit_synthetic_keys() { + let input = indoc! {r#" + Container = { @inner } + InnerWrapper = ? + "#}; + insta::assert_snapshot!(emit(input), @r" + #[derive(Debug, Clone)] + pub struct Container { + pub inner: InnerField, + } + + pub type InnerWrapper = Option; + "); +} + +#[test] +fn emit_mixed_wrappers_and_structs() { + let input = indoc! {r#" + Leaf = { string @text } + Branch = { Node @left Node @right } + Tree = [ Leaf: Leaf Branch: Branch ] + Forest = Tree* + MaybeForest = Forest? + "#}; + insta::assert_snapshot!(emit(input), @r" + #[derive(Debug, Clone)] + pub struct Leaf { + pub text: String, + } + + #[derive(Debug, Clone)] + pub struct Branch { + pub left: Node, + pub right: Node, + } + + #[derive(Debug, Clone)] + pub enum Tree { + Leaf { + text: String, + }, + Branch { + left: Node, + right: Node, + }, + } + + pub type Forest = Vec; + + pub type MaybeForest = Option>; + "); +} + +// --- Edge Cases --- + +#[test] +fn emit_single_variant_union() { + let input = indoc! {r#" + OnlyVariant = { Node @value } + Single = [ Only: OnlyVariant ] + "#}; + insta::assert_snapshot!(emit(input), @r" + #[derive(Debug, Clone)] + pub struct OnlyVariant { + pub value: Node, + } + + #[derive(Debug, Clone)] + pub enum Single { + Only { + value: Node, + }, + } + "); +} + +#[test] +fn emit_deeply_nested() { + let input = indoc! {r#" + A = { Node @val } + B = { A @a } + C = { B @b } + D = { C @c } + "#}; + insta::assert_snapshot!(emit(input), @r" + #[derive(Debug, Clone)] + pub struct A { + pub val: Node, + } + + #[derive(Debug, Clone)] + pub struct B { + pub a: A, + } + + #[derive(Debug, Clone)] + pub struct C { + pub b: B, + } + + #[derive(Debug, Clone)] + pub struct D { + pub c: C, + } + "); +} + +#[test] +fn emit_list_of_optionals() { + let input = indoc! {r#" + Item = { Node @value } + MaybeItem = Item? + Items = MaybeItem* + "#}; + insta::assert_snapshot!(emit(input), @r" + #[derive(Debug, Clone)] + pub struct Item { + pub value: Node, + } + + pub type MaybeItem = Option; + + pub type Items = Vec>; + "); +} + +#[test] +fn emit_builtin_value_with_named_key() { + let input = indoc! {r#" + AliasNode = Node + AliasString = string + AliasUnit = () + "#}; + insta::assert_snapshot!(emit(input), @""); +} diff --git a/crates/plotnik-lib/src/infer/emit/typescript.rs b/crates/plotnik-lib/src/infer/emit/typescript.rs new file mode 100644 index 00000000..3e9591ff --- /dev/null +++ b/crates/plotnik-lib/src/infer/emit/typescript.rs @@ -0,0 +1,289 @@ +//! TypeScript code emitter for inferred types. +//! +//! Emits TypeScript interface and type definitions from a `TypeTable`. + +use indexmap::IndexMap; + +use super::super::types::{TypeKey, TypeTable, TypeValue}; + +/// Configuration for TypeScript emission. +#[derive(Debug, Clone)] +pub struct TypeScriptEmitConfig { + /// How to represent optional values. + pub optional_style: OptionalStyle, + /// Whether to export types. + pub export: bool, + /// Whether to make fields readonly. + pub readonly: bool, + /// Whether to inline synthetic types. + pub inline_synthetic: bool, + /// Name for the Node type. + pub node_type_name: String, + /// Whether to emit `type Foo = ...` instead of `interface Foo { ... }`. + pub use_type_alias: bool, +} + +/// How to represent optional types. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OptionalStyle { + /// `T | null` + Null, + /// `T | undefined` + Undefined, + /// `T?` (optional property) + QuestionMark, +} + +impl Default for TypeScriptEmitConfig { + fn default() -> Self { + Self { + optional_style: OptionalStyle::Null, + export: false, + readonly: false, + inline_synthetic: true, + node_type_name: "SyntaxNode".to_string(), + use_type_alias: false, + } + } +} + +/// Emit TypeScript code from a type table. +pub fn emit_typescript(table: &TypeTable<'_>, config: &TypeScriptEmitConfig) -> String { + let mut output = String::new(); + let sorted = topological_sort(table); + + for key in sorted { + let Some(value) = table.get(&key) else { + continue; + }; + + // Skip built-in types + if matches!(key, TypeKey::Node | TypeKey::String | TypeKey::Unit) { + continue; + } + + // Skip synthetic types if inlining + if config.inline_synthetic && matches!(key, TypeKey::Synthetic(_)) { + continue; + } + + let type_def = emit_type_def(&key, value, table, config); + if !type_def.is_empty() { + output.push_str(&type_def); + output.push_str("\n\n"); + } + } + + output.trim_end().to_string() +} + +fn emit_type_def( + key: &TypeKey<'_>, + value: &TypeValue<'_>, + table: &TypeTable<'_>, + config: &TypeScriptEmitConfig, +) -> String { + let name = key.to_pascal_case(); + let export_prefix = if config.export && !matches!(key, TypeKey::Synthetic(_)) { + "export " + } else { + "" + }; + + match value { + TypeValue::Node | TypeValue::String | TypeValue::Unit => String::new(), + + TypeValue::Struct(fields) => { + if config.use_type_alias { + let inline = emit_inline_struct(fields, table, config); + format!("{}type {} = {};", export_prefix, name, inline) + } else if fields.is_empty() { + format!("{}interface {} {{}}", export_prefix, name) + } else { + let mut out = format!("{}interface {} {{\n", export_prefix, name); + for (field_name, field_type) in fields { + let (type_str, is_optional) = emit_field_type(field_type, table, config); + let readonly = if config.readonly { "readonly " } else { "" }; + let optional = + if is_optional && config.optional_style == OptionalStyle::QuestionMark { + "?" + } else { + "" + }; + out.push_str(&format!( + " {}{}{}: {};\n", + readonly, field_name, optional, type_str + )); + } + out.push('}'); + out + } + } + + TypeValue::TaggedUnion(variants) => { + let mut out = format!("{}type {} =\n", export_prefix, name); + let variant_count = variants.len(); + for (i, (variant_name, variant_key)) in variants.iter().enumerate() { + out.push_str(" | { tag: \""); + out.push_str(variant_name); + out.push('"'); + // Look up variant type to get fields + if let Some(TypeValue::Struct(fields)) = table.get(variant_key) { + for (field_name, field_type) in fields { + let (type_str, is_optional) = emit_field_type(field_type, table, config); + let optional = if is_optional + && config.optional_style == OptionalStyle::QuestionMark + { + "?" + } else { + "" + }; + out.push_str(&format!("; {}{}: {}", field_name, optional, type_str)); + } + } + out.push_str(" }"); + if i < variant_count - 1 { + out.push('\n'); + } + } + out.push(';'); + out + } + + TypeValue::Optional(_) | TypeValue::List(_) | TypeValue::NonEmptyList(_) => { + let (type_str, _) = emit_field_type(key, table, config); + format!("{}type {} = {};", export_prefix, name, type_str) + } + } +} + +/// Returns (type_string, is_optional) +pub(crate) fn emit_field_type( + key: &TypeKey<'_>, + table: &TypeTable<'_>, + config: &TypeScriptEmitConfig, +) -> (String, bool) { + match table.get(key) { + Some(TypeValue::Node) => (config.node_type_name.clone(), false), + Some(TypeValue::String) => ("string".to_string(), false), + Some(TypeValue::Unit) => ("{}".to_string(), false), + + Some(TypeValue::Optional(inner)) => { + let (inner_str, _) = emit_field_type(inner, table, config); + let type_str = match config.optional_style { + OptionalStyle::Null => format!("{} | null", inner_str), + OptionalStyle::Undefined => format!("{} | undefined", inner_str), + OptionalStyle::QuestionMark => inner_str, + }; + (type_str, true) + } + + Some(TypeValue::List(inner)) => { + let (inner_str, _) = emit_field_type(inner, table, config); + (format!("{}[]", wrap_if_union(&inner_str)), false) + } + + Some(TypeValue::NonEmptyList(inner)) => { + let (inner_str, _) = emit_field_type(inner, table, config); + (format!("[{}, ...{}[]]", inner_str, inner_str), false) + } + + Some(TypeValue::Struct(fields)) => { + if config.inline_synthetic && matches!(key, TypeKey::Synthetic(_)) { + (emit_inline_struct(fields, table, config), false) + } else { + (key.to_pascal_case(), false) + } + } + + Some(TypeValue::TaggedUnion(_)) => (key.to_pascal_case(), false), + + None => (key.to_pascal_case(), false), + } +} + +pub(crate) fn emit_inline_struct( + fields: &IndexMap<&str, TypeKey<'_>>, + table: &TypeTable<'_>, + config: &TypeScriptEmitConfig, +) -> String { + if fields.is_empty() { + return "{}".to_string(); + } + + let mut out = String::from("{ "); + for (i, (field_name, field_type)) in fields.iter().enumerate() { + let (type_str, is_optional) = emit_field_type(field_type, table, config); + let optional = if is_optional && config.optional_style == OptionalStyle::QuestionMark { + "?" + } else { + "" + }; + out.push_str(field_name); + out.push_str(optional); + out.push_str(": "); + out.push_str(&type_str); + if i < fields.len() - 1 { + out.push_str("; "); + } + } + out.push_str(" }"); + out +} + +pub(crate) fn wrap_if_union(type_str: &str) -> String { + if type_str.contains('|') { + format!("({})", type_str) + } else { + type_str.to_string() + } +} + +/// Topologically sort types so dependencies come before dependents. +pub(crate) fn topological_sort<'src>(table: &TypeTable<'src>) -> Vec> { + let mut result = Vec::new(); + let mut visited = IndexMap::new(); + + for key in table.types.keys() { + visit(key, table, &mut visited, &mut result); + } + + result +} + +fn visit<'src>( + key: &TypeKey<'src>, + table: &TypeTable<'src>, + visited: &mut IndexMap, bool>, + result: &mut Vec>, +) { + if visited.contains_key(key) { + return; + } + + visited.insert(key.clone(), true); + + let Some(value) = table.get(key) else { + visited.insert(key.clone(), false); + result.push(key.clone()); + return; + }; + + for dep in dependencies(value) { + visit(&dep, table, visited, result); + } + + visited.insert(key.clone(), false); + result.push(key.clone()); +} + +pub(crate) fn dependencies<'src>(value: &TypeValue<'src>) -> Vec> { + match value { + TypeValue::Node | TypeValue::String | TypeValue::Unit => vec![], + TypeValue::Struct(fields) => fields.values().cloned().collect(), + TypeValue::TaggedUnion(variants) => variants.values().cloned().collect(), + TypeValue::Optional(inner) | TypeValue::List(inner) | TypeValue::NonEmptyList(inner) => { + vec![inner.clone()] + } + } +} diff --git a/crates/plotnik-lib/src/infer/emit/typescript_tests.rs b/crates/plotnik-lib/src/infer/emit/typescript_tests.rs new file mode 100644 index 00000000..73ee2a15 --- /dev/null +++ b/crates/plotnik-lib/src/infer/emit/typescript_tests.rs @@ -0,0 +1,743 @@ +use super::typescript::{OptionalStyle, TypeScriptEmitConfig, emit_typescript}; +use crate::infer::tyton::parse; +use indoc::indoc; + +fn emit(input: &str) -> String { + let table = parse(input).expect("tyton parse failed"); + emit_typescript(&table, &TypeScriptEmitConfig::default()) +} + +fn emit_with_config(input: &str, config: &TypeScriptEmitConfig) -> String { + let table = parse(input).expect("tyton parse failed"); + emit_typescript(&table, config) +} + +// --- Simple Structs (Interfaces) --- + +#[test] +fn emit_interface_single_field() { + let input = "Foo = { Node @value }"; + insta::assert_snapshot!(emit(input), @r" + interface Foo { + value: SyntaxNode; + } + "); +} + +#[test] +fn emit_interface_multiple_fields() { + let input = "Func = { string @name Node @body Node @params }"; + insta::assert_snapshot!(emit(input), @r" + interface Func { + name: string; + body: SyntaxNode; + params: SyntaxNode; + } + "); +} + +#[test] +fn emit_interface_empty() { + let input = "Empty = {}"; + insta::assert_snapshot!(emit(input), @"interface Empty {}"); +} + +#[test] +fn emit_interface_with_unit_field() { + let input = "Wrapper = { () @marker }"; + insta::assert_snapshot!(emit(input), @r" + interface Wrapper { + marker: {}; + } + "); +} + +#[test] +fn emit_interface_nested_refs() { + let input = indoc! {r#" + Inner = { Node @value } + Outer = { Inner @inner string @label } + "#}; + insta::assert_snapshot!(emit(input), @r" + interface Inner { + value: SyntaxNode; + } + + interface Outer { + inner: Inner; + label: string; + } + "); +} + +// --- Tagged Unions --- + +#[test] +fn emit_tagged_union_simple() { + let input = indoc! {r#" + AssignStmt = { Node @target Node @value } + CallStmt = { Node @func } + Stmt = [ Assign: AssignStmt Call: CallStmt ] + "#}; + insta::assert_snapshot!(emit(input), @r#" + interface AssignStmt { + target: SyntaxNode; + value: SyntaxNode; + } + + interface CallStmt { + func: SyntaxNode; + } + + type Stmt = + | { tag: "Assign"; target: SyntaxNode; value: SyntaxNode } + | { tag: "Call"; func: SyntaxNode }; + "#); +} + +#[test] +fn emit_tagged_union_with_empty_variant() { + let input = indoc! {r#" + ValueVariant = { Node @value } + Expr = [ Some: ValueVariant None: () ] + "#}; + insta::assert_snapshot!(emit(input), @r#" + interface ValueVariant { + value: SyntaxNode; + } + + type Expr = + | { tag: "Some"; value: SyntaxNode } + | { tag: "None" }; + "#); +} + +#[test] +fn emit_tagged_union_all_empty() { + let input = "Token = [ Comma: () Dot: () Semi: () ]"; + insta::assert_snapshot!(emit(input), @r#" + type Token = + | { tag: "Comma" } + | { tag: "Dot" } + | { tag: "Semi" }; + "#); +} + +#[test] +fn emit_tagged_union_with_builtins() { + let input = "Value = [ Text: string Code: Node Empty: () ]"; + insta::assert_snapshot!(emit(input), @r#" + type Value = + | { tag: "Text" } + | { tag: "Code" } + | { tag: "Empty" }; + "#); +} + +// --- Wrapper Types --- + +#[test] +fn emit_optional_null() { + let input = "MaybeNode = Node?"; + insta::assert_snapshot!(emit(input), @"type MaybeNode = SyntaxNode | null;"); +} + +#[test] +fn emit_optional_undefined() { + let input = "MaybeNode = Node?"; + let config = TypeScriptEmitConfig { + optional_style: OptionalStyle::Undefined, + ..Default::default() + }; + insta::assert_snapshot!(emit_with_config(input, &config), @"type MaybeNode = SyntaxNode | undefined;"); +} + +#[test] +fn emit_optional_question_mark() { + let input = indoc! {r#" + MaybeNode = Node? + Foo = { MaybeNode @maybe } + "#}; + let config = TypeScriptEmitConfig { + optional_style: OptionalStyle::QuestionMark, + ..Default::default() + }; + insta::assert_snapshot!(emit_with_config(input, &config), @r" + type MaybeNode = SyntaxNode; + + interface Foo { + maybe?: SyntaxNode; + } + "); +} + +#[test] +fn emit_list() { + let input = "Nodes = Node*"; + insta::assert_snapshot!(emit(input), @"type Nodes = SyntaxNode[];"); +} + +#[test] +fn emit_non_empty_list() { + let input = "Nodes = Node+"; + insta::assert_snapshot!(emit(input), @"type Nodes = [SyntaxNode, ...SyntaxNode[]];"); +} + +#[test] +fn emit_optional_named() { + let input = indoc! {r#" + Stmt = { Node @value } + MaybeStmt = Stmt? + "#}; + insta::assert_snapshot!(emit(input), @r" + interface Stmt { + value: SyntaxNode; + } + + type MaybeStmt = Stmt | null; + "); +} + +#[test] +fn emit_list_named() { + let input = indoc! {r#" + Stmt = { Node @value } + Stmts = Stmt* + "#}; + insta::assert_snapshot!(emit(input), @r" + interface Stmt { + value: SyntaxNode; + } + + type Stmts = Stmt[]; + "); +} + +#[test] +fn emit_nested_wrappers() { + let input = indoc! {r#" + Item = { Node @value } + Items = Item* + MaybeItems = Items? + "#}; + insta::assert_snapshot!(emit(input), @r" + interface Item { + value: SyntaxNode; + } + + type Items = Item[]; + + type MaybeItems = Item[] | null; + "); +} + +#[test] +fn emit_list_of_optionals() { + let input = indoc! {r#" + Item = { Node @value } + MaybeItem = Item? + Items = MaybeItem* + "#}; + insta::assert_snapshot!(emit(input), @r" + interface Item { + value: SyntaxNode; + } + + type MaybeItem = Item | null; + + type Items = (Item | null)[]; + "); +} + +// --- Config Variations --- + +#[test] +fn emit_with_export() { + let input = "Foo = { Node @value }"; + let config = TypeScriptEmitConfig { + export: true, + ..Default::default() + }; + insta::assert_snapshot!(emit_with_config(input, &config), @r" + export interface Foo { + value: SyntaxNode; + } + "); +} + +#[test] +fn emit_readonly_fields() { + let input = "Foo = { Node @value string @name }"; + let config = TypeScriptEmitConfig { + readonly: true, + ..Default::default() + }; + insta::assert_snapshot!(emit_with_config(input, &config), @r" + interface Foo { + readonly value: SyntaxNode; + readonly name: string; + } + "); +} + +#[test] +fn emit_custom_node_type() { + let input = "Foo = { Node @value }"; + let config = TypeScriptEmitConfig { + node_type_name: "TSNode".to_string(), + ..Default::default() + }; + insta::assert_snapshot!(emit_with_config(input, &config), @r" + interface Foo { + value: TSNode; + } + "); +} + +#[test] +fn emit_type_alias_instead_of_interface() { + let input = "Foo = { Node @value string @name }"; + let config = TypeScriptEmitConfig { + use_type_alias: true, + ..Default::default() + }; + insta::assert_snapshot!(emit_with_config(input, &config), @"type Foo = { value: SyntaxNode; name: string };"); +} + +#[test] +fn emit_type_alias_empty() { + let input = "Empty = {}"; + let config = TypeScriptEmitConfig { + use_type_alias: true, + ..Default::default() + }; + insta::assert_snapshot!(emit_with_config(input, &config), @"type Empty = {};"); +} + +#[test] +fn emit_type_alias_nested() { + let input = indoc! {r#" + Inner = { Node @value } + Outer = { Inner @inner string @label } + "#}; + let config = TypeScriptEmitConfig { + use_type_alias: true, + ..Default::default() + }; + insta::assert_snapshot!(emit_with_config(input, &config), @r" + type Inner = { value: SyntaxNode }; + + type Outer = { inner: Inner; label: string }; + "); +} + +#[test] +fn emit_no_inline_synthetic() { + let input = indoc! {r#" + Container = { @inner } + "#}; + let config = TypeScriptEmitConfig { + inline_synthetic: false, + ..Default::default() + }; + insta::assert_snapshot!(emit_with_config(input, &config), @r" + interface Container { + inner: InnerField; + } + "); +} + +#[test] +fn emit_inline_synthetic() { + let input = indoc! {r#" + Container = { @inner } + "#}; + insta::assert_snapshot!(emit(input), @r" + interface Container { + inner: InnerField; + } + "); +} + +// --- Complex Scenarios --- + +#[test] +fn emit_complex_program() { + let input = indoc! {r#" + FuncInfo = { string @name Node @body } + Param = { string @name string @type_annotation } + Params = Param* + FuncDecl = { FuncInfo @info Params @params } + ExprStmt = { Node @expr } + Stmt = [ Func: FuncDecl Expr: ExprStmt ] + Program = { Stmt @statements } + "#}; + insta::assert_snapshot!(emit(input), @r#" + interface FuncInfo { + name: string; + body: SyntaxNode; + } + + interface Param { + name: string; + type_annotation: string; + } + + type Params = Param[]; + + interface FuncDecl { + info: FuncInfo; + params: Param[]; + } + + interface ExprStmt { + expr: SyntaxNode; + } + + type Stmt = + | { tag: "Func"; info: FuncInfo; params: Param[] } + | { tag: "Expr"; expr: SyntaxNode }; + + interface Program { + statements: Stmt; + } + "#); +} + +#[test] +fn emit_mixed_wrappers_and_structs() { + let input = indoc! {r#" + Leaf = { string @text } + Branch = { Node @left Node @right } + Tree = [ Leaf: Leaf Branch: Branch ] + Forest = Tree* + MaybeForest = Forest? + "#}; + insta::assert_snapshot!(emit(input), @r#" + interface Leaf { + text: string; + } + + interface Branch { + left: SyntaxNode; + right: SyntaxNode; + } + + type Tree = + | { tag: "Leaf"; text: string } + | { tag: "Branch"; left: SyntaxNode; right: SyntaxNode }; + + type Forest = Tree[]; + + type MaybeForest = Tree[] | null; + "#); +} + +#[test] +fn emit_all_config_options() { + let input = indoc! {r#" + MaybeNode = Node? + Item = { Node @value MaybeNode @maybe } + Items = Item* + "#}; + let config = TypeScriptEmitConfig { + optional_style: OptionalStyle::QuestionMark, + export: true, + readonly: true, + inline_synthetic: true, + node_type_name: "ASTNode".to_string(), + use_type_alias: false, + }; + insta::assert_snapshot!(emit_with_config(input, &config), @r" + export type MaybeNode = ASTNode; + + export interface Item { + readonly value: ASTNode; + readonly maybe?: ASTNode; + } + + export type Items = Item[]; + "); +} + +// --- Edge Cases --- + +#[test] +fn emit_single_variant_union() { + let input = indoc! {r#" + OnlyVariant = { Node @value } + Single = [ Only: OnlyVariant ] + "#}; + insta::assert_snapshot!(emit(input), @r#" + interface OnlyVariant { + value: SyntaxNode; + } + + type Single = + | { tag: "Only"; value: SyntaxNode }; + "#); +} + +#[test] +fn emit_deeply_nested() { + let input = indoc! {r#" + A = { Node @val } + B = { A @a } + C = { B @b } + D = { C @c } + "#}; + insta::assert_snapshot!(emit(input), @r" + interface A { + val: SyntaxNode; + } + + interface B { + a: A; + } + + interface C { + b: B; + } + + interface D { + c: C; + } + "); +} + +#[test] +fn emit_union_in_list() { + let input = indoc! {r#" + A = { Node @a } + B = { Node @b } + Choice = [ A: A B: B ] + Choices = Choice* + "#}; + insta::assert_snapshot!(emit(input), @r#" + interface A { + a: SyntaxNode; + } + + interface B { + b: SyntaxNode; + } + + type Choice = + | { tag: "A"; a: SyntaxNode } + | { tag: "B"; b: SyntaxNode }; + + type Choices = Choice[]; + "#); +} + +#[test] +fn emit_optional_in_struct_null_style() { + let input = indoc! {r#" + MaybeNode = Node? + Container = { MaybeNode @item string @name } + "#}; + insta::assert_snapshot!(emit(input), @r" + type MaybeNode = SyntaxNode | null; + + interface Container { + item: SyntaxNode | null; + name: string; + } + "); +} + +#[test] +fn emit_optional_in_struct_undefined_style() { + let input = indoc! {r#" + MaybeNode = Node? + Container = { MaybeNode @item string @name } + "#}; + let config = TypeScriptEmitConfig { + optional_style: OptionalStyle::Undefined, + ..Default::default() + }; + insta::assert_snapshot!(emit_with_config(input, &config), @r" + type MaybeNode = SyntaxNode | undefined; + + interface Container { + item: SyntaxNode | undefined; + name: string; + } + "); +} + +#[test] +fn emit_tagged_union_with_optional_field_question_mark() { + let input = indoc! {r#" + MaybeNode = Node? + VariantA = { MaybeNode @value } + VariantB = { Node @item } + Choice = [ A: VariantA B: VariantB ] + "#}; + let config = TypeScriptEmitConfig { + optional_style: OptionalStyle::QuestionMark, + ..Default::default() + }; + insta::assert_snapshot!(emit_with_config(input, &config), @r#" + type MaybeNode = SyntaxNode; + + interface VariantA { + value?: SyntaxNode; + } + + interface VariantB { + item: SyntaxNode; + } + + type Choice = + | { tag: "A"; value?: SyntaxNode } + | { tag: "B"; item: SyntaxNode }; + "#); +} + +#[test] +fn emit_struct_with_union_field() { + let input = indoc! {r#" + A = { Node @a } + B = { Node @b } + Choice = [ A: A B: B ] + Container = { Choice @choice string @name } + "#}; + insta::assert_snapshot!(emit(input), @r#" + interface A { + a: SyntaxNode; + } + + interface B { + b: SyntaxNode; + } + + type Choice = + | { tag: "A"; a: SyntaxNode } + | { tag: "B"; b: SyntaxNode }; + + interface Container { + choice: Choice; + name: string; + } + "#); +} + +#[test] +fn emit_struct_with_forward_ref() { + let input = indoc! {r#" + Container = { Later @item } + Later = { Node @value } + "#}; + insta::assert_snapshot!(emit(input), @r" + interface Later { + value: SyntaxNode; + } + + interface Container { + item: Later; + } + "); +} + +#[test] +fn emit_synthetic_type_no_inline() { + let input = " = { Node @value }"; + let config = TypeScriptEmitConfig { + inline_synthetic: false, + ..Default::default() + }; + insta::assert_snapshot!(emit_with_config(input, &config), @r" + interface FooBar { + value: SyntaxNode; + } + "); +} + +#[test] +fn emit_synthetic_type_with_inline() { + let input = " = { Node @value }"; + let config = TypeScriptEmitConfig { + inline_synthetic: true, + ..Default::default() + }; + insta::assert_snapshot!(emit_with_config(input, &config), @""); +} + +#[test] +fn emit_field_referencing_tagged_union() { + let input = indoc! {r#" + VarA = { Node @x } + VarB = { Node @y } + Choice = [ A: VarA B: VarB ] + Container = { Choice @choice } + "#}; + insta::assert_snapshot!(emit(input), @r#" + interface VarA { + x: SyntaxNode; + } + + interface VarB { + y: SyntaxNode; + } + + type Choice = + | { tag: "A"; x: SyntaxNode } + | { tag: "B"; y: SyntaxNode }; + + interface Container { + choice: Choice; + } + "#); +} + +#[test] +fn emit_field_referencing_unknown_type() { + let input = "Container = { DoesNotExist @unknown }"; + insta::assert_snapshot!(emit(input), @r" + interface Container { + unknown: DoesNotExist; + } + "); +} + +#[test] +fn emit_empty_interface_no_type_alias() { + let input = "Empty = {}"; + let config = TypeScriptEmitConfig { + use_type_alias: false, + ..Default::default() + }; + insta::assert_snapshot!(emit_with_config(input, &config), @"interface Empty {}"); +} + +#[test] +fn emit_inline_synthetic_struct_with_optional_field() { + let input = indoc! {r#" + MaybeNode = Node? + = { Node @value MaybeNode @maybe } + Container = { @inner } + "#}; + let config = TypeScriptEmitConfig { + inline_synthetic: true, + optional_style: OptionalStyle::QuestionMark, + ..Default::default() + }; + insta::assert_snapshot!(emit_with_config(input, &config), @r" + type MaybeNode = SyntaxNode; + + interface Container { + inner: { value: SyntaxNode; maybe?: SyntaxNode }; + } + "); +} + +#[test] +fn emit_builtin_value_with_named_key() { + let input = indoc! {r#" + AliasNode = Node + AliasString = string + AliasUnit = () + "#}; + insta::assert_snapshot!(emit(input), @""); +} diff --git a/crates/plotnik-lib/src/infer/mod.rs b/crates/plotnik-lib/src/infer/mod.rs new file mode 100644 index 00000000..46471372 --- /dev/null +++ b/crates/plotnik-lib/src/infer/mod.rs @@ -0,0 +1,21 @@ +//! Type inference for query output types. +//! +//! This module provides: +//! - `TypeTable`: collection of inferred types +//! - `TypeKey` / `TypeValue`: type representation +//! - `emit_rust`: Rust code emitter +//! - `emit_typescript`: TypeScript code emitter + +pub mod emit; +mod types; +pub mod tyton; + +#[cfg(test)] +mod types_tests; +#[cfg(test)] +mod tyton_tests; + +pub use emit::{ + Indirection, OptionalStyle, RustEmitConfig, TypeScriptEmitConfig, emit_rust, emit_typescript, +}; +pub use types::{TypeKey, TypeTable, TypeValue}; diff --git a/crates/plotnik-lib/src/infer/types.rs b/crates/plotnik-lib/src/infer/types.rs new file mode 100644 index 00000000..83ba2508 --- /dev/null +++ b/crates/plotnik-lib/src/infer/types.rs @@ -0,0 +1,126 @@ +//! Type representation for inferred query output types. +//! +//! The type system is flat: all types live in a `TypeTable` keyed by `TypeKey`. +//! Wrapper types (Optional, List, NonEmptyList) reference inner types by key. + +use indexmap::IndexMap; + +/// Identity of a type in the type table. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum TypeKey<'src> { + /// Tree-sitter node (built-in) + Node, + /// String value from `:: string` annotation (built-in) + String, + /// Unit type for empty captures (built-in) + Unit, + /// User-provided type name via `:: TypeName` + Named(&'src str), + /// Path-based synthetic name: ["Foo", "bar"] → FooBar + Synthetic(Vec<&'src str>), +} + +impl TypeKey<'_> { + /// Render as PascalCase type name. + pub fn to_pascal_case(&self) -> String { + match self { + TypeKey::Node => "Node".to_string(), + TypeKey::String => "String".to_string(), + TypeKey::Unit => "Unit".to_string(), + TypeKey::Named(name) => (*name).to_string(), + TypeKey::Synthetic(segments) => segments.iter().map(|s| to_pascal(s)).collect(), + } + } +} + +/// Convert snake_case or lowercase to PascalCase. +pub(crate) fn to_pascal(s: &str) -> String { + s.split('_') + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().chain(chars).collect(), + } + }) + .collect() +} + +/// Type definition stored in the type table. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TypeValue<'src> { + /// Tree-sitter node primitive + Node, + /// String primitive + String, + /// Unit type (empty struct) + Unit, + /// Struct with named fields + Struct(IndexMap<&'src str, TypeKey<'src>>), + /// Tagged union: variant name → variant type (must resolve to Struct or Unit) + TaggedUnion(IndexMap<&'src str, TypeKey<'src>>), + /// Optional wrapper + Optional(TypeKey<'src>), + /// Zero-or-more list wrapper + List(TypeKey<'src>), + /// One-or-more list wrapper + NonEmptyList(TypeKey<'src>), +} + +/// Collection of all inferred types for a query. +#[derive(Debug, Clone)] +pub struct TypeTable<'src> { + /// All type definitions, keyed by their identity. + /// Pre-populated with built-in types (Node, String, Unit). + pub types: IndexMap, TypeValue<'src>>, + /// Types that contain cyclic references (need Box in Rust). + pub cyclic: Vec>, +} + +impl<'src> TypeTable<'src> { + /// Create a new type table with built-in types pre-populated. + pub fn new() -> Self { + let mut types = IndexMap::new(); + types.insert(TypeKey::Node, TypeValue::Node); + types.insert(TypeKey::String, TypeValue::String); + types.insert(TypeKey::Unit, TypeValue::Unit); + Self { + types, + cyclic: Vec::new(), + } + } + + /// Insert a type definition. Returns the key for chaining. + pub fn insert(&mut self, key: TypeKey<'src>, value: TypeValue<'src>) -> TypeKey<'src> { + self.types.insert(key.clone(), value); + key + } + + /// Mark a type as cyclic (requires indirection in Rust). + pub fn mark_cyclic(&mut self, key: TypeKey<'src>) { + if !self.cyclic.contains(&key) { + self.cyclic.push(key); + } + } + + /// Check if a type is cyclic. + pub fn is_cyclic(&self, key: &TypeKey<'src>) -> bool { + self.cyclic.contains(key) + } + + /// Get a type by key. + pub fn get(&self, key: &TypeKey<'src>) -> Option<&TypeValue<'src>> { + self.types.get(key) + } + + /// Iterate over all types in insertion order. + pub fn iter(&self) -> impl Iterator, &TypeValue<'src>)> { + self.types.iter() + } +} + +impl Default for TypeTable<'_> { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/plotnik-lib/src/infer/types_tests.rs b/crates/plotnik-lib/src/infer/types_tests.rs new file mode 100644 index 00000000..04d48be5 --- /dev/null +++ b/crates/plotnik-lib/src/infer/types_tests.rs @@ -0,0 +1,222 @@ +use super::types::{TypeKey, TypeTable, TypeValue, to_pascal}; +use indexmap::IndexMap; + +#[test] +fn type_key_to_pascal_case_builtins() { + assert_eq!(TypeKey::Node.to_pascal_case(), "Node"); + assert_eq!(TypeKey::String.to_pascal_case(), "String"); + assert_eq!(TypeKey::Unit.to_pascal_case(), "Unit"); +} + +#[test] +fn type_key_to_pascal_case_named() { + assert_eq!( + TypeKey::Named("FunctionInfo").to_pascal_case(), + "FunctionInfo" + ); + assert_eq!(TypeKey::Named("Stmt").to_pascal_case(), "Stmt"); +} + +#[test] +fn type_key_to_pascal_case_synthetic() { + assert_eq!(TypeKey::Synthetic(vec!["Foo"]).to_pascal_case(), "Foo"); + assert_eq!( + TypeKey::Synthetic(vec!["Foo", "bar"]).to_pascal_case(), + "FooBar" + ); + assert_eq!( + TypeKey::Synthetic(vec!["Foo", "bar", "baz"]).to_pascal_case(), + "FooBarBaz" + ); +} + +#[test] +fn type_key_to_pascal_case_snake_case_segments() { + assert_eq!( + TypeKey::Synthetic(vec!["Foo", "bar_baz"]).to_pascal_case(), + "FooBarBaz" + ); + assert_eq!( + TypeKey::Synthetic(vec!["function_info", "params"]).to_pascal_case(), + "FunctionInfoParams" + ); +} + +#[test] +fn type_table_new_has_builtins() { + let table = TypeTable::new(); + assert_eq!(table.get(&TypeKey::Node), Some(&TypeValue::Node)); + assert_eq!(table.get(&TypeKey::String), Some(&TypeValue::String)); + assert_eq!(table.get(&TypeKey::Unit), Some(&TypeValue::Unit)); +} + +#[test] +fn type_table_insert_and_get() { + let mut table = TypeTable::new(); + let key = TypeKey::Named("Foo"); + let value = TypeValue::Struct(IndexMap::new()); + table.insert(key.clone(), value.clone()); + assert_eq!(table.get(&key), Some(&value)); +} + +#[test] +fn type_table_cyclic_tracking() { + let mut table = TypeTable::new(); + let key = TypeKey::Named("Recursive"); + + assert!(!table.is_cyclic(&key)); + table.mark_cyclic(key.clone()); + assert!(table.is_cyclic(&key)); + + // Double marking is idempotent + table.mark_cyclic(key.clone()); + assert_eq!(table.cyclic.len(), 1); +} + +#[test] +fn type_table_iter_preserves_order() { + let mut table = TypeTable::new(); + table.insert(TypeKey::Named("A"), TypeValue::Unit); + table.insert(TypeKey::Named("B"), TypeValue::Unit); + table.insert(TypeKey::Named("C"), TypeValue::Unit); + + let keys: Vec<_> = table.iter().map(|(k, _)| k.clone()).collect(); + // Builtins first, then inserted order + assert_eq!(keys[0], TypeKey::Node); + assert_eq!(keys[1], TypeKey::String); + assert_eq!(keys[2], TypeKey::Unit); + assert_eq!(keys[3], TypeKey::Named("A")); + assert_eq!(keys[4], TypeKey::Named("B")); + assert_eq!(keys[5], TypeKey::Named("C")); +} + +#[test] +fn type_table_default() { + let table: TypeTable = Default::default(); + assert!(table.get(&TypeKey::Node).is_some()); +} + +#[test] +fn type_value_equality() { + let s1 = TypeValue::Struct(IndexMap::new()); + let s2 = TypeValue::Struct(IndexMap::new()); + assert_eq!(s1, s2); + + let mut fields = IndexMap::new(); + fields.insert("x", TypeKey::Node); + let s3 = TypeValue::Struct(fields); + assert_ne!(s1, s3); +} + +#[test] +fn type_value_wrapper_types() { + let opt = TypeValue::Optional(TypeKey::Node); + let list = TypeValue::List(TypeKey::Node); + let ne_list = TypeValue::NonEmptyList(TypeKey::Node); + + assert_ne!(opt, list); + assert_ne!(list, ne_list); +} + +#[test] +fn type_value_tagged_union() { + let mut table = TypeTable::new(); + + let mut assign_fields = IndexMap::new(); + assign_fields.insert("target", TypeKey::String); + table.insert( + TypeKey::Synthetic(vec!["Stmt", "Assign"]), + TypeValue::Struct(assign_fields), + ); + + let mut call_fields = IndexMap::new(); + call_fields.insert("func", TypeKey::String); + table.insert( + TypeKey::Synthetic(vec!["Stmt", "Call"]), + TypeValue::Struct(call_fields), + ); + + let mut variants = IndexMap::new(); + variants.insert("Assign", TypeKey::Synthetic(vec!["Stmt", "Assign"])); + variants.insert("Call", TypeKey::Synthetic(vec!["Stmt", "Call"])); + + let union = TypeValue::TaggedUnion(variants); + table.insert(TypeKey::Named("Stmt"), union); + + let TypeValue::TaggedUnion(v) = table.get(&TypeKey::Named("Stmt")).unwrap() else { + panic!("expected TaggedUnion"); + }; + assert_eq!(v.len(), 2); + assert!(v.contains_key("Assign")); + assert!(v.contains_key("Call")); + assert!(table.get(&v["Assign"]).is_some()); +} + +#[test] +fn type_value_tagged_union_empty_variant() { + let mut table = TypeTable::new(); + + let mut variants = IndexMap::new(); + variants.insert("Empty", TypeKey::Unit); + table.insert( + TypeKey::Named("MaybeEmpty"), + TypeValue::TaggedUnion(variants), + ); + + let TypeValue::TaggedUnion(v) = table.get(&TypeKey::Named("MaybeEmpty")).unwrap() else { + panic!("expected TaggedUnion"); + }; + assert_eq!(v["Empty"], TypeKey::Unit); +} + +#[test] +fn to_pascal_empty_string() { + assert_eq!(to_pascal(""), ""); +} + +#[test] +fn to_pascal_single_char() { + assert_eq!(to_pascal("a"), "A"); + assert_eq!(to_pascal("Z"), "Z"); +} + +#[test] +fn to_pascal_already_pascal() { + assert_eq!(to_pascal("FooBar"), "FooBar"); +} + +#[test] +fn to_pascal_multiple_underscores() { + assert_eq!(to_pascal("foo__bar"), "FooBar"); + assert_eq!(to_pascal("_foo_"), "Foo"); +} + +#[test] +fn type_key_equality() { + assert_eq!(TypeKey::Node, TypeKey::Node); + assert_ne!(TypeKey::Node, TypeKey::String); + assert_eq!(TypeKey::Named("Foo"), TypeKey::Named("Foo")); + assert_ne!(TypeKey::Named("Foo"), TypeKey::Named("Bar")); + assert_eq!( + TypeKey::Synthetic(vec!["a", "b"]), + TypeKey::Synthetic(vec!["a", "b"]) + ); + assert_ne!( + TypeKey::Synthetic(vec!["a", "b"]), + TypeKey::Synthetic(vec!["a", "c"]) + ); +} + +#[test] +fn type_key_hash_consistency() { + use std::collections::HashSet; + let mut set = HashSet::new(); + set.insert(TypeKey::Node); + set.insert(TypeKey::Named("Foo")); + set.insert(TypeKey::Synthetic(vec!["a", "b"])); + + assert!(set.contains(&TypeKey::Node)); + assert!(set.contains(&TypeKey::Named("Foo"))); + assert!(set.contains(&TypeKey::Synthetic(vec!["a", "b"]))); + assert!(!set.contains(&TypeKey::String)); +} diff --git a/crates/plotnik-lib/src/infer/tyton.rs b/crates/plotnik-lib/src/infer/tyton.rs new file mode 100644 index 00000000..a5184131 --- /dev/null +++ b/crates/plotnik-lib/src/infer/tyton.rs @@ -0,0 +1,429 @@ +//! Tyton: Types Testing Object Notation +//! +//! A compact DSL for constructing `TypeTable` test fixtures. +//! +//! # Design +//! +//! Tyton uses a **flattened structure** mirroring `TypeTable`: all types are +//! top-level definitions referenced by name. No inline nesting is supported. +//! +//! ```text +//! // ✗ Invalid: inline optional +//! Foo = { Node? @maybe } +//! +//! // ✓ Valid: separate definition + reference +//! MaybeNode = Node? +//! Foo = { MaybeNode @maybe } +//! ``` +//! +//! # Syntax +//! +//! Keys: +//! - `Node` — built-in node type +//! - `string` — built-in string type +//! - `()` — built-in unit type +//! - `PascalName` — named type +//! - `` — synthetic key from path segments +//! +//! Values: +//! - `{ Type @field ... }` — struct with fields +//! - `[ Tag: Type ... ]` — tagged union +//! - `Key?` — optional wrapper +//! - `Key*` — list wrapper +//! - `Key+` — non-empty list wrapper +//! - `Node` / `string` / `()` — bare builtin alias +//! +//! Definitions: +//! - `Name = { ... }` — define a struct +//! - `Name = [ ... ]` — define a tagged union +//! - `Name = Other?` — define an optional +//! - ` = { ... }` — define with synthetic key +//! - `AliasNode = Node` — alias to builtin +//! +//! # Example +//! +//! ```text +//! FuncInfo = { string @name Node @body } +//! Stmt = [ Assign: AssignStmt Call: CallStmt ] +//! Stmts = Stmt* +//! ``` + +use logos::Logos; + +use super::{TypeKey, TypeTable, TypeValue}; +use indexmap::IndexMap; + +#[derive(Logos, Debug, Clone, PartialEq)] +#[logos(skip r"[ \t\n\r]+")] +enum Token<'src> { + // Built-in type keywords + #[token("Node")] + Node, + + #[token("string")] + String, + + #[token("()")] + Unit, + + // Symbols + #[token("=")] + Eq, + + #[token("{")] + LBrace, + + #[token("}")] + RBrace, + + #[token("[")] + LBracket, + + #[token("]")] + RBracket, + + #[token("<")] + LAngle, + + #[token(">")] + RAngle, + + #[token(":")] + Colon, + + #[token("@")] + At, + + #[token("?")] + Question, + + #[token("*")] + Star, + + #[token("+")] + Plus, + + // Identifiers: PascalCase for type names, snake_case for fields/segments + #[regex(r"[A-Z][a-zA-Z0-9]*", |lex| lex.slice())] + UpperIdent(&'src str), + + #[regex(r"[a-z][a-z0-9_]*", |lex| lex.slice())] + LowerIdent(&'src str), +} + +struct Parser<'src> { + tokens: Vec<(Token<'src>, std::ops::Range)>, + pos: usize, + input: &'src str, +} + +#[derive(Debug)] +pub struct ParseError { + pub message: String, + pub span: std::ops::Range, +} + +impl std::fmt::Display for ParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} at {:?}", self.message, self.span) + } +} + +impl std::error::Error for ParseError {} + +impl<'src> Parser<'src> { + fn new(input: &'src str) -> Result { + let lexer = Token::lexer(input); + let mut tokens = Vec::new(); + + for (result, span) in lexer.spanned() { + match result { + Ok(token) => tokens.push((token, span)), + Err(_) => { + return Err(ParseError { + message: format!("unexpected character: {:?}", &input[span.clone()]), + span, + }); + } + } + } + + Ok(Self { + tokens, + pos: 0, + input, + }) + } + + fn peek(&self) -> Option<&Token<'src>> { + self.tokens.get(self.pos).map(|(t, _)| t) + } + + fn advance(&mut self) -> Option<&Token<'src>> { + let token = self.tokens.get(self.pos).map(|(t, _)| t); + if token.is_some() { + self.pos += 1; + } + token + } + + fn current_span(&self) -> std::ops::Range { + self.tokens + .get(self.pos) + .map(|(_, s)| s.clone()) + .unwrap_or(self.input.len()..self.input.len()) + } + + fn expect(&mut self, expected: Token<'src>) -> Result<(), ParseError> { + let span = self.current_span(); + match self.advance() { + Some(t) if std::mem::discriminant(t) == std::mem::discriminant(&expected) => Ok(()), + Some(t) => Err(ParseError { + message: format!("expected {:?}, got {:?}", expected, t), + span, + }), + None => Err(ParseError { + message: format!("expected {:?}, got EOF", expected), + span, + }), + } + } + + fn parse_type_key(&mut self) -> Result, ParseError> { + let span = self.current_span(); + match self.peek() { + Some(Token::Node) => { + self.advance(); + Ok(TypeKey::Node) + } + Some(Token::String) => { + self.advance(); + Ok(TypeKey::String) + } + Some(Token::Unit) => { + self.advance(); + Ok(TypeKey::Unit) + } + Some(Token::UpperIdent(name)) => { + let name = *name; + self.advance(); + Ok(TypeKey::Named(name)) + } + Some(Token::LAngle) => self.parse_synthetic_key(), + _ => Err(ParseError { + message: "expected type key".to_string(), + span, + }), + } + } + + fn parse_synthetic_key(&mut self) -> Result, ParseError> { + self.expect(Token::LAngle)?; + let mut segments = Vec::new(); + + loop { + let span = self.current_span(); + match self.peek() { + Some(Token::RAngle) => { + self.advance(); + break; + } + Some(Token::UpperIdent(s)) => { + let s = *s; + self.advance(); + segments.push(s); + } + Some(Token::LowerIdent(s)) => { + let s = *s; + self.advance(); + segments.push(s); + } + _ => { + return Err(ParseError { + message: "expected identifier or '>'".to_string(), + span, + }); + } + } + } + + if segments.is_empty() { + return Err(ParseError { + message: "synthetic key cannot be empty".to_string(), + span: self.current_span(), + }); + } + + Ok(TypeKey::Synthetic(segments)) + } + + fn parse_type_value(&mut self) -> Result, ParseError> { + let span = self.current_span(); + match self.peek() { + Some(Token::LBrace) => self.parse_struct(), + Some(Token::LBracket) => self.parse_tagged_union(), + Some(Token::Node) => { + self.advance(); + self.parse_wrapper_or_bare(TypeKey::Node, TypeValue::Node) + } + Some(Token::String) => { + self.advance(); + self.parse_wrapper_or_bare(TypeKey::String, TypeValue::String) + } + Some(Token::Unit) => { + self.advance(); + self.parse_wrapper_or_bare(TypeKey::Unit, TypeValue::Unit) + } + Some(Token::UpperIdent(_)) | Some(Token::LAngle) => { + let key = self.parse_type_key()?; + self.parse_wrapper(key) + } + _ => Err(ParseError { + message: "expected type value".to_string(), + span, + }), + } + } + + fn parse_wrapper_or_bare( + &mut self, + key: TypeKey<'src>, + bare: TypeValue<'src>, + ) -> Result, ParseError> { + match self.peek() { + Some(Token::Question) => { + self.advance(); + Ok(TypeValue::Optional(key)) + } + Some(Token::Star) => { + self.advance(); + Ok(TypeValue::List(key)) + } + Some(Token::Plus) => { + self.advance(); + Ok(TypeValue::NonEmptyList(key)) + } + _ => Ok(bare), + } + } + + fn parse_struct(&mut self) -> Result, ParseError> { + self.expect(Token::LBrace)?; + let mut fields = IndexMap::new(); + + loop { + if matches!(self.peek(), Some(Token::RBrace)) { + self.advance(); + break; + } + + let type_key = self.parse_type_key()?; + self.expect(Token::At)?; + + let span = self.current_span(); + let field_name = match self.advance() { + Some(Token::LowerIdent(name)) => *name, + _ => { + return Err(ParseError { + message: "expected field name (lowercase)".to_string(), + span, + }); + } + }; + + fields.insert(field_name, type_key); + } + + Ok(TypeValue::Struct(fields)) + } + + fn parse_tagged_union(&mut self) -> Result, ParseError> { + self.expect(Token::LBracket)?; + let mut variants = IndexMap::new(); + + loop { + if matches!(self.peek(), Some(Token::RBracket)) { + self.advance(); + break; + } + + let span = self.current_span(); + let tag = match self.advance() { + Some(Token::UpperIdent(name)) => *name, + _ => { + return Err(ParseError { + message: "expected variant tag (uppercase)".to_string(), + span, + }); + } + }; + + self.expect(Token::Colon)?; + let type_key = self.parse_type_key()?; + variants.insert(tag, type_key); + } + + Ok(TypeValue::TaggedUnion(variants)) + } + + fn parse_wrapper(&mut self, inner: TypeKey<'src>) -> Result, ParseError> { + match self.peek() { + Some(Token::Question) => { + self.advance(); + Ok(TypeValue::Optional(inner)) + } + Some(Token::Star) => { + self.advance(); + Ok(TypeValue::List(inner)) + } + Some(Token::Plus) => { + self.advance(); + Ok(TypeValue::NonEmptyList(inner)) + } + _ => Err(ParseError { + message: "expected quantifier (?, *, +) after type key".to_string(), + span: self.current_span(), + }), + } + } + + fn parse_definition(&mut self) -> Result<(TypeKey<'src>, TypeValue<'src>), ParseError> { + let span = self.current_span(); + let key = match self.peek() { + Some(Token::UpperIdent(name)) => { + let name = *name; + self.advance(); + TypeKey::Named(name) + } + Some(Token::LAngle) => self.parse_synthetic_key()?, + _ => { + return Err(ParseError { + message: "expected type name (uppercase) or synthetic key".to_string(), + span, + }); + } + }; + + self.expect(Token::Eq)?; + let value = self.parse_type_value()?; + + Ok((key, value)) + } + + fn parse_all(&mut self) -> Result, ParseError> { + let mut table = TypeTable::new(); + + while self.peek().is_some() { + let (key, value) = self.parse_definition()?; + table.insert(key, value); + } + + Ok(table) + } +} + +/// Parse tyton notation into a TypeTable. +pub fn parse(input: &str) -> Result, ParseError> { + let mut parser = Parser::new(input)?; + parser.parse_all() +} diff --git a/crates/plotnik-lib/src/infer/tyton_tests.rs b/crates/plotnik-lib/src/infer/tyton_tests.rs new file mode 100644 index 00000000..3687a09c --- /dev/null +++ b/crates/plotnik-lib/src/infer/tyton_tests.rs @@ -0,0 +1,407 @@ +use super::tyton::parse; +use indoc::indoc; + +fn dump_table(input: &str) -> String { + match parse(input) { + Ok(table) => { + let mut out = String::new(); + for (key, value) in table.iter() { + out.push_str(&format!("{:?} = {:?}\n", key, value)); + } + out + } + Err(e) => format!("ERROR: {}", e), + } +} + +#[test] +fn parse_empty() { + insta::assert_snapshot!(dump_table(""), @r" + Node = Node + String = String + Unit = Unit + "); +} + +#[test] +fn parse_struct_simple() { + let input = "Foo = { Node @name }"; + insta::assert_snapshot!(dump_table(input), @r#" + Node = Node + String = String + Unit = Unit + Named("Foo") = Struct({"name": Node}) + "#); +} + +#[test] +fn parse_struct_multiple_fields() { + let input = "Func = { string @name Node @body Node @params }"; + insta::assert_snapshot!(dump_table(input), @r#" + Node = Node + String = String + Unit = Unit + Named("Func") = Struct({"name": String, "body": Node, "params": Node}) + "#); +} + +#[test] +fn parse_struct_empty() { + let input = "Empty = {}"; + insta::assert_snapshot!(dump_table(input), @r#" + Node = Node + String = String + Unit = Unit + Named("Empty") = Struct({}) + "#); +} + +#[test] +fn parse_struct_with_unit() { + let input = "Wrapper = { () @unit }"; + insta::assert_snapshot!(dump_table(input), @r#" + Node = Node + String = String + Unit = Unit + Named("Wrapper") = Struct({"unit": Unit}) + "#); +} + +#[test] +fn parse_tagged_union() { + let input = "Stmt = [ Assign: AssignStmt Call: CallStmt ]"; + insta::assert_snapshot!(dump_table(input), @r#" + Node = Node + String = String + Unit = Unit + Named("Stmt") = TaggedUnion({"Assign": Named("AssignStmt"), "Call": Named("CallStmt")}) + "#); +} + +#[test] +fn parse_tagged_union_single() { + let input = "Single = [ Only: OnlyVariant ]"; + insta::assert_snapshot!(dump_table(input), @r#" + Node = Node + String = String + Unit = Unit + Named("Single") = TaggedUnion({"Only": Named("OnlyVariant")}) + "#); +} + +#[test] +fn parse_tagged_union_with_builtins() { + let input = "Mixed = [ Text: string Code: Node Empty: () ]"; + insta::assert_snapshot!(dump_table(input), @r#" + Node = Node + String = String + Unit = Unit + Named("Mixed") = TaggedUnion({"Text": String, "Code": Node, "Empty": Unit}) + "#); +} + +#[test] +fn parse_optional() { + let input = "MaybeNode = Node?"; + insta::assert_snapshot!(dump_table(input), @r#" + Node = Node + String = String + Unit = Unit + Named("MaybeNode") = Optional(Node) + "#); +} + +#[test] +fn parse_list() { + let input = "Nodes = Node*"; + insta::assert_snapshot!(dump_table(input), @r#" + Node = Node + String = String + Unit = Unit + Named("Nodes") = List(Node) + "#); +} + +#[test] +fn parse_non_empty_list() { + let input = "Nodes = Node+"; + insta::assert_snapshot!(dump_table(input), @r#" + Node = Node + String = String + Unit = Unit + Named("Nodes") = NonEmptyList(Node) + "#); +} + +#[test] +fn parse_optional_named() { + let input = "MaybeStmt = Stmt?"; + insta::assert_snapshot!(dump_table(input), @r#" + Node = Node + String = String + Unit = Unit + Named("MaybeStmt") = Optional(Named("Stmt")) + "#); +} + +#[test] +fn parse_list_named() { + let input = "Stmts = Stmt*"; + insta::assert_snapshot!(dump_table(input), @r#" + Node = Node + String = String + Unit = Unit + Named("Stmts") = List(Named("Stmt")) + "#); +} + +#[test] +fn parse_synthetic_key_simple() { + let input = "Wrapper = ?"; + insta::assert_snapshot!(dump_table(input), @r#" + Node = Node + String = String + Unit = Unit + Named("Wrapper") = Optional(Synthetic(["Foo", "bar"])) + "#); +} + +#[test] +fn parse_synthetic_key_multiple_segments() { + let input = "Wrapper = *"; + insta::assert_snapshot!(dump_table(input), @r#" + Node = Node + String = String + Unit = Unit + Named("Wrapper") = List(Synthetic(["Foo", "bar", "baz"])) + "#); +} + +#[test] +fn parse_struct_with_synthetic() { + let input = "Container = { @inner }"; + insta::assert_snapshot!(dump_table(input), @r#" + Node = Node + String = String + Unit = Unit + Named("Container") = Struct({"inner": Synthetic(["Inner", "field"])}) + "#); +} + +#[test] +fn parse_union_with_synthetic() { + let input = "Choice = [ First: Second: ]"; + insta::assert_snapshot!(dump_table(input), @r#" + Node = Node + String = String + Unit = Unit + Named("Choice") = TaggedUnion({"First": Synthetic(["Choice", "first"]), "Second": Synthetic(["Choice", "second"])}) + "#); +} + +#[test] +fn parse_multiple_definitions() { + let input = indoc! {r#" + AssignStmt = { Node @target Node @value } + CallStmt = { Node @func Node @args } + Stmt = [ Assign: AssignStmt Call: CallStmt ] + Stmts = Stmt* + "#}; + insta::assert_snapshot!(dump_table(input), @r#" + Node = Node + String = String + Unit = Unit + Named("AssignStmt") = Struct({"target": Node, "value": Node}) + Named("CallStmt") = Struct({"func": Node, "args": Node}) + Named("Stmt") = TaggedUnion({"Assign": Named("AssignStmt"), "Call": Named("CallStmt")}) + Named("Stmts") = List(Named("Stmt")) + "#); +} + +#[test] +fn parse_complex_example() { + 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? + Program = { Stmt @statements } + "#}; + insta::assert_snapshot!(dump_table(input), @r#" + Node = Node + String = String + Unit = Unit + Named("FuncInfo") = Struct({"name": String, "body": Node}) + Named("Param") = Struct({"name": String, "type_annotation": String}) + Named("Params") = List(Named("Param")) + Named("FuncDecl") = Struct({"info": Named("FuncInfo"), "params": Named("Params")}) + Named("Stmt") = TaggedUnion({"Func": Named("FuncDecl"), "Expr": Node}) + Named("MaybeStmt") = Optional(Named("Stmt")) + Named("Program") = Struct({"statements": Named("Stmt")}) + "#); +} + +#[test] +fn parse_all_builtins() { + let input = indoc! {r#" + AllBuiltins = { Node @node string @str () @unit } + OptNode = Node? + ListStr = string* + NonEmptyUnit = ()+ + "#}; + insta::assert_snapshot!(dump_table(input), @r#" + Node = Node + String = String + Unit = Unit + Named("AllBuiltins") = Struct({"node": Node, "str": String, "unit": Unit}) + Named("OptNode") = Optional(Node) + Named("ListStr") = List(String) + Named("NonEmptyUnit") = NonEmptyList(Unit) + "#); +} + +#[test] +fn error_missing_eq() { + let input = "Foo { Node @x }"; + insta::assert_snapshot!(dump_table(input), @"ERROR: expected Eq, got LBrace at 4..5"); +} + +#[test] +fn error_missing_at() { + let input = "Foo = { Node name }"; + insta::assert_snapshot!(dump_table(input), @r#"ERROR: expected At, got LowerIdent("name") at 13..17"#); +} + +#[test] +fn error_missing_colon_in_union() { + let input = "Foo = [ A B ]"; + insta::assert_snapshot!(dump_table(input), @r#"ERROR: expected Colon, got UpperIdent("B") at 10..11"#); +} + +#[test] +fn error_empty_synthetic() { + let input = "Foo = <>?"; + insta::assert_snapshot!(dump_table(input), @"ERROR: synthetic key cannot be empty at 8..9"); +} + +#[test] +fn error_unclosed_brace() { + let input = "Foo = { Node @x"; + insta::assert_snapshot!(dump_table(input), @"ERROR: expected type key at 15..15"); +} + +#[test] +fn error_unclosed_bracket() { + let input = "Foo = [ A: B"; + insta::assert_snapshot!(dump_table(input), @"ERROR: expected variant tag (uppercase) at 12..12"); +} + +#[test] +fn error_lowercase_type_name() { + let input = "foo = { Node @x }"; + insta::assert_snapshot!(dump_table(input), @"ERROR: expected type name (uppercase) or synthetic key at 0..3"); +} + +#[test] +fn error_uppercase_field_name() { + let input = "Foo = { Node @Name }"; + insta::assert_snapshot!(dump_table(input), @"ERROR: expected field name (lowercase) at 14..18"); +} + +#[test] +fn parse_bare_builtin_alias_node() { + let input = "AliasNode = Node"; + insta::assert_snapshot!(dump_table(input), @r#" + Node = Node + String = String + Unit = Unit + Named("AliasNode") = Node + "#); +} + +#[test] +fn parse_bare_builtin_alias_string() { + let input = "AliasString = string"; + insta::assert_snapshot!(dump_table(input), @r#" + Node = Node + String = String + Unit = Unit + Named("AliasString") = String + "#); +} + +#[test] +fn parse_bare_builtin_alias_unit() { + let input = "AliasUnit = ()"; + insta::assert_snapshot!(dump_table(input), @r#" + Node = Node + String = String + Unit = Unit + Named("AliasUnit") = Unit + "#); +} + +#[test] +fn parse_synthetic_definition_struct() { + let input = " = { Node @value }"; + insta::assert_snapshot!(dump_table(input), @r#" + Node = Node + String = String + Unit = Unit + Synthetic(["Foo", "bar"]) = Struct({"value": Node}) + "#); +} + +#[test] +fn parse_synthetic_definition_union() { + let input = " = [ A: Node B: string ]"; + insta::assert_snapshot!(dump_table(input), @r#" + Node = Node + String = String + Unit = Unit + Synthetic(["Choice", "first"]) = TaggedUnion({"A": Node, "B": String}) + "#); +} + +#[test] +fn parse_synthetic_definition_wrapper() { + let input = " = Node?"; + insta::assert_snapshot!(dump_table(input), @r#" + Node = Node + String = String + Unit = Unit + Synthetic(["Inner", "nested"]) = Optional(Node) + "#); +} + +#[test] +fn error_invalid_char() { + let input = "Foo = { Node @x $ }"; + insta::assert_snapshot!(dump_table(input), @r#"ERROR: unexpected character: "$" at 16..17"#); +} + +#[test] +fn error_eof_in_struct() { + let input = "Foo = { Node @x"; + insta::assert_snapshot!(dump_table(input), @"ERROR: expected type key at 15..15"); +} + +#[test] +fn error_eof_expecting_colon() { + let input = "Foo = [ A"; + insta::assert_snapshot!(dump_table(input), @"ERROR: expected Colon, got EOF at 9..9"); +} + +#[test] +fn error_invalid_token_in_synthetic() { + let input = "Foo = ?"; + insta::assert_snapshot!(dump_table(input), @"ERROR: expected identifier or '>' at 9..10"); +} + +#[test] +fn error_invalid_type_value() { + let input = "Foo = @bar"; + insta::assert_snapshot!(dump_table(input), @"ERROR: expected type value at 6..7"); +} diff --git a/crates/plotnik-lib/src/lib.rs b/crates/plotnik-lib/src/lib.rs index 89202b12..86797bb6 100644 --- a/crates/plotnik-lib/src/lib.rs +++ b/crates/plotnik-lib/src/lib.rs @@ -17,6 +17,7 @@ #![cfg_attr(coverage_nightly, feature(coverage_attribute))] pub mod diagnostics; +pub mod infer; pub mod parser; pub mod query;