plotnik_lib/engine/
materializer.rs

1//! Materializer transforms effect logs into output values.
2
3use crate::bytecode::{StringsView, TypeData, TypeId, TypeKind, TypesView};
4
5use super::effect::RuntimeEffect;
6use super::value::{NodeHandle, Value};
7
8/// Materializer transforms effect logs into output values.
9pub trait Materializer<'t> {
10    type Output;
11
12    fn materialize(&self, effects: &[RuntimeEffect<'t>], result_type: TypeId) -> Self::Output;
13}
14
15/// Materializer that produces Value with resolved strings.
16pub struct ValueMaterializer<'a> {
17    source: &'a str,
18    types: TypesView<'a>,
19    strings: StringsView<'a>,
20}
21
22impl<'a> ValueMaterializer<'a> {
23    pub fn new(source: &'a str, types: TypesView<'a>, strings: StringsView<'a>) -> Self {
24        Self {
25            source,
26            types,
27            strings,
28        }
29    }
30
31    fn resolve_member_name(&self, idx: u16) -> String {
32        let member = self.types.get_member(idx as usize);
33        self.strings.get(member.name()).to_owned()
34    }
35
36    fn resolve_member_type(&self, idx: u16) -> TypeId {
37        self.types.get_member(idx as usize).type_id()
38    }
39
40    fn is_void_type(&self, type_id: TypeId) -> bool {
41        self.types
42            .get(type_id)
43            .is_some_and(|def| matches!(def.classify(), TypeData::Primitive(TypeKind::Void)))
44    }
45
46    /// Create initial builder based on result type.
47    fn builder_for_type(&self, type_id: TypeId) -> Builder {
48        let def = self
49            .types
50            .get(type_id)
51            .unwrap_or_else(|| panic!("unknown type_id {}", type_id.0));
52
53        match def.classify() {
54            TypeData::Composite {
55                kind: TypeKind::Struct,
56                ..
57            } => Builder::Object(vec![]),
58            TypeData::Composite {
59                kind: TypeKind::Enum,
60                ..
61            } => Builder::Scalar(None),
62            TypeData::Wrapper {
63                kind: TypeKind::ArrayZeroOrMore | TypeKind::ArrayOneOrMore,
64                ..
65            } => Builder::Array(vec![]),
66            _ => Builder::Scalar(None),
67        }
68    }
69}
70
71/// Value builder for stack-based materialization.
72enum Builder {
73    Scalar(Option<Value>),
74    Array(Vec<Value>),
75    Object(Vec<(String, Value)>),
76    Tagged {
77        tag: String,
78        payload_type: TypeId,
79        fields: Vec<(String, Value)>,
80    },
81}
82
83impl Builder {
84    fn build(self) -> Value {
85        match self {
86            Builder::Scalar(v) => v.unwrap_or(Value::Null),
87            Builder::Array(arr) => Value::Array(arr),
88            Builder::Object(fields) => Value::Object(fields),
89            Builder::Tagged { tag, fields, .. } => Value::Tagged {
90                tag,
91                data: Some(Box::new(Value::Object(fields))),
92            },
93        }
94    }
95
96    fn kind(&self) -> &'static str {
97        match self {
98            Builder::Scalar(_) => "Scalar",
99            Builder::Array(_) => "Array",
100            Builder::Object(_) => "Object",
101            Builder::Tagged { .. } => "Tagged",
102        }
103    }
104}
105
106impl<'t> Materializer<'t> for ValueMaterializer<'_> {
107    type Output = Value;
108
109    fn materialize(&self, effects: &[RuntimeEffect<'t>], result_type: TypeId) -> Value {
110        // Stack of containers being built
111        let mut stack: Vec<Builder> = vec![];
112
113        // Initialize with result type container
114        let result_builder = self.builder_for_type(result_type);
115        stack.push(result_builder);
116
117        // Pending value from Node/Text/Null (consumed by Set/Push)
118        let mut pending: Option<Value> = None;
119
120        for (effect_idx, effect) in effects.iter().enumerate() {
121            match effect {
122                RuntimeEffect::Node(n) => {
123                    pending = Some(Value::Node(NodeHandle::from_node(*n, self.source)));
124                }
125                RuntimeEffect::Text(n) => {
126                    let text = n
127                        .utf8_text(self.source.as_bytes())
128                        .expect("invalid UTF-8")
129                        .to_owned();
130                    pending = Some(Value::String(text));
131                }
132                RuntimeEffect::Null => {
133                    pending = Some(Value::Null);
134                }
135                RuntimeEffect::Arr => {
136                    stack.push(Builder::Array(vec![]));
137                }
138                RuntimeEffect::Push => {
139                    let val = pending.take().unwrap_or(Value::Null);
140                    let Some(Builder::Array(arr)) = stack.last_mut() else {
141                        panic!(
142                            "effect {effect_idx}: Push expects Array on stack, found {:?}",
143                            stack.last().map(|b| b.kind())
144                        );
145                    };
146                    arr.push(val);
147                }
148                RuntimeEffect::EndArr => {
149                    let top = stack.pop();
150                    let Some(Builder::Array(arr)) = top else {
151                        panic!(
152                            "effect {effect_idx}: EndArr expects Array on stack, found {:?}",
153                            top.as_ref().map(|b| b.kind())
154                        );
155                    };
156                    pending = Some(Value::Array(arr));
157                }
158                RuntimeEffect::Obj => {
159                    stack.push(Builder::Object(vec![]));
160                }
161                RuntimeEffect::Set(idx) => {
162                    let field_name = self.resolve_member_name(*idx);
163                    let val = pending.take().unwrap_or(Value::Null);
164                    match stack.last_mut() {
165                        Some(Builder::Object(obj)) => obj.push((field_name, val)),
166                        Some(Builder::Tagged { fields, .. }) => fields.push((field_name, val)),
167                        other => panic!(
168                            "effect {effect_idx}: Set expects Object/Tagged on stack, found {:?}",
169                            other.map(|b| b.kind())
170                        ),
171                    }
172                }
173                RuntimeEffect::EndObj => {
174                    let top = stack.pop();
175                    let Some(Builder::Object(fields)) = top else {
176                        panic!(
177                            "effect {effect_idx}: EndObj expects Object on stack, found {:?}",
178                            top.as_ref().map(|b| b.kind())
179                        );
180                    };
181                    if !fields.is_empty() {
182                        // Non-empty object: always produce the object value
183                        pending = Some(Value::Object(fields));
184                    } else if pending.is_none() {
185                        // Empty object with no pending value:
186                        // - If nested (stack.len() > 1): produce empty object {}
187                        //   This handles captured empty sequences like `{ } @x`
188                        //   Note: stack always has at least the result_builder, so we check > 1
189                        // - If at root (stack.len() <= 1): void result → null
190                        if stack.len() > 1 {
191                            pending = Some(Value::Object(vec![]));
192                        }
193                        // else: pending stays None (void result)
194                    }
195                    // else: pending has a value, keep it (passthrough for enums, suppressive, etc.)
196                }
197                RuntimeEffect::Enum(idx) => {
198                    let tag = self.resolve_member_name(*idx);
199                    let payload_type = self.resolve_member_type(*idx);
200                    stack.push(Builder::Tagged {
201                        tag,
202                        payload_type,
203                        fields: vec![],
204                    });
205                }
206                RuntimeEffect::EndEnum => {
207                    let top = stack.pop();
208                    let Some(Builder::Tagged {
209                        tag,
210                        payload_type,
211                        fields,
212                    }) = top
213                    else {
214                        panic!(
215                            "effect {effect_idx}: EndEnum expects Tagged on stack, found {:?}",
216                            top.as_ref().map(|b| b.kind())
217                        );
218                    };
219                    // Void payloads produce no $data field
220                    let data = if self.is_void_type(payload_type) {
221                        None
222                    } else {
223                        // If inner returned a structured value (via Obj/EndObj), use it as data
224                        // Otherwise use fields collected from direct Set effects
225                        Some(Box::new(pending.take().unwrap_or(Value::Object(fields))))
226                    };
227                    pending = Some(Value::Tagged { tag, data });
228                }
229                RuntimeEffect::Clear => {
230                    pending = None;
231                }
232            }
233        }
234
235        // Result: pending value takes precedence, otherwise pop the result container
236        pending
237            .or_else(|| stack.pop().map(Builder::build))
238            .unwrap_or(Value::Null)
239    }
240}