File Coverage

blib/lib/Template/Sluz.pm
Criterion Covered Total %
statement 846 958 88.3
branch 345 456 75.6
condition 139 189 73.5
subroutine 57 63 90.4
pod 7 17 41.1
total 1394 1683 82.8


line stmt bran cond sub pod time code
1             # Copyright (C) 2025 Scott Baker
2             #
3             # This program is free software: you can redistribute it and/or modify
4             # it under the terms of the GNU General Public License as published by
5             # the Free Software Foundation, either version 3 of the License, or
6             # (at your option) any later version.
7             #
8             # This program is distributed in the hope that it will be useful,
9             # but WITHOUT ANY WARRANTY; without even the implied warranty of
10             # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11             # GNU General Public License for more details.
12             #
13             # You should have received a copy of the GNU General Public License
14             # along with this program. If not, see .
15              
16             package Template::Sluz;
17              
18 11     11   2651718 use strict;
  11         22  
  11         495  
19 11     11   50 use warnings;
  11         15  
  11         464  
20 10     10   132 use 5.016;
  10         24  
21              
22 10     10   40 use File::Basename qw(dirname basename);
  10         20  
  10         607  
23 10     10   3829 use autouse 'Carp' => qw(croak);
  10         7590  
  10         52  
24              
25 10     10   911 use constant SLUZ_INLINE => 'INLINE_TEMPLATE';
  10         18  
  10         47642  
