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