diff --git a/crates/plotnik-lib/src/analyze/type_check/infer.rs b/crates/plotnik-lib/src/analyze/type_check/infer.rs index 638130f2..36eae1f0 100644 --- a/crates/plotnik-lib/src/analyze/type_check/infer.rs +++ b/crates/plotnik-lib/src/analyze/type_check/infer.rs @@ -12,7 +12,8 @@ use rowan::TextRange; use super::context::TypeContext; use super::symbol::Symbol; use super::types::{ - Arity, FieldInfo, QuantifierKind, TYPE_NODE, TYPE_STRING, TermInfo, TypeFlow, TypeId, TypeShape, + Arity, FieldInfo, QuantifierKind, TYPE_NODE, TYPE_STRING, TYPE_VOID, TermInfo, TypeFlow, + TypeId, TypeShape, }; use super::unify::{UnifyError, unify_flows}; @@ -213,9 +214,8 @@ impl<'a, 'd> InferenceVisitor<'a, 'd> { let label_sym = self.interner.intern(label.text()); let Some(body) = branch.body() else { - // Empty variant -> empty struct - let empty_struct = self.ctx.intern_struct(BTreeMap::new()); - variants.insert(label_sym, empty_struct); + // Empty variant -> Void (no payload) + variants.insert(label_sym, TYPE_VOID); continue; }; @@ -623,7 +623,7 @@ impl<'a, 'd> InferenceVisitor<'a, 'd> { fn flow_to_type(&mut self, flow: &TypeFlow) -> TypeId { match flow { - TypeFlow::Void => self.ctx.intern_struct(BTreeMap::new()), + TypeFlow::Void => TYPE_VOID, TypeFlow::Scalar(t) | TypeFlow::Bubble(t) => *t, } } diff --git a/crates/plotnik-lib/src/engine/materializer.rs b/crates/plotnik-lib/src/engine/materializer.rs index 5b8a982d..115ff918 100644 --- a/crates/plotnik-lib/src/engine/materializer.rs +++ b/crates/plotnik-lib/src/engine/materializer.rs @@ -33,6 +33,17 @@ impl<'ctx> ValueMaterializer<'ctx> { self.strings.get(member.name).to_owned() } + fn resolve_member_type(&self, idx: u16) -> QTypeId { + self.types.get_member(idx as usize).type_id + } + + fn is_void_type(&self, type_id: QTypeId) -> bool { + self.types + .get(type_id) + .and_then(|def| def.type_kind()) + .is_some_and(|k| k == TypeKind::Void) + } + /// Create initial builder based on result type. fn builder_for_type(&self, type_id: QTypeId) -> Builder { let def = match self.types.get(type_id) { @@ -54,7 +65,11 @@ enum Builder { Scalar(Option), Array(Vec), Object(Vec<(String, Value)>), - Tagged { tag: String, fields: Vec<(String, Value)> }, + Tagged { + tag: String, + payload_type: QTypeId, + fields: Vec<(String, Value)>, + }, } impl Builder { @@ -63,9 +78,9 @@ impl Builder { Builder::Scalar(v) => v.unwrap_or(Value::Null), Builder::Array(arr) => Value::Array(arr), Builder::Object(fields) => Value::Object(fields), - Builder::Tagged { tag, fields } => Value::Tagged { + Builder::Tagged { tag, fields, .. } => Value::Tagged { tag, - data: Box::new(Value::Object(fields)), + data: Some(Box::new(Value::Object(fields))), }, } } @@ -135,17 +150,29 @@ impl<'t> Materializer<'t> for ValueMaterializer<'_> { } RuntimeEffect::Enum(idx) => { let tag = self.resolve_member_name(*idx); - stack.push(Builder::Tagged { tag, fields: vec![] }); + let payload_type = self.resolve_member_type(*idx); + stack.push(Builder::Tagged { + tag, + payload_type, + fields: vec![], + }); } RuntimeEffect::EndEnum => { - if let Some(Builder::Tagged { tag, fields }) = stack.pop() { - // If inner returned a structured value (via Obj/EndObj), use it as data - // Otherwise use fields collected from direct Set effects - let data = pending.take().unwrap_or(Value::Object(fields)); - pending = Some(Value::Tagged { - tag, - data: Box::new(data), - }); + if let Some(Builder::Tagged { + tag, + payload_type, + fields, + }) = stack.pop() + { + // Void payloads produce no $data field + let data = if self.is_void_type(payload_type) { + None + } else { + // If inner returned a structured value (via Obj/EndObj), use it as data + // Otherwise use fields collected from direct Set effects + Some(Box::new(pending.take().unwrap_or(Value::Object(fields)))) + }; + pending = Some(Value::Tagged { tag, data }); } } RuntimeEffect::Clear => { diff --git a/crates/plotnik-lib/src/engine/value.rs b/crates/plotnik-lib/src/engine/value.rs index be790f8e..954a011f 100644 --- a/crates/plotnik-lib/src/engine/value.rs +++ b/crates/plotnik-lib/src/engine/value.rs @@ -23,10 +23,7 @@ pub struct NodeHandle { impl NodeHandle { /// Create from a tree-sitter node and source text. pub fn from_node(node: Node<'_>, source: &str) -> Self { - let text = node - .utf8_text(source.as_bytes()) - .unwrap_or("") - .to_owned(); + let text = node.utf8_text(source.as_bytes()).unwrap_or("").to_owned(); Self { kind: node.kind().to_owned(), text, @@ -60,8 +57,11 @@ pub enum Value { Array(Vec), /// Object with ordered fields. Object(Vec<(String, Value)>), - /// Tagged union. - Tagged { tag: String, data: Box }, + /// Tagged union. `data` is None for Void payloads. + Tagged { + tag: String, + data: Option>, + }, } impl Serialize for Value { @@ -88,9 +88,12 @@ impl Serialize for Value { map.end() } Value::Tagged { tag, data } => { - let mut map = serializer.serialize_map(Some(2))?; + let len = if data.is_some() { 2 } else { 1 }; + let mut map = serializer.serialize_map(Some(len))?; map.serialize_entry("$tag", tag)?; - map.serialize_entry("$data", data)?; + if let Some(d) = data { + map.serialize_entry("$data", d)?; + } map.end() } } @@ -333,7 +336,7 @@ fn format_object( fn format_tagged( out: &mut String, tag: &str, - data: &Value, + data: &Option>, c: &Colors, pretty: bool, indent: usize, @@ -369,29 +372,32 @@ fn format_tagged( out.push('"'); out.push_str(c.reset); - out.push_str(c.dim); - out.push(','); - out.push_str(c.reset); + // Only emit $data if present (Void payloads omit it) + if let Some(d) = data { + out.push_str(c.dim); + out.push(','); + out.push_str(c.reset); - if pretty { - out.push('\n'); - out.push_str(&" ".repeat(field_indent)); - } + if pretty { + out.push('\n'); + out.push_str(&" ".repeat(field_indent)); + } - // $data key in blue - out.push_str(c.blue); - out.push_str("\"$data\""); - out.push_str(c.reset); + // $data key in blue + out.push_str(c.blue); + out.push_str("\"$data\""); + out.push_str(c.reset); - out.push_str(c.dim); - out.push(':'); - out.push_str(c.reset); + out.push_str(c.dim); + out.push(':'); + out.push_str(c.reset); - if pretty { - out.push(' '); - } + if pretty { + out.push(' '); + } - format_value(out, data, c, pretty, field_indent); + format_value(out, d, c, pretty, field_indent); + } if pretty { out.push('\n'); diff --git a/crates/plotnik-lib/src/typegen/typescript.rs b/crates/plotnik-lib/src/typegen/typescript.rs index 9bbe2fe2..cc63f12b 100644 --- a/crates/plotnik-lib/src/typegen/typescript.rs +++ b/crates/plotnik-lib/src/typegen/typescript.rs @@ -589,7 +589,7 @@ impl<'a> Emitter<'a> { let variant_type_name = format!("{}{}", name, to_pascal_case(variant_name)); variant_types.push(variant_type_name.clone()); - let data_str = self.inline_data_type(member.type_id); + let is_void = self.is_void_type(member.type_id); // Header: export interface NameVariant { if self.config.export { @@ -605,11 +605,14 @@ impl<'a> Emitter<'a> { "{} $tag{}:{} {}\"{}\"{}{};{}\n", c.reset, c.dim, c.reset, c.green, variant_name, c.reset, c.dim, c.reset )); - // $data field - self.output.push_str(&format!( - " $data{}:{} {}{};\n", - c.dim, c.reset, data_str, c.dim - )); + // $data field (omit for Void payloads) + if !is_void { + let data_str = self.inline_data_type(member.type_id); + self.output.push_str(&format!( + " $data{}:{} {}{};\n", + c.dim, c.reset, data_str, c.dim + )); + } self.output.push_str(&format!("{}}}{}\n\n", c.dim, c.reset)); } @@ -772,11 +775,19 @@ impl<'a> Emitter<'a> { .members_of(type_def) .map(|member| { let name = self.strings.get(member.name); - let data_type = self.type_to_ts(member.type_id); - format!( - "{}{{{} $tag{}:{} {}\"{}\"{}{}; $data{}:{} {} {}}}{}", - c.dim, c.reset, c.dim, c.reset, c.green, name, c.reset, c.dim, c.dim, c.reset, data_type, c.dim, c.reset - ) + if self.is_void_type(member.type_id) { + // Void payload: omit $data + format!( + "{}{{{} $tag{}:{} {}\"{}\"{}{}}}{}", + c.dim, c.reset, c.dim, c.reset, c.green, name, c.reset, c.dim, c.reset + ) + } else { + let data_type = self.type_to_ts(member.type_id); + format!( + "{}{{{} $tag{}:{} {}\"{}\"{}{}; $data{}:{} {} {}}}{}", + c.dim, c.reset, c.dim, c.reset, c.green, name, c.reset, c.dim, c.dim, c.reset, data_type, c.dim, c.reset + ) + } }) .collect(); @@ -804,6 +815,13 @@ impl<'a> Emitter<'a> { } } + fn is_void_type(&self, type_id: QTypeId) -> bool { + self.types + .get(type_id) + .and_then(|def| def.type_kind()) + .is_some_and(|k| k == TypeKind::Void) + } + fn needs_generated_name(&self, type_def: &TypeDef) -> bool { matches!( type_def.type_kind(), diff --git a/docs/type-system.md b/docs/type-system.md index f2a7f987..c2fbc13b 100644 --- a/docs/type-system.md +++ b/docs/type-system.md @@ -163,19 +163,24 @@ Created by `[ ... ]`: ### Enum Variants -| Captures | Payload | -| -------- | ------------------- | -| 0 | `Struct {}` (Empty) | -| 1+ | Struct | +| Captures | Payload | +| -------- | ------- | +| 0 | Void | +| 1+ | Struct | + +Void payloads omit the `$data` field entirely: ``` -Result = [ - Ok: (value) @val - Err: (error (code) @code (message) @msg) +Expr = [ + Num: (number) @val + Empty: (string) ] ``` -Single-capture variants stay wrapped (`result.$data.val`), making field additions non-breaking. +- `Num` variant: `{ "$tag": "Num", "$data": { val: Node } }` +- `Empty` variant: `{ "$tag": "Empty" }` (no `$data`) + +Single-capture variants stay wrapped (`result.$data.val`). ## 4. Cardinality