26              
27             our $VERSION = 'v0.9.6';
28              
29             ################################################################################
30             # Built-in Sluz functions that can be used in templates
31             ################################################################################
32              
33             sub count {
34 3     3 0 7 my $v = shift;
35              
36 3 50       56 if (ref $v eq 'ARRAY') {
37 3         22 return scalar @$v;
38             }
39              
40 0 0       0 if (ref $v eq 'HASH') {
41 0         0 return scalar(keys %$v);
42             }
43              
44 0 0       0 if (defined $v) { return 1 }
  0         0  
45              
46 0         0 return 0;
47             }
48              
49             sub join {
50 4     4 0 5 my $arr = shift();
51 4   100     10 my $glue = shift() // ', ';
52              
53 4         14 return CORE::join($glue, @$arr);
54             }
55              
56             ################################################################################
57             ################################################################################
58              
59             sub new {
60 11     11 1 10929 my $class = shift;
61 11         28 my %args = @_;
62             my $self = {
63             version => $VERSION,
64             tpl_file => undef,
65             inc_tpl_file => undef,
66             debug => $args{debug} // 0,
67 11   50     204 auto_escape => $args{auto_escape} // 0,
      100        
68             tpl_vars => {},
69             parent_tpl => undef,
70             var_prefix => 'sluz_pfx',
71             perl_file => undef,
72             perl_file_dir => undef,
73             fetch_called => 0,
74             char_pos => -1,
75             open_delim => '{',
76             close_delim => '}',
77             _sub_cache => {},
78             __S => {}, # Cached prefixed var hash used by _peval
79             _convert_cache => {}, # Cached _convert_vars results (avoids re-running regex on repeated expressions)
80             _blocks_cache => {}, # Cached _get_blocks results (avoids re-tokenizing if payloads in loops)
81             _if_rules_cache => {}, # Cached parsed {if} rules (avoids re-parsing same if block in loops)
82             _verified_sub_cache => {}, # Cached subs that succeeded once — skip eval/SIG overhead
83             _mod_cache => {}, # Cached resolved modifier coderefs keyed by func name
84             };
85              
86 11         29 bless $self, $class;
87 11         40 $self->_precompute_tags();
88 11         34 return $self;
89             }
90              
91             sub assign {
92 244     244 1 2846 my $self = shift;
93              
94 244         321 my $pfx = $self->{var_prefix};
95              
96             # Accept either a hashref: assign($hash_ref)
97 244 100 100     624 if (@_ == 1 && ref $_[0] eq 'HASH') {
    100          
98 9         31 my $h = shift;
99 9         29 @{$self->{tpl_vars}}{keys %$h} = values %$h;
  9         39  
100 9         27 for my $k (keys %$h) {
101 24         62 $self->{__S}{"${pfx}_$k"} = $h->{$k};
102             }
103              
104             # Or a key-value list: assign(name => 'Scott', age => 42)
105             } elsif (@_ % 2 == 0) {
106 234         422 my %h = @_;
107 234         375 @{$self->{tpl_vars}}{keys %h} = values %h;
  234         383  
108 234         372 for my $k (keys %h) {
109 258         742 $self->{__S}{"${pfx}_$k"} = $h{$k};
110             }
111             } else {
112 1         6 $self->_error_out("Invalid assign. Must be a key/value or hash", 18956);
113             }
114             }
115              
116             sub fetch {
117 3     3 1 1679 my $self = shift;
118 3   50     12 my $tpl_file = shift // SLUZ_INLINE;
119 3         6 my $parent = shift;
120              
121 3 100       11 if (!$self->{perl_file}) {
122 1         5 $self->{perl_file} = $self->_get_perl_file;
123 1         120 $self->{perl_file_dir} = dirname($self->{perl_file});
124             }
125              
126 3         7 my $parent_tpl;
127 3 100       8 if (defined $parent) {
128 1         3 $parent_tpl = $parent;
129             } else {
130 2         5 $parent_tpl = $self->{parent_tpl};
131             }
132              
133 3 100       9 if ($parent_tpl) {
134 2         8 $self->assign('__CHILD_TPL', $tpl_file);
135 2         4 $tpl_file = $parent_tpl;
136             }
137              
138 3         12 my $str = $self->_get_tpl_content($tpl_file);
139 3         14 my @blocks = $self->_get_blocks($str);
140 3         13 my $html = $self->_process_blocks(\@blocks);
141              
142 3         8 $self->{fetch_called} = 1;
143 3         17 return $html;
144             }
145              
146             sub parse {
147 0     0 0 0 my $self = shift;
148 0         0 return $self->fetch(@_);
149             }
150              
151             sub display {
152 0     0 0 0 my $self = shift;
153 0         0 print $self->fetch(@_);
154             }
155              
156             # Parse a string instead of a file
157             sub parse_string {
158 220     220 1 142448 my $self = shift;
159 220   50     560 my $tpl_str = shift // '';
160 220         592 my @blocks = $self->_get_blocks($tpl_str);
161              
162 220         532 return $self->_process_blocks(\@blocks);
163             }
164              
165             # Getter/setter for parent TPL
166             sub parent_tpl {
167 2     2 0 2108 my $self = shift;
168              
169 2 50       9 if (@_) {
170 2         7 $self->{parent_tpl} = shift;
171             }
172              
173 2         5 return $self->{parent_tpl};
174             }
175              
176             sub set_delimiters {
177 1     1 1 4 my $self = shift;
178 1         1 my $open = shift;
179 1         2 my $close = shift;
180              
181 1 50 33     4 if (!defined $open || !defined $close) {
182 0         0 $self->_error_out("set_delimiters requires both open and close delimiter arguments", 51234);
183             }
184              
185 1 50 33     4 if (length($open) != 1 || length($close) != 1) {
186 0         0 $self->_error_out("Delimiters must be single characters", 51235);
187             }
188              
189 1 50       3 if ($open eq $close) {
190 0         0 $self->_error_out("Open and close delimiters must be different characters", 51236);
191             }
192              
193 1         1 $self->{open_delim} = $open;
194 1         2 $self->{close_delim} = $close;
195              
196 1         2 $self->_precompute_tags();
197              
198             # Clear all caches since results are delimiter-dependent
199 1         2 $self->{_blocks_cache} = {};
200 1         2 $self->{_if_rules_cache} = {};
201 1         1 $self->{_convert_cache} = {};
202 1         2 $self->{_sub_cache} = {};
203 1         1 $self->{_verified_sub_cache} = {};
204 1         2 $self->{_mod_cache} = {};
205              
206 1         2 return;
207             }
208              
209             # Dive down an array or hashref using our dotted syntax
210             sub array_dive {
211 80     80 0 99 my $self = shift;
212 80         103 my $needle = shift;
213 80         99 my $haystack = shift;
214              
215 80 50 33     220 if (!defined $needle || !defined $haystack) { return undef }
  0         0  
216              
217             # Quick path: needle is a direct key in the hash
218 80 100       174 if (exists $haystack->{$needle}) {
219 50         122 return $haystack->{$needle};
220             }
221              
222             # Walk dotted path (e.g. "user.address.city") through nested structures
223 30         73 my @parts = split /\./, $needle;
224 30         72 my $arr = $haystack;
225              
226 30         64 for my $elem (@parts) {
227 53 50       80 if (!defined $arr) { return undef }
  0         0  
228 53 100       159 if (ref $arr eq 'ARRAY') {
    50          
229 10 50 33     44 if (!($elem =~ /^\d+$/ && $elem < @$arr)) { return undef }
  0         0  
230 10         16 $arr = $arr->[$elem];
231             } elsif (ref $arr eq 'HASH') {
232 43 100       105 if (!exists $arr->{$elem}) { return undef }
  9         29  
233 34         56 $arr = $arr->{$elem};
234             } else {
235 0         0 return undef;
236             }
237             }
238 21         48 return $arr;
239             }
240              
241             # HTML-escape a string for safe output. Encodes & < > " ' to entities.
242             # Usable as a modifier: {$var|escape} or as a callable: {escape($var)}
243             sub escape {
244 32   50 32 1 64 my $str = shift // '';
245              
246 32 100       54 if (ref $str eq 'ARRAY') { return 'ARRAY' }
  1         4  
247 31 50       49 if (ref $str eq 'HASH') { return 'HASH' }
  0         0  
248              
249 31         49 $str =~ s/&/&/g;
250 31         75 $str =~ s/
251 31         61 $str =~ s/>/>/g;
252 31         48 $str =~ s/"/"/g;
253 31         36 $str =~ s/'/'/g;
254              
255 31         72 return $str;
256             }
257              
258             # Bypass auto-escaping when auto_escape is on: {$var|noescape}
259             sub noescape {
260 7     7 1 12 return shift;
261             }
262              
263             # Apply auto-escaping if enabled, otherwise return value unchanged.
264             # Ref types (ARRAY/HASH) pass through unescaped.
265             sub _esc {
266 0     0   0 my ($self, $val) = @_;
267              
268 0 0       0 if (!$self->{auto_escape}) { return $val }
  0         0  
269 0 0       0 if (ref $val) { return $val }
  0         0  
270              
271 0         0 return escape($val);
272             }
273              
274             sub ltrim_one {
275 129     129 0 150 my $self = shift;
276 129   50     227 my $str = shift // '';
277 129         143 my $char = shift;
278              
279 129 100 100     363 if (length $str && substr($str, 0, 1) eq $char) {
280 15         39 return substr($str, 1);
281             }
282              
283 114         230 return $str;
284             }
285              
286             sub find_ending_tag {
287 23     23 0 28 my $self = shift;
288 23   50     99 my $haystack = shift // '';
289 23         45 my $open_tag = shift;
290 23         26 my $close_tag = shift;
291              
292             # Find the first close tag; if there's only one open tag before it, we're done
293 23         35 my $pos = index($haystack, $close_tag);
294 23 50       37 if ($pos < 0) { return undef }
  0         0  
295              
296 23         45 my $substr = substr($haystack, 0, $pos);
297 23         130 my $open_count = () = $substr =~ /\Q$open_tag\E/g;
298 23 100       49 if ($open_count == 1) { return $pos }
  18         34  
299              
300             # Nested tags: scan forward through subsequent close tags until
301             # open/close counts balance (max 5 nesting levels)
302 5         7 my $close_len = length $close_tag;
303 5         7 my $offset = $pos + $close_len;
304              
305 5         11 for (0 .. 4) {
306 13         14 $pos = index($haystack, $close_tag, $offset);
307 13 50       19 if ($pos < 0) { return undef }
  0         0  
308              
309 13         14 $substr = substr($haystack, 0, $pos + 2);
310 13         52 $open_count = () = $substr =~ /\Q$open_tag\E/g;
311 13         74 my $close_count = () = $substr =~ /\Q$close_tag\E/g;
312 13 100       23 if ($open_count == $close_count) { return $pos }
  5         13  
313              
314 8         9 $offset = $pos + $close_len;
315             }
316              
317 0         0 return undef;
318             }
319              
320             sub get_tokens {
321 24     24 0 28 my $self = shift;
322 24   50     73 my $str = shift // '';
323 24         37 my $o = quotemeta($self->{open_delim});
324 24         33 my $c = quotemeta($self->{close_delim});
325 24         313 my @tokens = split /($o[^$c]+$c)/, $str;
326 24 50       55 @tokens = grep { defined && length } @tokens;
  196         455  
327 24         100 return @tokens;
328             }
329              
330             sub is_if_token {
331 195     195 0 209 my $self = shift;
332 195   50     291 my $str = shift // '';
333 195 100       291 if ($str eq $self->{_tag_else}) { return 1 }
  42         69  
334 153 100       221 if ($str eq $self->{_tag_if_close}) { return 1 }
  30         45  
335 123 100 100     328 if (index($str, $self->{_tag_if}) == 0 || index($str, $self->{_tag_elseif}) == 0) {
336 70         102 my $inner = substr($str, length($self->{open_delim}));
337 70         201 $inner =~ s/^\S+\s+//; # strip 'if ' or 'elseif '
338 70         292 $inner =~ s/\Q$self->{close_delim}\E$//;
339 70         146 return $inner;
340             }
341 53         108 return '';
342             }
343              
344             # -------------------------------------------------------------------
345             # Private methods
346             # -------------------------------------------------------------------
347              
348             sub _precompute_tags {
349 12     12   22 my $self = shift;
350 12         45 my $o = $self->{open_delim};
351 12         23 my $c = $self->{close_delim};
352              
353             # Tag strings
354 12         36 $self->{_tag_if} = "${o}if ";
355 12         27 $self->{_tag_if_close} = "${o}/if${c}";
356 12         30 $self->{_tag_else} = "${o}else${c}";
357 12         29 $self->{_tag_elseif} = "${o}elseif ";
358 12         24 $self->{_tag_foreach} = "${o}foreach ";
359 12         21 $self->{_tag_foreach_close} = "${o}/foreach${c}";
360 12         37 $self->{_tag_include} = "${o}include ";
361 12         27 $self->{_tag_literal} = "${o}literal${c}";
362 12         30 $self->{_tag_literal_close} = "${o}/literal${c}";
363 12         24 $self->{_tag_comment_open} = "${o}*";
364 12         23 $self->{_tag_comment_close} = "*${c}";
365              
366             # Precomputed tag lengths (avoids repeated length() calls in hot loops)
367 12         33 $self->{_tag_if_len} = length($self->{_tag_if});
368 12         21 $self->{_tag_if_close_len} = length($self->{_tag_if_close});
369 12         28 $self->{_tag_foreach_len} = length($self->{_tag_foreach});
370 12         23 $self->{_tag_include_len} = length($self->{_tag_include});
371 12         25 $self->{_tag_literal_len} = length($self->{_tag_literal});
372 12         20 $self->{_tag_literal_close_len} = length($self->{_tag_literal_close});
373              
374             # Precomputed quotemeta values (avoids per-call quotemeta in _get_blocks)
375 12         51 $self->{_od_qr} = quotemeta($o);
376 12         29 $self->{_cd_qr} = quotemeta($c);
377              
378             # Precompiled space-guard regex (avoids per-open-delimiter regex compile)
379 12         30 my $od_qr = $self->{_od_qr};
380 12         21 my $cd_qr = $self->{_cd_qr};
381 12         325 $self->{_space_guard_re} = qr/\s[$od_qr$cd_qr]\s/;
382              
383             # Precompiled variable regex: {$var} or {$var.dot.path}
384 12         1180 $self->{_re_var_simple} = qr/^\Q$o\E\$(\w[\w.]*)\Q$c\E$/;
385 12         862 $self->{_re_var_full} = qr/^\Q$o\E\$([\w|.'";\t :,!@#%^&*?_\/\\\-]+)\Q$c\E$/;
386              
387             # Precompiled foreach regex
388 12         816 $self->{_re_foreach} = qr/^\Q$o\Eforeach (\$\w[\w.]*) as \$(\w+)(?: => \$(\w+))?\Q$c\E(.+)\Q$o\E\/foreach\Q$c\E$/s;
389              
390             # Precompiled literal regex
391 12         171 $self->{_re_literal} = qr/^\Q$o\Eliteral\Q$c\E(.+)\Q$o\E\/literal\Q$c\E$/s;
392              
393             # Precompiled expression catch-all regex
394 12         115 $self->{_re_expr} = qr/^\Q$o\E(.+)\Q$c\E$/s;
395              
396             # Precompiled simple if regex (no else/elseif)
397 12         142 $self->{_re_if_simple} = qr/\Q$o\Eif (.+?)\Q$c\E(.+)\Q$o\E\/if\Q$c\E/s;
398              
399             # Precomputed ord values for fast single-character delimiter checks
400 12         28 $self->{_od_ord} = ord($o);
401 12         19 $self->{_cd_ord} = ord($c);
402 12         42 $self->{_dollar_ord} = ord('$');
403              
404             # Precompiled modifier-split regexes (avoids per-modifier-call recompile)
405 12         30 $self->{_pipe_re} = qr/\|(?![^"]*"(?:(?:[^"]*"){2})*[^"]*$)(?![^']*'(?:(?:[^']*'){2})*[^']*$)/;
406 12         26 $self->{_comma_re} = qr/,(?=(?:[^"]*"[^"]*")*[^"]*$)(?=(?:[^']*'[^']*')*[^']*$)/;
407              
408 12         33 return;
409             }
410              
411             sub _get_perl_file {
412 1     1   3 my $self = shift;
413 1         3 my $i = 0;
414 1         2 my $file;
415              
416 1         5 while (caller($i)) {
417 3         14 $file = (caller($i))[1];
418 3         11 $i++;
419             }
420              
421 1   50     5 return $file || __FILE__;
422             }
423              
424             sub _get_tpl_content {
425 4     4   7 my $self = shift;
426 4   50     14 my $tpl_file = shift // '';
427 4         9 $self->{tpl_file} = $tpl_file;
428 4         8 my $tf = $tpl_file;
429              
430 4 50       92 if ($self->{perl_file_dir}) {
431 4         19 $tf = $self->{perl_file_dir} . "/$tf";
432             }
433              
434 4 50       14 if (!length($tpl_file)) {
435 0         0 $self->_error_out("Template file name is empty", 86801);
436             }
437              
438 4 50       10 if ($tpl_file eq SLUZ_INLINE) {
439 0         0 my ($c, $line_offset) = $self->_get_inline_content($self->{perl_file});
440 0 0       0 if (defined $c) {
441 0         0 $self->{tpl_file_display} = $self->{perl_file};
442 0         0 $self->{tpl_line_offset} = $line_offset;
443 0         0 return $c;
444             }
445 0         0 delete $self->{tpl_file_display};
446 0         0 delete $self->{tpl_line_offset};
447 0         0 return '';
448             } else {
449 4         9 delete $self->{tpl_file_display};
450 4         6 delete $self->{tpl_line_offset};
451             }
452              
453 4 50 33     158 if ($tf && !-r $tf) {
454 0         0 $self->_error_out("Unable to load template file $tf", 42280);
455             }
456              
457 4 50       15 if ($tf) {
458 4         23 local $/;
459 4 50       191 open my $fh, '<', $tf or $self->_error_out("Cannot open $tf: $!", 13983);
460 4         231 my $str = <$fh>;
461 4         53 close $fh;
462 4   50     49 return $str // '';
463             }
464              
465 0         0 return '';
466             }
467              
468             sub _get_inline_content {
469 0     0   0 my $self = shift;
470 0         0 my $file = shift;
471 0         0 local $/;
472 0 0       0 open my $fh, '<', $file or return undef;
473 0         0 my $str = <$fh>;
474 0         0 close $fh;
475 0         0 my $idx = index($str, '__DATA__');
476 0 0       0 if ($idx < 0) { return undef }
  0         0  
477 0         0 my $before = substr($str, 0, $idx + 9);
478 0         0 my $line_offset = $before =~ tr/\n//;
479 0         0 return (substr($str, $idx + 9), $line_offset);
480             }
481              
482             # -------------------------------------------------------------------
483             # Tokenizer
484             # -------------------------------------------------------------------
485              
486             sub _get_blocks {
487 331     331   14900 my $self = shift;
488 331   50     578 my $str = shift // '';
489              
490             # Check blocks cache — avoids re-tokenizing the same payload string
491             # (e.g. an {if} payload re-parsed on every iteration of a {foreach} loop)
492 331 100       867 if (exists $self->{_blocks_cache}{$str}) {
493 15         23 return @{$self->{_blocks_cache}{$str}};
  15         55  
494             }
495              
496 316         546 my $od = $self->{open_delim};
497 316         496 my $cd = $self->{close_delim};
498 316         481 my $tag_if = $self->{_tag_if};
499 316         473 my $tag_foreach = $self->{_tag_foreach};
500 316         434 my $tag_literal = $self->{_tag_literal};
501 316         430 my $slen = length $str;
502 316         362 my $start = 0;
503 316         410 my $i;
504             my @blocks;
505              
506 316         506 my $z = index($str, $od);
507 316 100       542 if ($z < 0) { $z = $slen }
  44         65  
508              
509 316         644 for ($i = $z; $i < $slen; $i++) {
510 851         1259 my $char = substr($str, $i, 1);
511 851         1059 my $is_open = $char eq $od;
512 851         988 my $is_closed = $char eq $cd;
513              
514 851 100 100     1901 if (!$is_open && !$is_closed) {
515 293         418 my $next_open = index($str, $od, $i);
516 293 100       479 if ($next_open < 0) { $next_open = $slen }
  162         189  
517 293         390 my $next_close = index($str, $cd, $i);
518 293 100       432 if ($next_close < 0) { $next_close = $slen }
  17         28  
519 293 100       451 if ($next_open < $next_close) {
520 7         9 $i = $next_open - 1;
521             } else {
522 286         345 $i = $next_close - 1;
523             }
524 293         668 next;
525             }
526              
527 558         689 my $has_len = $start != $i;
528 558         638 my $is_comment = 0;
529              
530 558 100       920 if ($is_open) {
531 288         333 my $prev_c;
532 288 100       513 if ($i > 0) {
533 34         57 $prev_c = substr($str, $i - 1, 1);
534             } else {
535 254         326 $prev_c = ' ';
536             }
537 288         307 my $next_c;
538 288 50       494 if ($i + 1 < $slen) {
539 288         486 $next_c = substr($str, $i + 1, 1);
540             } else {
541 0         0 $next_c = ' ';
542             }
543 288         438 my $chk = $prev_c . $char . $next_c;
544 288 100       1940 if ($chk =~ $self->{_space_guard_re}) { $is_open = 0 }
  2         3  
545 288 100       624 if ($next_c eq '*') { $is_comment = 1 }
  23         29  
546             }
547              
548 558 100 100     1608 if ($is_open && $has_len) {
    100          
549 30         76 push @blocks, [substr($str, $start, $i - $start), $i];
550 30         40 $start = $i;
551             } elsif ($is_closed) {
552 270         366 my $len = $i - $start + 1;
553 270         424 my $block = substr($str, $start, $len);
554              
555 270         312 my $matched_block;
556 270 100       815 if (index($block, $tag_if) == 0) { $matched_block = 'if' }
  58 100       70  
    100          
557 39         53 elsif (index($block, $tag_foreach) == 0) { $matched_block = 'foreach' }
558 7         7 elsif (index($block, $tag_literal) == 0) { $matched_block = 'literal' }
559              
560 270 100       468 if ($matched_block) {
561 104         147 my $close_tag = "${od}/${matched_block}${cd}";
562 104         211 for (my $j = $i + 1; $j < length $str; $j++) {
563 2077 100       3531 if (substr($str, $j, 1) eq $cd) {
564 230         351 my $tmp = substr($str, $start, $j - $start + 1);
565 230         1114 my $oc = () = $tmp =~ /\Q${od}${matched_block}\E/g;
566 230         662 my $cc = () = $tmp =~ /\Q${close_tag}\E/g;
567 230 100       457 if ($oc == $cc) {
568 103         195 $block = $tmp;
569 103         187 last;
570             }
571             }
572             }
573             }
574              
575 270 50       430 if (length $block) { push @blocks, [$block, $i] }
  270         584  
576 270         401 $start += length($block);
577 270         371 $i = $start;
578             }
579              
580 558 100       1319 if ($is_comment) {
581 23         77 my $end = $self->find_ending_tag(substr($str, $start), $self->{_tag_comment_open}, $self->{_tag_comment_close});
582 23 50       51 if (!defined $end) {
583 0         0 my ($line, $col, $file) = $self->_get_char_location($i, $self->{tpl_file});
584 0         0 $self->_error_out("Missing closing $self->{_tag_comment_close} for comment in $file on line #$line", 48724);
585             }
586 23         35 my $after = $start + $end + length($self->{_tag_comment_close});
587              
588 23   100     54 my $pre_nl = ($i == 0 || substr($str, $i - 1, 1) eq "\n");
589 23   100     68 my $post_nl = ($after >= $slen || substr($str, $after, 1) eq "\n");
590 23 100 100     80 if ($pre_nl && $post_nl && $after < $slen) {
      100        
591 7         8 $after++;
592             }
593              
594 23         26 $start = $after;
595 23         53 $i = $start - 1;
596             }
597             }
598              
599 316 100       546 if ($start < $slen) {
600 70         198 push @blocks, [substr($str, $start), $i];
601             }
602              
603             # Strip leading newline from text blocks that follow {if} or
604             # {foreach} blocks, to avoid double-newlines when the block
605             # payload already ends with \n. For {foreach}, only strip when
606             # the payload actually ends with \n — if the payload is inline
607             # (no trailing \n), the newline is structural content, not
608             # whitespace noise.
609 316         373 my $prev_is_if = 0;
610 316         492 my $tag_foreach_close = $self->{_tag_foreach_close};
611 316         799 for my $i (0 .. $#blocks) {
612 370   50     746 my $bstr = $blocks[$i][0] // '';
613 370   100     1029 my $cur_is_if = (index($bstr, $tag_if) == 0 || index($bstr, $tag_foreach) == 0);
614 370 100       606 if ($prev_is_if) {
615 4         9 my $should_strip = 1;
616 4 100       109 if ($blocks[$i-1][0] =~ /^\Q$tag_foreach\E.+?\}(.*)\Q$tag_foreach_close\E$/s) {
617 2 100       11 $should_strip = (substr($1, -1) eq "\n") ? 1 : 0;
618             }
619 4 100       14 if ($should_strip) {
620 3         10 $blocks[$i][0] = $self->ltrim_one($bstr, "\n");
621             }
622             }
623 370         658 $prev_is_if = $cur_is_if;
624             }
625              
626 316         868 $self->{_blocks_cache}{$str} = \@blocks;
627 316         871 return @blocks;
628             }
629              
630             sub _process_blocks {
631 289     289   375 my $self = shift;
632 289         321 my $blocks = shift;
633 289         359 my $out = shift; # Optional: ref to append output to (avoids temp string + concat)
634              
635 289         429 my $od_ord = $self->{_od_ord};
636 289         396 my $od = $self->{open_delim};
637 289         407 my $var_tag = "${od}\$";
638 289         382 my $var_re = $self->{_re_var_simple};
639              
640 289 100       464 if ($out) {
641 57         93 for my $x (@$blocks) {
642 62         137 my $block = $x->[0];
643 62 50       117 next unless length $block;
644 62 100       110 if (ord($block) != $od_ord) {
645 42         65 $$out .= $block;
646 42         71 next;
647             }
648             # Fast path: {$var} or {$var.dot} with no modifier — inline
649             # variable resolution, skip _process_block AND _variable_block
650 20 100 100     148 if (substr($block, 0, 2) eq $var_tag && index($block, '|') < 0
      66        
651             && $block =~ $var_re) {
652 13         27 my $var = $1;
653 13         17 my $val;
654 13 100       26 if (index($var, '.') < 0) {
655 9         23 $val = $self->{tpl_vars}{$var};
656             } else {
657 4         38 $val = $self->array_dive($var, $self->{tpl_vars});
658             }
659 13 50       53 if (ref $val eq 'ARRAY') { $$out .= 'ARRAY' }
  0 50       0  
    50          
660 0         0 elsif (ref $val eq 'HASH') { $$out .= 'HASH' }
661 13 100       38 elsif (defined $val) { $$out .= ($self->{auto_escape} ? escape($val) : $val) }
662 13         21 next;
663             }
664             # If block fast path — skip _process_block dispatch (mirrors
665             # the $html branch so the $out path is equally fast).
666 7 100 66     41 if (substr($block, 0, $self->{_tag_if_len}) eq $self->{_tag_if}
667             && substr($block, -$self->{_tag_if_close_len}) eq $self->{_tag_if_close}) {
668 6         13 $self->{char_pos} = $x->[1];
669 6         34 $$out .= $self->_if_block($block);
670 6         10 next;
671             }
672 1         4 $$out .= $self->_process_block($block, $x->[1]);
673             }
674 57         96 return;
675             }
676              
677 232         311 my $html = '';
678 232         422 for my $x (@$blocks) {
679 272         368 my $block = $x->[0];
680 272 50       470 next unless length $block;
681 272 100       470 if (ord($block) != $od_ord) {
682 52         76 $html .= $block;
683 52         80 next;
684             }
685             # Fast path: {$var} or {$var.dot} with no modifier
686 220 100 100     1110 if (substr($block, 0, 2) eq $var_tag && index($block, '|') < 0
      100        
687             && $block =~ $var_re) {
688 49         123 my $var = $1;
689 49         58 my $val;
690 49 100       85 if (index($var, '.') < 0) {
691 45         100 $val = $self->{tpl_vars}{$var};
692             } else {
693 4         18 $val = $self->array_dive($var, $self->{tpl_vars});
694             }
695 49 100       148 if (ref $val eq 'ARRAY') { $html .= 'ARRAY' }
  4 50       7  
    100          
696 0         0 elsif (ref $val eq 'HASH') { $html .= 'HASH' }
697 38 100       87 elsif (defined $val) { $html .= ($self->{auto_escape} ? escape($val) : $val) }
698 49         94 next;
699             }
700             # If block fast path — skip _process_block dispatch
701 171 100 100     566 if (substr($block, 0, $self->{_tag_if_len}) eq $self->{_tag_if}
702             && substr($block, -$self->{_tag_if_close_len}) eq $self->{_tag_if_close}) {
703 44         66 $self->{char_pos} = $x->[1];
704 44         120 $html .= $self->_if_block($block);
705 44         75 next;
706             }
707 127         300 $html .= $self->_process_block($block, $x->[1]);
708             }
709              
710 222         825 return $html;
711             }
712              
713             sub _process_block {
714 140     140   187 my $self = shift;
715 140   50     247 my $str = shift // '';
716 140   50     230 my $char_pos = shift // -1;
717              
718 140         219 $self->{char_pos} = $char_pos;
719              
720 140         215 my $od = $self->{open_delim};
721 140         245 my $cd_ord = $self->{_cd_ord};
722              
723             # 1. Variable block {$foo} or {$foo|modifier}
724 140 100 100     758 if (substr($str, 0, 2) eq "${od}\$" && $str =~ $self->{_re_var_full}) {
725 60         155 return $self->_variable_block($1);
726             }
727              
728             # 2. If block {if ...}{/if}
729 80 50 66     237 if (substr($str, 0, $self->{_tag_if_len}) eq $self->{_tag_if}
730             && substr($str, -$self->{_tag_if_close_len}) eq $self->{_tag_if_close}) {
731 0         0 return $self->_if_block($str);
732             }
733              
734             # 3. Foreach block {foreach ...}{/foreach}
735 80 100 66     606 if (substr($str, 0, $self->{_tag_foreach_len}) eq $self->{_tag_foreach} && $str =~ $self->{_re_foreach}) {
736 39         214 return $self->_foreach_block($1, $2, $3, $4);
737             }
738              
739             # 4. Include block {include ...}
740 41 100       95 if (substr($str, 0, $self->{_tag_include_len}) eq $self->{_tag_include}) {
741 9         25 return $self->_include_block($str);
742             }
743              
744             # 5. Literal block {literal}...{/literal}
745 32 100 66     117 if (substr($str, 0, $self->{_tag_literal_len}) eq $self->{_tag_literal} && $str =~ $self->{_re_literal}) {
746 7         25 return $1;
747             }
748              
749             # 6. Expression / function block
750 25 100       174 if ($str =~ $self->{_re_expr}) {
751 21         75 return $self->_expression_block($str, $1);
752             }
753              
754             # 7. Unclosed tag
755 4 100       32 if (ord(substr($str, -1)) != $cd_ord) {
756 3         5 my $tag_start = $self->{char_pos} - length($str);
757 3         9 my ($line, $col, $file) = $self->_get_char_location($tag_start, $self->{tpl_file});
758 3         12 $self->_error_out("Unclosed tag $str in $file on line #$line", 45821);
759             }
760              
761             # 8. Fallthrough
762 1         4 return $str;
763             }
764              
765             # -------------------------------------------------------------------
766             # Block handlers
767             # -------------------------------------------------------------------
768              
769             sub _variable_block {
770 60     60   74 my $self = shift;
771 60         149 my $str = shift;
772              
773             # Fast path: no pipe means no modifier, just resolve the variable.
774             # Avoids running the pipe-split regex on every plain variable block.
775 60 50       117 if (index($str, '|') < 0) {
776 0         0 my $ret;
777             # Inline simple key lookup (no dots) — skips array_dive method call
778 0 0       0 if (index($str, '.') < 0) {
779 0         0 $ret = $self->{tpl_vars}{$str};
780             } else {
781 0         0 $ret = $self->array_dive($str, $self->{tpl_vars});
782             }
783 0 0       0 if (ref $ret eq 'ARRAY') { return 'ARRAY' }
  0         0  
784 0 0       0 if (ref $ret eq 'HASH') { return 'HASH' }
  0         0  
785 0 0       0 if (defined $ret) { return ($self->{auto_escape} ? escape($ret) : $ret) }
  0 0       0  
786 0         0 return '';
787             }
788              
789 60 50       255 if ($str =~ /(.+?)\|(.*)/) {
790 60         104 my $key = $1;
791 60         99 my $mod = $2;
792              
793 60         147 my $tmp = $self->array_dive($key, $self->{tpl_vars});
794 60   100     304 my $is_nothing = (!defined $tmp || (defined $tmp && ref $tmp eq '' && !length $tmp && $tmp ne '0'));
795 60         88 my $is_default = index($mod, 'default:') >= 0;
796              
797 60 100 100     233 if ($is_nothing && $is_default) {
    100 100        
798 6         12 my $dval = $mod;
799 6         28 $dval =~ s/^.*?default://;
800 6         23 my ($ret) = $self->_peval($dval);
801 6 50       11 if (defined $ret) { return $ret }
  6         71  
802 0         0 return '';
803             } elsif (!$is_nothing && $is_default) {
804 3   50     17 return $tmp // '';
805             } else {
806 51 100       77 if ($is_nothing) {
807 6         22 return '';
808             }
809 45         55 my $pre = $tmp;
810              
811 45         53 my $seen_escape = 0;
812 45         52 my $seen_noescape = 0;
813              
814             # Split on | not inside double or single quotes (supports chained
815             # modifiers like {$x|uc|substr:0,3}). Regex precompiled in
816             # _precompute_tags to avoid per-call recompile cost.
817 45         72 my $pipe_re = $self->{_pipe_re};
818 45         199 for my $m_part (split $pipe_re, $mod) {
819 49         93 my @x = split /:/, $m_part, 2;
820 49   50     94 my $func = $x[0] // '';
821 49 100       78 if ($func eq 'escape') { $seen_escape = 1 }
  19         25  
822 49 100       69 if ($func eq 'noescape') { $seen_noescape = 1 }
  7         8  
823 49   100     117 my $param_str = $x[1] // '';
824 49         113 my @params = ($pre);
825              
826 49 100       82 if (length $param_str) {
827             # Split on commas not inside double or single quotes
828             # (parameter separator in modifier calls like substr:2,2)
829             my @new = map {
830 16         30 my ($v) = $self->_peval($_);
831 16         33 $v;
832 15         49 } split $self->{_comma_re}, $param_str;
833 15         29 push @params, @new;
834             }
835              
836             # Resolve the modifier coderef once per func name and cache it.
837             # Priority: main::, current package (Template::Sluz built-ins),
838             # then CORE:: built-in operators.
839 49         77 my $cref = $self->{_mod_cache}{$func};
840 49 100       95 if (!defined $cref) {
841 10     10   90 no strict 'refs';
  10         19  
  10         47569  
842 12 100       15 if (defined &{"main::$func"}) { $cref = \&{"main::$func"} }
  12 100       69  
  2 50       3  
  2         5  
843 10         58 elsif (defined &{$func}) { $cref = \&{$func} }
  6         8  
  6         13  
844 4         71 elsif (defined &{"CORE::$func"}) { $cref = \&{"CORE::$func"} }
  4         6  
  4         11  
845 0         0 else { $cref = 0 }
846 12         29 $self->{_mod_cache}{$func} = $cref;
847             }
848              
849 49 50       81 if (!$cref) {
850 0         0 my ($line, $col, $file) = $self->_get_char_location($self->{char_pos}, $self->{tpl_file});
851 0         0 $self->_error_out("Unknown function call $func in $file on line #$line", 47204);
852             }
853              
854 49         64 $pre = eval { $cref->(@params) };
  49         117  
855 49 50       174 if ($@) {
856 0         0 $self->_error_out("Exception: $@", 79134);
857             }
858             }
859              
860 45 100 100     106 if ($self->{auto_escape} && !$seen_noescape && !$seen_escape) {
      100        
861 1         18 return escape($pre);
862             }
863 44         154 return $pre;
864             }
865             }
866              
867 0         0 my $ret = $self->array_dive($str, $self->{tpl_vars});
868 0 0       0 if (ref $ret eq 'ARRAY') { return 'ARRAY' }
  0         0  
869 0 0       0 if (ref $ret eq 'HASH') { return 'HASH' }
  0         0  
870 0 0       0 if (defined $ret) { return ($self->{auto_escape} ? escape($ret) : $ret) }
  0 0       0  
871 0         0 return '';
872             }
873              
874             sub _if_block {
875 68     68   80 my $self = shift;
876 68         77 my $str = shift;
877              
878 68         72 my @rules;
879 68 100       139 if (exists $self->{_if_rules_cache}{$str}) {
880 12         13 @rules = @{$self->{_if_rules_cache}{$str}};
  12         19  
881             } else {
882 56         112 my $od = $self->{open_delim};
883 56         65 my $cd = $self->{close_delim};
884 56         79 my $isimple_start = length($od) + 1;
885 56         66 my $else_check = $od . 'else';
886 56         101 my $is_simple = index($str, $else_check, $isimple_start) < 0;
887              
888 56 100       102 if ($is_simple) {
889 32         214 $str =~ $self->{_re_if_simple};
890 32   100     135 my $cond = $1 // '';
891 32   50     159 my $payload = $2 // '';
892 32         68 $payload = $self->ltrim_one($payload, "\n");
893 32         93 @rules = ([$cond, $payload]);
894             } else {
895 24         64 my @toks = $self->get_tokens($str);
896 24         73 @rules = $self->_if_rules_from_tokens(\@toks);
897             }
898              
899 56         143 $self->{_if_rules_cache}{$str} = \@rules;
900             }
901              
902 68         93 my $ret = '';
903 68         88 for my $rule (@rules) {
904 86         124 my $raw = $rule->[0];
905             # Inline _convert_vars for cached expressions — saves method call per iteration
906             my $test = (index($raw, '$') < 0) ? $raw :
907 86 100 66     254 ($self->{_convert_cache}{$raw} // $self->_convert_vars($raw));
908 86         164 my $payload = $rule->[1];
909 86         193 my ($res) = $self->_peval($test);
910 86 100       177 if ($res) {
911             # Inline _get_blocks for cached payloads. Pass \$ret to
912             # _process_blocks so it appends directly — avoids a temp
913             # string allocation + concat per if-payload render.
914 57         103 my $cached = $self->{_blocks_cache}{$payload};
915 57 100       129 my @in_blocks = $cached ? @$cached : $self->_get_blocks($payload);
916 57         188 $self->_process_blocks(\@in_blocks, \$ret);
917 57         98 last;
918             }
919             }
920              
921 68         145 return $ret;
922             }
923              
924             sub _foreach_block {
925 39     39   49 my $self = shift;
926 39         83 my $src_expr = shift;
927 39         60 my $okey = shift;
928 39         78 my $oval = shift;
929 39         88 my $payload = shift;
930              
931 39         130 my $conv_src = $self->_convert_vars($src_expr);
932 39         114 $payload = $self->ltrim_one($payload, "\n");
933 39         74 my @blocks = $self->_get_blocks($payload);
934              
935             # Pre-classify blocks for fast dispatch in the loop (cached in block arrays)
936             # type: -1=empty, 0=text, 1=simple_var, 2=if_block, 99=other
937 39         58 my $od = $self->{open_delim};
938 39         61 my $od_ord = $self->{_od_ord};
939 39         83 for my $b (@blocks) {
940 62 100       111 next if defined $b->[2];
941 56         62 my $bs = $b->[0];
942 56 50 100     409 if (!length $bs) {
    100 66        
    100 66        
    100          
943 0         0 $b->[2] = -1;
944             } elsif (ord($bs) != $od_ord) {
945 16         26 $b->[2] = 0;
946             } elsif (substr($bs, 0, 2) eq "${od}\$" && index($bs, '|') < 0
947             && $bs =~ $self->{_re_var_simple}) {
948 30         69 $b->[2] = 1;
949 30         73 $b->[3] = $1;
950             } elsif (substr($bs, 0, $self->{_tag_if_len}) eq $self->{_tag_if}
951             && substr($bs, -$self->{_tag_if_close_len}) eq $self->{_tag_if_close}) {
952 6         11 $b->[2] = 2;
953             } else {
954 4         13 $b->[2] = 99;
955             }
956             }
957              
958 39         86 my ($src) = $self->_peval($conv_src);
959              
960 39 100 100     146 if (!defined $src) {
    100          
961 2         4 $src = [];
962             } elsif (ref $src ne 'ARRAY' && ref $src ne 'HASH') {
963 1         3 $src = [$src];
964             }
965              
966 39         56 my $pfx = $self->{var_prefix};
967              
968             # Precompute __S keys for the loop variables
969 39         60 my $okey_ks = "${pfx}_$okey";
970 39 100       67 my $oval_ks = defined $oval ? "${pfx}_$oval" : undef;
971 39         50 my $first_ks = "${pfx}__FOREACH_FIRST";
972 39         49 my $last_ks = "${pfx}__FOREACH_LAST";
973 39         50 my $index_ks = "${pfx}__FOREACH_INDEX";
974              
975 39         51 my $ret = '';
976 39         38 my $idx = 0;
977              
978 39         61 my $need_first = index($payload, '__FOREACH_FIRST') >= 0;
979 39         49 my $need_last = index($payload, '__FOREACH_LAST') >= 0;
980 39         66 my $need_index = index($payload, '__FOREACH_INDEX') >= 0;
981              
982             # Save only the keys we'll modify — O(k) where k <= 5, vs O(n) for
983             # copying the entire tpl_vars/__S hashes. Big win for nested foreach.
984 39         72 my @tpl_keys = ($okey);
985 39         56 my @ks_keys = ($okey_ks);
986 39 100       62 if (defined $oval) {
987 9         10 push @tpl_keys, $oval;
988 9         10 push @ks_keys, $oval_ks;
989             }
990 39 100       69 push @tpl_keys, '__FOREACH_FIRST' if $need_first;
991 39 100       59 push @ks_keys, $first_ks if $need_first;
992 39 100       55 push @tpl_keys, '__FOREACH_LAST' if $need_last;
993 39 100       57 push @ks_keys, $last_ks if $need_last;
994 39 100       56 push @tpl_keys, '__FOREACH_INDEX' if $need_index;
995 39 100       58 push @ks_keys, $index_ks if $need_index;
996              
997 39         70 my @tpl_exists = map { exists $self->{tpl_vars}{$_} } @tpl_keys;
  52         118  
998 39         62 my @tpl_vals = map { $self->{tpl_vars}{$_} } @tpl_keys;
  52         90  
999 39         47 my @ks_exists = map { exists $self->{__S}{$_} } @ks_keys;
  52         94  
1000 39         51 my @ks_vals = map { $self->{__S}{$_} } @ks_keys;
  52         132  
1001              
1002 39 100       104 if (ref $src eq 'ARRAY') {
    50          
1003 35         52 my $last = $#$src;
1004 35         95 for my $i (0 .. $last) {
1005 88 100       137 if ($need_first) {
1006 6 100       14 $self->{tpl_vars}{__FOREACH_FIRST} = ($idx == 0) ? 1 : 0;
1007 6 100       11 $self->{__S}{$first_ks} = ($idx == 0) ? 1 : 0;
1008             }
1009 88 100       128 if ($need_last) {
1010 3 100       7 $self->{tpl_vars}{__FOREACH_LAST} = ($idx == $last) ? 1 : 0;
1011 3 100       10 $self->{__S}{$last_ks} = ($idx == $last) ? 1 : 0;
1012             }
1013 88 100       119 if ($need_index) {
1014 3         5 $self->{tpl_vars}{__FOREACH_INDEX} = $idx;
1015 3         4 $self->{__S}{$index_ks} = $idx;
1016             }
1017 88 100       144 if (defined $oval) {
1018 16         21 $self->{tpl_vars}{$okey} = $i;
1019 16         21 $self->{tpl_vars}{$oval} = $src->[$i];
1020 16         19 $self->{__S}{$okey_ks} = $i;
1021 16         24 $self->{__S}{$oval_ks} = $src->[$i];
1022             } else {
1023 72         155 $self->{tpl_vars}{$okey} = $src->[$i];
1024 72         124 $self->{__S}{$okey_ks} = $src->[$i];
1025             }
1026             # Inline block processing with pre-classified types — no substr/regex per iteration
1027 88         113 for my $b (@blocks) {
1028 132         146 my $type = $b->[2];
1029 132 100       210 if ($type == 0) {
    100          
    100          
    50          
1030 34         55 $ret .= $b->[0];
1031             } elsif ($type == 1) {
1032 68         87 my $var = $b->[3];
1033 68         73 my $val;
1034 68 100       81 if (index($var, '.') < 0) {
1035 56         74 $val = $self->{tpl_vars}{$var};
1036             } else {
1037 12         22 $val = $self->array_dive($var, $self->{tpl_vars});
1038             }
1039 68 50       131 if (ref $val eq 'ARRAY') { $ret .= 'ARRAY' }
  0 50       0  
    50          
1040 0         0 elsif (ref $val eq 'HASH') { $ret .= 'HASH' }
1041 68 100       120 elsif (defined $val) { $ret .= ($self->{auto_escape} ? escape($val) : $val) }
1042             } elsif ($type == 2) {
1043 18         21 $self->{char_pos} = $b->[1];
1044 18         31 $ret .= $self->_if_block($b->[0]);
1045             } elsif ($type == -1) {
1046 0         0 next;
1047             } else {
1048 12         34 $ret .= $self->_process_block($b->[0], $b->[1]);
1049             }
1050             }
1051 88         124 $idx++;
1052             }
1053             } elsif (ref $src eq 'HASH') {
1054 4         21 my @keys = sort keys %$src;
1055 4         7 my $last = $#keys;
1056 4         10 for my $i (0 .. $last) {
1057 11         13 my $k = $keys[$i];
1058 11 50       16 if ($need_first) {
1059 0 0       0 $self->{tpl_vars}{__FOREACH_FIRST} = ($idx == 0) ? 1 : 0;
1060 0 0       0 $self->{__S}{$first_ks} = ($idx == 0) ? 1 : 0;
1061             }
1062 11 50       18 if ($need_last) {
1063 0 0       0 $self->{tpl_vars}{__FOREACH_LAST} = ($idx == $last) ? 1 : 0;
1064 0 0       0 $self->{__S}{$last_ks} = ($idx == $last) ? 1 : 0;
1065             }
1066 11 50       14 if ($need_index) {
1067 0         0 $self->{tpl_vars}{__FOREACH_INDEX} = $idx;
1068 0         0 $self->{__S}{$index_ks} = $idx;
1069             }
1070 11 100       15 if (defined $oval) {
1071 6         9 $self->{tpl_vars}{$okey} = $k;
1072 6         9 $self->{tpl_vars}{$oval} = $src->{$k};
1073 6         8 $self->{__S}{$okey_ks} = $k;
1074 6         11 $self->{__S}{$oval_ks} = $src->{$k};
1075             } else {
1076 5         8 $self->{tpl_vars}{$okey} = $src->{$k};
1077 5         9 $self->{__S}{$okey_ks} = $src->{$k};
1078             }
1079             # Inline block processing with pre-classified types — no substr/regex per iteration
1080 11         13 for my $b (@blocks) {
1081 32         33 my $type = $b->[2];
1082 32 100       40 if ($type == 0) {
    50          
    0          
    0          
1083 15         44 $ret .= $b->[0];
1084             } elsif ($type == 1) {
1085 17         19 my $var = $b->[3];
1086 17         14 my $val;
1087 17 50       21 if (index($var, '.') < 0) {
1088 17         38 $val = $self->{tpl_vars}{$var};
1089             } else {
1090 0         0 $val = $self->array_dive($var, $self->{tpl_vars});
1091             }
1092 17 50       52 if (ref $val eq 'ARRAY') { $ret .= 'ARRAY' }
  0 50       0  
    50          
1093 0         0 elsif (ref $val eq 'HASH') { $ret .= 'HASH' }
1094 17 100       29 elsif (defined $val) { $ret .= ($self->{auto_escape} ? escape($val) : $val) }
1095             } elsif ($type == 2) {
1096 0         0 $self->{char_pos} = $b->[1];
1097 0         0 $ret .= $self->_if_block($b->[0]);
1098             } elsif ($type == -1) {
1099 0         0 next;
1100             } else {
1101 0         0 $ret .= $self->_process_block($b->[0], $b->[1]);
1102             }
1103             }
1104 11         17 $idx++;
1105             }
1106             }
1107              
1108             # Restore only the keys we modified
1109 39         75 for my $i (0 .. $#tpl_keys) {
1110 52 100       88 if ($tpl_exists[$i]) {
1111 23         48 $self->{tpl_vars}{$tpl_keys[$i]} = $tpl_vals[$i];
1112             } else {
1113 29         46 delete $self->{tpl_vars}{$tpl_keys[$i]};
1114             }
1115 52 100       81 if ($ks_exists[$i]) {
1116 23         43 $self->{__S}{$ks_keys[$i]} = $ks_vals[$i];
1117             } else {
1118 29         48 delete $self->{__S}{$ks_keys[$i]};
1119             }
1120             }
1121              
1122 39         223 return $ret;
1123             }
1124              
1125             sub _include_block {
1126 9     9   12 my $self = shift;
1127 9         11 my $str = shift;
1128              
1129 9         22 my $save = $self->{tpl_vars};
1130 9         22 my $inc_tpl = $self->_extract_include_file($str);
1131              
1132 9 50       23 if ($self->{perl_file_dir}) {
1133 9         21 $inc_tpl = $self->{perl_file_dir} . "/$inc_tpl";
1134             }
1135              
1136 9         80 while ($str =~ m/(\w+)=(['"](.+?)['"])/g) {
1137 9         36 my $key = $1;
1138 9         50 my $val = $2;
1139 9 100       17 if ($key eq 'file') { next }
  8         23  
1140 1         3 $val = $self->_convert_vars($val);
1141 1         3 my ($res) = $self->_peval($val);
1142 1 50       3 if (defined $res) {
1143 1         2 $self->assign($key => $res);
1144             } else {
1145 0         0 $self->assign($key => $val);
1146             }
1147             }
1148              
1149 9 50 33     312 if (!-f $inc_tpl || !-r $inc_tpl) {
1150 0         0 $self->{inc_tpl_file} = undef;
1151 0         0 my ($line, $col, $file) = $self->_get_char_location($self->{char_pos}, $self->{tpl_file});
1152 0         0 $self->_error_out("Unable to load include template $inc_tpl in $file on line #$line", 18485);
1153             }
1154              
1155 9         39 local $/;
1156 9 50       302 open my $fh, '<', $inc_tpl or $self->_error_out("Cannot open $inc_tpl: $!", 63579);
1157 9         289 my $content = <$fh>;
1158 9         103 close $fh;
1159              
1160 9         44 my @blocks = $self->_get_blocks($content);
1161 9         37 my $r = $self->_process_blocks(\@blocks);
1162              
1163 9         16 $self->{tpl_vars} = $save;
1164 9         21 $self->{inc_tpl_file} = undef;
1165              
1166 9         63 return $r;
1167             }
1168              
1169             sub _expression_block {
1170 21     21   29 my $self = shift;
1171 21         32 my $str = shift;
1172 21         50 my $inner = shift;
1173              
1174 21 100       76 if ($str !~ /["\d\$\(]/) {
1175 5         20 my ($line, $col, $file) = $self->_get_char_location($self->{char_pos}, $self->{tpl_file});
1176 5         30 $self->_error_out("Unknown block type $str in $file on line #$line", 73467);
1177             }
1178              
1179 16         40 my $after = $self->_convert_vars($inner);
1180 16         42 my ($ret, $err) = $self->_peval($after);
1181              
1182 16         64 my $valid;
1183 16 100 33     85 if (defined $ret && (!ref $ret || ref $ret eq '')) {
      66        
1184 14         23 $valid = 1;
1185             } else {
1186 2         3 $valid = 0;
1187             }
1188              
1189 16 100 100     57 if ($err || !$valid) {
1190 2         12 my ($line, $col, $file) = $self->_get_char_location($self->{char_pos}, $self->{tpl_file});
1191 2         10 $self->_error_out("Unknown tag $str in $file on line #$line", 18933);
1192             }
1193              
1194 14         71 return $ret;
1195             }
1196              
1197             # -------------------------------------------------------------------
1198             # Variable / eval engine
1199             # -------------------------------------------------------------------
1200              
1201             sub _convert_vars {
1202 97     97   120 my $self = shift;
1203 97   50     213 my $str = shift // '';
1204 97 100       210 if (index($str, '$') < 0) { return $str }
  13         27  
1205              
1206             # Check conversion cache — avoids re-running regex substitutions on
1207             # the same expression (e.g. an {if} condition inside a {foreach} loop)
1208 84 100       208 if (exists $self->{_convert_cache}{$str}) {
1209 21         61 return $self->{_convert_cache}{$str};
1210             }
1211              
1212 63         84 my $orig = $str;
1213              
1214             # Step 1: $var.key -> $__S->{sluz_pfx_var}->{key}
1215 63         325 $str =~ s/(\$\w[\w\.]*)/ $self->_dot_to_bracket_cb($1) /ge;
  67         151  
1216              
1217             # Step 2: $__S->{...}["key"] -> $__S->{...}->{key} (PHP bracket syntax)
1218 63 100       152 if (index($str, '[') >= 0) {
1219 3         15 $str =~ s/(\$__S(?:->\{[^}]+\})+)\[(["'])([^\]]+?)\2\]/$1 . '->{' . $3 . '}'/ge;
  1         8  
1220             }
1221              
1222 63         174 $self->{_convert_cache}{$orig} = $str;
1223 63         157 return $str;
1224             }
1225              
1226             sub _dot_to_bracket_cb {
1227 67     67   81 my $self = shift;
1228 67         111 my $match = shift;
1229 67         170 my @parts = split /\./, $match;
1230 67         114 my $first = shift @parts;
1231 67         110 my $var = substr($first, 1);
1232 67         141 my $res = "\$__S->\{$self->{var_prefix}_$var\}";
1233 67         110 for my $p (@parts) {
1234 6 100       21 if ($p =~ /^\d+$/) {
1235 1         3 $res .= "->[$p]";
1236             } else {
1237 5         13 $res .= "->{$p}";
1238             }
1239             }
1240 67         244 return $res;
1241             }
1242              
1243             sub _micro_optimize {
1244 173     173   194 my $self = shift;
1245 173   50     291 my $str = shift // '';
1246 173 100       624 if ($str =~ /^-?\d+(?:\.\d+)?$/) { return $str }
  19         36  
1247              
1248 154 100       299 if (!length $str) { return undef }
  1         3  
1249 153         179 my $first = ord($str);
1250 153         242 my $last = ord(substr($str, -1));
1251              
1252 153 100 66     309 if ($first == 39 && $last == 39) {
1253 10         27 my $tmp = substr($str, 1, length($str) - 2);
1254 10 50       20 if (index($tmp, "'") < 0) { return $tmp }
  10         20  
1255             }
1256              
1257 143 100 66     302 if ($first == 34 && $last == 34) {
1258 16         34 my $tmp = substr($str, 1, length($str) - 2);
1259 16 100 66     51 if (index($tmp, '$') < 0 && index($tmp, '"') < 0) { return $tmp }
  13         47  
1260             }
1261              
1262 130 100 100     732 if ($str =~ /^(!?)\$__S->\{sluz_pfx_(\w+)\}$/ && exists $self->{tpl_vars}{$2}) {
1263 80 100       334 return $1 ? !$self->{tpl_vars}{$2} : $self->{tpl_vars}{$2};
1264             }
1265              
1266 50 50 66     168 if ($str =~ /^(!?)(\w+)$/ && exists $self->{tpl_vars}{$2}) {
1267 3 50       12 return $1 ? !$self->{tpl_vars}{$2} : $self->{tpl_vars}{$2};
1268             }
1269              
1270 47         84 return undef;
1271             }
1272              
1273             sub _peval {
1274 173     173   240 my $self = shift;
1275 173   50     333 my $str = shift // '';
1276              
1277 173 50       322 if (index($str, '===') >= 0) {
1278 0         0 $str =~ s/===/==/g;
1279             }
1280              
1281 173         330 my $opt = $self->_micro_optimize($str);
1282 173 100       304 if (defined $opt) { return ($opt, 0) }
  123         250  
1283              
1284             # Use the persistent $__S hash (maintained by assign/foreach) instead
1285             # of rebuilding it from tpl_vars on every call
1286 50         81 my $__S = $self->{__S};
1287              
1288             # Check verified sub cache — subs that have succeeded at least once.
1289             # Skip eval/local $SIG overhead (the biggest per-call cost in loops).
1290             # no warnings in the compiled sub suppresses uninitialized-value warnings.
1291 50         97 my $vsub = $self->{_verified_sub_cache}{$str};
1292 50 100       111 if ($vsub) {
1293 13         313 return ($vsub->($__S), 0);
1294             }
1295              
1296             # Check compiled sub cache — avoids re-parsing the same expression
1297 37         63 my $sub = $self->{_sub_cache}{$str};
1298 37 50       78 if (!defined $sub) {
1299             # Compile in main:: first (where user functions live), then Template::Sluz
1300 6     6   79 $sub = eval "package main; no warnings; sub { my \$__S = \$_[0]; return ($str); }";
  6     4   11  
  6     4   580  
  4     3   28  
  4     2   8  
  4     2   340  
  4     2   28  
  4     2   9  
  4     2   299  
  3     2   25  
  3     2   6  
  3     2   292  
  2     2   14  
  2         4  
  2         127  
  2         13  
  2         4  
  2         143  
  2         12  
  2         5  
  2         128  
  2         12  
  2         4  
  2         135  
  2         14  
  2         4  
  2         163  
  2         13  
  2         4  
  2         148  
  2         15  
  2         3  
  2         141  
  2         11  
  2         5  
  2         154  
  2         19  
  2         3  
  2         199  
  37         3594  
1301 37 100       133 if ($@) {
1302 1     1   4 $sub = eval "no warnings; sub { my \$__S = \$_[0]; return ($str); }";
  1         1  
  1         50  
  1         60  
1303             }
1304             # Cache the result (even undef) so we don't recompile failures
1305 37         121 $self->{_sub_cache}{$str} = $sub;
1306             }
1307              
1308 37         66 my $ret;
1309 37 100       74 if ($sub) {
1310 36     0   255 local $SIG{__WARN__} = sub {};
1311 36         106 $ret = eval { $sub->($__S) };
  36         966  
1312 36 100       144 unless ($@) {
1313             # Promote to verified cache — skip eval/SIG on future calls
1314 33         71 $self->{_verified_sub_cache}{$str} = $sub;
1315 33         58 delete $self->{_sub_cache}{$str};
1316 33         244 return ($ret, 0);
1317             }
1318             # Cached sub failed (e.g. function not in main::) — evict and fall through
1319 3         18 delete $self->{_sub_cache}{$str};
1320             }
1321              
1322             {
1323 4     0   8 local $SIG{__WARN__} = sub {};
  4         19  
1324 4     4   43 $ret = eval "no warnings; return ($str);";
  4         7  
  4         228  
  4         304  
1325 4 100       34 if ($@) {
1326 1     1   19 $ret = eval "package main; no warnings; return ($str);";
  1         3  
  1         69  
  1         94  
1327             }
1328             }
1329              
1330 4 100       18 if ($@) {
1331 1         6 return (undef, -1);
1332             }
1333              
1334 3         33 return ($ret, 0);
1335             }
1336              
1337             # -------------------------------------------------------------------
1338             # Error handling
1339             # -------------------------------------------------------------------
1340              
1341             sub _error_out {
1342 11     11   14 my $self = shift;
1343 11         16 my $msg = shift;
1344 11         13 my $err_num = shift;
1345 11         1683 croak "Template::Sluz error #$err_num: $msg";
1346             }
1347              
1348             sub _get_char_location {
1349 10     10   17 my $self = shift;
1350 10         13 my $pos = shift;
1351 10   100     34 my $tpl_file = shift // '';
1352              
1353 10 50       55 if ($self->{inc_tpl_file}) { $tpl_file = $self->{inc_tpl_file} }
  0         0  
1354              
1355             # Use display file name and line offset when available (e.g. inline DATA templates)
1356 10 50       25 my $display_file = exists $self->{tpl_file_display} ? $self->{tpl_file_display} : $tpl_file;
1357 10 50       21 my $line_offset = exists $self->{tpl_line_offset} ? $self->{tpl_line_offset} : 0;
1358              
1359             # Guard: no file context (e.g. parse_string) — skip _get_tpl_content
1360             # to avoid trying to open the perl_file_dir directory as a template file
1361 10 100       23 if (!length $tpl_file) { return (-1, -1, $display_file) }
  9         28  
1362              
1363 1         6 my $str = $self->_get_tpl_content($tpl_file);
1364 1 50 33     10 if ($pos < 0 || !defined $str) { return (-1, -1, $display_file) }
  0         0  
1365              
1366 1         3 my $line = 1;
1367 1         3 my $col = 0;
1368 1         5 for (my $i = 0; $i < length $str; $i++) {
1369 4         6 $col++;
1370 4 50       11 if (substr($str, $i, 1) eq "\n") {
1371 0         0 $line++;
1372 0         0 $col = 0;
1373             }
1374 4 100       11 if ($pos == $i) { return ($line + $line_offset, $col, $display_file) }
  1         6  
1375             }
1376              
1377 0 0       0 if ($pos == length $str) { return ($line + $line_offset, $col, $display_file) }
  0         0  
1378 0         0 return (-1, -1, $display_file);
1379             }
1380              
1381             sub _extract_include_file {
1382 9     9   11 my $self = shift;
1383 9         13 my $str = shift;
1384              
1385 9 100       51 if ($str =~ /\s(file=)(['"].+?['"])/) {
1386 8         20 my $xstr = $self->_convert_vars($2);
1387 8         19 my ($ret) = $self->_peval($xstr);
1388 8         19 $self->{inc_tpl_file} = $ret;
1389 8         15 return $ret;
1390             }
1391              
1392 1 50       5 if ($str =~ /\s(['"].+?['"])/) {
1393 1         3 my $xstr = $self->_convert_vars($1);
1394 1         3 my ($ret) = $self->_peval($xstr);
1395 1         2 $self->{inc_tpl_file} = $ret;
1396 1         2 return $ret;
1397             }
1398              
1399 0         0 my ($line, $col, $file) = $self->_get_char_location($self->{char_pos}, $self->{tpl_file});
1400 0         0 $self->_error_out("Unable to find a file in include block $str in $file on line #$line", 68493);
1401             }
1402              
1403             sub _if_rules_from_tokens {
1404 24     24   26 my $self = shift;
1405 24         26 my $toks = shift;
1406 24         36 my $num = scalar @$toks;
1407 24         28 my $nested = 0;
1408 24         29 my @tmp;
1409              
1410 24         41 my $tif_tag = $self->{_tag_if};
1411 24         30 my $tifc_tag = $self->{_tag_if_close};
1412 24         34 my $tif_prefix = $self->{open_delim} . 'if';
1413              
1414 24         55 for my $i (0 .. $num - 1) {
1415 160         248 my $item = $toks->[$i];
1416 160 100       270 if (index($item, $tif_prefix) == 0) { $nested++ }
  30         34  
1417 160 100       218 if ($item eq $tifc_tag) { $nested-- }
  30         32  
1418              
1419 160         165 my $yes = 0;
1420 160 100       211 if ($nested == 1) {
1421 116   100     176 $yes = $self->is_if_token($item) || 0;
1422 116 100       174 $yes = 0 if $item eq $tifc_tag;
1423             }
1424 160         236 $tmp[$i] = $yes;
1425             }
1426              
1427 24         36 $tmp[$num - 1] = 1;
1428              
1429 24         29 my @conds;
1430 24         38 for my $i (0 .. $num - 1) {
1431 160 100       253 if ($tmp[$i]) {
1432 79         151 my $test = $self->is_if_token($toks->[$i]);
1433 79 100       142 if ($i != $num - 1) { push @conds, $test }
  55         97  
1434             }
1435             }
1436              
1437 24         56 my $str = '';
1438 24         24 my @payloads;
1439 24         27 my $first = 1;
1440 24         37 for my $i (0 .. $num - 1) {
1441 160 100       199 if ($tmp[$i]) {
1442 79 100       114 if (!$first) { push @payloads, $str }
  55         65  
1443 79         81 $first = 0;
1444 79         93 $str = '';
1445             } else {
1446 81         109 $str .= $toks->[$i];
1447             }
1448             }
1449              
1450 24 50       44 if (@conds != @payloads) {
1451 0         0 $self->_error_out("Error parsing if conditions in '$str'", 95320);
1452             }
1453              
1454 24         28 my @ret;
1455 24         119 push @ret, [$conds[$_], $payloads[$_]] for 0 .. $#conds;
1456 24         33 for my $rule (@ret) {
1457 55         94 $rule->[1] = $self->ltrim_one($rule->[1], "\n");
1458             }
1459 24         131 return @ret;
1460             }
1461              
1462             1;
1463              
1464             __END__