Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions crates/plotnik-lib/src/analyze/type_check/infer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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;
};

Expand Down Expand Up @@ -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,
}
}
Expand Down
51 changes: 39 additions & 12 deletions crates/plotnik-lib/src/engine/materializer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -54,7 +65,11 @@ enum Builder {
Scalar(Option<Value>),
Array(Vec<Value>),
Object(Vec<(String, Value)>),
Tagged { tag: String, fields: Vec<(String, Value)> },
Tagged {
tag: String,
payload_type: QTypeId,
fields: Vec<(String, Value)>,
},
}

impl Builder {
Expand All @@ -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))),
},
}
}
Expand Down Expand Up @@ -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 => {
Expand Down
60 changes: 33 additions & 27 deletions crates/plotnik-lib/src/engine/value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -60,8 +57,11 @@ pub enum Value {
Array(Vec<Value>),
/// Object with ordered fields.
Object(Vec<(String, Value)>),
/// Tagged union.
Tagged { tag: String, data: Box<Value> },
/// Tagged union. `data` is None for Void payloads.
Tagged {
tag: String,
data: Option<Box<Value>>,
},
}

impl Serialize for Value {
Expand All @@ -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()
}
}
Expand Down Expand Up @@ -333,7 +336,7 @@ fn format_object(
fn format_tagged(
out: &mut String,
tag: &str,
data: &Value,
data: &Option<Box<Value>>,
c: &Colors,
pretty: bool,
indent: usize,
Expand Down Expand Up @@ -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');
Expand Down
40 changes: 29 additions & 11 deletions crates/plotnik-lib/src/typegen/typescript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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));
}

Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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(),
Expand Down
21 changes: 13 additions & 8 deletions docs/type-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down