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
113 changes: 71 additions & 42 deletions crates/plotnik-lib/src/engine/materializer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,10 @@ impl<'a> ValueMaterializer<'a> {

/// Create initial builder based on result type.
fn builder_for_type(&self, type_id: TypeId) -> Builder {
let def = match self.types.get(type_id) {
Some(d) => d,
None => return Builder::Scalar(None),
};
let def = self
.types
.get(type_id)
.unwrap_or_else(|| panic!("unknown type_id {}", type_id.0));

match TypeKind::from_u8(def.kind) {
Some(TypeKind::Struct) => Builder::Object(vec![]),
Expand Down Expand Up @@ -84,6 +84,15 @@ impl Builder {
},
}
}

fn kind(&self) -> &'static str {
match self {
Builder::Scalar(_) => "Scalar",
Builder::Array(_) => "Array",
Builder::Object(_) => "Object",
Builder::Tagged { .. } => "Tagged",
}
}
}

impl<'t> Materializer<'t> for ValueMaterializer<'_> {
Expand All @@ -100,7 +109,7 @@ impl<'t> Materializer<'t> for ValueMaterializer<'_> {
// Pending value from Node/Text/Null (consumed by Set/Push)
let mut pending: Option<Value> = None;

for effect in effects {
for (effect_idx, effect) in effects.iter().enumerate() {
match effect {
RuntimeEffect::Node(n) => {
pending = Some(Value::Node(NodeHandle::from_node(*n, self.source)));
Expand All @@ -119,48 +128,63 @@ impl<'t> Materializer<'t> for ValueMaterializer<'_> {
stack.push(Builder::Array(vec![]));
}
RuntimeEffect::Push => {
// Take pending value (or completed container) and push to parent array
let val = pending.take().unwrap_or(Value::Null);
if let Some(Builder::Array(arr)) = stack.last_mut() {
arr.push(val);
}
let Some(Builder::Array(arr)) = stack.last_mut() else {
panic!(
"effect {effect_idx}: Push expects Array on stack, found {:?}",
stack.last().map(|b| b.kind())
);
};
arr.push(val);
}
RuntimeEffect::EndArr => {
if let Some(Builder::Array(arr)) = stack.pop() {
pending = Some(Value::Array(arr));
}
let top = stack.pop();
let Some(Builder::Array(arr)) = top else {
panic!(
"effect {effect_idx}: EndArr expects Array on stack, found {:?}",
top.as_ref().map(|b| b.kind())
);
};
pending = Some(Value::Array(arr));
}
RuntimeEffect::Obj => {
stack.push(Builder::Object(vec![]));
}
RuntimeEffect::Set(idx) => {
let field_name = self.resolve_member_name(*idx);
let val = pending.take().unwrap_or(Value::Null);
// Set works on both Object and Tagged (enum variant data)
match stack.last_mut() {
Some(Builder::Object(obj)) => obj.push((field_name, val)),
Some(Builder::Tagged { fields, .. }) => fields.push((field_name, val)),
_ => {}
other => panic!(
"effect {effect_idx}: Set expects Object/Tagged on stack, found {:?}",
other.map(|b| b.kind())
),
}
}
RuntimeEffect::EndObj => {
if let Some(Builder::Object(fields)) = stack.pop() {
if !fields.is_empty() {
// Non-empty object: always produce the object value
pending = Some(Value::Object(fields));
} else if pending.is_none() {
// Empty object with no pending value:
// - If nested (stack.len() > 1): produce empty object {}
// This handles captured empty sequences like `{ } @x`
// Note: stack always has at least the result_builder, so we check > 1
// - If at root (stack.len() <= 1): void result → null
if stack.len() > 1 {
pending = Some(Value::Object(vec![]));
}
// else: pending stays None (void result)
let top = stack.pop();
let Some(Builder::Object(fields)) = top else {
panic!(
"effect {effect_idx}: EndObj expects Object on stack, found {:?}",
top.as_ref().map(|b| b.kind())
);
};
if !fields.is_empty() {
// Non-empty object: always produce the object value
pending = Some(Value::Object(fields));
} else if pending.is_none() {
// Empty object with no pending value:
// - If nested (stack.len() > 1): produce empty object {}
// This handles captured empty sequences like `{ } @x`
// Note: stack always has at least the result_builder, so we check > 1
// - If at root (stack.len() <= 1): void result → null
if stack.len() > 1 {
pending = Some(Value::Object(vec![]));
}
// else: pending has a value, keep it (passthrough for enums, suppressive, etc.)
// else: pending stays None (void result)
}
// else: pending has a value, keep it (passthrough for enums, suppressive, etc.)
}
RuntimeEffect::Enum(idx) => {
let tag = self.resolve_member_name(*idx);
Expand All @@ -172,22 +196,27 @@ impl<'t> Materializer<'t> for ValueMaterializer<'_> {
});
}
RuntimeEffect::EndEnum => {
if let Some(Builder::Tagged {
let top = stack.pop();
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 });
}
}) = top
else {
panic!(
"effect {effect_idx}: EndEnum expects Tagged on stack, found {:?}",
top.as_ref().map(|b| b.kind())
);
};
// 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 => {
pending = None;
Expand Down
5 changes: 4 additions & 1 deletion crates/plotnik-lib/src/engine/value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ 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())
.expect("node text extraction failed")
.to_owned();
Self {
kind: node.kind().to_owned(),
text,
Expand Down
15 changes: 10 additions & 5 deletions crates/plotnik-lib/src/engine/vm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ use crate::bytecode::{
};

/// Get the nav for continue_search (always a sibling move).
/// Up/Stay variants return Next as a default since they don't do sibling search.
fn continuation_nav(nav: Nav) -> Nav {
match nav {
Nav::Down | Nav::Next => Nav::Next,
Nav::DownSkip | Nav::NextSkip => Nav::NextSkip,
Nav::DownExact | Nav::NextExact => Nav::NextExact,
// Up/Stay don't have search loops
_ => Nav::Next,
Nav::Up(_) | Nav::UpSkipTrivia(_) | Nav::UpExact(_) | Nav::Stay | Nav::StayExact => {
Nav::Next
}
}
}

Expand All @@ -24,13 +26,13 @@ use super::cursor::{CursorWrapper, SkipPolicy};

/// Derive skip policy from navigation mode without navigating.
/// Used when retrying a Call to determine the policy for the next checkpoint.
/// Stay/Up variants return None since they don't retry among siblings.
fn skip_policy_for_nav(nav: Nav) -> Option<SkipPolicy> {
match nav {
Nav::Down | Nav::Next => Some(SkipPolicy::Any),
Nav::DownSkip | Nav::NextSkip => Some(SkipPolicy::Trivia),
Nav::DownExact | Nav::NextExact => Some(SkipPolicy::Exact),
// Stay doesn't navigate, Up doesn't retry among siblings
_ => None,
Nav::Stay | Nav::StayExact | Nav::Up(_) | Nav::UpSkipTrivia(_) | Nav::UpExact(_) => None,
}
}
use super::effect::{EffectLog, RuntimeEffect};
Expand Down Expand Up @@ -462,7 +464,10 @@ impl<'t> VM<'t> {
return;
}
SuppressEnd => {
self.suppress_depth = self.suppress_depth.saturating_sub(1);
self.suppress_depth = self
.suppress_depth
.checked_sub(1)
.expect("SuppressEnd without matching SuppressBegin");
tracer.trace_suppress_control(SuppressEnd, self.suppress_depth > 0);
return;
}
Expand Down