Skip to main content

webots_proto_template/template/
mod.rs

1use boa_engine::object::ObjectInitializer;
2use boa_engine::property::{Attribute, PropertyKey};
3use boa_engine::{Context, JsString, Source};
4use std::collections::HashMap;
5use thiserror::Error;
6use webots_proto_ast::proto::parser::Parser;
7
8pub mod js_api;
9pub mod parser;
10pub mod types;
11
12#[derive(Error, Debug)]
13pub enum TemplateError {
14    #[error("JS execution error: {0}")]
15    JsError(String), // We might lose span info if not careful, but we'll try to map it later
16    #[error("Template parsing error: {0}")]
17    ParseError(String),
18    #[error("Generated VRML invalid: {0}")]
19    ValidationError(String),
20}
21
22use crate::template::parser::{TemplateChunk, parse};
23use crate::template::types::{
24    TemplateContext, TemplateField, TemplateFieldBinding, TemplateWebotsVersion,
25};
26
27#[derive(Debug, Clone, Default)]
28pub struct TemplateEvaluator {
29    context_data: TemplateContext,
30}
31
32impl TemplateEvaluator {
33    pub fn new() -> Self {
34        Self::default()
35    }
36
37    pub fn with_context(context_data: TemplateContext) -> Self {
38        Self { context_data }
39    }
40
41    /// Evaluates a Webots PROTO template with explicit field bindings and context.
42    ///
43    /// This mirrors Webots procedural PROTO behavior:
44    /// - `fields.<name>.value`
45    /// - `fields.<name>.defaultValue`
46    /// - global `context` object with fixed metadata keys.
47    pub fn evaluate_with_environment(
48        &self,
49        template_content: &str,
50        fields: &HashMap<String, TemplateFieldBinding>,
51    ) -> Result<String, TemplateError> {
52        evaluate_template_with_environment(template_content, fields, &self.context_data)
53    }
54
55    /// Evaluates a template with value-only field inputs.
56    ///
57    /// For each entry, `defaultValue` mirrors `value`.
58    pub fn evaluate_with_fields<I, K, V>(
59        &self,
60        template_content: &str,
61        fields: I,
62    ) -> Result<String, TemplateError>
63    where
64        I: IntoIterator<Item = (K, V)>,
65        K: Into<String>,
66        V: Into<TemplateField>,
67    {
68        let bindings = fields
69            .into_iter()
70            .map(|(field_name, field_value)| {
71                let field_value = field_value.into();
72                (
73                    field_name.into(),
74                    TemplateFieldBinding::new(field_value.clone(), field_value),
75                )
76            })
77            .collect::<HashMap<_, _>>();
78        self.evaluate_with_environment(template_content, &bindings)
79    }
80
81    /// Evaluates a template with fully specified field bindings.
82    pub fn evaluate_with_bindings<I, K>(
83        &self,
84        template_content: &str,
85        fields: I,
86    ) -> Result<String, TemplateError>
87    where
88        I: IntoIterator<Item = (K, TemplateFieldBinding)>,
89        K: Into<String>,
90    {
91        let bindings = fields
92            .into_iter()
93            .map(|(field_name, binding)| (field_name.into(), binding))
94            .collect::<HashMap<_, _>>();
95        self.evaluate_with_environment(template_content, &bindings)
96    }
97}
98
99/// Evaluates a Webots PROTO template with explicit field bindings and context.
100///
101/// This mirrors Webots procedural PROTO behavior:
102/// - `fields.<name>.value`
103/// - `fields.<name>.defaultValue`
104/// - global `context` object with fixed metadata keys.
105fn evaluate_template_with_environment(
106    template_content: &str,
107    fields: &HashMap<String, TemplateFieldBinding>,
108    context_data: &TemplateContext,
109) -> Result<String, TemplateError> {
110    // 1. Parse the template
111    let chunks =
112        parse(template_content).map_err(|e| TemplateError::ParseError(format!("{:?}", e)))?;
113
114    // 2. Setup JS Context
115    let mut context = Context::default();
116
117    // Remove nondeterministic globals
118    let global = context.global_object();
119    global
120        .delete_property_or_throw(PropertyKey::from(JsString::from("Date")), &mut context)
121        .ok();
122
123    if let Ok(math_obj) = global.get(PropertyKey::from(JsString::from("Math")), &mut context)
124        && let Some(math_obj) = math_obj.as_object()
125    {
126        math_obj
127            .delete_property_or_throw(PropertyKey::from(JsString::from("random")), &mut context)
128            .ok();
129    }
130
131    // 3. Setup Output Buffer
132    // We utilize a closure to hide the real accumulator from the user.
133    // `__webots_output_buffer` is exposed as a dummy to prevent interference.
134    // `__webots_write` is locked down.
135    let init_script = r#"
136        (function() {
137            var real_buffer = [];
138            
139            // Expose a dummy buffer for users who try to access it
140            var dummy_buffer = [];
141            Object.defineProperty(this, "__webots_output_buffer", {
142                value: dummy_buffer,
143                writable: false,
144                configurable: false,
145                enumerable: false
146            });
147
148            // The write function pushes to the real buffer
149            Object.defineProperty(this, "__webots_write", {
150                value: function(s) {
151                    real_buffer.push(String(s));
152                },
153                writable: false,
154                configurable: false,
155                enumerable: false
156            });
157            
158            // Internal accessor for Rust to retrieve output
159            Object.defineProperty(this, "__webots_get_output_internal", {
160                value: function() {
161                    return real_buffer.join("");
162                },
163                writable: false,
164                configurable: false,
165                enumerable: false
166            });
167        }).call(this);
168    "#;
169    context
170        .eval(Source::from_bytes(init_script.as_bytes()))
171        .map_err(|e| TemplateError::JsError(e.to_string()))?;
172
173    // 4. Map fields
174    let fields_obj = ObjectInitializer::new(&mut context).build();
175    for (field_name, field_binding) in fields {
176        let field_value = field_binding.value.to_js_value(&mut context);
177        let field_default_value = field_binding.default_value.to_js_value(&mut context);
178        let field_wrapper = ObjectInitializer::new(&mut context)
179            .property(
180                PropertyKey::from(JsString::from("value")),
181                field_value,
182                Attribute::READONLY,
183            )
184            .property(
185                PropertyKey::from(JsString::from("defaultValue")),
186                field_default_value,
187                Attribute::READONLY,
188            )
189            .build();
190
191        fields_obj
192            .set(
193                PropertyKey::from(JsString::from(field_name.as_str())),
194                field_wrapper,
195                false,
196                &mut context,
197            )
198            .map_err(|e| TemplateError::JsError(e.to_string()))?;
199    }
200
201    context
202        .register_global_property(
203            PropertyKey::from(JsString::from("fields")),
204            fields_obj,
205            Attribute::READONLY,
206        )
207        .map_err(|e| TemplateError::JsError(e.to_string()))?;
208
209    // 5. Register fixed `context` metadata object
210    let context_object = build_context_object(context_data, &mut context)?;
211    context
212        .register_global_property(
213            PropertyKey::from(JsString::from("context")),
214            context_object,
215            Attribute::READONLY,
216        )
217        .map_err(|e| TemplateError::JsError(e.to_string()))?;
218
219    // 6. Transpile chunks to JS calls to `__webots_write`
220    let mut js_code = String::new();
221    for chunk in chunks {
222        match chunk {
223            TemplateChunk::Text { content, .. } => {
224                // Use serde_json to produce a safe JS string literal
225                match serde_json::to_string(&content) {
226                    Ok(s) => js_code.push_str(&format!("__webots_write({});\n", s)),
227                    Err(_) => {
228                        return Err(TemplateError::ParseError(
229                            "Failed to serialize text chunk".into(),
230                        ));
231                    }
232                }
233            }
234            TemplateChunk::ExpressionBlock { content, .. } => {
235                js_code.push_str(&format!("__webots_write({});\n", content));
236            }
237            TemplateChunk::ExecutionBlock { content, .. } => {
238                js_code.push_str(&content);
239                js_code.push('\n');
240            }
241        }
242    }
243
244    // 7. Execute
245    match context.eval(Source::from_bytes(js_code.as_bytes())) {
246        Ok(_) => {
247            // Retrieve output
248            let output_script = r#"__webots_get_output_internal()"#;
249            match context.eval(Source::from_bytes(output_script.as_bytes())) {
250                Ok(res) => {
251                    match res.as_string() {
252                        Some(s) => {
253                            let output = s
254                                .to_std_string()
255                                .map_err(|e| TemplateError::JsError(e.to_string()))?;
256
257                            // 8. Validate generated output (Basic parse check)
258                            // We use strict parsing for the generated body
259                            let mut parser = Parser::new(&output);
260                            match parser.parse_body() {
261                                Ok(_) => Ok(output),
262                                Err(e) => Err(TemplateError::ValidationError(format!("{:?}", e))),
263                            }
264                        }
265                        None => Ok(String::new()), // Empty output
266                    }
267                }
268                Err(e) => Err(TemplateError::JsError(e.to_string())),
269            }
270        }
271        Err(e) => {
272            // TODO: Attempt to map line number from `e` back to `chunks` spans
273            // For now just return the JS error.
274            Err(TemplateError::JsError(e.to_string()))
275        }
276    }
277}
278
279fn build_context_object(
280    context_data: &TemplateContext,
281    context: &mut Context,
282) -> Result<boa_engine::JsValue, TemplateError> {
283    let context_object = ObjectInitializer::new(context).build();
284
285    if let Some(world_path) = &context_data.world {
286        context_object
287            .set(
288                PropertyKey::from(JsString::from("world")),
289                JsString::from(world_path.as_str()),
290                false,
291                context,
292            )
293            .map_err(|e| TemplateError::JsError(e.to_string()))?;
294    }
295    if let Some(proto_path) = &context_data.proto {
296        context_object
297            .set(
298                PropertyKey::from(JsString::from("proto")),
299                JsString::from(proto_path.as_str()),
300                false,
301                context,
302            )
303            .map_err(|e| TemplateError::JsError(e.to_string()))?;
304    }
305    if let Some(project_path) = &context_data.project_path {
306        context_object
307            .set(
308                PropertyKey::from(JsString::from("project_path")),
309                JsString::from(project_path.as_str()),
310                false,
311                context,
312            )
313            .map_err(|e| TemplateError::JsError(e.to_string()))?;
314    }
315    if let Some(webots_home) = &context_data.webots_home {
316        context_object
317            .set(
318                PropertyKey::from(JsString::from("webots_home")),
319                JsString::from(webots_home.as_str()),
320                false,
321                context,
322            )
323            .map_err(|e| TemplateError::JsError(e.to_string()))?;
324    }
325    if let Some(temporary_files_path) = &context_data.temporary_files_path {
326        context_object
327            .set(
328                PropertyKey::from(JsString::from("temporary_files_path")),
329                JsString::from(temporary_files_path.as_str()),
330                false,
331                context,
332            )
333            .map_err(|e| TemplateError::JsError(e.to_string()))?;
334    }
335    if let Some(operating_system) = &context_data.os {
336        context_object
337            .set(
338                PropertyKey::from(JsString::from("os")),
339                JsString::from(operating_system.as_str()),
340                false,
341                context,
342            )
343            .map_err(|e| TemplateError::JsError(e.to_string()))?;
344    }
345    if let Some(node_id) = &context_data.id {
346        context_object
347            .set(
348                PropertyKey::from(JsString::from("id")),
349                JsString::from(node_id.as_str()),
350                false,
351                context,
352            )
353            .map_err(|e| TemplateError::JsError(e.to_string()))?;
354    }
355    if let Some(coordinate_system) = &context_data.coordinate_system {
356        context_object
357            .set(
358                PropertyKey::from(JsString::from("coordinate_system")),
359                JsString::from(coordinate_system.as_str()),
360                false,
361                context,
362            )
363            .map_err(|e| TemplateError::JsError(e.to_string()))?;
364    }
365    if let Some(version) = &context_data.webots_version {
366        context_object
367            .set(
368                PropertyKey::from(JsString::from("webots_version")),
369                build_webots_version_object(version, context)?,
370                false,
371                context,
372            )
373            .map_err(|e| TemplateError::JsError(e.to_string()))?;
374    }
375
376    Ok(context_object.into())
377}
378
379fn build_webots_version_object(
380    version: &TemplateWebotsVersion,
381    context: &mut Context,
382) -> Result<boa_engine::JsValue, TemplateError> {
383    let version_object = ObjectInitializer::new(context).build();
384    version_object
385        .set(
386            PropertyKey::from(JsString::from("major")),
387            JsString::from(version.major.as_str()),
388            false,
389            context,
390        )
391        .map_err(|e| TemplateError::JsError(e.to_string()))?;
392    version_object
393        .set(
394            PropertyKey::from(JsString::from("revision")),
395            JsString::from(version.revision.as_str()),
396            false,
397            context,
398        )
399        .map_err(|e| TemplateError::JsError(e.to_string()))?;
400
401    Ok(version_object.into())
402}