diff --git a/crates/plotnik-lib/src/engine/materializer.rs b/crates/plotnik-lib/src/engine/materializer.rs index f0dd678c..63f07bc5 100644 --- a/crates/plotnik-lib/src/engine/materializer.rs +++ b/crates/plotnik-lib/src/engine/materializer.rs @@ -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![]), @@ -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<'_> { @@ -100,7 +109,7 @@ impl<'t> Materializer<'t> for ValueMaterializer<'_> { // Pending value from Node/Text/Null (consumed by Set/Push) let mut pending: Option = 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))); @@ -119,16 +128,24 @@ 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![])); @@ -136,31 +153,38 @@ impl<'t> Materializer<'t> for ValueMaterializer<'_> { 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); @@ -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; diff --git a/crates/plotnik-lib/src/engine/value.rs b/crates/plotnik-lib/src/engine/value.rs index 954a011f..5d178f7a 100644 --- a/crates/plotnik-lib/src/engine/value.rs +++ b/crates/plotnik-lib/src/engine/value.rs @@ -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, diff --git a/crates/plotnik-lib/src/engine/vm.rs b/crates/plotnik-lib/src/engine/vm.rs index 81d0eb20..c932ed25 100644 --- a/crates/plotnik-lib/src/engine/vm.rs +++ b/crates/plotnik-lib/src/engine/vm.rs @@ -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 + } } } @@ -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 { 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}; @@ -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; }