neutralts/template.rs
1use crate::{
2 block_parser::BlockInherit, block_parser::BlockParser, constants::*, default_json::*,
3 shared::Shared, utils::*,
4};
5use regex::Regex;
6use serde_json::{json, Value};
7use std::fs;
8use std::path::Path;
9use std::sync::OnceLock;
10use std::time::{Duration, Instant};
11use std::rc::Rc;
12
13pub struct Template<'a> {
14 raw: String,
15 file_path: &'a str,
16 schema: Value,
17 shared: Shared,
18 time_start: Instant,
19 time_elapsed: Duration,
20 out: String,
21}
22
23fn default_schema_template() -> Result<Value, String> {
24 static DEFAULT_SCHEMA: OnceLock<Result<Value, String>> = OnceLock::new();
25 DEFAULT_SCHEMA
26 .get_or_init(|| {
27 serde_json::from_str(DEFAULT)
28 .map_err(|_| "const DEFAULT is not a valid JSON string".to_string())
29 })
30 .clone()
31}
32
33/// A struct representing a template that can be rendered.
34///
35/// This struct is used to handle the rendering of templates.
36impl<'a> Template<'a> {
37 /// Constructs a new `Template` instance with default settings.
38 ///
39 /// It allows you to set up a template and schema with different types.
40 pub fn new() -> Result<Self, String> {
41 let default_schema = default_schema_template()?;
42 let shared = Shared::new(default_schema.clone());
43
44 Ok(Template {
45 raw: String::new(),
46 file_path: "",
47 schema: default_schema,
48 shared,
49 time_start: Instant::now(),
50 time_elapsed: Instant::now().elapsed(),
51 out: String::new(),
52 })
53 }
54
55 /// Constructs a new `Template` instance from a file path and a JSON schema.
56 ///
57 /// # Arguments
58 ///
59 /// * `file_path` - A reference to the path of the file containing the template content.
60 /// * `schema` - A JSON value representing the custom schema to be used with the template.
61 ///
62 /// # Returns
63 ///
64 /// A `Result` containing the new `Template` instance or an error message if:
65 /// - The file cannot be read.
66 pub fn from_file_value(file_path: &'a str, schema: Value) -> Result<Self, String> {
67 let raw: String = match fs::read_to_string(file_path) {
68 Ok(s) => s,
69 Err(e) => {
70 eprintln!("Cannot be read: {}", file_path);
71 return Err(e.to_string());
72 }
73 };
74 let mut default_schema = default_schema_template()?;
75
76 update_schema_owned(&mut default_schema, schema);
77 // Avoid cloning a potentially huge merged schema during construction.
78 // `shared` will be fully initialized in `init_render` when needed.
79 let shared = Shared::new(default_schema_template()?);
80
81 Ok(Template {
82 raw,
83 file_path,
84 schema: default_schema,
85 shared,
86 time_start: Instant::now(),
87 time_elapsed: Instant::now().elapsed(),
88 out: String::new(),
89 })
90 }
91
92 /// Sets the source path of the template.
93 ///
94 /// # Arguments
95 ///
96 /// * `file_path` - A reference to the path of the file containing the template content.
97 ///
98 /// # Returns
99 ///
100 /// A `Result` indicating success or an error message if the file cannot be read
101 pub fn set_src_path(&mut self, file_path: &'a str) -> Result<(), String> {
102 self.file_path = file_path;
103 self.raw = match fs::read_to_string(file_path) {
104 Ok(s) => s,
105 Err(e) => {
106 eprintln!("Cannot be read: {}", file_path);
107 return Err(e.to_string());
108 }
109 };
110
111 Ok(())
112 }
113
114 /// Sets the content of the template from a string.
115 ///
116 /// # Arguments
117 ///
118 /// * `source` - A reference to the new string content to be set as the raw content.
119 pub fn set_src_str(&mut self, source: &str) {
120 self.raw = source.to_string();
121 }
122
123 /// Merges the schema from a file with the current template schema.
124 ///
125 /// # Arguments
126 ///
127 /// * `schema_path` - A reference to the path of the file containing the schema content.
128 ///
129 /// # Returns
130 ///
131 /// A `Result` indicating success or an error message if:
132 /// - The file cannot be read.
133 /// - The file's content is not a valid JSON string.
134 pub fn merge_schema_path(&mut self, schema_path: &str) -> Result<(), String> {
135 let schema_bytes = match fs::read(schema_path) {
136 Ok(bytes) => bytes,
137 Err(e) => {
138 eprintln!("Cannot be read: {}", schema_path);
139 return Err(e.to_string());
140 }
141 };
142 let schema_value: Value = match serde_json::from_slice(&schema_bytes) {
143 Ok(value) => value,
144 Err(_) => {
145 return Err("Is not a valid JSON file".to_string());
146 }
147 };
148 update_schema_owned(&mut self.schema, schema_value);
149
150 Ok(())
151 }
152
153 /// Merges the schema from a JSON string with the current template schema.
154 ///
155 /// # Arguments
156 ///
157 /// * `schema` - A reference to the JSON string of the schema content.
158 ///
159 /// # Returns
160 ///
161 /// A `Result` indicating success or an error message if:
162 /// - The file's content is not a valid JSON string.
163 pub fn merge_schema_str(&mut self, schema: &str) -> Result<(), String> {
164 let schema_value: Value = match serde_json::from_str(schema) {
165 Ok(value) => value,
166 Err(_) => {
167 return Err("Is not a valid JSON string".to_string());
168 }
169 };
170 update_schema_owned(&mut self.schema, schema_value);
171
172 Ok(())
173 }
174
175 /// Merges the provided JSON value with the current schema.
176 ///
177 /// # Arguments
178 ///
179 /// * `schema` - The JSON Value to be merged with the current schema.
180 pub fn merge_schema_value(&mut self, schema: Value) {
181 update_schema_owned(&mut self.schema, schema);
182 }
183
184 /// Constructs a new `Template` instance from a file path and MessagePack schema bytes.
185 ///
186 /// # Arguments
187 ///
188 /// * `file_path` - A reference to the path of the file containing the template content.
189 /// * `bytes` - A byte slice containing the MessagePack schema.
190 ///
191 /// # Returns
192 ///
193 /// A `Result` containing the new `Template` instance or an error message if:
194 /// - The template file cannot be read.
195 /// - The MessagePack data is invalid.
196 ///
197 /// # Example
198 ///
199 /// ```no_run
200 /// use neutralts::Template;
201 /// let bytes = vec![129, 164, 100, 97, 116, 97, 129, 163, 107, 101, 121, 165, 118, 97, 108, 117, 101];
202 /// let template = Template::from_file_msgpack("template.ntpl", &bytes).unwrap();
203 /// ```
204 pub fn from_file_msgpack(file_path: &'a str, bytes: &[u8]) -> Result<Self, String> {
205 let schema: Value = if bytes.is_empty() {
206 json!({})
207 } else {
208 match rmp_serde::from_slice(bytes) {
209 Ok(v) => v,
210 Err(e) => return Err(format!("Invalid MessagePack data: {}", e)),
211 }
212 };
213
214 Self::from_file_value(file_path, schema)
215 }
216
217 /// Merges the schema from a MessagePack file with the current template schema.
218 ///
219 /// # Arguments
220 ///
221 /// * `msgpack_path` - A reference to the path of the file containing the MessagePack schema.
222 ///
223 /// # Returns
224 ///
225 /// A `Result` indicating success or an error message if:
226 /// - The file cannot be read.
227 /// - The file's content is not a valid MessagePack.
228 ///
229 /// # Example
230 ///
231 /// ```no_run
232 /// use neutralts::Template;
233 /// let mut template = Template::new().unwrap();
234 /// template.merge_schema_msgpack_path("extra_data.msgpack").unwrap();
235 /// ```
236 pub fn merge_schema_msgpack_path(&mut self, msgpack_path: &str) -> Result<(), String> {
237 let msgpack_data = match fs::read(msgpack_path) {
238 Ok(data) => data,
239 Err(e) => {
240 eprintln!("Cannot be read: {}", msgpack_path);
241 return Err(e.to_string());
242 }
243 };
244
245 self.merge_schema_msgpack(&msgpack_data)
246 }
247
248 /// Merges the schema from MessagePack bytes with the current template schema.
249 ///
250 /// # Arguments
251 ///
252 /// * `bytes` - A byte slice containing the MessagePack schema.
253 ///
254 /// # Returns
255 ///
256 /// A `Result` indicating success or an error message if:
257 /// - The bytes are not a valid MessagePack.
258 ///
259 /// # Example
260 ///
261 /// ```
262 /// use neutralts::Template;
263 /// let mut template = Template::new().unwrap();
264 /// let bytes = vec![129, 164, 100, 97, 116, 97, 129, 163, 107, 101, 121, 165, 118, 97, 108, 117, 101];
265 /// template.merge_schema_msgpack(&bytes).unwrap();
266 /// ```
267 pub fn merge_schema_msgpack(&mut self, bytes: &[u8]) -> Result<(), String> {
268 let schema_value: Value = match rmp_serde::from_slice(bytes) {
269 Ok(value) => value,
270 Err(e) => {
271 return Err(format!("Is not a valid MessagePack data: {}", e));
272 }
273 };
274 update_schema_owned(&mut self.schema, schema_value);
275
276 Ok(())
277 }
278
279 /// Renders the template content.
280 ///
281 /// This function initializes the rendering process.
282 /// The resulting output is returned as a string.
283 ///
284 /// # Returns
285 ///
286 /// The rendered template content as a string.
287 pub fn render(&mut self) -> String {
288 // Fast path: when there are no blocks, skip full render initialization.
289 // This avoids cloning large schemas for templates with plain text/empty source.
290 self.time_start = Instant::now();
291 if !self.raw.contains(BIF_OPEN) {
292 self.out = self.raw.trim().to_string();
293 self.time_elapsed = self.time_start.elapsed();
294 return self.out.clone();
295 }
296
297 let inherit = self.init_render();
298 self.out = BlockParser::new(&mut self.shared, inherit.clone()).parse(&self.raw, "");
299
300 while self.out.contains("{:!cache;") {
301 let out;
302 out = BlockParser::new(&mut self.shared, inherit.clone()).parse(&self.out, "!cache");
303 self.out = out;
304 }
305
306 self.ends_render();
307
308 self.out.clone()
309 }
310
311 /// Renders the template content without cloning the schema.
312 ///
313 /// This is an optimized version of `render()` that takes ownership of the schema
314 /// instead of cloning it. Use this when you only need to render once per template
315 /// instance, which is the most common use case in web applications.
316 ///
317 /// # When to Use
318 ///
319 /// - **Single render per request**: Most web applications create a template, render it once,
320 /// and discard it. This is the ideal use case for `render_once()`.
321 /// - **Large schemas**: When your schema contains thousands of keys, the performance
322 /// improvement can be 5-10x faster than `render()`.
323 /// - **Memory-constrained environments**: Avoids the memory spike of cloning large schemas.
324 ///
325 /// # When NOT to Use
326 ///
327 /// - **Multiple renders**: If you need to render the same template multiple times with
328 /// the same schema, use `render()` instead.
329 /// - **Template reuse**: After `render_once()`, the template cannot be reused because
330 /// the schema is consumed.
331 ///
332 /// # Performance
333 ///
334 /// Benchmarks show significant improvements for large schemas:
335 /// - 100 keys: ~3.7x faster
336 /// - 500 keys: ~7x faster
337 /// - 1000+ keys: ~10x faster
338 ///
339 /// # Post-Call Behavior
340 ///
341 /// After calling this method, the template's schema will be empty (`{}`) and subsequent
342 /// calls to `render()` or `render_once()` will produce empty output for schema variables.
343 /// The template struct itself remains valid but should be discarded after use.
344 ///
345 /// # Example
346 ///
347 /// ```rust
348 /// use neutralts::Template;
349 ///
350 /// let schema = serde_json::json!({
351 /// "data": {
352 /// "title": "Hello World"
353 /// }
354 /// });
355 ///
356 /// let mut template = Template::new().unwrap();
357 /// template.merge_schema_value(schema);
358 /// template.set_src_str("{:;title:}");
359 ///
360 /// // Single render - use render_once() for best performance
361 /// let output = template.render_once();
362 /// assert!(output.contains("Hello World"));
363 ///
364 /// // Template should NOT be reused after render_once()
365 /// // Create a new Template instance for the next render
366 /// ```
367 ///
368 /// # Returns
369 ///
370 /// The rendered template content as a string.
371 pub fn render_once(&mut self) -> String {
372 // Fast path: when there are no blocks, skip full render initialization.
373 self.time_start = Instant::now();
374 if !self.raw.contains(BIF_OPEN) {
375 self.out = self.raw.trim().to_string();
376 self.time_elapsed = self.time_start.elapsed();
377 return self.out.clone();
378 }
379
380 let inherit = self.init_render_once();
381 self.out = BlockParser::new(&mut self.shared, inherit.clone()).parse(&self.raw, "");
382
383 while self.out.contains("{:!cache;") {
384 let out;
385 out = BlockParser::new(&mut self.shared, inherit.clone()).parse(&self.out, "!cache");
386 self.out = out;
387 }
388
389 self.ends_render();
390
391 self.out.clone()
392 }
393
394 // Restore vars for render (clones schema for reusability)
395 fn init_render(&mut self) -> BlockInherit {
396 self.time_start = Instant::now();
397 self.shared = Shared::new(self.schema.clone());
398
399 if self.shared.comments.contains("remove") {
400 self.raw = remove_comments(&self.raw);
401 }
402
403 // init inherit
404 let mut inherit = BlockInherit::new();
405 let indir = inherit.create_block_schema(&mut self.shared);
406 self.shared.schema["__moveto"] = json!({});
407 self.shared.schema["__error"] = json!([]);
408 self.shared.indir_store.clear();
409 self.shared.indir_store.insert(indir, Rc::new(self.shared.schema["inherit"].clone()));
410 inherit.current_file = self.file_path.to_string();
411
412 // Escape CONTEXT values
413 filter_value(&mut self.shared.schema["data"]["CONTEXT"]);
414
415 // Escape CONTEXT keys names
416 filter_value_keys(&mut self.shared.schema["data"]["CONTEXT"]);
417
418 if !self.file_path.is_empty() {
419 let path = Path::new(&self.file_path);
420
421 if let Some(parent) = path.parent() {
422 inherit.current_dir = parent.display().to_string();
423 }
424 } else {
425 inherit.current_dir = self.shared.working_dir.clone();
426 }
427
428 if !self.shared.debug_file.is_empty() {
429 eprintln!("WARNING: config->debug_file is not empty: {} (Remember to remove this in production)", self.shared.debug_file);
430 }
431
432 inherit
433 }
434
435 // Restore vars for render_once (takes ownership of schema, no clone)
436 fn init_render_once(&mut self) -> BlockInherit {
437 self.time_start = Instant::now();
438 // Take ownership of schema instead of cloning - leaves empty object in place
439 let schema = std::mem::take(&mut self.schema);
440 self.shared = Shared::new(schema);
441
442 if self.shared.comments.contains("remove") {
443 self.raw = remove_comments(&self.raw);
444 }
445
446 // init inherit
447 let mut inherit = BlockInherit::new();
448 let indir = inherit.create_block_schema(&mut self.shared);
449 self.shared.schema["__moveto"] = json!({});
450 self.shared.schema["__error"] = json!([]);
451 self.shared.indir_store.clear();
452 self.shared.indir_store.insert(indir, Rc::new(self.shared.schema["inherit"].clone()));
453 inherit.current_file = self.file_path.to_string();
454
455 // Escape CONTEXT values
456 filter_value(&mut self.shared.schema["data"]["CONTEXT"]);
457
458 // Escape CONTEXT keys names
459 filter_value_keys(&mut self.shared.schema["data"]["CONTEXT"]);
460
461 if !self.file_path.is_empty() {
462 let path = Path::new(&self.file_path);
463
464 if let Some(parent) = path.parent() {
465 inherit.current_dir = parent.display().to_string();
466 }
467 } else {
468 inherit.current_dir = self.shared.working_dir.clone();
469 }
470
471 if !self.shared.debug_file.is_empty() {
472 eprintln!("WARNING: config->debug_file is not empty: {} (Remember to remove this in production)", self.shared.debug_file);
473 }
474
475 inherit
476 }
477
478 // Rendering ends
479 fn ends_render(&mut self) {
480 self.set_moveto();
481 self.replacements();
482 self.set_status_code();
483 self.time_elapsed = self.time_start.elapsed();
484 }
485
486 fn set_status_code(&mut self) {
487 let status_code = self.shared.status_code.as_str();
488
489 if ("400"..="599").contains(&status_code) {
490 self.out = format!("{} {}", self.shared.status_code, self.shared.status_text);
491
492 return;
493 }
494
495 if status_code == "301"
496 || status_code == "302"
497 || status_code == "303"
498 || status_code == "307"
499 || status_code == "308"
500 {
501 self.out = format!(
502 "{} {}\n{}",
503 self.shared.status_code, self.shared.status_text, self.shared.status_param
504 );
505
506 return;
507 }
508
509 if !self.shared.redirect_js.is_empty() {
510 self.out = self.shared.redirect_js.clone();
511 }
512 }
513
514 fn set_moveto(&mut self) {
515 if let Value::Object(data_map) = &self.shared.schema["__moveto"] {
516 for (_key, value) in data_map {
517 if let Value::Object(inner_map) = value {
518 for (inner_key, inner_value) in inner_map {
519 let mut tag;
520
521 // although it should be "<tag" or "</tag" it also supports
522 // "tag", "/tag", "<tag>" and "</tag>
523 if !inner_key.starts_with("<") {
524 tag = format!("<{}", inner_key);
525 } else {
526 tag = inner_key.to_string();
527 }
528 if tag.ends_with(">") {
529 tag = tag[..tag.len() - 1].to_string();
530 }
531
532 // if it does not find it, it does nothing
533 let position = find_tag_position(&self.out, &tag);
534 if let Some(pos) = position {
535 let mut insert = inner_value.as_str().unwrap().to_string();
536 insert = insert.to_string();
537 self.out.insert_str(pos, &insert);
538 }
539 }
540 }
541 }
542 }
543 }
544
545 fn replacements(&mut self) {
546 if self.out.contains(BACKSPACE) {
547 lazy_static::lazy_static! {
548 static ref RE: Regex = Regex::new(&format!(r"\s*{}", BACKSPACE)).expect("Failed to create regex with constant pattern");
549 }
550 if let std::borrow::Cow::Owned(s) = RE.replace_all(&self.out, "") {
551 self.out = s;
552 }
553 }
554
555 // UNPRINTABLE should be substituted after BACKSPACE
556 if self.out.contains(UNPRINTABLE) {
557 self.out = self.out.replace(UNPRINTABLE, "");
558 }
559 }
560
561 /// Retrieves the status code.
562 ///
563 /// The status code is "200" unless "exit", "redirect" is used or the
564 /// template contains a syntax error, which will return a status code
565 /// of "500". Although the codes are numeric, a string is returned.
566 ///
567 /// # Returns
568 ///
569 /// A reference to the status code as a string.
570 pub fn get_status_code(&self) -> &String {
571 &self.shared.status_code
572 }
573
574 /// Retrieves the status text.
575 ///
576 /// It will correspond to the one set by the HTTP protocol.
577 ///
578 /// # Returns
579 ///
580 /// A reference to the status text as a string.
581 pub fn get_status_text(&self) -> &String {
582 &self.shared.status_text
583 }
584
585 /// Retrieves the status parameter.
586 ///
587 /// Some statuses such as 301 (redirect) may contain additional data, such
588 /// as the destination URL, and in similar cases “param” will contain
589 /// that value.
590 ///
591 /// # Returns
592 ///
593 /// A reference to the status parameter as a string.
594 pub fn get_status_param(&self) -> &String {
595 &self.shared.status_param
596 }
597
598 /// Checks if there is an error.
599 ///
600 /// If any error has occurred, in the parse or otherwise, it will return true.
601 ///
602 /// # Returns
603 ///
604 /// A boolean indicating whether there is an error.
605 pub fn has_error(&self) -> bool {
606 self.shared.has_error
607 }
608
609 /// Get bifs errors list
610 ///
611 /// # Returns
612 ///
613 /// * `Value`: A clone of the value with the list of errors in the bifs during rendering.
614 pub fn get_error(&self) -> Value {
615 self.shared.schema["__error"].clone()
616 }
617
618 /// Retrieves the time duration for template rendering.
619 ///
620 /// # Returns
621 ///
622 /// The time duration elapsed .
623 pub fn get_time_duration(&self) -> Duration {
624 let duration: std::time::Duration = self.time_elapsed;
625
626 duration
627 }
628}