From cd2b9694c79f78f32f8f4e066b25c3275e9c4d89 Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sat, 6 Dec 2025 20:03:35 -0300 Subject: [PATCH 1/8] feat: Output type inference --- crates/plotnik-lib/src/infer/emit_rs.rs | 837 ++++++++++++++++++++ crates/plotnik-lib/src/infer/emit_ts.rs | 979 ++++++++++++++++++++++++ crates/plotnik-lib/src/infer/mod.rs | 15 + crates/plotnik-lib/src/infer/types.rs | 322 ++++++++ crates/plotnik-lib/src/lib.rs | 1 + 5 files changed, 2154 insertions(+) create mode 100644 crates/plotnik-lib/src/infer/emit_rs.rs create mode 100644 crates/plotnik-lib/src/infer/emit_ts.rs create mode 100644 crates/plotnik-lib/src/infer/mod.rs create mode 100644 crates/plotnik-lib/src/infer/types.rs diff --git a/crates/plotnik-lib/src/infer/emit_rs.rs b/crates/plotnik-lib/src/infer/emit_rs.rs new file mode 100644 index 00000000..d11c4339 --- /dev/null +++ b/crates/plotnik-lib/src/infer/emit_rs.rs @@ -0,0 +1,837 @@ +//! Rust code emitter for inferred types. +//! +//! Emits Rust struct and enum definitions from a `TypeTable`. + +use indexmap::IndexMap; + +use 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, fields) in variants { + if fields.is_empty() { + out.push_str(&format!(" {},\n", variant_name)); + } else { + out.push_str(&format!(" {} {{\n", variant_name)); + for (field_name, field_type) in fields { + 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('}'); + 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 + } + } +} + +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) + } + Some(TypeValue::Struct(_)) | Some(TypeValue::TaggedUnion(_)) => key.to_pascal_case(), + None => key.to_pascal_case(), + }; + + if is_cyclic { + wrap_indirection(&base, config.indirection) + } else { + base + } +} + +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), + } +} + +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. +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 let Some(&in_progress) = visited.get(key) { + if in_progress { + // Cycle detected, already handled by cyclic marking + return; + } + // Already fully visited + return; + } + + visited.insert(key.clone(), true); // Mark as in progress + + if let Some(value) = table.get(key) { + for dep in dependencies(value) { + visit(&dep, table, visited, result); + } + } + + visited.insert(key.clone(), false); // Mark as done + result.push(key.clone()); +} + +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() + .flat_map(|f| f.values()) + .cloned() + .collect(), + + TypeValue::Optional(inner) | TypeValue::List(inner) | TypeValue::NonEmptyList(inner) => { + vec![inner.clone()] + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn emit_empty_table() { + let table = TypeTable::new(); + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); + assert_eq!(output, ""); + } + + #[test] + fn emit_simple_struct() { + let mut table = TypeTable::new(); + let mut fields = IndexMap::new(); + fields.insert("name", TypeKey::String); + fields.insert("node", TypeKey::Node); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); + + insta::assert_snapshot!(output, @r" + #[derive(Debug, Clone)] + pub struct Foo { + pub name: String, + pub node: Node, + } + "); + } + + #[test] + fn emit_empty_struct() { + let mut table = TypeTable::new(); + table.insert(TypeKey::Named("Empty"), TypeValue::Struct(IndexMap::new())); + + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); + + insta::assert_snapshot!(output, @r" + #[derive(Debug, Clone)] + pub struct Empty; + "); + } + + #[test] + fn emit_unit_field() { + let mut table = TypeTable::new(); + let mut fields = IndexMap::new(); + fields.insert("marker", TypeKey::Unit); + table.insert(TypeKey::Named("WithUnit"), TypeValue::Struct(fields)); + + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); + + insta::assert_snapshot!(output, @r" + #[derive(Debug, Clone)] + pub struct WithUnit { + pub marker: (), + } + "); + } + + #[test] + fn emit_tagged_union() { + let mut table = TypeTable::new(); + let mut variants = IndexMap::new(); + + let mut assign_fields = IndexMap::new(); + assign_fields.insert("target", TypeKey::String); + variants.insert("Assign", assign_fields); + + let mut call_fields = IndexMap::new(); + call_fields.insert("func", TypeKey::String); + variants.insert("Call", call_fields); + + table.insert(TypeKey::Named("Stmt"), TypeValue::TaggedUnion(variants)); + + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); + + insta::assert_snapshot!(output, @r" + #[derive(Debug, Clone)] + pub enum Stmt { + Assign { + target: String, + }, + Call { + func: String, + }, + } + "); + } + + #[test] + fn emit_tagged_union_unit_variant() { + let mut table = TypeTable::new(); + let mut variants = IndexMap::new(); + + variants.insert("None", IndexMap::new()); + + let mut some_fields = IndexMap::new(); + some_fields.insert("value", TypeKey::Node); + variants.insert("Some", some_fields); + + table.insert(TypeKey::Named("Maybe"), TypeValue::TaggedUnion(variants)); + + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); + + insta::assert_snapshot!(output, @r" + #[derive(Debug, Clone)] + pub enum Maybe { + None, + Some { + value: Node, + }, + } + "); + } + + #[test] + fn emit_optional_field() { + let mut table = TypeTable::new(); + table.insert( + TypeKey::Synthetic(vec!["Foo", "bar"]), + TypeValue::Optional(TypeKey::Node), + ); + + let mut fields = IndexMap::new(); + fields.insert("bar", TypeKey::Synthetic(vec!["Foo", "bar"])); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); + + insta::assert_snapshot!(output, @r" + pub type FooBar = Option; + + #[derive(Debug, Clone)] + pub struct Foo { + pub bar: Option, + } + "); + } + + #[test] + fn emit_list_field() { + let mut table = TypeTable::new(); + table.insert( + TypeKey::Synthetic(vec!["Foo", "items"]), + TypeValue::List(TypeKey::Node), + ); + + let mut fields = IndexMap::new(); + fields.insert("items", TypeKey::Synthetic(vec!["Foo", "items"])); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); + + insta::assert_snapshot!(output, @r" + pub type FooItems = Vec; + + #[derive(Debug, Clone)] + pub struct Foo { + pub items: Vec, + } + "); + } + + #[test] + fn emit_non_empty_list_field() { + let mut table = TypeTable::new(); + table.insert( + TypeKey::Synthetic(vec!["Foo", "items"]), + TypeValue::NonEmptyList(TypeKey::String), + ); + + let mut fields = IndexMap::new(); + fields.insert("items", TypeKey::Synthetic(vec!["Foo", "items"])); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); + + insta::assert_snapshot!(output, @r" + pub type FooItems = Vec; + + #[derive(Debug, Clone)] + pub struct Foo { + pub items: Vec, + } + "); + } + + #[test] + fn emit_nested_struct() { + let mut table = TypeTable::new(); + + let mut inner_fields = IndexMap::new(); + inner_fields.insert("value", TypeKey::String); + table.insert(TypeKey::Named("Inner"), TypeValue::Struct(inner_fields)); + + let mut outer_fields = IndexMap::new(); + outer_fields.insert("inner", TypeKey::Named("Inner")); + table.insert(TypeKey::Named("Outer"), TypeValue::Struct(outer_fields)); + + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); + + insta::assert_snapshot!(output, @r" + #[derive(Debug, Clone)] + pub struct Inner { + pub value: String, + } + + #[derive(Debug, Clone)] + pub struct Outer { + pub inner: Inner, + } + "); + } + + #[test] + fn emit_cyclic_type_box() { + let mut table = TypeTable::new(); + + table.insert( + TypeKey::Synthetic(vec!["Tree", "child"]), + TypeValue::Optional(TypeKey::Named("Tree")), + ); + + let mut fields = IndexMap::new(); + fields.insert("value", TypeKey::String); + fields.insert("child", TypeKey::Synthetic(vec!["Tree", "child"])); + table.insert(TypeKey::Named("Tree"), TypeValue::Struct(fields)); + + table.mark_cyclic(TypeKey::Named("Tree")); + + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); + + insta::assert_snapshot!(output, @r" + #[derive(Debug, Clone)] + pub struct Tree { + pub value: String, + pub child: Option>, + } + + pub type TreeChild = Option>; + "); + } + + #[test] + fn emit_cyclic_type_rc() { + let mut table = TypeTable::new(); + + table.insert( + TypeKey::Synthetic(vec!["Tree", "child"]), + TypeValue::Optional(TypeKey::Named("Tree")), + ); + + let mut fields = IndexMap::new(); + fields.insert("child", TypeKey::Synthetic(vec!["Tree", "child"])); + table.insert(TypeKey::Named("Tree"), TypeValue::Struct(fields)); + + table.mark_cyclic(TypeKey::Named("Tree")); + + let mut config = RustEmitConfig::default(); + config.indirection = Indirection::Rc; + let output = emit_rust(&table, &config); + + insta::assert_snapshot!(output, @r" + #[derive(Debug, Clone)] + pub struct Tree { + pub child: Option>, + } + + pub type TreeChild = Option>; + "); + } + + #[test] + fn emit_cyclic_type_arc() { + let mut table = TypeTable::new(); + + let mut fields = IndexMap::new(); + fields.insert("next", TypeKey::Named("Node")); + table.insert(TypeKey::Named("Node"), TypeValue::Struct(fields)); + + table.mark_cyclic(TypeKey::Named("Node")); + + let mut config = RustEmitConfig::default(); + config.indirection = Indirection::Arc; + let output = emit_rust(&table, &config); + + insta::assert_snapshot!(output, @r" + #[derive(Debug, Clone)] + pub struct Node { + pub next: Arc, + } + "); + } + + #[test] + fn emit_no_derives() { + let mut table = TypeTable::new(); + table.insert(TypeKey::Named("Plain"), TypeValue::Struct(IndexMap::new())); + + let config = RustEmitConfig { + indirection: Indirection::Box, + derive_debug: false, + derive_clone: false, + derive_partial_eq: false, + }; + let output = emit_rust(&table, &config); + + insta::assert_snapshot!(output, @"pub struct Plain;"); + } + + #[test] + fn emit_all_derives() { + let mut table = TypeTable::new(); + table.insert(TypeKey::Named("Full"), TypeValue::Struct(IndexMap::new())); + + let config = RustEmitConfig { + indirection: Indirection::Box, + derive_debug: true, + derive_clone: true, + derive_partial_eq: true, + }; + let output = emit_rust(&table, &config); + + insta::assert_snapshot!(output, @r" + #[derive(Debug, Clone, PartialEq)] + pub struct Full; + "); + } + + #[test] + fn emit_synthetic_type_name() { + let mut table = TypeTable::new(); + + let mut fields = IndexMap::new(); + fields.insert("x", TypeKey::Node); + table.insert( + TypeKey::Synthetic(vec!["Foo", "bar", "baz"]), + TypeValue::Struct(fields), + ); + + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); + + insta::assert_snapshot!(output, @r" + #[derive(Debug, Clone)] + pub struct FooBarBaz { + pub x: Node, + } + "); + } + + #[test] + fn emit_complex_nested() { + let mut table = TypeTable::new(); + + // Inner struct + let mut inner = IndexMap::new(); + inner.insert("value", TypeKey::String); + table.insert( + TypeKey::Synthetic(vec!["Root", "item"]), + TypeValue::Struct(inner), + ); + + // List of inner + table.insert( + TypeKey::Synthetic(vec!["Root", "items"]), + TypeValue::List(TypeKey::Synthetic(vec!["Root", "item"])), + ); + + // Root struct + let mut root = IndexMap::new(); + root.insert("items", TypeKey::Synthetic(vec!["Root", "items"])); + table.insert(TypeKey::Named("Root"), TypeValue::Struct(root)); + + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); + + insta::assert_snapshot!(output, @r" + #[derive(Debug, Clone)] + pub struct RootItem { + pub value: String, + } + + pub type RootItems = Vec; + + #[derive(Debug, Clone)] + pub struct Root { + pub items: Vec, + } + "); + } + + #[test] + fn emit_optional_list() { + let mut table = TypeTable::new(); + + table.insert( + TypeKey::Synthetic(vec!["Foo", "items", "inner"]), + TypeValue::List(TypeKey::Node), + ); + table.insert( + TypeKey::Synthetic(vec!["Foo", "items"]), + TypeValue::Optional(TypeKey::Synthetic(vec!["Foo", "items", "inner"])), + ); + + let mut fields = IndexMap::new(); + fields.insert("items", TypeKey::Synthetic(vec!["Foo", "items"])); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); + + insta::assert_snapshot!(output, @r" + pub type FooItemsInner = Vec; + + pub type FooItems = Option>; + + #[derive(Debug, Clone)] + pub struct Foo { + pub items: Option>, + } + "); + } + + #[test] + fn topological_sort_simple() { + let mut table = TypeTable::new(); + table.insert(TypeKey::Named("A"), TypeValue::Unit); + table.insert(TypeKey::Named("B"), TypeValue::Unit); + + let sorted = topological_sort(&table); + let names: Vec<_> = sorted.iter().map(|k| k.to_pascal_case()).collect(); + + // Builtins first + assert!(names.iter().position(|n| n == "Node") < names.iter().position(|n| n == "A")); + } + + #[test] + fn topological_sort_with_dependency() { + let mut table = TypeTable::new(); + + let mut b_fields = IndexMap::new(); + b_fields.insert("a", TypeKey::Named("A")); + table.insert(TypeKey::Named("B"), TypeValue::Struct(b_fields)); + + table.insert(TypeKey::Named("A"), TypeValue::Unit); + + let sorted = topological_sort(&table); + let names: Vec<_> = sorted.iter().map(|k| k.to_pascal_case()).collect(); + + let a_pos = names.iter().position(|n| n == "A").unwrap(); + let b_pos = names.iter().position(|n| n == "B").unwrap(); + assert!(a_pos < b_pos, "A should come before B"); + } + + #[test] + fn topological_sort_cycle() { + let mut table = TypeTable::new(); + + let mut a_fields = IndexMap::new(); + a_fields.insert("b", TypeKey::Named("B")); + table.insert(TypeKey::Named("A"), TypeValue::Struct(a_fields)); + + let mut b_fields = IndexMap::new(); + b_fields.insert("a", TypeKey::Named("A")); + table.insert(TypeKey::Named("B"), TypeValue::Struct(b_fields)); + + // Should not panic + let sorted = topological_sort(&table); + assert!(sorted.iter().any(|k| *k == TypeKey::Named("A"))); + assert!(sorted.iter().any(|k| *k == TypeKey::Named("B"))); + } + + #[test] + fn dependencies_struct() { + let mut fields = IndexMap::new(); + fields.insert("a", TypeKey::Named("A")); + fields.insert("b", TypeKey::Named("B")); + let value = TypeValue::Struct(fields); + + let deps = dependencies(&value); + assert_eq!(deps.len(), 2); + assert!(deps.contains(&TypeKey::Named("A"))); + assert!(deps.contains(&TypeKey::Named("B"))); + } + + #[test] + fn dependencies_tagged_union() { + let mut variants = IndexMap::new(); + let mut v1 = IndexMap::new(); + v1.insert("x", TypeKey::Named("X")); + variants.insert("V1", v1); + + let mut v2 = IndexMap::new(); + v2.insert("y", TypeKey::Named("Y")); + variants.insert("V2", v2); + + let value = TypeValue::TaggedUnion(variants); + let deps = dependencies(&value); + + assert_eq!(deps.len(), 2); + assert!(deps.contains(&TypeKey::Named("X"))); + assert!(deps.contains(&TypeKey::Named("Y"))); + } + + #[test] + fn dependencies_primitives() { + assert!(dependencies(&TypeValue::Node).is_empty()); + assert!(dependencies(&TypeValue::String).is_empty()); + assert!(dependencies(&TypeValue::Unit).is_empty()); + } + + #[test] + fn dependencies_wrappers() { + let opt = TypeValue::Optional(TypeKey::Named("T")); + let list = TypeValue::List(TypeKey::Named("T")); + let ne = TypeValue::NonEmptyList(TypeKey::Named("T")); + + assert_eq!(dependencies(&opt), vec![TypeKey::Named("T")]); + assert_eq!(dependencies(&list), vec![TypeKey::Named("T")]); + assert_eq!(dependencies(&ne), vec![TypeKey::Named("T")]); + } + + #[test] + fn indirection_equality() { + assert_eq!(Indirection::Box, Indirection::Box); + assert_ne!(Indirection::Box, Indirection::Rc); + assert_ne!(Indirection::Rc, Indirection::Arc); + } + + #[test] + fn wrap_indirection_all_variants() { + assert_eq!(wrap_indirection("Foo", Indirection::Box), "Box"); + assert_eq!(wrap_indirection("Foo", Indirection::Rc), "Rc"); + assert_eq!(wrap_indirection("Foo", Indirection::Arc), "Arc"); + } + + #[test] + fn emit_derives_partial() { + let config = RustEmitConfig { + derive_debug: true, + derive_clone: false, + derive_partial_eq: true, + ..Default::default() + }; + let derives = emit_derives(&config); + assert_eq!(derives, "#[derive(Debug, PartialEq)]\n"); + } + + #[test] + fn emit_type_ref_unknown_key() { + let table = TypeTable::new(); + let config = RustEmitConfig::default(); + let type_str = emit_type_ref(&TypeKey::Named("Unknown"), &table, &config); + assert_eq!(type_str, "Unknown"); + } + + #[test] + fn topological_sort_missing_dependency() { + let mut table = TypeTable::new(); + + // Struct references a type that doesn't exist in the table + let mut fields = IndexMap::new(); + fields.insert("missing", TypeKey::Named("DoesNotExist")); + table.insert(TypeKey::Named("HasMissing"), TypeValue::Struct(fields)); + + // Should not panic, includes all visited keys + let sorted = topological_sort(&table); + assert!(sorted.iter().any(|k| *k == TypeKey::Named("HasMissing"))); + // The missing key is visited and added to result (dependency comes before dependent) + assert!(sorted.iter().any(|k| *k == TypeKey::Named("DoesNotExist"))); + } + + #[test] + fn emit_with_missing_dependency() { + let mut table = TypeTable::new(); + + let mut fields = IndexMap::new(); + fields.insert("ref_field", TypeKey::Named("Missing")); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); + + // Should emit with the unknown type name + insta::assert_snapshot!(output, @r" + #[derive(Debug, Clone)] + pub struct Foo { + pub ref_field: Missing, + } + "); + } +} diff --git a/crates/plotnik-lib/src/infer/emit_ts.rs b/crates/plotnik-lib/src/infer/emit_ts.rs new file mode 100644 index 00000000..764b9778 --- /dev/null +++ b/crates/plotnik-lib/src/infer/emit_ts.rs @@ -0,0 +1,979 @@ +//! TypeScript code emitter for inferred types. +//! +//! Emits TypeScript interface and type definitions from a `TypeTable`. + +use indexmap::IndexMap; + +use 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, +} + +/// 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: true, + readonly: false, + inline_synthetic: true, + node_type_name: "SyntaxNode".to_string(), + } + } +} + +/// 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 { "export " } else { "" }; + + match value { + TypeValue::Node | TypeValue::String | TypeValue::Unit => String::new(), + + TypeValue::Struct(fields) => { + 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, fields)) in variants.iter().enumerate() { + out.push_str(" | { tag: \""); + out.push_str(variant_name); + out.push('"'); + 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) +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), + } +} + +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 +} + +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. +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 let Some(&in_progress) = visited.get(key) { + if in_progress { + return; + } + return; + } + + visited.insert(key.clone(), true); + + if let Some(value) = table.get(key) { + for dep in dependencies(value) { + visit(&dep, table, visited, result); + } + } + + visited.insert(key.clone(), false); + result.push(key.clone()); +} + +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() + .flat_map(|f| f.values()) + .cloned() + .collect(), + TypeValue::Optional(inner) | TypeValue::List(inner) | TypeValue::NonEmptyList(inner) => { + vec![inner.clone()] + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn emit_empty_table() { + let table = TypeTable::new(); + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); + assert_eq!(output, ""); + } + + #[test] + fn emit_simple_interface() { + let mut table = TypeTable::new(); + let mut fields = IndexMap::new(); + fields.insert("name", TypeKey::String); + fields.insert("node", TypeKey::Node); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @r" + export interface Foo { + name: string; + node: SyntaxNode; + } + "); + } + + #[test] + fn emit_empty_interface() { + let mut table = TypeTable::new(); + table.insert(TypeKey::Named("Empty"), TypeValue::Struct(IndexMap::new())); + + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @"export interface Empty {}"); + } + + #[test] + fn emit_unit_field() { + let mut table = TypeTable::new(); + let mut fields = IndexMap::new(); + fields.insert("marker", TypeKey::Unit); + table.insert(TypeKey::Named("WithUnit"), TypeValue::Struct(fields)); + + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @r" + export interface WithUnit { + marker: {}; + } + "); + } + + #[test] + fn emit_tagged_union() { + let mut table = TypeTable::new(); + let mut variants = IndexMap::new(); + + let mut assign_fields = IndexMap::new(); + assign_fields.insert("target", TypeKey::String); + variants.insert("Assign", assign_fields); + + let mut call_fields = IndexMap::new(); + call_fields.insert("func", TypeKey::String); + variants.insert("Call", call_fields); + + table.insert(TypeKey::Named("Stmt"), TypeValue::TaggedUnion(variants)); + + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @r#" + export type Stmt = + | { tag: "Assign"; target: string } + | { tag: "Call"; func: string }; + "#); + } + + #[test] + fn emit_tagged_union_empty_variant() { + let mut table = TypeTable::new(); + let mut variants = IndexMap::new(); + + variants.insert("None", IndexMap::new()); + + let mut some_fields = IndexMap::new(); + some_fields.insert("value", TypeKey::Node); + variants.insert("Some", some_fields); + + table.insert(TypeKey::Named("Maybe"), TypeValue::TaggedUnion(variants)); + + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @r#" + export type Maybe = + | { tag: "None" } + | { tag: "Some"; value: SyntaxNode }; + "#); + } + + #[test] + fn emit_optional_null() { + let mut table = TypeTable::new(); + table.insert( + TypeKey::Synthetic(vec!["Foo", "bar"]), + TypeValue::Optional(TypeKey::Node), + ); + + let mut fields = IndexMap::new(); + fields.insert("bar", TypeKey::Synthetic(vec!["Foo", "bar"])); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @r" + export interface Foo { + bar: SyntaxNode | null; + } + "); + } + + #[test] + fn emit_optional_undefined() { + let mut table = TypeTable::new(); + table.insert( + TypeKey::Synthetic(vec!["Foo", "bar"]), + TypeValue::Optional(TypeKey::Node), + ); + + let mut fields = IndexMap::new(); + fields.insert("bar", TypeKey::Synthetic(vec!["Foo", "bar"])); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + + let mut config = TypeScriptEmitConfig::default(); + config.optional_style = OptionalStyle::Undefined; + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @r" + export interface Foo { + bar: SyntaxNode | undefined; + } + "); + } + + #[test] + fn emit_optional_question_mark() { + let mut table = TypeTable::new(); + table.insert( + TypeKey::Synthetic(vec!["Foo", "bar"]), + TypeValue::Optional(TypeKey::Node), + ); + + let mut fields = IndexMap::new(); + fields.insert("bar", TypeKey::Synthetic(vec!["Foo", "bar"])); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + + let mut config = TypeScriptEmitConfig::default(); + config.optional_style = OptionalStyle::QuestionMark; + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @r" + export interface Foo { + bar?: SyntaxNode; + } + "); + } + + #[test] + fn emit_list_field() { + let mut table = TypeTable::new(); + table.insert( + TypeKey::Synthetic(vec!["Foo", "items"]), + TypeValue::List(TypeKey::Node), + ); + + let mut fields = IndexMap::new(); + fields.insert("items", TypeKey::Synthetic(vec!["Foo", "items"])); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @r" + export interface Foo { + items: SyntaxNode[]; + } + "); + } + + #[test] + fn emit_non_empty_list_field() { + let mut table = TypeTable::new(); + table.insert( + TypeKey::Synthetic(vec!["Foo", "items"]), + TypeValue::NonEmptyList(TypeKey::String), + ); + + let mut fields = IndexMap::new(); + fields.insert("items", TypeKey::Synthetic(vec!["Foo", "items"])); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @r" + export interface Foo { + items: [string, ...string[]]; + } + "); + } + + #[test] + fn emit_nested_interface() { + let mut table = TypeTable::new(); + + let mut inner_fields = IndexMap::new(); + inner_fields.insert("value", TypeKey::String); + table.insert(TypeKey::Named("Inner"), TypeValue::Struct(inner_fields)); + + let mut outer_fields = IndexMap::new(); + outer_fields.insert("inner", TypeKey::Named("Inner")); + table.insert(TypeKey::Named("Outer"), TypeValue::Struct(outer_fields)); + + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @r" + export interface Inner { + value: string; + } + + export interface Outer { + inner: Inner; + } + "); + } + + #[test] + fn emit_inline_synthetic() { + let mut table = TypeTable::new(); + + let mut inner_fields = IndexMap::new(); + inner_fields.insert("x", TypeKey::Node); + table.insert( + TypeKey::Synthetic(vec!["Foo", "bar"]), + TypeValue::Struct(inner_fields), + ); + + let mut outer_fields = IndexMap::new(); + outer_fields.insert("bar", TypeKey::Synthetic(vec!["Foo", "bar"])); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(outer_fields)); + + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @r" + export interface Foo { + bar: { x: SyntaxNode }; + } + "); + } + + #[test] + fn emit_no_inline_synthetic() { + let mut table = TypeTable::new(); + + let mut inner_fields = IndexMap::new(); + inner_fields.insert("x", TypeKey::Node); + table.insert( + TypeKey::Synthetic(vec!["Foo", "bar"]), + TypeValue::Struct(inner_fields), + ); + + let mut outer_fields = IndexMap::new(); + outer_fields.insert("bar", TypeKey::Synthetic(vec!["Foo", "bar"])); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(outer_fields)); + + let mut config = TypeScriptEmitConfig::default(); + config.inline_synthetic = false; + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @r" + export interface FooBar { + x: SyntaxNode; + } + + export interface Foo { + bar: FooBar; + } + "); + } + + #[test] + fn emit_readonly_fields() { + let mut table = TypeTable::new(); + let mut fields = IndexMap::new(); + fields.insert("name", TypeKey::String); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + + let mut config = TypeScriptEmitConfig::default(); + config.readonly = true; + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @r" + export interface Foo { + readonly name: string; + } + "); + } + + #[test] + fn emit_no_export() { + let mut table = TypeTable::new(); + table.insert( + TypeKey::Named("Private"), + TypeValue::Struct(IndexMap::new()), + ); + + let mut config = TypeScriptEmitConfig::default(); + config.export = false; + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @"interface Private {}"); + } + + #[test] + fn emit_custom_node_type() { + let mut table = TypeTable::new(); + let mut fields = IndexMap::new(); + fields.insert("node", TypeKey::Node); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + + let mut config = TypeScriptEmitConfig::default(); + config.node_type_name = "TSNode".to_string(); + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @r" + export interface Foo { + node: TSNode; + } + "); + } + + #[test] + fn emit_cyclic_type_no_box() { + let mut table = TypeTable::new(); + + table.insert( + TypeKey::Synthetic(vec!["Tree", "child"]), + TypeValue::Optional(TypeKey::Named("Tree")), + ); + + let mut fields = IndexMap::new(); + fields.insert("value", TypeKey::String); + fields.insert("child", TypeKey::Synthetic(vec!["Tree", "child"])); + table.insert(TypeKey::Named("Tree"), TypeValue::Struct(fields)); + + table.mark_cyclic(TypeKey::Named("Tree")); + + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); + + // TypeScript handles cycles natively, no Box needed + insta::assert_snapshot!(output, @r" + export interface Tree { + value: string; + child: Tree | null; + } + "); + } + + #[test] + fn emit_list_of_optional() { + let mut table = TypeTable::new(); + table.insert( + TypeKey::Synthetic(vec!["Foo", "inner"]), + TypeValue::Optional(TypeKey::Node), + ); + table.insert( + TypeKey::Synthetic(vec!["Foo", "items"]), + TypeValue::List(TypeKey::Synthetic(vec!["Foo", "inner"])), + ); + + let mut fields = IndexMap::new(); + fields.insert("items", TypeKey::Synthetic(vec!["Foo", "items"])); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @r" + export interface Foo { + items: (SyntaxNode | null)[]; + } + "); + } + + #[test] + fn emit_deeply_nested_inline() { + let mut table = TypeTable::new(); + + let mut level2 = IndexMap::new(); + level2.insert("val", TypeKey::String); + table.insert( + TypeKey::Synthetic(vec!["A", "b", "c"]), + TypeValue::Struct(level2), + ); + + let mut level1 = IndexMap::new(); + level1.insert("c", TypeKey::Synthetic(vec!["A", "b", "c"])); + table.insert( + TypeKey::Synthetic(vec!["A", "b"]), + TypeValue::Struct(level1), + ); + + let mut root = IndexMap::new(); + root.insert("b", TypeKey::Synthetic(vec!["A", "b"])); + table.insert(TypeKey::Named("A"), TypeValue::Struct(root)); + + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @r" + export interface A { + b: { c: { val: string } }; + } + "); + } + + #[test] + fn emit_type_alias_when_not_inlined() { + let mut table = TypeTable::new(); + table.insert( + TypeKey::Named("OptionalNode"), + TypeValue::Optional(TypeKey::Node), + ); + + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @"export type OptionalNode = SyntaxNode | null;"); + } + + #[test] + fn emit_type_alias_list() { + let mut table = TypeTable::new(); + table.insert(TypeKey::Named("NodeList"), TypeValue::List(TypeKey::Node)); + + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @"export type NodeList = SyntaxNode[];"); + } + + #[test] + fn emit_type_alias_non_empty_list() { + let mut table = TypeTable::new(); + table.insert( + TypeKey::Named("NonEmptyNodes"), + TypeValue::NonEmptyList(TypeKey::Node), + ); + + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @"export type NonEmptyNodes = [SyntaxNode, ...SyntaxNode[]];"); + } + + #[test] + fn wrap_if_union_simple() { + assert_eq!(wrap_if_union("string"), "string"); + assert_eq!(wrap_if_union("SyntaxNode"), "SyntaxNode"); + } + + #[test] + fn wrap_if_union_with_pipe() { + assert_eq!(wrap_if_union("string | null"), "(string | null)"); + assert_eq!(wrap_if_union("A | B | C"), "(A | B | C)"); + } + + #[test] + fn inline_empty_struct() { + let fields = IndexMap::new(); + let table = TypeTable::new(); + let config = TypeScriptEmitConfig::default(); + let result = emit_inline_struct(&fields, &table, &config); + assert_eq!(result, "{}"); + } + + #[test] + fn inline_struct_multiple_fields() { + let mut fields = IndexMap::new(); + fields.insert("a", TypeKey::String); + fields.insert("b", TypeKey::Node); + let table = TypeTable::new(); + let config = TypeScriptEmitConfig::default(); + let result = emit_inline_struct(&fields, &table, &config); + assert_eq!(result, "{ a: string; b: SyntaxNode }"); + } + + #[test] + fn dependencies_primitives() { + assert!(dependencies(&TypeValue::Node).is_empty()); + assert!(dependencies(&TypeValue::String).is_empty()); + assert!(dependencies(&TypeValue::Unit).is_empty()); + } + + #[test] + fn dependencies_struct() { + let mut fields = IndexMap::new(); + fields.insert("a", TypeKey::Named("A")); + fields.insert("b", TypeKey::Named("B")); + let value = TypeValue::Struct(fields); + + let deps = dependencies(&value); + assert_eq!(deps.len(), 2); + } + + #[test] + fn dependencies_wrappers() { + let opt = TypeValue::Optional(TypeKey::Named("T")); + let list = TypeValue::List(TypeKey::Named("T")); + let ne = TypeValue::NonEmptyList(TypeKey::Named("T")); + + assert_eq!(dependencies(&opt), vec![TypeKey::Named("T")]); + assert_eq!(dependencies(&list), vec![TypeKey::Named("T")]); + assert_eq!(dependencies(&ne), vec![TypeKey::Named("T")]); + } + + #[test] + fn optional_style_equality() { + assert_eq!(OptionalStyle::Null, OptionalStyle::Null); + assert_ne!(OptionalStyle::Null, OptionalStyle::Undefined); + assert_ne!(OptionalStyle::Undefined, OptionalStyle::QuestionMark); + } + + #[test] + fn config_default() { + let config = TypeScriptEmitConfig::default(); + assert_eq!(config.optional_style, OptionalStyle::Null); + assert!(config.export); + assert!(!config.readonly); + assert!(config.inline_synthetic); + assert_eq!(config.node_type_name, "SyntaxNode"); + } + + #[test] + fn emit_tagged_union_optional_field_question() { + let mut table = TypeTable::new(); + + table.insert( + TypeKey::Synthetic(vec!["Stmt", "x"]), + TypeValue::Optional(TypeKey::Node), + ); + + let mut variants = IndexMap::new(); + let mut v_fields = IndexMap::new(); + v_fields.insert("x", TypeKey::Synthetic(vec!["Stmt", "x"])); + variants.insert("V", v_fields); + + table.insert(TypeKey::Named("Stmt"), TypeValue::TaggedUnion(variants)); + + let mut config = TypeScriptEmitConfig::default(); + config.optional_style = OptionalStyle::QuestionMark; + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @r#" + export type Stmt = + | { tag: "V"; x?: SyntaxNode }; + "#); + } + + #[test] + fn topological_sort_with_cycle() { + let mut table = TypeTable::new(); + + let mut a_fields = IndexMap::new(); + a_fields.insert("b", TypeKey::Named("B")); + table.insert(TypeKey::Named("A"), TypeValue::Struct(a_fields)); + + let mut b_fields = IndexMap::new(); + b_fields.insert("a", TypeKey::Named("A")); + table.insert(TypeKey::Named("B"), TypeValue::Struct(b_fields)); + + let sorted = topological_sort(&table); + assert!(sorted.iter().any(|k| *k == TypeKey::Named("A"))); + assert!(sorted.iter().any(|k| *k == TypeKey::Named("B"))); + } + + #[test] + fn emit_field_type_unknown_key() { + let table = TypeTable::new(); + let config = TypeScriptEmitConfig::default(); + let (type_str, is_optional) = emit_field_type(&TypeKey::Named("Unknown"), &table, &config); + assert_eq!(type_str, "Unknown"); + assert!(!is_optional); + } + + #[test] + fn emit_readonly_optional_question_mark() { + let mut table = TypeTable::new(); + table.insert( + TypeKey::Synthetic(vec!["Foo", "bar"]), + TypeValue::Optional(TypeKey::String), + ); + + let mut fields = IndexMap::new(); + fields.insert("bar", TypeKey::Synthetic(vec!["Foo", "bar"])); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + + let mut config = TypeScriptEmitConfig::default(); + config.readonly = true; + config.optional_style = OptionalStyle::QuestionMark; + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @r" + export interface Foo { + readonly bar?: string; + } + "); + } + + #[test] + fn inline_struct_with_optional_question_mark() { + let mut table = TypeTable::new(); + table.insert( + TypeKey::Synthetic(vec!["inner", "opt"]), + TypeValue::Optional(TypeKey::Node), + ); + + let mut fields = IndexMap::new(); + fields.insert("opt", TypeKey::Synthetic(vec!["inner", "opt"])); + + let mut config = TypeScriptEmitConfig::default(); + config.optional_style = OptionalStyle::QuestionMark; + + let result = emit_inline_struct(&fields, &table, &config); + assert_eq!(result, "{ opt?: SyntaxNode }"); + } + + #[test] + fn dependencies_tagged_union() { + let mut variants = IndexMap::new(); + let mut v1 = IndexMap::new(); + v1.insert("x", TypeKey::Named("X")); + variants.insert("V1", v1); + + let mut v2 = IndexMap::new(); + v2.insert("y", TypeKey::Named("Y")); + variants.insert("V2", v2); + + let value = TypeValue::TaggedUnion(variants); + let deps = dependencies(&value); + + assert_eq!(deps.len(), 2); + assert!(deps.contains(&TypeKey::Named("X"))); + assert!(deps.contains(&TypeKey::Named("Y"))); + } + + #[test] + fn topological_sort_missing_dependency() { + let mut table = TypeTable::new(); + + let mut fields = IndexMap::new(); + fields.insert("missing", TypeKey::Named("DoesNotExist")); + table.insert(TypeKey::Named("HasMissing"), TypeValue::Struct(fields)); + + // Should not panic, includes all visited keys + let sorted = topological_sort(&table); + assert!(sorted.iter().any(|k| *k == TypeKey::Named("HasMissing"))); + // The missing key is visited and added to result (dependency comes before dependent) + assert!(sorted.iter().any(|k| *k == TypeKey::Named("DoesNotExist"))); + } + + #[test] + fn emit_with_missing_dependency() { + let mut table = TypeTable::new(); + + let mut fields = IndexMap::new(); + fields.insert("ref_field", TypeKey::Named("Missing")); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @r" + export interface Foo { + ref_field: Missing; + } + "); + } +} diff --git a/crates/plotnik-lib/src/infer/mod.rs b/crates/plotnik-lib/src/infer/mod.rs new file mode 100644 index 00000000..addb93e4 --- /dev/null +++ b/crates/plotnik-lib/src/infer/mod.rs @@ -0,0 +1,15 @@ +//! 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_rs; +pub mod emit_ts; +mod types; + +pub use emit_rs::{Indirection, RustEmitConfig, emit_rust}; +pub use emit_ts::{OptionalStyle, TypeScriptEmitConfig, 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..9aacf642 --- /dev/null +++ b/crates/plotnik-lib/src/infer/types.rs @@ -0,0 +1,322 @@ +//! 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. +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 → fields + TaggedUnion(IndexMap<&'src str, 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() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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 variants = IndexMap::new(); + let mut assign_fields = IndexMap::new(); + assign_fields.insert("target", TypeKey::String); + variants.insert("Assign", assign_fields); + + let mut call_fields = IndexMap::new(); + call_fields.insert("func", TypeKey::String); + variants.insert("Call", call_fields); + + let union = TypeValue::TaggedUnion(variants); + + if let TypeValue::TaggedUnion(v) = union { + assert_eq!(v.len(), 2); + assert!(v.contains_key("Assign")); + assert!(v.contains_key("Call")); + } else { + panic!("expected TaggedUnion"); + } + } + + #[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/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; From 43cd865c3a3791e5babf01ee23b1afb9b8bb09be Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sat, 6 Dec 2025 20:09:01 -0300 Subject: [PATCH 2/8] Reorganize infer module's emit code into a submodule --- crates/plotnik-lib/src/infer/emit/mod.rs | 9 + crates/plotnik-lib/src/infer/emit/rust.rs | 231 +++++++++++++++ .../infer/{emit_rs.rs => emit/rust_tests.rs} | 232 --------------- .../plotnik-lib/src/infer/emit/typescript.rs | 278 +++++++++++++++++ .../{emit_ts.rs => emit/typescript_tests.rs} | 279 ------------------ crates/plotnik-lib/src/infer/mod.rs | 8 +- 6 files changed, 522 insertions(+), 515 deletions(-) create mode 100644 crates/plotnik-lib/src/infer/emit/mod.rs create mode 100644 crates/plotnik-lib/src/infer/emit/rust.rs rename crates/plotnik-lib/src/infer/{emit_rs.rs => emit/rust_tests.rs} (72%) create mode 100644 crates/plotnik-lib/src/infer/emit/typescript.rs rename crates/plotnik-lib/src/infer/{emit_ts.rs => emit/typescript_tests.rs} (71%) 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..5971b9b2 --- /dev/null +++ b/crates/plotnik-lib/src/infer/emit/mod.rs @@ -0,0 +1,9 @@ +//! Code emitters for inferred types. +//! +//! This module provides language-specific code generation from a `TypeTable`. + +pub mod rust; +pub mod typescript; + +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..0879f329 --- /dev/null +++ b/crates/plotnik-lib/src/infer/emit/rust.rs @@ -0,0 +1,231 @@ +//! 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, fields) in variants { + if fields.is_empty() { + out.push_str(&format!(" {},\n", variant_name)); + } else { + out.push_str(&format!(" {} {{\n", variant_name)); + for (field_name, field_type) in fields { + 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('}'); + 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 + } + } +} + +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) + } + Some(TypeValue::Struct(_)) | Some(TypeValue::TaggedUnion(_)) => key.to_pascal_case(), + None => key.to_pascal_case(), + }; + + if is_cyclic { + wrap_indirection(&base, config.indirection) + } else { + base + } +} + +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), + } +} + +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. +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 let Some(&in_progress) = visited.get(key) { + if in_progress { + // Cycle detected, already handled by cyclic marking + return; + } + // Already fully visited + return; + } + + visited.insert(key.clone(), true); // Mark as in progress + + if let Some(value) = table.get(key) { + for dep in dependencies(value) { + visit(&dep, table, visited, result); + } + } + + visited.insert(key.clone(), false); // Mark as done + result.push(key.clone()); +} + +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() + .flat_map(|f| f.values()) + .cloned() + .collect(), + + TypeValue::Optional(inner) | TypeValue::List(inner) | TypeValue::NonEmptyList(inner) => { + vec![inner.clone()] + } + } +} diff --git a/crates/plotnik-lib/src/infer/emit_rs.rs b/crates/plotnik-lib/src/infer/emit/rust_tests.rs similarity index 72% rename from crates/plotnik-lib/src/infer/emit_rs.rs rename to crates/plotnik-lib/src/infer/emit/rust_tests.rs index d11c4339..afd449c5 100644 --- a/crates/plotnik-lib/src/infer/emit_rs.rs +++ b/crates/plotnik-lib/src/infer/emit/rust_tests.rs @@ -1,235 +1,3 @@ -//! Rust code emitter for inferred types. -//! -//! Emits Rust struct and enum definitions from a `TypeTable`. - -use indexmap::IndexMap; - -use 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, fields) in variants { - if fields.is_empty() { - out.push_str(&format!(" {},\n", variant_name)); - } else { - out.push_str(&format!(" {} {{\n", variant_name)); - for (field_name, field_type) in fields { - 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('}'); - 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 - } - } -} - -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) - } - Some(TypeValue::Struct(_)) | Some(TypeValue::TaggedUnion(_)) => key.to_pascal_case(), - None => key.to_pascal_case(), - }; - - if is_cyclic { - wrap_indirection(&base, config.indirection) - } else { - base - } -} - -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), - } -} - -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. -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 let Some(&in_progress) = visited.get(key) { - if in_progress { - // Cycle detected, already handled by cyclic marking - return; - } - // Already fully visited - return; - } - - visited.insert(key.clone(), true); // Mark as in progress - - if let Some(value) = table.get(key) { - for dep in dependencies(value) { - visit(&dep, table, visited, result); - } - } - - visited.insert(key.clone(), false); // Mark as done - result.push(key.clone()); -} - -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() - .flat_map(|f| f.values()) - .cloned() - .collect(), - - TypeValue::Optional(inner) | TypeValue::List(inner) | TypeValue::NonEmptyList(inner) => { - vec![inner.clone()] - } - } -} - #[cfg(test)] mod tests { use super::*; 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..f8f1be9b --- /dev/null +++ b/crates/plotnik-lib/src/infer/emit/typescript.rs @@ -0,0 +1,278 @@ +//! 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, +} + +/// 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: true, + readonly: false, + inline_synthetic: true, + node_type_name: "SyntaxNode".to_string(), + } + } +} + +/// 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 { "export " } else { "" }; + + match value { + TypeValue::Node | TypeValue::String | TypeValue::Unit => String::new(), + + TypeValue::Struct(fields) => { + 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, fields)) in variants.iter().enumerate() { + out.push_str(" | { tag: \""); + out.push_str(variant_name); + out.push('"'); + 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) +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), + } +} + +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 +} + +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. +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 let Some(&in_progress) = visited.get(key) { + if in_progress { + return; + } + return; + } + + visited.insert(key.clone(), true); + + if let Some(value) = table.get(key) { + for dep in dependencies(value) { + visit(&dep, table, visited, result); + } + } + + visited.insert(key.clone(), false); + result.push(key.clone()); +} + +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() + .flat_map(|f| f.values()) + .cloned() + .collect(), + TypeValue::Optional(inner) | TypeValue::List(inner) | TypeValue::NonEmptyList(inner) => { + vec![inner.clone()] + } + } +} diff --git a/crates/plotnik-lib/src/infer/emit_ts.rs b/crates/plotnik-lib/src/infer/emit/typescript_tests.rs similarity index 71% rename from crates/plotnik-lib/src/infer/emit_ts.rs rename to crates/plotnik-lib/src/infer/emit/typescript_tests.rs index 764b9778..a49a0395 100644 --- a/crates/plotnik-lib/src/infer/emit_ts.rs +++ b/crates/plotnik-lib/src/infer/emit/typescript_tests.rs @@ -1,282 +1,3 @@ -//! TypeScript code emitter for inferred types. -//! -//! Emits TypeScript interface and type definitions from a `TypeTable`. - -use indexmap::IndexMap; - -use 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, -} - -/// 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: true, - readonly: false, - inline_synthetic: true, - node_type_name: "SyntaxNode".to_string(), - } - } -} - -/// 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 { "export " } else { "" }; - - match value { - TypeValue::Node | TypeValue::String | TypeValue::Unit => String::new(), - - TypeValue::Struct(fields) => { - 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, fields)) in variants.iter().enumerate() { - out.push_str(" | { tag: \""); - out.push_str(variant_name); - out.push('"'); - 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) -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), - } -} - -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 -} - -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. -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 let Some(&in_progress) = visited.get(key) { - if in_progress { - return; - } - return; - } - - visited.insert(key.clone(), true); - - if let Some(value) = table.get(key) { - for dep in dependencies(value) { - visit(&dep, table, visited, result); - } - } - - visited.insert(key.clone(), false); - result.push(key.clone()); -} - -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() - .flat_map(|f| f.values()) - .cloned() - .collect(), - TypeValue::Optional(inner) | TypeValue::List(inner) | TypeValue::NonEmptyList(inner) => { - vec![inner.clone()] - } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/plotnik-lib/src/infer/mod.rs b/crates/plotnik-lib/src/infer/mod.rs index addb93e4..9b88f0ef 100644 --- a/crates/plotnik-lib/src/infer/mod.rs +++ b/crates/plotnik-lib/src/infer/mod.rs @@ -6,10 +6,10 @@ //! - `emit_rust`: Rust code emitter //! - `emit_typescript`: TypeScript code emitter -pub mod emit_rs; -pub mod emit_ts; +pub mod emit; mod types; -pub use emit_rs::{Indirection, RustEmitConfig, emit_rust}; -pub use emit_ts::{OptionalStyle, TypeScriptEmitConfig, emit_typescript}; +pub use emit::{ + Indirection, OptionalStyle, RustEmitConfig, TypeScriptEmitConfig, emit_rust, emit_typescript, +}; pub use types::{TypeKey, TypeTable, TypeValue}; From 4fe74c3e918e1ab8c37ac4b82dd6cf8aae5b07f6 Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sat, 6 Dec 2025 20:30:16 -0300 Subject: [PATCH 3/8] Fix tests --- crates/plotnik-lib/src/infer/emit/mod.rs | 4 + crates/plotnik-lib/src/infer/emit/rust.rs | 14 +- .../plotnik-lib/src/infer/emit/rust_tests.rs | 859 ++++++------- .../plotnik-lib/src/infer/emit/typescript.rs | 10 +- .../src/infer/emit/typescript_tests.rs | 1131 +++++++++-------- 5 files changed, 1028 insertions(+), 990 deletions(-) diff --git a/crates/plotnik-lib/src/infer/emit/mod.rs b/crates/plotnik-lib/src/infer/emit/mod.rs index 5971b9b2..41055054 100644 --- a/crates/plotnik-lib/src/infer/emit/mod.rs +++ b/crates/plotnik-lib/src/infer/emit/mod.rs @@ -3,7 +3,11 @@ //! This module provides language-specific code generation from a `TypeTable`. pub mod rust; +#[cfg(test)] +pub mod rust_tests; pub mod typescript; +#[cfg(test)] +pub 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 index 0879f329..b6598ae9 100644 --- a/crates/plotnik-lib/src/infer/emit/rust.rs +++ b/crates/plotnik-lib/src/infer/emit/rust.rs @@ -116,7 +116,11 @@ fn emit_type_def( } } -fn emit_type_ref(key: &TypeKey<'_>, table: &TypeTable<'_>, config: &RustEmitConfig) -> String { +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) { @@ -146,7 +150,7 @@ fn emit_type_ref(key: &TypeKey<'_>, table: &TypeTable<'_>, config: &RustEmitConf } } -fn wrap_indirection(type_str: &str, indirection: Indirection) -> String { +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), @@ -154,7 +158,7 @@ fn wrap_indirection(type_str: &str, indirection: Indirection) -> String { } } -fn emit_derives(config: &RustEmitConfig) -> String { +pub(crate) fn emit_derives(config: &RustEmitConfig) -> String { let mut derives = Vec::new(); if config.derive_debug { derives.push("Debug"); @@ -174,7 +178,7 @@ fn emit_derives(config: &RustEmitConfig) -> String { } /// Topologically sort types so dependencies come before dependents. -fn topological_sort<'src>(table: &TypeTable<'src>) -> Vec> { +pub(crate) fn topological_sort<'src>(table: &TypeTable<'src>) -> Vec> { let mut result = Vec::new(); let mut visited = IndexMap::new(); @@ -212,7 +216,7 @@ fn visit<'src>( result.push(key.clone()); } -fn dependencies<'src>(value: &TypeValue<'src>) -> Vec> { +pub(crate) fn dependencies<'src>(value: &TypeValue<'src>) -> Vec> { match value { TypeValue::Node | TypeValue::String | TypeValue::Unit => vec![], diff --git a/crates/plotnik-lib/src/infer/emit/rust_tests.rs b/crates/plotnik-lib/src/infer/emit/rust_tests.rs index afd449c5..c7537ed5 100644 --- a/crates/plotnik-lib/src/infer/emit/rust_tests.rs +++ b/crates/plotnik-lib/src/infer/emit/rust_tests.rs @@ -1,86 +1,90 @@ -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn emit_empty_table() { - let table = TypeTable::new(); - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); - assert_eq!(output, ""); - } - - #[test] - fn emit_simple_struct() { - let mut table = TypeTable::new(); - let mut fields = IndexMap::new(); - fields.insert("name", TypeKey::String); - fields.insert("node", TypeKey::Node); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); - - insta::assert_snapshot!(output, @r" +use indexmap::IndexMap; + +use crate::infer::{ + Indirection, RustEmitConfig, TypeKey, TypeTable, TypeValue, + emit::rust::{dependencies, emit_derives, emit_type_ref, topological_sort, wrap_indirection}, + emit_rust, +}; + +#[test] +fn emit_empty_table() { + let table = TypeTable::new(); + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); + assert_eq!(output, ""); +} + +#[test] +fn emit_simple_struct() { + let mut table = TypeTable::new(); + let mut fields = IndexMap::new(); + fields.insert("name", TypeKey::String); + fields.insert("node", TypeKey::Node); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); + + insta::assert_snapshot!(output, @r" #[derive(Debug, Clone)] pub struct Foo { pub name: String, pub node: Node, } "); - } +} - #[test] - fn emit_empty_struct() { - let mut table = TypeTable::new(); - table.insert(TypeKey::Named("Empty"), TypeValue::Struct(IndexMap::new())); +#[test] +fn emit_empty_struct() { + let mut table = TypeTable::new(); + table.insert(TypeKey::Named("Empty"), TypeValue::Struct(IndexMap::new())); - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); - insta::assert_snapshot!(output, @r" + insta::assert_snapshot!(output, @r" #[derive(Debug, Clone)] pub struct Empty; "); - } +} - #[test] - fn emit_unit_field() { - let mut table = TypeTable::new(); - let mut fields = IndexMap::new(); - fields.insert("marker", TypeKey::Unit); - table.insert(TypeKey::Named("WithUnit"), TypeValue::Struct(fields)); +#[test] +fn emit_unit_field() { + let mut table = TypeTable::new(); + let mut fields = IndexMap::new(); + fields.insert("marker", TypeKey::Unit); + table.insert(TypeKey::Named("WithUnit"), TypeValue::Struct(fields)); - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); - insta::assert_snapshot!(output, @r" + insta::assert_snapshot!(output, @r" #[derive(Debug, Clone)] pub struct WithUnit { pub marker: (), } "); - } +} - #[test] - fn emit_tagged_union() { - let mut table = TypeTable::new(); - let mut variants = IndexMap::new(); +#[test] +fn emit_tagged_union() { + let mut table = TypeTable::new(); + let mut variants = IndexMap::new(); - let mut assign_fields = IndexMap::new(); - assign_fields.insert("target", TypeKey::String); - variants.insert("Assign", assign_fields); + let mut assign_fields = IndexMap::new(); + assign_fields.insert("target", TypeKey::String); + variants.insert("Assign", assign_fields); - let mut call_fields = IndexMap::new(); - call_fields.insert("func", TypeKey::String); - variants.insert("Call", call_fields); + let mut call_fields = IndexMap::new(); + call_fields.insert("func", TypeKey::String); + variants.insert("Call", call_fields); - table.insert(TypeKey::Named("Stmt"), TypeValue::TaggedUnion(variants)); + table.insert(TypeKey::Named("Stmt"), TypeValue::TaggedUnion(variants)); - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); - insta::assert_snapshot!(output, @r" + insta::assert_snapshot!(output, @r" #[derive(Debug, Clone)] pub enum Stmt { Assign { @@ -91,25 +95,25 @@ mod tests { }, } "); - } +} - #[test] - fn emit_tagged_union_unit_variant() { - let mut table = TypeTable::new(); - let mut variants = IndexMap::new(); +#[test] +fn emit_tagged_union_unit_variant() { + let mut table = TypeTable::new(); + let mut variants = IndexMap::new(); - variants.insert("None", IndexMap::new()); + variants.insert("None", IndexMap::new()); - let mut some_fields = IndexMap::new(); - some_fields.insert("value", TypeKey::Node); - variants.insert("Some", some_fields); + let mut some_fields = IndexMap::new(); + some_fields.insert("value", TypeKey::Node); + variants.insert("Some", some_fields); - table.insert(TypeKey::Named("Maybe"), TypeValue::TaggedUnion(variants)); + table.insert(TypeKey::Named("Maybe"), TypeValue::TaggedUnion(variants)); - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); - insta::assert_snapshot!(output, @r" + insta::assert_snapshot!(output, @r" #[derive(Debug, Clone)] pub enum Maybe { None, @@ -118,24 +122,24 @@ mod tests { }, } "); - } +} - #[test] - fn emit_optional_field() { - let mut table = TypeTable::new(); - table.insert( - TypeKey::Synthetic(vec!["Foo", "bar"]), - TypeValue::Optional(TypeKey::Node), - ); +#[test] +fn emit_optional_field() { + let mut table = TypeTable::new(); + table.insert( + TypeKey::Synthetic(vec!["Foo", "bar"]), + TypeValue::Optional(TypeKey::Node), + ); - let mut fields = IndexMap::new(); - fields.insert("bar", TypeKey::Synthetic(vec!["Foo", "bar"])); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + let mut fields = IndexMap::new(); + fields.insert("bar", TypeKey::Synthetic(vec!["Foo", "bar"])); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); - insta::assert_snapshot!(output, @r" + insta::assert_snapshot!(output, @r" pub type FooBar = Option; #[derive(Debug, Clone)] @@ -143,24 +147,24 @@ mod tests { pub bar: Option, } "); - } +} - #[test] - fn emit_list_field() { - let mut table = TypeTable::new(); - table.insert( - TypeKey::Synthetic(vec!["Foo", "items"]), - TypeValue::List(TypeKey::Node), - ); +#[test] +fn emit_list_field() { + let mut table = TypeTable::new(); + table.insert( + TypeKey::Synthetic(vec!["Foo", "items"]), + TypeValue::List(TypeKey::Node), + ); - let mut fields = IndexMap::new(); - fields.insert("items", TypeKey::Synthetic(vec!["Foo", "items"])); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + let mut fields = IndexMap::new(); + fields.insert("items", TypeKey::Synthetic(vec!["Foo", "items"])); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); - insta::assert_snapshot!(output, @r" + insta::assert_snapshot!(output, @r" pub type FooItems = Vec; #[derive(Debug, Clone)] @@ -168,24 +172,24 @@ mod tests { pub items: Vec, } "); - } +} - #[test] - fn emit_non_empty_list_field() { - let mut table = TypeTable::new(); - table.insert( - TypeKey::Synthetic(vec!["Foo", "items"]), - TypeValue::NonEmptyList(TypeKey::String), - ); +#[test] +fn emit_non_empty_list_field() { + let mut table = TypeTable::new(); + table.insert( + TypeKey::Synthetic(vec!["Foo", "items"]), + TypeValue::NonEmptyList(TypeKey::String), + ); - let mut fields = IndexMap::new(); - fields.insert("items", TypeKey::Synthetic(vec!["Foo", "items"])); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + let mut fields = IndexMap::new(); + fields.insert("items", TypeKey::Synthetic(vec!["Foo", "items"])); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); - insta::assert_snapshot!(output, @r" + insta::assert_snapshot!(output, @r" pub type FooItems = Vec; #[derive(Debug, Clone)] @@ -193,24 +197,24 @@ mod tests { pub items: Vec, } "); - } +} - #[test] - fn emit_nested_struct() { - let mut table = TypeTable::new(); +#[test] +fn emit_nested_struct() { + let mut table = TypeTable::new(); - let mut inner_fields = IndexMap::new(); - inner_fields.insert("value", TypeKey::String); - table.insert(TypeKey::Named("Inner"), TypeValue::Struct(inner_fields)); + let mut inner_fields = IndexMap::new(); + inner_fields.insert("value", TypeKey::String); + table.insert(TypeKey::Named("Inner"), TypeValue::Struct(inner_fields)); - let mut outer_fields = IndexMap::new(); - outer_fields.insert("inner", TypeKey::Named("Inner")); - table.insert(TypeKey::Named("Outer"), TypeValue::Struct(outer_fields)); + let mut outer_fields = IndexMap::new(); + outer_fields.insert("inner", TypeKey::Named("Inner")); + table.insert(TypeKey::Named("Outer"), TypeValue::Struct(outer_fields)); - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); - insta::assert_snapshot!(output, @r" + insta::assert_snapshot!(output, @r" #[derive(Debug, Clone)] pub struct Inner { pub value: String, @@ -221,28 +225,28 @@ mod tests { pub inner: Inner, } "); - } +} - #[test] - fn emit_cyclic_type_box() { - let mut table = TypeTable::new(); +#[test] +fn emit_cyclic_type_box() { + let mut table = TypeTable::new(); - table.insert( - TypeKey::Synthetic(vec!["Tree", "child"]), - TypeValue::Optional(TypeKey::Named("Tree")), - ); + table.insert( + TypeKey::Synthetic(vec!["Tree", "child"]), + TypeValue::Optional(TypeKey::Named("Tree")), + ); - let mut fields = IndexMap::new(); - fields.insert("value", TypeKey::String); - fields.insert("child", TypeKey::Synthetic(vec!["Tree", "child"])); - table.insert(TypeKey::Named("Tree"), TypeValue::Struct(fields)); + let mut fields = IndexMap::new(); + fields.insert("value", TypeKey::String); + fields.insert("child", TypeKey::Synthetic(vec!["Tree", "child"])); + table.insert(TypeKey::Named("Tree"), TypeValue::Struct(fields)); - table.mark_cyclic(TypeKey::Named("Tree")); + table.mark_cyclic(TypeKey::Named("Tree")); - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); - insta::assert_snapshot!(output, @r" + insta::assert_snapshot!(output, @r" #[derive(Debug, Clone)] pub struct Tree { pub value: String, @@ -251,28 +255,30 @@ mod tests { pub type TreeChild = Option>; "); - } +} - #[test] - fn emit_cyclic_type_rc() { - let mut table = TypeTable::new(); +#[test] +fn emit_cyclic_type_rc() { + let mut table = TypeTable::new(); - table.insert( - TypeKey::Synthetic(vec!["Tree", "child"]), - TypeValue::Optional(TypeKey::Named("Tree")), - ); + table.insert( + TypeKey::Synthetic(vec!["Tree", "child"]), + TypeValue::Optional(TypeKey::Named("Tree")), + ); - let mut fields = IndexMap::new(); - fields.insert("child", TypeKey::Synthetic(vec!["Tree", "child"])); - table.insert(TypeKey::Named("Tree"), TypeValue::Struct(fields)); + let mut fields = IndexMap::new(); + fields.insert("child", TypeKey::Synthetic(vec!["Tree", "child"])); + table.insert(TypeKey::Named("Tree"), TypeValue::Struct(fields)); - table.mark_cyclic(TypeKey::Named("Tree")); + table.mark_cyclic(TypeKey::Named("Tree")); - let mut config = RustEmitConfig::default(); - config.indirection = Indirection::Rc; - let output = emit_rust(&table, &config); + let config = RustEmitConfig { + indirection: Indirection::Rc, + ..Default::default() + }; + let output = emit_rust(&table, &config); - insta::assert_snapshot!(output, @r" + insta::assert_snapshot!(output, @r" #[derive(Debug, Clone)] pub struct Tree { pub child: Option>, @@ -280,114 +286,116 @@ mod tests { pub type TreeChild = Option>; "); - } +} - #[test] - fn emit_cyclic_type_arc() { - let mut table = TypeTable::new(); +#[test] +fn emit_cyclic_type_arc() { + let mut table = TypeTable::new(); - let mut fields = IndexMap::new(); - fields.insert("next", TypeKey::Named("Node")); - table.insert(TypeKey::Named("Node"), TypeValue::Struct(fields)); + let mut fields = IndexMap::new(); + fields.insert("next", TypeKey::Named("Node")); + table.insert(TypeKey::Named("Node"), TypeValue::Struct(fields)); - table.mark_cyclic(TypeKey::Named("Node")); + table.mark_cyclic(TypeKey::Named("Node")); - let mut config = RustEmitConfig::default(); - config.indirection = Indirection::Arc; - let output = emit_rust(&table, &config); + let config = RustEmitConfig { + indirection: Indirection::Arc, + ..Default::default() + }; + let output = emit_rust(&table, &config); - insta::assert_snapshot!(output, @r" + insta::assert_snapshot!(output, @r" #[derive(Debug, Clone)] pub struct Node { pub next: Arc, } "); - } - - #[test] - fn emit_no_derives() { - let mut table = TypeTable::new(); - table.insert(TypeKey::Named("Plain"), TypeValue::Struct(IndexMap::new())); - - let config = RustEmitConfig { - indirection: Indirection::Box, - derive_debug: false, - derive_clone: false, - derive_partial_eq: false, - }; - let output = emit_rust(&table, &config); - - insta::assert_snapshot!(output, @"pub struct Plain;"); - } - - #[test] - fn emit_all_derives() { - let mut table = TypeTable::new(); - table.insert(TypeKey::Named("Full"), TypeValue::Struct(IndexMap::new())); - - let config = RustEmitConfig { - indirection: Indirection::Box, - derive_debug: true, - derive_clone: true, - derive_partial_eq: true, - }; - let output = emit_rust(&table, &config); - - insta::assert_snapshot!(output, @r" +} + +#[test] +fn emit_no_derives() { + let mut table = TypeTable::new(); + table.insert(TypeKey::Named("Plain"), TypeValue::Struct(IndexMap::new())); + + let config = RustEmitConfig { + indirection: Indirection::Box, + derive_debug: false, + derive_clone: false, + derive_partial_eq: false, + }; + let output = emit_rust(&table, &config); + + insta::assert_snapshot!(output, @"pub struct Plain;"); +} + +#[test] +fn emit_all_derives() { + let mut table = TypeTable::new(); + table.insert(TypeKey::Named("Full"), TypeValue::Struct(IndexMap::new())); + + let config = RustEmitConfig { + indirection: Indirection::Box, + derive_debug: true, + derive_clone: true, + derive_partial_eq: true, + }; + let output = emit_rust(&table, &config); + + insta::assert_snapshot!(output, @r" #[derive(Debug, Clone, PartialEq)] pub struct Full; "); - } +} - #[test] - fn emit_synthetic_type_name() { - let mut table = TypeTable::new(); +#[test] +fn emit_synthetic_type_name() { + let mut table = TypeTable::new(); - let mut fields = IndexMap::new(); - fields.insert("x", TypeKey::Node); - table.insert( - TypeKey::Synthetic(vec!["Foo", "bar", "baz"]), - TypeValue::Struct(fields), - ); + let mut fields = IndexMap::new(); + fields.insert("x", TypeKey::Node); + table.insert( + TypeKey::Synthetic(vec!["Foo", "bar", "baz"]), + TypeValue::Struct(fields), + ); - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); - insta::assert_snapshot!(output, @r" + insta::assert_snapshot!(output, @r" #[derive(Debug, Clone)] pub struct FooBarBaz { pub x: Node, } "); - } - - #[test] - fn emit_complex_nested() { - let mut table = TypeTable::new(); - - // Inner struct - let mut inner = IndexMap::new(); - inner.insert("value", TypeKey::String); - table.insert( - TypeKey::Synthetic(vec!["Root", "item"]), - TypeValue::Struct(inner), - ); - - // List of inner - table.insert( - TypeKey::Synthetic(vec!["Root", "items"]), - TypeValue::List(TypeKey::Synthetic(vec!["Root", "item"])), - ); - - // Root struct - let mut root = IndexMap::new(); - root.insert("items", TypeKey::Synthetic(vec!["Root", "items"])); - table.insert(TypeKey::Named("Root"), TypeValue::Struct(root)); - - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); - - insta::assert_snapshot!(output, @r" +} + +#[test] +fn emit_complex_nested() { + let mut table = TypeTable::new(); + + // Inner struct + let mut inner = IndexMap::new(); + inner.insert("value", TypeKey::String); + table.insert( + TypeKey::Synthetic(vec!["Root", "item"]), + TypeValue::Struct(inner), + ); + + // List of inner + table.insert( + TypeKey::Synthetic(vec!["Root", "items"]), + TypeValue::List(TypeKey::Synthetic(vec!["Root", "item"])), + ); + + // Root struct + let mut root = IndexMap::new(); + root.insert("items", TypeKey::Synthetic(vec!["Root", "items"])); + table.insert(TypeKey::Named("Root"), TypeValue::Struct(root)); + + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); + + insta::assert_snapshot!(output, @r" #[derive(Debug, Clone)] pub struct RootItem { pub value: String, @@ -400,29 +408,29 @@ mod tests { pub items: Vec, } "); - } +} - #[test] - fn emit_optional_list() { - let mut table = TypeTable::new(); +#[test] +fn emit_optional_list() { + let mut table = TypeTable::new(); - table.insert( - TypeKey::Synthetic(vec!["Foo", "items", "inner"]), - TypeValue::List(TypeKey::Node), - ); - table.insert( - TypeKey::Synthetic(vec!["Foo", "items"]), - TypeValue::Optional(TypeKey::Synthetic(vec!["Foo", "items", "inner"])), - ); + table.insert( + TypeKey::Synthetic(vec!["Foo", "items", "inner"]), + TypeValue::List(TypeKey::Node), + ); + table.insert( + TypeKey::Synthetic(vec!["Foo", "items"]), + TypeValue::Optional(TypeKey::Synthetic(vec!["Foo", "items", "inner"])), + ); - let mut fields = IndexMap::new(); - fields.insert("items", TypeKey::Synthetic(vec!["Foo", "items"])); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + let mut fields = IndexMap::new(); + fields.insert("items", TypeKey::Synthetic(vec!["Foo", "items"])); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); - insta::assert_snapshot!(output, @r" + insta::assert_snapshot!(output, @r" pub type FooItemsInner = Vec; pub type FooItems = Option>; @@ -432,174 +440,173 @@ mod tests { pub items: Option>, } "); - } - - #[test] - fn topological_sort_simple() { - let mut table = TypeTable::new(); - table.insert(TypeKey::Named("A"), TypeValue::Unit); - table.insert(TypeKey::Named("B"), TypeValue::Unit); - - let sorted = topological_sort(&table); - let names: Vec<_> = sorted.iter().map(|k| k.to_pascal_case()).collect(); - - // Builtins first - assert!(names.iter().position(|n| n == "Node") < names.iter().position(|n| n == "A")); - } - - #[test] - fn topological_sort_with_dependency() { - let mut table = TypeTable::new(); - - let mut b_fields = IndexMap::new(); - b_fields.insert("a", TypeKey::Named("A")); - table.insert(TypeKey::Named("B"), TypeValue::Struct(b_fields)); - - table.insert(TypeKey::Named("A"), TypeValue::Unit); - - let sorted = topological_sort(&table); - let names: Vec<_> = sorted.iter().map(|k| k.to_pascal_case()).collect(); - - let a_pos = names.iter().position(|n| n == "A").unwrap(); - let b_pos = names.iter().position(|n| n == "B").unwrap(); - assert!(a_pos < b_pos, "A should come before B"); - } - - #[test] - fn topological_sort_cycle() { - let mut table = TypeTable::new(); - - let mut a_fields = IndexMap::new(); - a_fields.insert("b", TypeKey::Named("B")); - table.insert(TypeKey::Named("A"), TypeValue::Struct(a_fields)); - - let mut b_fields = IndexMap::new(); - b_fields.insert("a", TypeKey::Named("A")); - table.insert(TypeKey::Named("B"), TypeValue::Struct(b_fields)); - - // Should not panic - let sorted = topological_sort(&table); - assert!(sorted.iter().any(|k| *k == TypeKey::Named("A"))); - assert!(sorted.iter().any(|k| *k == TypeKey::Named("B"))); - } - - #[test] - fn dependencies_struct() { - let mut fields = IndexMap::new(); - fields.insert("a", TypeKey::Named("A")); - fields.insert("b", TypeKey::Named("B")); - let value = TypeValue::Struct(fields); - - let deps = dependencies(&value); - assert_eq!(deps.len(), 2); - assert!(deps.contains(&TypeKey::Named("A"))); - assert!(deps.contains(&TypeKey::Named("B"))); - } - - #[test] - fn dependencies_tagged_union() { - let mut variants = IndexMap::new(); - let mut v1 = IndexMap::new(); - v1.insert("x", TypeKey::Named("X")); - variants.insert("V1", v1); - - let mut v2 = IndexMap::new(); - v2.insert("y", TypeKey::Named("Y")); - variants.insert("V2", v2); - - let value = TypeValue::TaggedUnion(variants); - let deps = dependencies(&value); - - assert_eq!(deps.len(), 2); - assert!(deps.contains(&TypeKey::Named("X"))); - assert!(deps.contains(&TypeKey::Named("Y"))); - } - - #[test] - fn dependencies_primitives() { - assert!(dependencies(&TypeValue::Node).is_empty()); - assert!(dependencies(&TypeValue::String).is_empty()); - assert!(dependencies(&TypeValue::Unit).is_empty()); - } - - #[test] - fn dependencies_wrappers() { - let opt = TypeValue::Optional(TypeKey::Named("T")); - let list = TypeValue::List(TypeKey::Named("T")); - let ne = TypeValue::NonEmptyList(TypeKey::Named("T")); - - assert_eq!(dependencies(&opt), vec![TypeKey::Named("T")]); - assert_eq!(dependencies(&list), vec![TypeKey::Named("T")]); - assert_eq!(dependencies(&ne), vec![TypeKey::Named("T")]); - } - - #[test] - fn indirection_equality() { - assert_eq!(Indirection::Box, Indirection::Box); - assert_ne!(Indirection::Box, Indirection::Rc); - assert_ne!(Indirection::Rc, Indirection::Arc); - } - - #[test] - fn wrap_indirection_all_variants() { - assert_eq!(wrap_indirection("Foo", Indirection::Box), "Box"); - assert_eq!(wrap_indirection("Foo", Indirection::Rc), "Rc"); - assert_eq!(wrap_indirection("Foo", Indirection::Arc), "Arc"); - } - - #[test] - fn emit_derives_partial() { - let config = RustEmitConfig { - derive_debug: true, - derive_clone: false, - derive_partial_eq: true, - ..Default::default() - }; - let derives = emit_derives(&config); - assert_eq!(derives, "#[derive(Debug, PartialEq)]\n"); - } - - #[test] - fn emit_type_ref_unknown_key() { - let table = TypeTable::new(); - let config = RustEmitConfig::default(); - let type_str = emit_type_ref(&TypeKey::Named("Unknown"), &table, &config); - assert_eq!(type_str, "Unknown"); - } - - #[test] - fn topological_sort_missing_dependency() { - let mut table = TypeTable::new(); - - // Struct references a type that doesn't exist in the table - let mut fields = IndexMap::new(); - fields.insert("missing", TypeKey::Named("DoesNotExist")); - table.insert(TypeKey::Named("HasMissing"), TypeValue::Struct(fields)); - - // Should not panic, includes all visited keys - let sorted = topological_sort(&table); - assert!(sorted.iter().any(|k| *k == TypeKey::Named("HasMissing"))); - // The missing key is visited and added to result (dependency comes before dependent) - assert!(sorted.iter().any(|k| *k == TypeKey::Named("DoesNotExist"))); - } - - #[test] - fn emit_with_missing_dependency() { - let mut table = TypeTable::new(); - - let mut fields = IndexMap::new(); - fields.insert("ref_field", TypeKey::Named("Missing")); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); - - // Should emit with the unknown type name - insta::assert_snapshot!(output, @r" +} + +#[test] +fn topological_sort_simple() { + let mut table = TypeTable::new(); + table.insert(TypeKey::Named("A"), TypeValue::Unit); + table.insert(TypeKey::Named("B"), TypeValue::Unit); + + let sorted = topological_sort(&table); + let names: Vec<_> = sorted.iter().map(|k| k.to_pascal_case()).collect(); + + // Builtins first + assert!(names.iter().position(|n| n == "Node") < names.iter().position(|n| n == "A")); +} + +#[test] +fn topological_sort_with_dependency() { + let mut table = TypeTable::new(); + + let mut b_fields = IndexMap::new(); + b_fields.insert("a", TypeKey::Named("A")); + table.insert(TypeKey::Named("B"), TypeValue::Struct(b_fields)); + + table.insert(TypeKey::Named("A"), TypeValue::Unit); + + let sorted = topological_sort(&table); + let names: Vec<_> = sorted.iter().map(|k| k.to_pascal_case()).collect(); + + let a_pos = names.iter().position(|n| n == "A").unwrap(); + let b_pos = names.iter().position(|n| n == "B").unwrap(); + assert!(a_pos < b_pos, "A should come before B"); +} + +#[test] +fn topological_sort_cycle() { + let mut table = TypeTable::new(); + + let mut a_fields = IndexMap::new(); + a_fields.insert("b", TypeKey::Named("B")); + table.insert(TypeKey::Named("A"), TypeValue::Struct(a_fields)); + + let mut b_fields = IndexMap::new(); + b_fields.insert("a", TypeKey::Named("A")); + table.insert(TypeKey::Named("B"), TypeValue::Struct(b_fields)); + + // Should not panic + let sorted = topological_sort(&table); + assert!(sorted.contains(&TypeKey::Named("A"))); + assert!(sorted.contains(&TypeKey::Named("B"))); +} + +#[test] +fn dependencies_struct() { + let mut fields = IndexMap::new(); + fields.insert("a", TypeKey::Named("A")); + fields.insert("b", TypeKey::Named("B")); + let value = TypeValue::Struct(fields); + + let deps = dependencies(&value); + assert_eq!(deps.len(), 2); + assert!(deps.contains(&TypeKey::Named("A"))); + assert!(deps.contains(&TypeKey::Named("B"))); +} + +#[test] +fn dependencies_tagged_union() { + let mut variants = IndexMap::new(); + let mut v1 = IndexMap::new(); + v1.insert("x", TypeKey::Named("X")); + variants.insert("V1", v1); + + let mut v2 = IndexMap::new(); + v2.insert("y", TypeKey::Named("Y")); + variants.insert("V2", v2); + + let value = TypeValue::TaggedUnion(variants); + let deps = dependencies(&value); + + assert_eq!(deps.len(), 2); + assert!(deps.contains(&TypeKey::Named("X"))); + assert!(deps.contains(&TypeKey::Named("Y"))); +} + +#[test] +fn dependencies_primitives() { + assert!(dependencies(&TypeValue::Node).is_empty()); + assert!(dependencies(&TypeValue::String).is_empty()); + assert!(dependencies(&TypeValue::Unit).is_empty()); +} + +#[test] +fn dependencies_wrappers() { + let opt = TypeValue::Optional(TypeKey::Named("T")); + let list = TypeValue::List(TypeKey::Named("T")); + let ne = TypeValue::NonEmptyList(TypeKey::Named("T")); + + assert_eq!(dependencies(&opt), vec![TypeKey::Named("T")]); + assert_eq!(dependencies(&list), vec![TypeKey::Named("T")]); + assert_eq!(dependencies(&ne), vec![TypeKey::Named("T")]); +} + +#[test] +fn indirection_equality() { + assert_eq!(Indirection::Box, Indirection::Box); + assert_ne!(Indirection::Box, Indirection::Rc); + assert_ne!(Indirection::Rc, Indirection::Arc); +} + +#[test] +fn wrap_indirection_all_variants() { + assert_eq!(wrap_indirection("Foo", Indirection::Box), "Box"); + assert_eq!(wrap_indirection("Foo", Indirection::Rc), "Rc"); + assert_eq!(wrap_indirection("Foo", Indirection::Arc), "Arc"); +} + +#[test] +fn emit_derives_partial() { + let config = RustEmitConfig { + derive_debug: true, + derive_clone: false, + derive_partial_eq: true, + ..Default::default() + }; + let derives = emit_derives(&config); + assert_eq!(derives, "#[derive(Debug, PartialEq)]\n"); +} + +#[test] +fn emit_type_ref_unknown_key() { + let table = TypeTable::new(); + let config = RustEmitConfig::default(); + let type_str = emit_type_ref(&TypeKey::Named("Unknown"), &table, &config); + assert_eq!(type_str, "Unknown"); +} + +#[test] +fn topological_sort_missing_dependency() { + let mut table = TypeTable::new(); + + // Struct references a type that doesn't exist in the table + let mut fields = IndexMap::new(); + fields.insert("missing", TypeKey::Named("DoesNotExist")); + table.insert(TypeKey::Named("HasMissing"), TypeValue::Struct(fields)); + + // Should not panic, includes all visited keys + let sorted = topological_sort(&table); + assert!(sorted.contains(&TypeKey::Named("HasMissing"))); + // The missing key is visited and added to result (dependency comes before dependent) + assert!(sorted.contains(&TypeKey::Named("DoesNotExist"))); +} + +#[test] +fn emit_with_missing_dependency() { + let mut table = TypeTable::new(); + + let mut fields = IndexMap::new(); + fields.insert("ref_field", TypeKey::Named("Missing")); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + + let config = RustEmitConfig::default(); + let output = emit_rust(&table, &config); + + // Should emit with the unknown type name + insta::assert_snapshot!(output, @r" #[derive(Debug, Clone)] pub struct Foo { pub ref_field: Missing, } "); - } } diff --git a/crates/plotnik-lib/src/infer/emit/typescript.rs b/crates/plotnik-lib/src/infer/emit/typescript.rs index f8f1be9b..6439a924 100644 --- a/crates/plotnik-lib/src/infer/emit/typescript.rs +++ b/crates/plotnik-lib/src/infer/emit/typescript.rs @@ -144,7 +144,7 @@ fn emit_type_def( } /// Returns (type_string, is_optional) -fn emit_field_type( +pub(crate) fn emit_field_type( key: &TypeKey<'_>, table: &TypeTable<'_>, config: &TypeScriptEmitConfig, @@ -188,7 +188,7 @@ fn emit_field_type( } } -fn emit_inline_struct( +pub(crate) fn emit_inline_struct( fields: &IndexMap<&str, TypeKey<'_>>, table: &TypeTable<'_>, config: &TypeScriptEmitConfig, @@ -217,7 +217,7 @@ fn emit_inline_struct( out } -fn wrap_if_union(type_str: &str) -> String { +pub(crate) fn wrap_if_union(type_str: &str) -> String { if type_str.contains('|') { format!("({})", type_str) } else { @@ -226,7 +226,7 @@ fn wrap_if_union(type_str: &str) -> String { } /// Topologically sort types so dependencies come before dependents. -fn topological_sort<'src>(table: &TypeTable<'src>) -> Vec> { +pub(crate) fn topological_sort<'src>(table: &TypeTable<'src>) -> Vec> { let mut result = Vec::new(); let mut visited = IndexMap::new(); @@ -262,7 +262,7 @@ fn visit<'src>( result.push(key.clone()); } -fn dependencies<'src>(value: &TypeValue<'src>) -> Vec> { +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(), diff --git a/crates/plotnik-lib/src/infer/emit/typescript_tests.rs b/crates/plotnik-lib/src/infer/emit/typescript_tests.rs index a49a0395..0b3e5700 100644 --- a/crates/plotnik-lib/src/infer/emit/typescript_tests.rs +++ b/crates/plotnik-lib/src/infer/emit/typescript_tests.rs @@ -1,238 +1,248 @@ -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn emit_empty_table() { - let table = TypeTable::new(); - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); - assert_eq!(output, ""); - } - - #[test] - fn emit_simple_interface() { - let mut table = TypeTable::new(); - let mut fields = IndexMap::new(); - fields.insert("name", TypeKey::String); - fields.insert("node", TypeKey::Node); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @r" +use indexmap::IndexMap; + +use crate::infer::{ + OptionalStyle, TypeKey, TypeScriptEmitConfig, TypeTable, TypeValue, + emit::typescript::{ + dependencies, emit_field_type, emit_inline_struct, topological_sort, wrap_if_union, + }, + emit_typescript, +}; + +#[test] +fn emit_empty_table() { + let table = TypeTable::new(); + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); + assert_eq!(output, ""); +} + +#[test] +fn emit_simple_interface() { + let mut table = TypeTable::new(); + let mut fields = IndexMap::new(); + fields.insert("name", TypeKey::String); + fields.insert("node", TypeKey::Node); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @r" export interface Foo { name: string; node: SyntaxNode; } "); - } +} - #[test] - fn emit_empty_interface() { - let mut table = TypeTable::new(); - table.insert(TypeKey::Named("Empty"), TypeValue::Struct(IndexMap::new())); +#[test] +fn emit_empty_interface() { + let mut table = TypeTable::new(); + table.insert(TypeKey::Named("Empty"), TypeValue::Struct(IndexMap::new())); - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); - insta::assert_snapshot!(output, @"export interface Empty {}"); - } + insta::assert_snapshot!(output, @"export interface Empty {}"); +} - #[test] - fn emit_unit_field() { - let mut table = TypeTable::new(); - let mut fields = IndexMap::new(); - fields.insert("marker", TypeKey::Unit); - table.insert(TypeKey::Named("WithUnit"), TypeValue::Struct(fields)); +#[test] +fn emit_unit_field() { + let mut table = TypeTable::new(); + let mut fields = IndexMap::new(); + fields.insert("marker", TypeKey::Unit); + table.insert(TypeKey::Named("WithUnit"), TypeValue::Struct(fields)); - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); - insta::assert_snapshot!(output, @r" + insta::assert_snapshot!(output, @r" export interface WithUnit { marker: {}; } "); - } +} - #[test] - fn emit_tagged_union() { - let mut table = TypeTable::new(); - let mut variants = IndexMap::new(); +#[test] +fn emit_tagged_union() { + let mut table = TypeTable::new(); + let mut variants = IndexMap::new(); - let mut assign_fields = IndexMap::new(); - assign_fields.insert("target", TypeKey::String); - variants.insert("Assign", assign_fields); + let mut assign_fields = IndexMap::new(); + assign_fields.insert("target", TypeKey::String); + variants.insert("Assign", assign_fields); - let mut call_fields = IndexMap::new(); - call_fields.insert("func", TypeKey::String); - variants.insert("Call", call_fields); + let mut call_fields = IndexMap::new(); + call_fields.insert("func", TypeKey::String); + variants.insert("Call", call_fields); - table.insert(TypeKey::Named("Stmt"), TypeValue::TaggedUnion(variants)); + table.insert(TypeKey::Named("Stmt"), TypeValue::TaggedUnion(variants)); - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); - insta::assert_snapshot!(output, @r#" + insta::assert_snapshot!(output, @r#" export type Stmt = | { tag: "Assign"; target: string } | { tag: "Call"; func: string }; "#); - } +} - #[test] - fn emit_tagged_union_empty_variant() { - let mut table = TypeTable::new(); - let mut variants = IndexMap::new(); +#[test] +fn emit_tagged_union_empty_variant() { + let mut table = TypeTable::new(); + let mut variants = IndexMap::new(); - variants.insert("None", IndexMap::new()); + variants.insert("None", IndexMap::new()); - let mut some_fields = IndexMap::new(); - some_fields.insert("value", TypeKey::Node); - variants.insert("Some", some_fields); + let mut some_fields = IndexMap::new(); + some_fields.insert("value", TypeKey::Node); + variants.insert("Some", some_fields); - table.insert(TypeKey::Named("Maybe"), TypeValue::TaggedUnion(variants)); + table.insert(TypeKey::Named("Maybe"), TypeValue::TaggedUnion(variants)); - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); - insta::assert_snapshot!(output, @r#" + insta::assert_snapshot!(output, @r#" export type Maybe = | { tag: "None" } | { tag: "Some"; value: SyntaxNode }; "#); - } +} - #[test] - fn emit_optional_null() { - let mut table = TypeTable::new(); - table.insert( - TypeKey::Synthetic(vec!["Foo", "bar"]), - TypeValue::Optional(TypeKey::Node), - ); +#[test] +fn emit_optional_null() { + let mut table = TypeTable::new(); + table.insert( + TypeKey::Synthetic(vec!["Foo", "bar"]), + TypeValue::Optional(TypeKey::Node), + ); - let mut fields = IndexMap::new(); - fields.insert("bar", TypeKey::Synthetic(vec!["Foo", "bar"])); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + let mut fields = IndexMap::new(); + fields.insert("bar", TypeKey::Synthetic(vec!["Foo", "bar"])); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); - insta::assert_snapshot!(output, @r" + insta::assert_snapshot!(output, @r" export interface Foo { bar: SyntaxNode | null; } "); - } - - #[test] - fn emit_optional_undefined() { - let mut table = TypeTable::new(); - table.insert( - TypeKey::Synthetic(vec!["Foo", "bar"]), - TypeValue::Optional(TypeKey::Node), - ); - - let mut fields = IndexMap::new(); - fields.insert("bar", TypeKey::Synthetic(vec!["Foo", "bar"])); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - - let mut config = TypeScriptEmitConfig::default(); - config.optional_style = OptionalStyle::Undefined; - let output = emit_typescript(&table, &config); +} - insta::assert_snapshot!(output, @r" +#[test] +fn emit_optional_undefined() { + let mut table = TypeTable::new(); + table.insert( + TypeKey::Synthetic(vec!["Foo", "bar"]), + TypeValue::Optional(TypeKey::Node), + ); + + let mut fields = IndexMap::new(); + fields.insert("bar", TypeKey::Synthetic(vec!["Foo", "bar"])); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + + let config = TypeScriptEmitConfig { + optional_style: OptionalStyle::Undefined, + ..Default::default() + }; + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @r" export interface Foo { bar: SyntaxNode | undefined; } "); - } - - #[test] - fn emit_optional_question_mark() { - let mut table = TypeTable::new(); - table.insert( - TypeKey::Synthetic(vec!["Foo", "bar"]), - TypeValue::Optional(TypeKey::Node), - ); - - let mut fields = IndexMap::new(); - fields.insert("bar", TypeKey::Synthetic(vec!["Foo", "bar"])); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - - let mut config = TypeScriptEmitConfig::default(); - config.optional_style = OptionalStyle::QuestionMark; - let output = emit_typescript(&table, &config); +} - insta::assert_snapshot!(output, @r" +#[test] +fn emit_optional_question_mark() { + let mut table = TypeTable::new(); + table.insert( + TypeKey::Synthetic(vec!["Foo", "bar"]), + TypeValue::Optional(TypeKey::Node), + ); + + let mut fields = IndexMap::new(); + fields.insert("bar", TypeKey::Synthetic(vec!["Foo", "bar"])); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + + let config = TypeScriptEmitConfig { + optional_style: OptionalStyle::QuestionMark, + ..Default::default() + }; + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @r" export interface Foo { bar?: SyntaxNode; } "); - } +} - #[test] - fn emit_list_field() { - let mut table = TypeTable::new(); - table.insert( - TypeKey::Synthetic(vec!["Foo", "items"]), - TypeValue::List(TypeKey::Node), - ); +#[test] +fn emit_list_field() { + let mut table = TypeTable::new(); + table.insert( + TypeKey::Synthetic(vec!["Foo", "items"]), + TypeValue::List(TypeKey::Node), + ); - let mut fields = IndexMap::new(); - fields.insert("items", TypeKey::Synthetic(vec!["Foo", "items"])); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + let mut fields = IndexMap::new(); + fields.insert("items", TypeKey::Synthetic(vec!["Foo", "items"])); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); - insta::assert_snapshot!(output, @r" + insta::assert_snapshot!(output, @r" export interface Foo { items: SyntaxNode[]; } "); - } +} - #[test] - fn emit_non_empty_list_field() { - let mut table = TypeTable::new(); - table.insert( - TypeKey::Synthetic(vec!["Foo", "items"]), - TypeValue::NonEmptyList(TypeKey::String), - ); +#[test] +fn emit_non_empty_list_field() { + let mut table = TypeTable::new(); + table.insert( + TypeKey::Synthetic(vec!["Foo", "items"]), + TypeValue::NonEmptyList(TypeKey::String), + ); - let mut fields = IndexMap::new(); - fields.insert("items", TypeKey::Synthetic(vec!["Foo", "items"])); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + let mut fields = IndexMap::new(); + fields.insert("items", TypeKey::Synthetic(vec!["Foo", "items"])); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); - insta::assert_snapshot!(output, @r" + insta::assert_snapshot!(output, @r" export interface Foo { items: [string, ...string[]]; } "); - } +} - #[test] - fn emit_nested_interface() { - let mut table = TypeTable::new(); +#[test] +fn emit_nested_interface() { + let mut table = TypeTable::new(); - let mut inner_fields = IndexMap::new(); - inner_fields.insert("value", TypeKey::String); - table.insert(TypeKey::Named("Inner"), TypeValue::Struct(inner_fields)); + let mut inner_fields = IndexMap::new(); + inner_fields.insert("value", TypeKey::String); + table.insert(TypeKey::Named("Inner"), TypeValue::Struct(inner_fields)); - let mut outer_fields = IndexMap::new(); - outer_fields.insert("inner", TypeKey::Named("Inner")); - table.insert(TypeKey::Named("Outer"), TypeValue::Struct(outer_fields)); + let mut outer_fields = IndexMap::new(); + outer_fields.insert("inner", TypeKey::Named("Inner")); + table.insert(TypeKey::Named("Outer"), TypeValue::Struct(outer_fields)); - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); - insta::assert_snapshot!(output, @r" + insta::assert_snapshot!(output, @r" export interface Inner { value: string; } @@ -241,53 +251,55 @@ mod tests { inner: Inner; } "); - } +} - #[test] - fn emit_inline_synthetic() { - let mut table = TypeTable::new(); +#[test] +fn emit_inline_synthetic() { + let mut table = TypeTable::new(); - let mut inner_fields = IndexMap::new(); - inner_fields.insert("x", TypeKey::Node); - table.insert( - TypeKey::Synthetic(vec!["Foo", "bar"]), - TypeValue::Struct(inner_fields), - ); + let mut inner_fields = IndexMap::new(); + inner_fields.insert("x", TypeKey::Node); + table.insert( + TypeKey::Synthetic(vec!["Foo", "bar"]), + TypeValue::Struct(inner_fields), + ); - let mut outer_fields = IndexMap::new(); - outer_fields.insert("bar", TypeKey::Synthetic(vec!["Foo", "bar"])); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(outer_fields)); + let mut outer_fields = IndexMap::new(); + outer_fields.insert("bar", TypeKey::Synthetic(vec!["Foo", "bar"])); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(outer_fields)); - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); - insta::assert_snapshot!(output, @r" + insta::assert_snapshot!(output, @r" export interface Foo { bar: { x: SyntaxNode }; } "); - } +} - #[test] - fn emit_no_inline_synthetic() { - let mut table = TypeTable::new(); +#[test] +fn emit_no_inline_synthetic() { + let mut table = TypeTable::new(); - let mut inner_fields = IndexMap::new(); - inner_fields.insert("x", TypeKey::Node); - table.insert( - TypeKey::Synthetic(vec!["Foo", "bar"]), - TypeValue::Struct(inner_fields), - ); + let mut inner_fields = IndexMap::new(); + inner_fields.insert("x", TypeKey::Node); + table.insert( + TypeKey::Synthetic(vec!["Foo", "bar"]), + TypeValue::Struct(inner_fields), + ); - let mut outer_fields = IndexMap::new(); - outer_fields.insert("bar", TypeKey::Synthetic(vec!["Foo", "bar"])); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(outer_fields)); + let mut outer_fields = IndexMap::new(); + outer_fields.insert("bar", TypeKey::Synthetic(vec!["Foo", "bar"])); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(outer_fields)); - let mut config = TypeScriptEmitConfig::default(); - config.inline_synthetic = false; - let output = emit_typescript(&table, &config); + let config = TypeScriptEmitConfig { + inline_synthetic: false, + ..Default::default() + }; + let output = emit_typescript(&table, &config); - insta::assert_snapshot!(output, @r" + insta::assert_snapshot!(output, @r" export interface FooBar { x: SyntaxNode; } @@ -296,405 +308,416 @@ mod tests { bar: FooBar; } "); - } +} - #[test] - fn emit_readonly_fields() { - let mut table = TypeTable::new(); - let mut fields = IndexMap::new(); - fields.insert("name", TypeKey::String); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); +#[test] +fn emit_readonly_fields() { + let mut table = TypeTable::new(); + let mut fields = IndexMap::new(); + fields.insert("name", TypeKey::String); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - let mut config = TypeScriptEmitConfig::default(); - config.readonly = true; - let output = emit_typescript(&table, &config); + let config = TypeScriptEmitConfig { + readonly: true, + ..Default::default() + }; + let output = emit_typescript(&table, &config); - insta::assert_snapshot!(output, @r" + insta::assert_snapshot!(output, @r" export interface Foo { readonly name: string; } "); - } - - #[test] - fn emit_no_export() { - let mut table = TypeTable::new(); - table.insert( - TypeKey::Named("Private"), - TypeValue::Struct(IndexMap::new()), - ); - - let mut config = TypeScriptEmitConfig::default(); - config.export = false; - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @"interface Private {}"); - } - - #[test] - fn emit_custom_node_type() { - let mut table = TypeTable::new(); - let mut fields = IndexMap::new(); - fields.insert("node", TypeKey::Node); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - - let mut config = TypeScriptEmitConfig::default(); - config.node_type_name = "TSNode".to_string(); - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @r" +} + +#[test] +fn emit_no_export() { + let mut table = TypeTable::new(); + table.insert( + TypeKey::Named("Private"), + TypeValue::Struct(IndexMap::new()), + ); + + let config = TypeScriptEmitConfig { + export: false, + ..Default::default() + }; + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @"interface Private {}"); +} + +#[test] +fn emit_custom_node_type() { + let mut table = TypeTable::new(); + let mut fields = IndexMap::new(); + fields.insert("node", TypeKey::Node); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + + let config = TypeScriptEmitConfig { + node_type_name: "TSNode".to_string(), + ..Default::default() + }; + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @r" export interface Foo { node: TSNode; } "); - } +} - #[test] - fn emit_cyclic_type_no_box() { - let mut table = TypeTable::new(); +#[test] +fn emit_cyclic_type_no_box() { + let mut table = TypeTable::new(); - table.insert( - TypeKey::Synthetic(vec!["Tree", "child"]), - TypeValue::Optional(TypeKey::Named("Tree")), - ); + table.insert( + TypeKey::Synthetic(vec!["Tree", "child"]), + TypeValue::Optional(TypeKey::Named("Tree")), + ); - let mut fields = IndexMap::new(); - fields.insert("value", TypeKey::String); - fields.insert("child", TypeKey::Synthetic(vec!["Tree", "child"])); - table.insert(TypeKey::Named("Tree"), TypeValue::Struct(fields)); + let mut fields = IndexMap::new(); + fields.insert("value", TypeKey::String); + fields.insert("child", TypeKey::Synthetic(vec!["Tree", "child"])); + table.insert(TypeKey::Named("Tree"), TypeValue::Struct(fields)); - table.mark_cyclic(TypeKey::Named("Tree")); + table.mark_cyclic(TypeKey::Named("Tree")); - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); - // TypeScript handles cycles natively, no Box needed - insta::assert_snapshot!(output, @r" + // TypeScript handles cycles natively, no Box needed + insta::assert_snapshot!(output, @r" export interface Tree { value: string; child: Tree | null; } "); - } - - #[test] - fn emit_list_of_optional() { - let mut table = TypeTable::new(); - table.insert( - TypeKey::Synthetic(vec!["Foo", "inner"]), - TypeValue::Optional(TypeKey::Node), - ); - table.insert( - TypeKey::Synthetic(vec!["Foo", "items"]), - TypeValue::List(TypeKey::Synthetic(vec!["Foo", "inner"])), - ); - - let mut fields = IndexMap::new(); - fields.insert("items", TypeKey::Synthetic(vec!["Foo", "items"])); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @r" +} + +#[test] +fn emit_list_of_optional() { + let mut table = TypeTable::new(); + table.insert( + TypeKey::Synthetic(vec!["Foo", "inner"]), + TypeValue::Optional(TypeKey::Node), + ); + table.insert( + TypeKey::Synthetic(vec!["Foo", "items"]), + TypeValue::List(TypeKey::Synthetic(vec!["Foo", "inner"])), + ); + + let mut fields = IndexMap::new(); + fields.insert("items", TypeKey::Synthetic(vec!["Foo", "items"])); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @r" export interface Foo { items: (SyntaxNode | null)[]; } "); - } - - #[test] - fn emit_deeply_nested_inline() { - let mut table = TypeTable::new(); - - let mut level2 = IndexMap::new(); - level2.insert("val", TypeKey::String); - table.insert( - TypeKey::Synthetic(vec!["A", "b", "c"]), - TypeValue::Struct(level2), - ); - - let mut level1 = IndexMap::new(); - level1.insert("c", TypeKey::Synthetic(vec!["A", "b", "c"])); - table.insert( - TypeKey::Synthetic(vec!["A", "b"]), - TypeValue::Struct(level1), - ); - - let mut root = IndexMap::new(); - root.insert("b", TypeKey::Synthetic(vec!["A", "b"])); - table.insert(TypeKey::Named("A"), TypeValue::Struct(root)); - - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @r" +} + +#[test] +fn emit_deeply_nested_inline() { + let mut table = TypeTable::new(); + + let mut level2 = IndexMap::new(); + level2.insert("val", TypeKey::String); + table.insert( + TypeKey::Synthetic(vec!["A", "b", "c"]), + TypeValue::Struct(level2), + ); + + let mut level1 = IndexMap::new(); + level1.insert("c", TypeKey::Synthetic(vec!["A", "b", "c"])); + table.insert( + TypeKey::Synthetic(vec!["A", "b"]), + TypeValue::Struct(level1), + ); + + let mut root = IndexMap::new(); + root.insert("b", TypeKey::Synthetic(vec!["A", "b"])); + table.insert(TypeKey::Named("A"), TypeValue::Struct(root)); + + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @r" export interface A { b: { c: { val: string } }; } "); - } - - #[test] - fn emit_type_alias_when_not_inlined() { - let mut table = TypeTable::new(); - table.insert( - TypeKey::Named("OptionalNode"), - TypeValue::Optional(TypeKey::Node), - ); - - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @"export type OptionalNode = SyntaxNode | null;"); - } - - #[test] - fn emit_type_alias_list() { - let mut table = TypeTable::new(); - table.insert(TypeKey::Named("NodeList"), TypeValue::List(TypeKey::Node)); - - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @"export type NodeList = SyntaxNode[];"); - } - - #[test] - fn emit_type_alias_non_empty_list() { - let mut table = TypeTable::new(); - table.insert( - TypeKey::Named("NonEmptyNodes"), - TypeValue::NonEmptyList(TypeKey::Node), - ); - - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @"export type NonEmptyNodes = [SyntaxNode, ...SyntaxNode[]];"); - } - - #[test] - fn wrap_if_union_simple() { - assert_eq!(wrap_if_union("string"), "string"); - assert_eq!(wrap_if_union("SyntaxNode"), "SyntaxNode"); - } - - #[test] - fn wrap_if_union_with_pipe() { - assert_eq!(wrap_if_union("string | null"), "(string | null)"); - assert_eq!(wrap_if_union("A | B | C"), "(A | B | C)"); - } - - #[test] - fn inline_empty_struct() { - let fields = IndexMap::new(); - let table = TypeTable::new(); - let config = TypeScriptEmitConfig::default(); - let result = emit_inline_struct(&fields, &table, &config); - assert_eq!(result, "{}"); - } - - #[test] - fn inline_struct_multiple_fields() { - let mut fields = IndexMap::new(); - fields.insert("a", TypeKey::String); - fields.insert("b", TypeKey::Node); - let table = TypeTable::new(); - let config = TypeScriptEmitConfig::default(); - let result = emit_inline_struct(&fields, &table, &config); - assert_eq!(result, "{ a: string; b: SyntaxNode }"); - } - - #[test] - fn dependencies_primitives() { - assert!(dependencies(&TypeValue::Node).is_empty()); - assert!(dependencies(&TypeValue::String).is_empty()); - assert!(dependencies(&TypeValue::Unit).is_empty()); - } - - #[test] - fn dependencies_struct() { - let mut fields = IndexMap::new(); - fields.insert("a", TypeKey::Named("A")); - fields.insert("b", TypeKey::Named("B")); - let value = TypeValue::Struct(fields); - - let deps = dependencies(&value); - assert_eq!(deps.len(), 2); - } - - #[test] - fn dependencies_wrappers() { - let opt = TypeValue::Optional(TypeKey::Named("T")); - let list = TypeValue::List(TypeKey::Named("T")); - let ne = TypeValue::NonEmptyList(TypeKey::Named("T")); - - assert_eq!(dependencies(&opt), vec![TypeKey::Named("T")]); - assert_eq!(dependencies(&list), vec![TypeKey::Named("T")]); - assert_eq!(dependencies(&ne), vec![TypeKey::Named("T")]); - } - - #[test] - fn optional_style_equality() { - assert_eq!(OptionalStyle::Null, OptionalStyle::Null); - assert_ne!(OptionalStyle::Null, OptionalStyle::Undefined); - assert_ne!(OptionalStyle::Undefined, OptionalStyle::QuestionMark); - } - - #[test] - fn config_default() { - let config = TypeScriptEmitConfig::default(); - assert_eq!(config.optional_style, OptionalStyle::Null); - assert!(config.export); - assert!(!config.readonly); - assert!(config.inline_synthetic); - assert_eq!(config.node_type_name, "SyntaxNode"); - } - - #[test] - fn emit_tagged_union_optional_field_question() { - let mut table = TypeTable::new(); - - table.insert( - TypeKey::Synthetic(vec!["Stmt", "x"]), - TypeValue::Optional(TypeKey::Node), - ); - - let mut variants = IndexMap::new(); - let mut v_fields = IndexMap::new(); - v_fields.insert("x", TypeKey::Synthetic(vec!["Stmt", "x"])); - variants.insert("V", v_fields); - - table.insert(TypeKey::Named("Stmt"), TypeValue::TaggedUnion(variants)); - - let mut config = TypeScriptEmitConfig::default(); - config.optional_style = OptionalStyle::QuestionMark; - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @r#" +} + +#[test] +fn emit_type_alias_when_not_inlined() { + let mut table = TypeTable::new(); + table.insert( + TypeKey::Named("OptionalNode"), + TypeValue::Optional(TypeKey::Node), + ); + + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @"export type OptionalNode = SyntaxNode | null;"); +} + +#[test] +fn emit_type_alias_list() { + let mut table = TypeTable::new(); + table.insert(TypeKey::Named("NodeList"), TypeValue::List(TypeKey::Node)); + + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @"export type NodeList = SyntaxNode[];"); +} + +#[test] +fn emit_type_alias_non_empty_list() { + let mut table = TypeTable::new(); + table.insert( + TypeKey::Named("NonEmptyNodes"), + TypeValue::NonEmptyList(TypeKey::Node), + ); + + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @"export type NonEmptyNodes = [SyntaxNode, ...SyntaxNode[]];"); +} + +#[test] +fn wrap_if_union_simple() { + assert_eq!(wrap_if_union("string"), "string"); + assert_eq!(wrap_if_union("SyntaxNode"), "SyntaxNode"); +} + +#[test] +fn wrap_if_union_with_pipe() { + assert_eq!(wrap_if_union("string | null"), "(string | null)"); + assert_eq!(wrap_if_union("A | B | C"), "(A | B | C)"); +} + +#[test] +fn inline_empty_struct() { + let fields = IndexMap::new(); + let table = TypeTable::new(); + let config = TypeScriptEmitConfig::default(); + let result = emit_inline_struct(&fields, &table, &config); + assert_eq!(result, "{}"); +} + +#[test] +fn inline_struct_multiple_fields() { + let mut fields = IndexMap::new(); + fields.insert("a", TypeKey::String); + fields.insert("b", TypeKey::Node); + let table = TypeTable::new(); + let config = TypeScriptEmitConfig::default(); + let result = emit_inline_struct(&fields, &table, &config); + assert_eq!(result, "{ a: string; b: SyntaxNode }"); +} + +#[test] +fn dependencies_primitives() { + assert!(dependencies(&TypeValue::Node).is_empty()); + assert!(dependencies(&TypeValue::String).is_empty()); + assert!(dependencies(&TypeValue::Unit).is_empty()); +} + +#[test] +fn dependencies_struct() { + let mut fields = IndexMap::new(); + fields.insert("a", TypeKey::Named("A")); + fields.insert("b", TypeKey::Named("B")); + let value = TypeValue::Struct(fields); + + let deps = dependencies(&value); + assert_eq!(deps.len(), 2); +} + +#[test] +fn dependencies_wrappers() { + let opt = TypeValue::Optional(TypeKey::Named("T")); + let list = TypeValue::List(TypeKey::Named("T")); + let ne = TypeValue::NonEmptyList(TypeKey::Named("T")); + + assert_eq!(dependencies(&opt), vec![TypeKey::Named("T")]); + assert_eq!(dependencies(&list), vec![TypeKey::Named("T")]); + assert_eq!(dependencies(&ne), vec![TypeKey::Named("T")]); +} + +#[test] +fn optional_style_equality() { + assert_eq!(OptionalStyle::Null, OptionalStyle::Null); + assert_ne!(OptionalStyle::Null, OptionalStyle::Undefined); + assert_ne!(OptionalStyle::Undefined, OptionalStyle::QuestionMark); +} + +#[test] +fn config_default() { + let config = TypeScriptEmitConfig::default(); + assert_eq!(config.optional_style, OptionalStyle::Null); + assert!(config.export); + assert!(!config.readonly); + assert!(config.inline_synthetic); + assert_eq!(config.node_type_name, "SyntaxNode"); +} + +#[test] +fn emit_tagged_union_optional_field_question() { + let mut table = TypeTable::new(); + + table.insert( + TypeKey::Synthetic(vec!["Stmt", "x"]), + TypeValue::Optional(TypeKey::Node), + ); + + let mut variants = IndexMap::new(); + let mut v_fields = IndexMap::new(); + v_fields.insert("x", TypeKey::Synthetic(vec!["Stmt", "x"])); + variants.insert("V", v_fields); + + table.insert(TypeKey::Named("Stmt"), TypeValue::TaggedUnion(variants)); + + let config = TypeScriptEmitConfig { + optional_style: OptionalStyle::QuestionMark, + ..Default::default() + }; + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @r#" export type Stmt = | { tag: "V"; x?: SyntaxNode }; "#); - } - - #[test] - fn topological_sort_with_cycle() { - let mut table = TypeTable::new(); - - let mut a_fields = IndexMap::new(); - a_fields.insert("b", TypeKey::Named("B")); - table.insert(TypeKey::Named("A"), TypeValue::Struct(a_fields)); - - let mut b_fields = IndexMap::new(); - b_fields.insert("a", TypeKey::Named("A")); - table.insert(TypeKey::Named("B"), TypeValue::Struct(b_fields)); - - let sorted = topological_sort(&table); - assert!(sorted.iter().any(|k| *k == TypeKey::Named("A"))); - assert!(sorted.iter().any(|k| *k == TypeKey::Named("B"))); - } - - #[test] - fn emit_field_type_unknown_key() { - let table = TypeTable::new(); - let config = TypeScriptEmitConfig::default(); - let (type_str, is_optional) = emit_field_type(&TypeKey::Named("Unknown"), &table, &config); - assert_eq!(type_str, "Unknown"); - assert!(!is_optional); - } - - #[test] - fn emit_readonly_optional_question_mark() { - let mut table = TypeTable::new(); - table.insert( - TypeKey::Synthetic(vec!["Foo", "bar"]), - TypeValue::Optional(TypeKey::String), - ); - - let mut fields = IndexMap::new(); - fields.insert("bar", TypeKey::Synthetic(vec!["Foo", "bar"])); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - - let mut config = TypeScriptEmitConfig::default(); - config.readonly = true; - config.optional_style = OptionalStyle::QuestionMark; - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @r" +} + +#[test] +fn topological_sort_with_cycle() { + let mut table = TypeTable::new(); + + let mut a_fields = IndexMap::new(); + a_fields.insert("b", TypeKey::Named("B")); + table.insert(TypeKey::Named("A"), TypeValue::Struct(a_fields)); + + let mut b_fields = IndexMap::new(); + b_fields.insert("a", TypeKey::Named("A")); + table.insert(TypeKey::Named("B"), TypeValue::Struct(b_fields)); + + let sorted = topological_sort(&table); + assert!(sorted.contains(&TypeKey::Named("A"))); + assert!(sorted.contains(&TypeKey::Named("B"))); +} + +#[test] +fn emit_field_type_unknown_key() { + let table = TypeTable::new(); + let config = TypeScriptEmitConfig::default(); + let (type_str, is_optional) = emit_field_type(&TypeKey::Named("Unknown"), &table, &config); + assert_eq!(type_str, "Unknown"); + assert!(!is_optional); +} + +#[test] +fn emit_readonly_optional_question_mark() { + let mut table = TypeTable::new(); + table.insert( + TypeKey::Synthetic(vec!["Foo", "bar"]), + TypeValue::Optional(TypeKey::String), + ); + + let mut fields = IndexMap::new(); + fields.insert("bar", TypeKey::Synthetic(vec!["Foo", "bar"])); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + + let config = TypeScriptEmitConfig { + readonly: true, + optional_style: OptionalStyle::QuestionMark, + ..Default::default() + }; + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @r" export interface Foo { readonly bar?: string; } "); - } - - #[test] - fn inline_struct_with_optional_question_mark() { - let mut table = TypeTable::new(); - table.insert( - TypeKey::Synthetic(vec!["inner", "opt"]), - TypeValue::Optional(TypeKey::Node), - ); - - let mut fields = IndexMap::new(); - fields.insert("opt", TypeKey::Synthetic(vec!["inner", "opt"])); - - let mut config = TypeScriptEmitConfig::default(); - config.optional_style = OptionalStyle::QuestionMark; - - let result = emit_inline_struct(&fields, &table, &config); - assert_eq!(result, "{ opt?: SyntaxNode }"); - } - - #[test] - fn dependencies_tagged_union() { - let mut variants = IndexMap::new(); - let mut v1 = IndexMap::new(); - v1.insert("x", TypeKey::Named("X")); - variants.insert("V1", v1); - - let mut v2 = IndexMap::new(); - v2.insert("y", TypeKey::Named("Y")); - variants.insert("V2", v2); - - let value = TypeValue::TaggedUnion(variants); - let deps = dependencies(&value); - - assert_eq!(deps.len(), 2); - assert!(deps.contains(&TypeKey::Named("X"))); - assert!(deps.contains(&TypeKey::Named("Y"))); - } - - #[test] - fn topological_sort_missing_dependency() { - let mut table = TypeTable::new(); - - let mut fields = IndexMap::new(); - fields.insert("missing", TypeKey::Named("DoesNotExist")); - table.insert(TypeKey::Named("HasMissing"), TypeValue::Struct(fields)); - - // Should not panic, includes all visited keys - let sorted = topological_sort(&table); - assert!(sorted.iter().any(|k| *k == TypeKey::Named("HasMissing"))); - // The missing key is visited and added to result (dependency comes before dependent) - assert!(sorted.iter().any(|k| *k == TypeKey::Named("DoesNotExist"))); - } - - #[test] - fn emit_with_missing_dependency() { - let mut table = TypeTable::new(); - - let mut fields = IndexMap::new(); - fields.insert("ref_field", TypeKey::Named("Missing")); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @r" +} + +#[test] +fn inline_struct_with_optional_question_mark() { + let mut table = TypeTable::new(); + table.insert( + TypeKey::Synthetic(vec!["inner", "opt"]), + TypeValue::Optional(TypeKey::Node), + ); + + let mut fields = IndexMap::new(); + fields.insert("opt", TypeKey::Synthetic(vec!["inner", "opt"])); + + let config = TypeScriptEmitConfig { + optional_style: OptionalStyle::QuestionMark, + ..Default::default() + }; + + let result = emit_inline_struct(&fields, &table, &config); + assert_eq!(result, "{ opt?: SyntaxNode }"); +} + +#[test] +fn dependencies_tagged_union() { + let mut variants = IndexMap::new(); + let mut v1 = IndexMap::new(); + v1.insert("x", TypeKey::Named("X")); + variants.insert("V1", v1); + + let mut v2 = IndexMap::new(); + v2.insert("y", TypeKey::Named("Y")); + variants.insert("V2", v2); + + let value = TypeValue::TaggedUnion(variants); + let deps = dependencies(&value); + + assert_eq!(deps.len(), 2); + assert!(deps.contains(&TypeKey::Named("X"))); + assert!(deps.contains(&TypeKey::Named("Y"))); +} + +#[test] +fn topological_sort_missing_dependency() { + let mut table = TypeTable::new(); + + let mut fields = IndexMap::new(); + fields.insert("missing", TypeKey::Named("DoesNotExist")); + table.insert(TypeKey::Named("HasMissing"), TypeValue::Struct(fields)); + + // Should not panic, includes all visited keys + let sorted = topological_sort(&table); + assert!(sorted.contains(&TypeKey::Named("HasMissing"))); + // The missing key is visited and added to result (dependency comes before dependent) + assert!(sorted.contains(&TypeKey::Named("DoesNotExist"))); +} + +#[test] +fn emit_with_missing_dependency() { + let mut table = TypeTable::new(); + + let mut fields = IndexMap::new(); + fields.insert("ref_field", TypeKey::Named("Missing")); + table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); + + let config = TypeScriptEmitConfig::default(); + let output = emit_typescript(&table, &config); + + insta::assert_snapshot!(output, @r" export interface Foo { ref_field: Missing; } "); - } } From ed3c441c5e714a22547e2bd4f35e170670bc80cf Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sat, 6 Dec 2025 20:51:24 -0300 Subject: [PATCH 4/8] Refactor emit code to support new tagged union representation --- crates/plotnik-lib/src/infer/emit/mod.rs | 4 - crates/plotnik-lib/src/infer/emit/rust.rs | 32 +- .../plotnik-lib/src/infer/emit/rust_tests.rs | 612 --------------- .../plotnik-lib/src/infer/emit/typescript.rs | 22 +- .../src/infer/emit/typescript_tests.rs | 723 ------------------ crates/plotnik-lib/src/infer/types.rs | 48 +- 6 files changed, 71 insertions(+), 1370 deletions(-) delete mode 100644 crates/plotnik-lib/src/infer/emit/rust_tests.rs delete mode 100644 crates/plotnik-lib/src/infer/emit/typescript_tests.rs diff --git a/crates/plotnik-lib/src/infer/emit/mod.rs b/crates/plotnik-lib/src/infer/emit/mod.rs index 41055054..5971b9b2 100644 --- a/crates/plotnik-lib/src/infer/emit/mod.rs +++ b/crates/plotnik-lib/src/infer/emit/mod.rs @@ -3,11 +3,7 @@ //! This module provides language-specific code generation from a `TypeTable`. pub mod rust; -#[cfg(test)] -pub mod rust_tests; pub mod typescript; -#[cfg(test)] -pub 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 index b6598ae9..9aee8d23 100644 --- a/crates/plotnik-lib/src/infer/emit/rust.rs +++ b/crates/plotnik-lib/src/infer/emit/rust.rs @@ -90,16 +90,24 @@ fn emit_type_def( TypeValue::TaggedUnion(variants) => { let mut out = emit_derives(config); out.push_str(&format!("pub enum {} {{\n", name)); - for (variant_name, fields) in variants { - if fields.is_empty() { - out.push_str(&format!(" {},\n", variant_name)); - } else { - out.push_str(&format!(" {} {{\n", variant_name)); - for (field_name, field_type) in fields { - let type_str = emit_type_ref(field_type, table, config); - out.push_str(&format!(" {}: {},\n", field_name, type_str)); + 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_str(" },\n"); } } out.push('}'); @@ -222,11 +230,7 @@ pub(crate) fn dependencies<'src>(value: &TypeValue<'src>) -> Vec> TypeValue::Struct(fields) => fields.values().cloned().collect(), - TypeValue::TaggedUnion(variants) => variants - .values() - .flat_map(|f| f.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 deleted file mode 100644 index c7537ed5..00000000 --- a/crates/plotnik-lib/src/infer/emit/rust_tests.rs +++ /dev/null @@ -1,612 +0,0 @@ -use indexmap::IndexMap; - -use crate::infer::{ - Indirection, RustEmitConfig, TypeKey, TypeTable, TypeValue, - emit::rust::{dependencies, emit_derives, emit_type_ref, topological_sort, wrap_indirection}, - emit_rust, -}; - -#[test] -fn emit_empty_table() { - let table = TypeTable::new(); - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); - assert_eq!(output, ""); -} - -#[test] -fn emit_simple_struct() { - let mut table = TypeTable::new(); - let mut fields = IndexMap::new(); - fields.insert("name", TypeKey::String); - fields.insert("node", TypeKey::Node); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); - - insta::assert_snapshot!(output, @r" - #[derive(Debug, Clone)] - pub struct Foo { - pub name: String, - pub node: Node, - } - "); -} - -#[test] -fn emit_empty_struct() { - let mut table = TypeTable::new(); - table.insert(TypeKey::Named("Empty"), TypeValue::Struct(IndexMap::new())); - - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); - - insta::assert_snapshot!(output, @r" - #[derive(Debug, Clone)] - pub struct Empty; - "); -} - -#[test] -fn emit_unit_field() { - let mut table = TypeTable::new(); - let mut fields = IndexMap::new(); - fields.insert("marker", TypeKey::Unit); - table.insert(TypeKey::Named("WithUnit"), TypeValue::Struct(fields)); - - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); - - insta::assert_snapshot!(output, @r" - #[derive(Debug, Clone)] - pub struct WithUnit { - pub marker: (), - } - "); -} - -#[test] -fn emit_tagged_union() { - let mut table = TypeTable::new(); - let mut variants = IndexMap::new(); - - let mut assign_fields = IndexMap::new(); - assign_fields.insert("target", TypeKey::String); - variants.insert("Assign", assign_fields); - - let mut call_fields = IndexMap::new(); - call_fields.insert("func", TypeKey::String); - variants.insert("Call", call_fields); - - table.insert(TypeKey::Named("Stmt"), TypeValue::TaggedUnion(variants)); - - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); - - insta::assert_snapshot!(output, @r" - #[derive(Debug, Clone)] - pub enum Stmt { - Assign { - target: String, - }, - Call { - func: String, - }, - } - "); -} - -#[test] -fn emit_tagged_union_unit_variant() { - let mut table = TypeTable::new(); - let mut variants = IndexMap::new(); - - variants.insert("None", IndexMap::new()); - - let mut some_fields = IndexMap::new(); - some_fields.insert("value", TypeKey::Node); - variants.insert("Some", some_fields); - - table.insert(TypeKey::Named("Maybe"), TypeValue::TaggedUnion(variants)); - - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); - - insta::assert_snapshot!(output, @r" - #[derive(Debug, Clone)] - pub enum Maybe { - None, - Some { - value: Node, - }, - } - "); -} - -#[test] -fn emit_optional_field() { - let mut table = TypeTable::new(); - table.insert( - TypeKey::Synthetic(vec!["Foo", "bar"]), - TypeValue::Optional(TypeKey::Node), - ); - - let mut fields = IndexMap::new(); - fields.insert("bar", TypeKey::Synthetic(vec!["Foo", "bar"])); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); - - insta::assert_snapshot!(output, @r" - pub type FooBar = Option; - - #[derive(Debug, Clone)] - pub struct Foo { - pub bar: Option, - } - "); -} - -#[test] -fn emit_list_field() { - let mut table = TypeTable::new(); - table.insert( - TypeKey::Synthetic(vec!["Foo", "items"]), - TypeValue::List(TypeKey::Node), - ); - - let mut fields = IndexMap::new(); - fields.insert("items", TypeKey::Synthetic(vec!["Foo", "items"])); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); - - insta::assert_snapshot!(output, @r" - pub type FooItems = Vec; - - #[derive(Debug, Clone)] - pub struct Foo { - pub items: Vec, - } - "); -} - -#[test] -fn emit_non_empty_list_field() { - let mut table = TypeTable::new(); - table.insert( - TypeKey::Synthetic(vec!["Foo", "items"]), - TypeValue::NonEmptyList(TypeKey::String), - ); - - let mut fields = IndexMap::new(); - fields.insert("items", TypeKey::Synthetic(vec!["Foo", "items"])); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); - - insta::assert_snapshot!(output, @r" - pub type FooItems = Vec; - - #[derive(Debug, Clone)] - pub struct Foo { - pub items: Vec, - } - "); -} - -#[test] -fn emit_nested_struct() { - let mut table = TypeTable::new(); - - let mut inner_fields = IndexMap::new(); - inner_fields.insert("value", TypeKey::String); - table.insert(TypeKey::Named("Inner"), TypeValue::Struct(inner_fields)); - - let mut outer_fields = IndexMap::new(); - outer_fields.insert("inner", TypeKey::Named("Inner")); - table.insert(TypeKey::Named("Outer"), TypeValue::Struct(outer_fields)); - - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); - - insta::assert_snapshot!(output, @r" - #[derive(Debug, Clone)] - pub struct Inner { - pub value: String, - } - - #[derive(Debug, Clone)] - pub struct Outer { - pub inner: Inner, - } - "); -} - -#[test] -fn emit_cyclic_type_box() { - let mut table = TypeTable::new(); - - table.insert( - TypeKey::Synthetic(vec!["Tree", "child"]), - TypeValue::Optional(TypeKey::Named("Tree")), - ); - - let mut fields = IndexMap::new(); - fields.insert("value", TypeKey::String); - fields.insert("child", TypeKey::Synthetic(vec!["Tree", "child"])); - table.insert(TypeKey::Named("Tree"), TypeValue::Struct(fields)); - - table.mark_cyclic(TypeKey::Named("Tree")); - - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); - - insta::assert_snapshot!(output, @r" - #[derive(Debug, Clone)] - pub struct Tree { - pub value: String, - pub child: Option>, - } - - pub type TreeChild = Option>; - "); -} - -#[test] -fn emit_cyclic_type_rc() { - let mut table = TypeTable::new(); - - table.insert( - TypeKey::Synthetic(vec!["Tree", "child"]), - TypeValue::Optional(TypeKey::Named("Tree")), - ); - - let mut fields = IndexMap::new(); - fields.insert("child", TypeKey::Synthetic(vec!["Tree", "child"])); - table.insert(TypeKey::Named("Tree"), TypeValue::Struct(fields)); - - table.mark_cyclic(TypeKey::Named("Tree")); - - let config = RustEmitConfig { - indirection: Indirection::Rc, - ..Default::default() - }; - let output = emit_rust(&table, &config); - - insta::assert_snapshot!(output, @r" - #[derive(Debug, Clone)] - pub struct Tree { - pub child: Option>, - } - - pub type TreeChild = Option>; - "); -} - -#[test] -fn emit_cyclic_type_arc() { - let mut table = TypeTable::new(); - - let mut fields = IndexMap::new(); - fields.insert("next", TypeKey::Named("Node")); - table.insert(TypeKey::Named("Node"), TypeValue::Struct(fields)); - - table.mark_cyclic(TypeKey::Named("Node")); - - let config = RustEmitConfig { - indirection: Indirection::Arc, - ..Default::default() - }; - let output = emit_rust(&table, &config); - - insta::assert_snapshot!(output, @r" - #[derive(Debug, Clone)] - pub struct Node { - pub next: Arc, - } - "); -} - -#[test] -fn emit_no_derives() { - let mut table = TypeTable::new(); - table.insert(TypeKey::Named("Plain"), TypeValue::Struct(IndexMap::new())); - - let config = RustEmitConfig { - indirection: Indirection::Box, - derive_debug: false, - derive_clone: false, - derive_partial_eq: false, - }; - let output = emit_rust(&table, &config); - - insta::assert_snapshot!(output, @"pub struct Plain;"); -} - -#[test] -fn emit_all_derives() { - let mut table = TypeTable::new(); - table.insert(TypeKey::Named("Full"), TypeValue::Struct(IndexMap::new())); - - let config = RustEmitConfig { - indirection: Indirection::Box, - derive_debug: true, - derive_clone: true, - derive_partial_eq: true, - }; - let output = emit_rust(&table, &config); - - insta::assert_snapshot!(output, @r" - #[derive(Debug, Clone, PartialEq)] - pub struct Full; - "); -} - -#[test] -fn emit_synthetic_type_name() { - let mut table = TypeTable::new(); - - let mut fields = IndexMap::new(); - fields.insert("x", TypeKey::Node); - table.insert( - TypeKey::Synthetic(vec!["Foo", "bar", "baz"]), - TypeValue::Struct(fields), - ); - - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); - - insta::assert_snapshot!(output, @r" - #[derive(Debug, Clone)] - pub struct FooBarBaz { - pub x: Node, - } - "); -} - -#[test] -fn emit_complex_nested() { - let mut table = TypeTable::new(); - - // Inner struct - let mut inner = IndexMap::new(); - inner.insert("value", TypeKey::String); - table.insert( - TypeKey::Synthetic(vec!["Root", "item"]), - TypeValue::Struct(inner), - ); - - // List of inner - table.insert( - TypeKey::Synthetic(vec!["Root", "items"]), - TypeValue::List(TypeKey::Synthetic(vec!["Root", "item"])), - ); - - // Root struct - let mut root = IndexMap::new(); - root.insert("items", TypeKey::Synthetic(vec!["Root", "items"])); - table.insert(TypeKey::Named("Root"), TypeValue::Struct(root)); - - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); - - insta::assert_snapshot!(output, @r" - #[derive(Debug, Clone)] - pub struct RootItem { - pub value: String, - } - - pub type RootItems = Vec; - - #[derive(Debug, Clone)] - pub struct Root { - pub items: Vec, - } - "); -} - -#[test] -fn emit_optional_list() { - let mut table = TypeTable::new(); - - table.insert( - TypeKey::Synthetic(vec!["Foo", "items", "inner"]), - TypeValue::List(TypeKey::Node), - ); - table.insert( - TypeKey::Synthetic(vec!["Foo", "items"]), - TypeValue::Optional(TypeKey::Synthetic(vec!["Foo", "items", "inner"])), - ); - - let mut fields = IndexMap::new(); - fields.insert("items", TypeKey::Synthetic(vec!["Foo", "items"])); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); - - insta::assert_snapshot!(output, @r" - pub type FooItemsInner = Vec; - - pub type FooItems = Option>; - - #[derive(Debug, Clone)] - pub struct Foo { - pub items: Option>, - } - "); -} - -#[test] -fn topological_sort_simple() { - let mut table = TypeTable::new(); - table.insert(TypeKey::Named("A"), TypeValue::Unit); - table.insert(TypeKey::Named("B"), TypeValue::Unit); - - let sorted = topological_sort(&table); - let names: Vec<_> = sorted.iter().map(|k| k.to_pascal_case()).collect(); - - // Builtins first - assert!(names.iter().position(|n| n == "Node") < names.iter().position(|n| n == "A")); -} - -#[test] -fn topological_sort_with_dependency() { - let mut table = TypeTable::new(); - - let mut b_fields = IndexMap::new(); - b_fields.insert("a", TypeKey::Named("A")); - table.insert(TypeKey::Named("B"), TypeValue::Struct(b_fields)); - - table.insert(TypeKey::Named("A"), TypeValue::Unit); - - let sorted = topological_sort(&table); - let names: Vec<_> = sorted.iter().map(|k| k.to_pascal_case()).collect(); - - let a_pos = names.iter().position(|n| n == "A").unwrap(); - let b_pos = names.iter().position(|n| n == "B").unwrap(); - assert!(a_pos < b_pos, "A should come before B"); -} - -#[test] -fn topological_sort_cycle() { - let mut table = TypeTable::new(); - - let mut a_fields = IndexMap::new(); - a_fields.insert("b", TypeKey::Named("B")); - table.insert(TypeKey::Named("A"), TypeValue::Struct(a_fields)); - - let mut b_fields = IndexMap::new(); - b_fields.insert("a", TypeKey::Named("A")); - table.insert(TypeKey::Named("B"), TypeValue::Struct(b_fields)); - - // Should not panic - let sorted = topological_sort(&table); - assert!(sorted.contains(&TypeKey::Named("A"))); - assert!(sorted.contains(&TypeKey::Named("B"))); -} - -#[test] -fn dependencies_struct() { - let mut fields = IndexMap::new(); - fields.insert("a", TypeKey::Named("A")); - fields.insert("b", TypeKey::Named("B")); - let value = TypeValue::Struct(fields); - - let deps = dependencies(&value); - assert_eq!(deps.len(), 2); - assert!(deps.contains(&TypeKey::Named("A"))); - assert!(deps.contains(&TypeKey::Named("B"))); -} - -#[test] -fn dependencies_tagged_union() { - let mut variants = IndexMap::new(); - let mut v1 = IndexMap::new(); - v1.insert("x", TypeKey::Named("X")); - variants.insert("V1", v1); - - let mut v2 = IndexMap::new(); - v2.insert("y", TypeKey::Named("Y")); - variants.insert("V2", v2); - - let value = TypeValue::TaggedUnion(variants); - let deps = dependencies(&value); - - assert_eq!(deps.len(), 2); - assert!(deps.contains(&TypeKey::Named("X"))); - assert!(deps.contains(&TypeKey::Named("Y"))); -} - -#[test] -fn dependencies_primitives() { - assert!(dependencies(&TypeValue::Node).is_empty()); - assert!(dependencies(&TypeValue::String).is_empty()); - assert!(dependencies(&TypeValue::Unit).is_empty()); -} - -#[test] -fn dependencies_wrappers() { - let opt = TypeValue::Optional(TypeKey::Named("T")); - let list = TypeValue::List(TypeKey::Named("T")); - let ne = TypeValue::NonEmptyList(TypeKey::Named("T")); - - assert_eq!(dependencies(&opt), vec![TypeKey::Named("T")]); - assert_eq!(dependencies(&list), vec![TypeKey::Named("T")]); - assert_eq!(dependencies(&ne), vec![TypeKey::Named("T")]); -} - -#[test] -fn indirection_equality() { - assert_eq!(Indirection::Box, Indirection::Box); - assert_ne!(Indirection::Box, Indirection::Rc); - assert_ne!(Indirection::Rc, Indirection::Arc); -} - -#[test] -fn wrap_indirection_all_variants() { - assert_eq!(wrap_indirection("Foo", Indirection::Box), "Box"); - assert_eq!(wrap_indirection("Foo", Indirection::Rc), "Rc"); - assert_eq!(wrap_indirection("Foo", Indirection::Arc), "Arc"); -} - -#[test] -fn emit_derives_partial() { - let config = RustEmitConfig { - derive_debug: true, - derive_clone: false, - derive_partial_eq: true, - ..Default::default() - }; - let derives = emit_derives(&config); - assert_eq!(derives, "#[derive(Debug, PartialEq)]\n"); -} - -#[test] -fn emit_type_ref_unknown_key() { - let table = TypeTable::new(); - let config = RustEmitConfig::default(); - let type_str = emit_type_ref(&TypeKey::Named("Unknown"), &table, &config); - assert_eq!(type_str, "Unknown"); -} - -#[test] -fn topological_sort_missing_dependency() { - let mut table = TypeTable::new(); - - // Struct references a type that doesn't exist in the table - let mut fields = IndexMap::new(); - fields.insert("missing", TypeKey::Named("DoesNotExist")); - table.insert(TypeKey::Named("HasMissing"), TypeValue::Struct(fields)); - - // Should not panic, includes all visited keys - let sorted = topological_sort(&table); - assert!(sorted.contains(&TypeKey::Named("HasMissing"))); - // The missing key is visited and added to result (dependency comes before dependent) - assert!(sorted.contains(&TypeKey::Named("DoesNotExist"))); -} - -#[test] -fn emit_with_missing_dependency() { - let mut table = TypeTable::new(); - - let mut fields = IndexMap::new(); - fields.insert("ref_field", TypeKey::Named("Missing")); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - - let config = RustEmitConfig::default(); - let output = emit_rust(&table, &config); - - // Should emit with the unknown type name - insta::assert_snapshot!(output, @r" - #[derive(Debug, Clone)] - pub struct Foo { - pub ref_field: Missing, - } - "); -} diff --git a/crates/plotnik-lib/src/infer/emit/typescript.rs b/crates/plotnik-lib/src/infer/emit/typescript.rs index 6439a924..685f3422 100644 --- a/crates/plotnik-lib/src/infer/emit/typescript.rs +++ b/crates/plotnik-lib/src/infer/emit/typescript.rs @@ -113,19 +113,23 @@ fn emit_type_def( TypeValue::TaggedUnion(variants) => { let mut out = format!("{}type {} =\n", export_prefix, name); let variant_count = variants.len(); - for (i, (variant_name, fields)) in variants.iter().enumerate() { + for (i, (variant_name, variant_key)) in variants.iter().enumerate() { out.push_str(" | { tag: \""); out.push_str(variant_name); out.push('"'); - 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 { + // 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(&format!("; {}{}: {}", field_name, optional, type_str)); + } } out.push_str(" }"); if i < variant_count - 1 { @@ -266,11 +270,7 @@ 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() - .flat_map(|f| f.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 deleted file mode 100644 index 0b3e5700..00000000 --- a/crates/plotnik-lib/src/infer/emit/typescript_tests.rs +++ /dev/null @@ -1,723 +0,0 @@ -use indexmap::IndexMap; - -use crate::infer::{ - OptionalStyle, TypeKey, TypeScriptEmitConfig, TypeTable, TypeValue, - emit::typescript::{ - dependencies, emit_field_type, emit_inline_struct, topological_sort, wrap_if_union, - }, - emit_typescript, -}; - -#[test] -fn emit_empty_table() { - let table = TypeTable::new(); - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); - assert_eq!(output, ""); -} - -#[test] -fn emit_simple_interface() { - let mut table = TypeTable::new(); - let mut fields = IndexMap::new(); - fields.insert("name", TypeKey::String); - fields.insert("node", TypeKey::Node); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @r" - export interface Foo { - name: string; - node: SyntaxNode; - } - "); -} - -#[test] -fn emit_empty_interface() { - let mut table = TypeTable::new(); - table.insert(TypeKey::Named("Empty"), TypeValue::Struct(IndexMap::new())); - - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @"export interface Empty {}"); -} - -#[test] -fn emit_unit_field() { - let mut table = TypeTable::new(); - let mut fields = IndexMap::new(); - fields.insert("marker", TypeKey::Unit); - table.insert(TypeKey::Named("WithUnit"), TypeValue::Struct(fields)); - - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @r" - export interface WithUnit { - marker: {}; - } - "); -} - -#[test] -fn emit_tagged_union() { - let mut table = TypeTable::new(); - let mut variants = IndexMap::new(); - - let mut assign_fields = IndexMap::new(); - assign_fields.insert("target", TypeKey::String); - variants.insert("Assign", assign_fields); - - let mut call_fields = IndexMap::new(); - call_fields.insert("func", TypeKey::String); - variants.insert("Call", call_fields); - - table.insert(TypeKey::Named("Stmt"), TypeValue::TaggedUnion(variants)); - - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @r#" - export type Stmt = - | { tag: "Assign"; target: string } - | { tag: "Call"; func: string }; - "#); -} - -#[test] -fn emit_tagged_union_empty_variant() { - let mut table = TypeTable::new(); - let mut variants = IndexMap::new(); - - variants.insert("None", IndexMap::new()); - - let mut some_fields = IndexMap::new(); - some_fields.insert("value", TypeKey::Node); - variants.insert("Some", some_fields); - - table.insert(TypeKey::Named("Maybe"), TypeValue::TaggedUnion(variants)); - - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @r#" - export type Maybe = - | { tag: "None" } - | { tag: "Some"; value: SyntaxNode }; - "#); -} - -#[test] -fn emit_optional_null() { - let mut table = TypeTable::new(); - table.insert( - TypeKey::Synthetic(vec!["Foo", "bar"]), - TypeValue::Optional(TypeKey::Node), - ); - - let mut fields = IndexMap::new(); - fields.insert("bar", TypeKey::Synthetic(vec!["Foo", "bar"])); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @r" - export interface Foo { - bar: SyntaxNode | null; - } - "); -} - -#[test] -fn emit_optional_undefined() { - let mut table = TypeTable::new(); - table.insert( - TypeKey::Synthetic(vec!["Foo", "bar"]), - TypeValue::Optional(TypeKey::Node), - ); - - let mut fields = IndexMap::new(); - fields.insert("bar", TypeKey::Synthetic(vec!["Foo", "bar"])); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - - let config = TypeScriptEmitConfig { - optional_style: OptionalStyle::Undefined, - ..Default::default() - }; - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @r" - export interface Foo { - bar: SyntaxNode | undefined; - } - "); -} - -#[test] -fn emit_optional_question_mark() { - let mut table = TypeTable::new(); - table.insert( - TypeKey::Synthetic(vec!["Foo", "bar"]), - TypeValue::Optional(TypeKey::Node), - ); - - let mut fields = IndexMap::new(); - fields.insert("bar", TypeKey::Synthetic(vec!["Foo", "bar"])); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - - let config = TypeScriptEmitConfig { - optional_style: OptionalStyle::QuestionMark, - ..Default::default() - }; - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @r" - export interface Foo { - bar?: SyntaxNode; - } - "); -} - -#[test] -fn emit_list_field() { - let mut table = TypeTable::new(); - table.insert( - TypeKey::Synthetic(vec!["Foo", "items"]), - TypeValue::List(TypeKey::Node), - ); - - let mut fields = IndexMap::new(); - fields.insert("items", TypeKey::Synthetic(vec!["Foo", "items"])); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @r" - export interface Foo { - items: SyntaxNode[]; - } - "); -} - -#[test] -fn emit_non_empty_list_field() { - let mut table = TypeTable::new(); - table.insert( - TypeKey::Synthetic(vec!["Foo", "items"]), - TypeValue::NonEmptyList(TypeKey::String), - ); - - let mut fields = IndexMap::new(); - fields.insert("items", TypeKey::Synthetic(vec!["Foo", "items"])); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @r" - export interface Foo { - items: [string, ...string[]]; - } - "); -} - -#[test] -fn emit_nested_interface() { - let mut table = TypeTable::new(); - - let mut inner_fields = IndexMap::new(); - inner_fields.insert("value", TypeKey::String); - table.insert(TypeKey::Named("Inner"), TypeValue::Struct(inner_fields)); - - let mut outer_fields = IndexMap::new(); - outer_fields.insert("inner", TypeKey::Named("Inner")); - table.insert(TypeKey::Named("Outer"), TypeValue::Struct(outer_fields)); - - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @r" - export interface Inner { - value: string; - } - - export interface Outer { - inner: Inner; - } - "); -} - -#[test] -fn emit_inline_synthetic() { - let mut table = TypeTable::new(); - - let mut inner_fields = IndexMap::new(); - inner_fields.insert("x", TypeKey::Node); - table.insert( - TypeKey::Synthetic(vec!["Foo", "bar"]), - TypeValue::Struct(inner_fields), - ); - - let mut outer_fields = IndexMap::new(); - outer_fields.insert("bar", TypeKey::Synthetic(vec!["Foo", "bar"])); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(outer_fields)); - - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @r" - export interface Foo { - bar: { x: SyntaxNode }; - } - "); -} - -#[test] -fn emit_no_inline_synthetic() { - let mut table = TypeTable::new(); - - let mut inner_fields = IndexMap::new(); - inner_fields.insert("x", TypeKey::Node); - table.insert( - TypeKey::Synthetic(vec!["Foo", "bar"]), - TypeValue::Struct(inner_fields), - ); - - let mut outer_fields = IndexMap::new(); - outer_fields.insert("bar", TypeKey::Synthetic(vec!["Foo", "bar"])); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(outer_fields)); - - let config = TypeScriptEmitConfig { - inline_synthetic: false, - ..Default::default() - }; - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @r" - export interface FooBar { - x: SyntaxNode; - } - - export interface Foo { - bar: FooBar; - } - "); -} - -#[test] -fn emit_readonly_fields() { - let mut table = TypeTable::new(); - let mut fields = IndexMap::new(); - fields.insert("name", TypeKey::String); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - - let config = TypeScriptEmitConfig { - readonly: true, - ..Default::default() - }; - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @r" - export interface Foo { - readonly name: string; - } - "); -} - -#[test] -fn emit_no_export() { - let mut table = TypeTable::new(); - table.insert( - TypeKey::Named("Private"), - TypeValue::Struct(IndexMap::new()), - ); - - let config = TypeScriptEmitConfig { - export: false, - ..Default::default() - }; - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @"interface Private {}"); -} - -#[test] -fn emit_custom_node_type() { - let mut table = TypeTable::new(); - let mut fields = IndexMap::new(); - fields.insert("node", TypeKey::Node); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - - let config = TypeScriptEmitConfig { - node_type_name: "TSNode".to_string(), - ..Default::default() - }; - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @r" - export interface Foo { - node: TSNode; - } - "); -} - -#[test] -fn emit_cyclic_type_no_box() { - let mut table = TypeTable::new(); - - table.insert( - TypeKey::Synthetic(vec!["Tree", "child"]), - TypeValue::Optional(TypeKey::Named("Tree")), - ); - - let mut fields = IndexMap::new(); - fields.insert("value", TypeKey::String); - fields.insert("child", TypeKey::Synthetic(vec!["Tree", "child"])); - table.insert(TypeKey::Named("Tree"), TypeValue::Struct(fields)); - - table.mark_cyclic(TypeKey::Named("Tree")); - - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); - - // TypeScript handles cycles natively, no Box needed - insta::assert_snapshot!(output, @r" - export interface Tree { - value: string; - child: Tree | null; - } - "); -} - -#[test] -fn emit_list_of_optional() { - let mut table = TypeTable::new(); - table.insert( - TypeKey::Synthetic(vec!["Foo", "inner"]), - TypeValue::Optional(TypeKey::Node), - ); - table.insert( - TypeKey::Synthetic(vec!["Foo", "items"]), - TypeValue::List(TypeKey::Synthetic(vec!["Foo", "inner"])), - ); - - let mut fields = IndexMap::new(); - fields.insert("items", TypeKey::Synthetic(vec!["Foo", "items"])); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @r" - export interface Foo { - items: (SyntaxNode | null)[]; - } - "); -} - -#[test] -fn emit_deeply_nested_inline() { - let mut table = TypeTable::new(); - - let mut level2 = IndexMap::new(); - level2.insert("val", TypeKey::String); - table.insert( - TypeKey::Synthetic(vec!["A", "b", "c"]), - TypeValue::Struct(level2), - ); - - let mut level1 = IndexMap::new(); - level1.insert("c", TypeKey::Synthetic(vec!["A", "b", "c"])); - table.insert( - TypeKey::Synthetic(vec!["A", "b"]), - TypeValue::Struct(level1), - ); - - let mut root = IndexMap::new(); - root.insert("b", TypeKey::Synthetic(vec!["A", "b"])); - table.insert(TypeKey::Named("A"), TypeValue::Struct(root)); - - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @r" - export interface A { - b: { c: { val: string } }; - } - "); -} - -#[test] -fn emit_type_alias_when_not_inlined() { - let mut table = TypeTable::new(); - table.insert( - TypeKey::Named("OptionalNode"), - TypeValue::Optional(TypeKey::Node), - ); - - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @"export type OptionalNode = SyntaxNode | null;"); -} - -#[test] -fn emit_type_alias_list() { - let mut table = TypeTable::new(); - table.insert(TypeKey::Named("NodeList"), TypeValue::List(TypeKey::Node)); - - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @"export type NodeList = SyntaxNode[];"); -} - -#[test] -fn emit_type_alias_non_empty_list() { - let mut table = TypeTable::new(); - table.insert( - TypeKey::Named("NonEmptyNodes"), - TypeValue::NonEmptyList(TypeKey::Node), - ); - - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @"export type NonEmptyNodes = [SyntaxNode, ...SyntaxNode[]];"); -} - -#[test] -fn wrap_if_union_simple() { - assert_eq!(wrap_if_union("string"), "string"); - assert_eq!(wrap_if_union("SyntaxNode"), "SyntaxNode"); -} - -#[test] -fn wrap_if_union_with_pipe() { - assert_eq!(wrap_if_union("string | null"), "(string | null)"); - assert_eq!(wrap_if_union("A | B | C"), "(A | B | C)"); -} - -#[test] -fn inline_empty_struct() { - let fields = IndexMap::new(); - let table = TypeTable::new(); - let config = TypeScriptEmitConfig::default(); - let result = emit_inline_struct(&fields, &table, &config); - assert_eq!(result, "{}"); -} - -#[test] -fn inline_struct_multiple_fields() { - let mut fields = IndexMap::new(); - fields.insert("a", TypeKey::String); - fields.insert("b", TypeKey::Node); - let table = TypeTable::new(); - let config = TypeScriptEmitConfig::default(); - let result = emit_inline_struct(&fields, &table, &config); - assert_eq!(result, "{ a: string; b: SyntaxNode }"); -} - -#[test] -fn dependencies_primitives() { - assert!(dependencies(&TypeValue::Node).is_empty()); - assert!(dependencies(&TypeValue::String).is_empty()); - assert!(dependencies(&TypeValue::Unit).is_empty()); -} - -#[test] -fn dependencies_struct() { - let mut fields = IndexMap::new(); - fields.insert("a", TypeKey::Named("A")); - fields.insert("b", TypeKey::Named("B")); - let value = TypeValue::Struct(fields); - - let deps = dependencies(&value); - assert_eq!(deps.len(), 2); -} - -#[test] -fn dependencies_wrappers() { - let opt = TypeValue::Optional(TypeKey::Named("T")); - let list = TypeValue::List(TypeKey::Named("T")); - let ne = TypeValue::NonEmptyList(TypeKey::Named("T")); - - assert_eq!(dependencies(&opt), vec![TypeKey::Named("T")]); - assert_eq!(dependencies(&list), vec![TypeKey::Named("T")]); - assert_eq!(dependencies(&ne), vec![TypeKey::Named("T")]); -} - -#[test] -fn optional_style_equality() { - assert_eq!(OptionalStyle::Null, OptionalStyle::Null); - assert_ne!(OptionalStyle::Null, OptionalStyle::Undefined); - assert_ne!(OptionalStyle::Undefined, OptionalStyle::QuestionMark); -} - -#[test] -fn config_default() { - let config = TypeScriptEmitConfig::default(); - assert_eq!(config.optional_style, OptionalStyle::Null); - assert!(config.export); - assert!(!config.readonly); - assert!(config.inline_synthetic); - assert_eq!(config.node_type_name, "SyntaxNode"); -} - -#[test] -fn emit_tagged_union_optional_field_question() { - let mut table = TypeTable::new(); - - table.insert( - TypeKey::Synthetic(vec!["Stmt", "x"]), - TypeValue::Optional(TypeKey::Node), - ); - - let mut variants = IndexMap::new(); - let mut v_fields = IndexMap::new(); - v_fields.insert("x", TypeKey::Synthetic(vec!["Stmt", "x"])); - variants.insert("V", v_fields); - - table.insert(TypeKey::Named("Stmt"), TypeValue::TaggedUnion(variants)); - - let config = TypeScriptEmitConfig { - optional_style: OptionalStyle::QuestionMark, - ..Default::default() - }; - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @r#" - export type Stmt = - | { tag: "V"; x?: SyntaxNode }; - "#); -} - -#[test] -fn topological_sort_with_cycle() { - let mut table = TypeTable::new(); - - let mut a_fields = IndexMap::new(); - a_fields.insert("b", TypeKey::Named("B")); - table.insert(TypeKey::Named("A"), TypeValue::Struct(a_fields)); - - let mut b_fields = IndexMap::new(); - b_fields.insert("a", TypeKey::Named("A")); - table.insert(TypeKey::Named("B"), TypeValue::Struct(b_fields)); - - let sorted = topological_sort(&table); - assert!(sorted.contains(&TypeKey::Named("A"))); - assert!(sorted.contains(&TypeKey::Named("B"))); -} - -#[test] -fn emit_field_type_unknown_key() { - let table = TypeTable::new(); - let config = TypeScriptEmitConfig::default(); - let (type_str, is_optional) = emit_field_type(&TypeKey::Named("Unknown"), &table, &config); - assert_eq!(type_str, "Unknown"); - assert!(!is_optional); -} - -#[test] -fn emit_readonly_optional_question_mark() { - let mut table = TypeTable::new(); - table.insert( - TypeKey::Synthetic(vec!["Foo", "bar"]), - TypeValue::Optional(TypeKey::String), - ); - - let mut fields = IndexMap::new(); - fields.insert("bar", TypeKey::Synthetic(vec!["Foo", "bar"])); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - - let config = TypeScriptEmitConfig { - readonly: true, - optional_style: OptionalStyle::QuestionMark, - ..Default::default() - }; - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @r" - export interface Foo { - readonly bar?: string; - } - "); -} - -#[test] -fn inline_struct_with_optional_question_mark() { - let mut table = TypeTable::new(); - table.insert( - TypeKey::Synthetic(vec!["inner", "opt"]), - TypeValue::Optional(TypeKey::Node), - ); - - let mut fields = IndexMap::new(); - fields.insert("opt", TypeKey::Synthetic(vec!["inner", "opt"])); - - let config = TypeScriptEmitConfig { - optional_style: OptionalStyle::QuestionMark, - ..Default::default() - }; - - let result = emit_inline_struct(&fields, &table, &config); - assert_eq!(result, "{ opt?: SyntaxNode }"); -} - -#[test] -fn dependencies_tagged_union() { - let mut variants = IndexMap::new(); - let mut v1 = IndexMap::new(); - v1.insert("x", TypeKey::Named("X")); - variants.insert("V1", v1); - - let mut v2 = IndexMap::new(); - v2.insert("y", TypeKey::Named("Y")); - variants.insert("V2", v2); - - let value = TypeValue::TaggedUnion(variants); - let deps = dependencies(&value); - - assert_eq!(deps.len(), 2); - assert!(deps.contains(&TypeKey::Named("X"))); - assert!(deps.contains(&TypeKey::Named("Y"))); -} - -#[test] -fn topological_sort_missing_dependency() { - let mut table = TypeTable::new(); - - let mut fields = IndexMap::new(); - fields.insert("missing", TypeKey::Named("DoesNotExist")); - table.insert(TypeKey::Named("HasMissing"), TypeValue::Struct(fields)); - - // Should not panic, includes all visited keys - let sorted = topological_sort(&table); - assert!(sorted.contains(&TypeKey::Named("HasMissing"))); - // The missing key is visited and added to result (dependency comes before dependent) - assert!(sorted.contains(&TypeKey::Named("DoesNotExist"))); -} - -#[test] -fn emit_with_missing_dependency() { - let mut table = TypeTable::new(); - - let mut fields = IndexMap::new(); - fields.insert("ref_field", TypeKey::Named("Missing")); - table.insert(TypeKey::Named("Foo"), TypeValue::Struct(fields)); - - let config = TypeScriptEmitConfig::default(); - let output = emit_typescript(&table, &config); - - insta::assert_snapshot!(output, @r" - export interface Foo { - ref_field: Missing; - } - "); -} diff --git a/crates/plotnik-lib/src/infer/types.rs b/crates/plotnik-lib/src/infer/types.rs index 9aacf642..61eb0e90 100644 --- a/crates/plotnik-lib/src/infer/types.rs +++ b/crates/plotnik-lib/src/infer/types.rs @@ -57,8 +57,8 @@ pub enum TypeValue<'src> { Unit, /// Struct with named fields Struct(IndexMap<&'src str, TypeKey<'src>>), - /// Tagged union: variant name → fields - TaggedUnion(IndexMap<&'src str, 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 @@ -248,21 +248,57 @@ mod tests { #[test] fn type_value_tagged_union() { - let mut variants = IndexMap::new(); + let mut table = TypeTable::new(); + + // Register variant types as structs let mut assign_fields = IndexMap::new(); assign_fields.insert("target", TypeKey::String); - variants.insert("Assign", assign_fields); + table.insert( + TypeKey::Synthetic(vec!["Stmt", "Assign"]), + TypeValue::Struct(assign_fields), + ); let mut call_fields = IndexMap::new(); call_fields.insert("func", TypeKey::String); - variants.insert("Call", call_fields); + table.insert( + TypeKey::Synthetic(vec!["Stmt", "Call"]), + TypeValue::Struct(call_fields), + ); + + // TaggedUnion maps variant name → type key + 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); - if let TypeValue::TaggedUnion(v) = union { + // Smoke: variant lookup works + if let Some(TypeValue::TaggedUnion(v)) = table.get(&TypeKey::Named("Stmt")) { assert_eq!(v.len(), 2); assert!(v.contains_key("Assign")); assert!(v.contains_key("Call")); + // Can resolve variant types + assert!(table.get(&v["Assign"]).is_some()); + } else { + panic!("expected TaggedUnion"); + } + } + + #[test] + fn type_value_tagged_union_empty_variant() { + let mut table = TypeTable::new(); + + // Empty variant uses Unit + let mut variants = IndexMap::new(); + variants.insert("Empty", TypeKey::Unit); + table.insert( + TypeKey::Named("MaybeEmpty"), + TypeValue::TaggedUnion(variants), + ); + + if let Some(TypeValue::TaggedUnion(v)) = table.get(&TypeKey::Named("MaybeEmpty")) { + assert_eq!(v["Empty"], TypeKey::Unit); } else { panic!("expected TaggedUnion"); } From 8fb68014a50ae0f185e49debf86fda8b74a8389a Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sat, 6 Dec 2025 21:08:17 -0300 Subject: [PATCH 5/8] Add Tyton parser module for type table construction --- crates/plotnik-lib/src/infer/mod.rs | 4 + crates/plotnik-lib/src/infer/tyton.rs | 379 ++++++++++++++++++++ crates/plotnik-lib/src/infer/tyton_tests.rs | 323 +++++++++++++++++ 3 files changed, 706 insertions(+) create mode 100644 crates/plotnik-lib/src/infer/tyton.rs create mode 100644 crates/plotnik-lib/src/infer/tyton_tests.rs diff --git a/crates/plotnik-lib/src/infer/mod.rs b/crates/plotnik-lib/src/infer/mod.rs index 9b88f0ef..7d67fbee 100644 --- a/crates/plotnik-lib/src/infer/mod.rs +++ b/crates/plotnik-lib/src/infer/mod.rs @@ -8,6 +8,10 @@ pub mod emit; mod types; +pub mod tyton; + +#[cfg(test)] +mod tyton_tests; pub use emit::{ Indirection, OptionalStyle, RustEmitConfig, TypeScriptEmitConfig, emit_rust, emit_typescript, diff --git a/crates/plotnik-lib/src/infer/tyton.rs b/crates/plotnik-lib/src/infer/tyton.rs new file mode 100644 index 00000000..89eb0ddb --- /dev/null +++ b/crates/plotnik-lib/src/infer/tyton.rs @@ -0,0 +1,379 @@ +//! Tyton: Types Testing Object Notation +//! +//! A compact DSL for constructing `TypeTable` test fixtures. +//! +//! # 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 +//! +//! Definitions: +//! - `Name = { ... }` — define a struct +//! - `Name = [ ... ]` — define a tagged union +//! - `Name = Other?` — define an optional +//! +//! # 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(_)) => { + if let Some(Token::UpperIdent(name)) = self.advance().cloned() { + Ok(TypeKey::Named(name)) + } else { + unreachable!() + } + } + 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(_)) => { + if let Some(Token::UpperIdent(s)) = self.advance().cloned() { + segments.push(s); + } + } + Some(Token::LowerIdent(_)) => { + if let Some(Token::LowerIdent(s)) = self.advance().cloned() { + 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) + | Some(Token::String) + | Some(Token::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_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 name = match self.advance() { + Some(Token::UpperIdent(name)) => *name, + _ => { + return Err(ParseError { + message: "expected type name (uppercase)".to_string(), + span, + }); + } + }; + + self.expect(Token::Eq)?; + let value = self.parse_type_value()?; + + Ok((TypeKey::Named(name), 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..f04cef42 --- /dev/null +++ b/crates/plotnik-lib/src/infer/tyton_tests.rs @@ -0,0 +1,323 @@ +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) 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 error_missing_quantifier() { + let input = "Foo = Node"; + insta::assert_snapshot!(dump_table(input), @"ERROR: expected quantifier (?, *, +) after type key at 10..10"); +} + +#[test] +fn error_invalid_char() { + let input = "Foo = { Node @x $ }"; + insta::assert_snapshot!(dump_table(input), @r#"ERROR: unexpected character: "$" at 16..17"#); +} From 0f152989bb56b3477f84ac3ea4bc28a5dacbe384 Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sat, 6 Dec 2025 21:24:14 -0300 Subject: [PATCH 6/8] Add TypeScript and Rust test modules for emit crate --- crates/plotnik-lib/src/infer/emit/mod.rs | 5 + .../plotnik-lib/src/infer/emit/rust_tests.rs | 529 ++++++++++++++++ .../plotnik-lib/src/infer/emit/typescript.rs | 16 +- .../src/infer/emit/typescript_tests.rs | 567 ++++++++++++++++++ crates/plotnik-lib/src/infer/tyton.rs | 14 + 5 files changed, 1128 insertions(+), 3 deletions(-) create mode 100644 crates/plotnik-lib/src/infer/emit/rust_tests.rs create mode 100644 crates/plotnik-lib/src/infer/emit/typescript_tests.rs diff --git a/crates/plotnik-lib/src/infer/emit/mod.rs b/crates/plotnik-lib/src/infer/emit/mod.rs index 5971b9b2..2131fffe 100644 --- a/crates/plotnik-lib/src/infer/emit/mod.rs +++ b/crates/plotnik-lib/src/infer/emit/mod.rs @@ -5,5 +5,10 @@ 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_tests.rs b/crates/plotnik-lib/src/infer/emit/rust_tests.rs new file mode 100644 index 00000000..2c83589b --- /dev/null +++ b/crates/plotnik-lib/src/infer/emit/rust_tests.rs @@ -0,0 +1,529 @@ +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>; + "); +} diff --git a/crates/plotnik-lib/src/infer/emit/typescript.rs b/crates/plotnik-lib/src/infer/emit/typescript.rs index 685f3422..7bdb84cc 100644 --- a/crates/plotnik-lib/src/infer/emit/typescript.rs +++ b/crates/plotnik-lib/src/infer/emit/typescript.rs @@ -19,6 +19,8 @@ pub struct TypeScriptEmitConfig { 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. @@ -36,10 +38,11 @@ impl Default for TypeScriptEmitConfig { fn default() -> Self { Self { optional_style: OptionalStyle::Null, - export: true, + export: false, readonly: false, inline_synthetic: true, node_type_name: "SyntaxNode".to_string(), + use_type_alias: false, } } } @@ -81,13 +84,20 @@ fn emit_type_def( config: &TypeScriptEmitConfig, ) -> String { let name = key.to_pascal_case(); - let export_prefix = if config.export { "export " } else { "" }; + 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 fields.is_empty() { + 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); 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..3ad40c67 --- /dev/null +++ b/crates/plotnik-lib/src/infer/emit/typescript_tests.rs @@ -0,0 +1,567 @@ +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; + } + "); +} diff --git a/crates/plotnik-lib/src/infer/tyton.rs b/crates/plotnik-lib/src/infer/tyton.rs index 89eb0ddb..7d8da13f 100644 --- a/crates/plotnik-lib/src/infer/tyton.rs +++ b/crates/plotnik-lib/src/infer/tyton.rs @@ -2,6 +2,20 @@ //! //! 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: From c6e4c06e5c4767ad0b9f1588d72003d66167cc2c Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sat, 6 Dec 2025 21:27:01 -0300 Subject: [PATCH 7/8] Simplify type inference traversal and parsing logic --- crates/plotnik-lib/src/infer/emit/rust.rs | 23 ++++++++-------- .../plotnik-lib/src/infer/emit/typescript.rs | 17 ++++++------ crates/plotnik-lib/src/infer/tyton.rs | 26 +++++++++---------- 3 files changed, 32 insertions(+), 34 deletions(-) diff --git a/crates/plotnik-lib/src/infer/emit/rust.rs b/crates/plotnik-lib/src/infer/emit/rust.rs index 9aee8d23..82194d0b 100644 --- a/crates/plotnik-lib/src/infer/emit/rust.rs +++ b/crates/plotnik-lib/src/infer/emit/rust.rs @@ -203,24 +203,23 @@ fn visit<'src>( visited: &mut IndexMap, bool>, result: &mut Vec>, ) { - if let Some(&in_progress) = visited.get(key) { - if in_progress { - // Cycle detected, already handled by cyclic marking - return; - } - // Already fully visited + if visited.contains_key(key) { return; } - visited.insert(key.clone(), true); // Mark as in progress + visited.insert(key.clone(), true); - if let Some(value) = table.get(key) { - for dep in dependencies(value) { - visit(&dep, table, visited, result); - } + 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); // Mark as done + visited.insert(key.clone(), false); result.push(key.clone()); } diff --git a/crates/plotnik-lib/src/infer/emit/typescript.rs b/crates/plotnik-lib/src/infer/emit/typescript.rs index 7bdb84cc..3e9591ff 100644 --- a/crates/plotnik-lib/src/infer/emit/typescript.rs +++ b/crates/plotnik-lib/src/infer/emit/typescript.rs @@ -257,19 +257,20 @@ fn visit<'src>( visited: &mut IndexMap, bool>, result: &mut Vec>, ) { - if let Some(&in_progress) = visited.get(key) { - if in_progress { - return; - } + if visited.contains_key(key) { return; } visited.insert(key.clone(), true); - if let Some(value) = table.get(key) { - for dep in dependencies(value) { - visit(&dep, table, visited, result); - } + 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); diff --git a/crates/plotnik-lib/src/infer/tyton.rs b/crates/plotnik-lib/src/infer/tyton.rs index 7d8da13f..966cb31c 100644 --- a/crates/plotnik-lib/src/infer/tyton.rs +++ b/crates/plotnik-lib/src/infer/tyton.rs @@ -201,12 +201,10 @@ impl<'src> Parser<'src> { self.advance(); Ok(TypeKey::Unit) } - Some(Token::UpperIdent(_)) => { - if let Some(Token::UpperIdent(name)) = self.advance().cloned() { - Ok(TypeKey::Named(name)) - } else { - unreachable!() - } + Some(Token::UpperIdent(name)) => { + let name = *name; + self.advance(); + Ok(TypeKey::Named(name)) } Some(Token::LAngle) => self.parse_synthetic_key(), _ => Err(ParseError { @@ -227,15 +225,15 @@ impl<'src> Parser<'src> { self.advance(); break; } - Some(Token::UpperIdent(_)) => { - if let Some(Token::UpperIdent(s)) = self.advance().cloned() { - segments.push(s); - } + Some(Token::UpperIdent(s)) => { + let s = *s; + self.advance(); + segments.push(s); } - Some(Token::LowerIdent(_)) => { - if let Some(Token::LowerIdent(s)) = self.advance().cloned() { - segments.push(s); - } + Some(Token::LowerIdent(s)) => { + let s = *s; + self.advance(); + segments.push(s); } _ => { return Err(ParseError { From 1b31051a48b0b2e86ceed247fd3b755d556e278e Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sat, 6 Dec 2025 21:56:52 -0300 Subject: [PATCH 8/8] Improve tyton and tests --- crates/plotnik-lib/src/infer/emit/rust.rs | 4 +- .../plotnik-lib/src/infer/emit/rust_tests.rs | 10 + .../src/infer/emit/typescript_tests.rs | 176 +++++++++++++ crates/plotnik-lib/src/infer/mod.rs | 2 + crates/plotnik-lib/src/infer/types.rs | 234 +----------------- crates/plotnik-lib/src/infer/types_tests.rs | 222 +++++++++++++++++ crates/plotnik-lib/src/infer/tyton.rs | 56 ++++- crates/plotnik-lib/src/infer/tyton_tests.rs | 92 ++++++- 8 files changed, 548 insertions(+), 248 deletions(-) create mode 100644 crates/plotnik-lib/src/infer/types_tests.rs diff --git a/crates/plotnik-lib/src/infer/emit/rust.rs b/crates/plotnik-lib/src/infer/emit/rust.rs index 82194d0b..6a9c8a8b 100644 --- a/crates/plotnik-lib/src/infer/emit/rust.rs +++ b/crates/plotnik-lib/src/infer/emit/rust.rs @@ -147,8 +147,8 @@ pub(crate) fn emit_type_ref( let inner_str = emit_type_ref(inner, table, config); format!("Vec<{}>", inner_str) } - Some(TypeValue::Struct(_)) | Some(TypeValue::TaggedUnion(_)) => key.to_pascal_case(), - None => key.to_pascal_case(), + // Struct, TaggedUnion, or undefined forward reference - use pascal-cased name + Some(TypeValue::Struct(_)) | Some(TypeValue::TaggedUnion(_)) | None => key.to_pascal_case(), }; if is_cyclic { diff --git a/crates/plotnik-lib/src/infer/emit/rust_tests.rs b/crates/plotnik-lib/src/infer/emit/rust_tests.rs index 2c83589b..89b4822f 100644 --- a/crates/plotnik-lib/src/infer/emit/rust_tests.rs +++ b/crates/plotnik-lib/src/infer/emit/rust_tests.rs @@ -527,3 +527,13 @@ fn emit_list_of_optionals() { 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_tests.rs b/crates/plotnik-lib/src/infer/emit/typescript_tests.rs index 3ad40c67..73ee2a15 100644 --- a/crates/plotnik-lib/src/infer/emit/typescript_tests.rs +++ b/crates/plotnik-lib/src/infer/emit/typescript_tests.rs @@ -565,3 +565,179 @@ fn emit_optional_in_struct_undefined_style() { } "); } + +#[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 index 7d67fbee..46471372 100644 --- a/crates/plotnik-lib/src/infer/mod.rs +++ b/crates/plotnik-lib/src/infer/mod.rs @@ -10,6 +10,8 @@ pub mod emit; mod types; pub mod tyton; +#[cfg(test)] +mod types_tests; #[cfg(test)] mod tyton_tests; diff --git a/crates/plotnik-lib/src/infer/types.rs b/crates/plotnik-lib/src/infer/types.rs index 61eb0e90..83ba2508 100644 --- a/crates/plotnik-lib/src/infer/types.rs +++ b/crates/plotnik-lib/src/infer/types.rs @@ -34,7 +34,7 @@ impl TypeKey<'_> { } /// Convert snake_case or lowercase to PascalCase. -fn to_pascal(s: &str) -> String { +pub(crate) fn to_pascal(s: &str) -> String { s.split('_') .map(|part| { let mut chars = part.chars(); @@ -124,235 +124,3 @@ impl Default for TypeTable<'_> { Self::new() } } - -#[cfg(test)] -mod tests { - use super::*; - - #[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(); - - // Register variant types as structs - 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), - ); - - // TaggedUnion maps variant name → type key - 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); - - // Smoke: variant lookup works - if let Some(TypeValue::TaggedUnion(v)) = table.get(&TypeKey::Named("Stmt")) { - assert_eq!(v.len(), 2); - assert!(v.contains_key("Assign")); - assert!(v.contains_key("Call")); - // Can resolve variant types - assert!(table.get(&v["Assign"]).is_some()); - } else { - panic!("expected TaggedUnion"); - } - } - - #[test] - fn type_value_tagged_union_empty_variant() { - let mut table = TypeTable::new(); - - // Empty variant uses Unit - let mut variants = IndexMap::new(); - variants.insert("Empty", TypeKey::Unit); - table.insert( - TypeKey::Named("MaybeEmpty"), - TypeValue::TaggedUnion(variants), - ); - - if let Some(TypeValue::TaggedUnion(v)) = table.get(&TypeKey::Named("MaybeEmpty")) { - assert_eq!(v["Empty"], TypeKey::Unit); - } else { - panic!("expected TaggedUnion"); - } - } - - #[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/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 index 966cb31c..a5184131 100644 --- a/crates/plotnik-lib/src/infer/tyton.rs +++ b/crates/plotnik-lib/src/infer/tyton.rs @@ -31,11 +31,14 @@ //! - `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 //! @@ -259,11 +262,19 @@ impl<'src> Parser<'src> { match self.peek() { Some(Token::LBrace) => self.parse_struct(), Some(Token::LBracket) => self.parse_tagged_union(), - Some(Token::Node) - | Some(Token::String) - | Some(Token::Unit) - | Some(Token::UpperIdent(_)) - | Some(Token::LAngle) => { + 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) } @@ -274,6 +285,28 @@ impl<'src> Parser<'src> { } } + 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(); @@ -356,11 +389,16 @@ impl<'src> Parser<'src> { fn parse_definition(&mut self) -> Result<(TypeKey<'src>, TypeValue<'src>), ParseError> { let span = self.current_span(); - let name = match self.advance() { - Some(Token::UpperIdent(name)) => *name, + 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)".to_string(), + message: "expected type name (uppercase) or synthetic key".to_string(), span, }); } @@ -369,7 +407,7 @@ impl<'src> Parser<'src> { self.expect(Token::Eq)?; let value = self.parse_type_value()?; - Ok((TypeKey::Named(name), value)) + Ok((key, value)) } fn parse_all(&mut self) -> Result, ParseError> { diff --git a/crates/plotnik-lib/src/infer/tyton_tests.rs b/crates/plotnik-lib/src/infer/tyton_tests.rs index f04cef42..3687a09c 100644 --- a/crates/plotnik-lib/src/infer/tyton_tests.rs +++ b/crates/plotnik-lib/src/infer/tyton_tests.rs @@ -301,7 +301,7 @@ fn error_unclosed_bracket() { #[test] fn error_lowercase_type_name() { let input = "foo = { Node @x }"; - insta::assert_snapshot!(dump_table(input), @"ERROR: expected type name (uppercase) at 0..3"); + insta::assert_snapshot!(dump_table(input), @"ERROR: expected type name (uppercase) or synthetic key at 0..3"); } #[test] @@ -311,9 +311,69 @@ fn error_uppercase_field_name() { } #[test] -fn error_missing_quantifier() { - let input = "Foo = Node"; - insta::assert_snapshot!(dump_table(input), @"ERROR: expected quantifier (?, *, +) after type key at 10..10"); +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] @@ -321,3 +381,27 @@ 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"); +}