File Coverage

lib/Template/Sluz.pm
Criterion Covered Total %
statement 566 629 89.9
branch 229 284 80.6
condition 89 131 67.9
subroutine 38 43 88.3
pod 4 14 28.5
total 926 1101 84.1


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 1     1   7143 use strict;
  1         2  
  1         51  
19 1     1   6 use warnings;
  1         5  
  1         88  
20 1     1   22 use 5.016;
  1         4  
21              
22 1     1   6 use File::Basename qw(dirname basename);
  1         1  
  1         79  
23 1     1   683 use autouse 'Carp' => qw(croak);
  1         840  
  1         4  
24              
25 1     1   92 use constant SLUZ_INLINE => 'INLINE_TEMPLATE';
  1         1  
  1         4659  
26              
27             our $VERSION = 'v0.9.2';
28              
29             ################################################################################
30             # Built-in Sluz functions that can be used in templates
31             ################################################################################
32              
33             sub count {
34 2     2 0 3 my $v = shift;
35              
36 2 50       6 if (ref $v eq 'ARRAY') {
37 2         11 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 6 my $arr = shift();
51 4   100     9 my $glue = shift() // ', ';
52              
53 4         15 return CORE::join($glue, @$arr);
54             }
55              
56             ################################################################################
57             ################################################################################
58              
59             sub new {
60 1     1 1 268709 my $class = shift;
61 1         17 my $self = {
62             version => $VERSION,
63             tpl_file => undef,
64             inc_tpl_file => undef,
65             debug => 0,
66             tpl_vars => {},
67             parent_tpl => undef,
68             var_prefix => 'sluz_pfx',
69             perl_file => undef,
70             perl_file_dir => undef,
71             fetch_called => 0,
72             char_pos => -1,
73             _sub_cache => {},
74             };
75              
76 1         3 bless $self, $class;
77 1         23 return $self;
78             }
79              
80             sub assign {
81 32     32 1 2227 my $self = shift;
82              
83             # Accept either a hashref: assign($hash_ref)
84 32 100 100     110 if (@_ == 1 && ref $_[0] eq 'HASH') {
    100          
85 2         30 my $h = shift;
86 2         9 @{$self->{tpl_vars}}{keys %$h} = values %$h;
  2         10  
87              
88             # Or a key-value list: assign(name => 'Scott', age => 42)
89             } elsif (@_ % 2 == 0) {
90 29         66 my %h = @_;
91 29         48 @{$self->{tpl_vars}}{keys %h} = values %h;
  29         92  
92             } else {
93 1         7 $self->_error_out("Invalid assign. Must be a key/value or hash", 18956);
94             }
95             }
96              
97             sub fetch {
98 3     3 1 1481 my $self = shift;
99 3   50     8 my $tpl_file = shift || '';
100 3         3 my $parent = shift;
101              
102 3 100       6 if (!$self->{perl_file}) {
103 1         5 $self->{perl_file} = $self->_get_perl_file;
104 1         46 $self->{perl_file_dir} = dirname($self->{perl_file});
105             }
106              
107 3 50       5 if (!$tpl_file) {
108 0         0 $tpl_file = $self->_guess_tpl_file($self->{perl_file});
109             }
110              
111 3         3 my $parent_tpl;
112 3 100       6 if (defined $parent) {
113 1         2 $parent_tpl = $parent;
114             } else {
115 2         3 $parent_tpl = $self->{parent_tpl};
116             }
117 3 100       5 if ($parent_tpl) {
118 2         7 $self->assign('__CHILD_TPL', $tpl_file);
119 2         3 $tpl_file = $parent_tpl;
120             }
121              
122 3         8 my $str = $self->_get_tpl_content($tpl_file);
123 3         67 my @blocks = $self->_get_blocks($str);
124 3         11 my $html = $self->_process_blocks(\@blocks);
125              
126 3         8 $self->{fetch_called} = 1;
127 3         16 return $html;
128             }
129              
130             sub parse {
131 0     0 0 0 my $self = shift;
132 0         0 return $self->fetch(@_);
133             }
134              
135             sub display {
136 0     0 0 0 my $self = shift;
137 0         0 print $self->fetch(@_);
138             }
139              
140             # Parse a string instead of a file
141             sub parse_string {
142 154     154 1 111335 my $self = shift;
143 154   50     400 my $tpl_str = shift // '';
144 154         353 my @blocks = $self->_get_blocks($tpl_str);
145              
146 154         335 return $self->_process_blocks(\@blocks);
147             }
148              
149             # Getter/setter for parent TPL
150             sub parent_tpl {
151 2     2 0 2055 my $self = shift;
152              
153 2 50       7 if (@_) {
154 2         6 $self->{parent_tpl} = shift;
155             }
156              
157 2         4 return $self->{parent_tpl};
158             }
159              
160             # Dive down an array or hashref using our dotted syntax
161             sub array_dive {
162 167     167 0 144 my $self = shift;
163 167         158 my $needle = shift;
164 167         128 my $haystack = shift;
165              
166 167 50 33     356 if (!defined $needle || !defined $haystack) { return undef }
  0         0  
167              
168             # Quick path: needle is a direct key in the hash
169 167 100       220 if (exists $haystack->{$needle}) {
170 135         230 return $haystack->{$needle};
171             }
172              
173             # Walk dotted path (e.g. "user.address.city") through nested structures
174 32         63 my @parts = split /\./, $needle;
175 32         36 my $arr = $haystack;
176              
177 32         39 for my $elem (@parts) {
178 55 50       64 if (!defined $arr) { return undef }
  0         0  
179 55 100       91 if (ref $arr eq 'ARRAY') {
    50          
180 10 50 33     38 if (!($elem =~ /^\d+$/ && $elem < @$arr)) { return undef }
  0         0  
181 10         22 $arr = $arr->[$elem];
182             } elsif (ref $arr eq 'HASH') {
183 45 100       64 if (!exists $arr->{$elem}) { return undef }
  11         25  
184 34         53 $arr = $arr->{$elem};
185             } else {
186 0         0 return undef;
187             }
188             }
189 21         43 return $arr;
190             }
191              
192             sub ltrim_one {
193 65     65 0 66 my $self = shift;
194 65   50     92 my $str = shift // '';
195 65         63 my $char = shift;
196              
197 65 100 100     174 if (length $str && substr($str, 0, 1) eq $char) {
198 5         10 return substr($str, 1);
199             }
200              
201 60         83 return $str;
202             }
203              
204             sub find_ending_tag {
205 14     14 0 13 my $self = shift;
206 14   50     51 my $haystack = shift // '';
207 14         15 my $open_tag = shift;
208 14         16 my $close_tag = shift;
209              
210             # Find the first close tag; if there's only one open tag before it, we're done
211 14         15 my $pos = index($haystack, $close_tag);
212 14 50       17 if ($pos < 0) { return undef }
  0         0  
213              
214 14         20 my $substr = substr($haystack, 0, $pos);
215 14         74 my $open_count = () = $substr =~ /\Q$open_tag\E/g;
216 14 100       30 if ($open_count == 1) { return $pos }
  9         12  
217              
218             # Nested tags: scan forward through subsequent close tags until
219             # open/close counts balance (max 5 nesting levels)
220 5         7 my $close_len = length $close_tag;
221 5         7 my $offset = $pos + $close_len;
222              
223 5         37 for (0 .. 4) {
224 13         19 $pos = index($haystack, $close_tag, $offset);
225 13 50       35 if ($pos < 0) { return undef }
  0         0  
226              
227 13         15 $substr = substr($haystack, 0, $pos + 2);
228 13         42 $open_count = () = $substr =~ /\Q$open_tag\E/g;
229 13         36 my $close_count = () = $substr =~ /\Q$close_tag\E/g;
230 13 100       21 if ($open_count == $close_count) { return $pos }
  5         12  
231              
232 8         11 $offset = $pos + $close_len;
233             }
234              
235 0         0 return undef;
236             }
237              
238             sub get_tokens {
239 19     19 0 17 my $self = shift;
240 19   50     26 my $str = shift // '';
241 19         132 my @tokens = split /({[^}]+})/, $str;
242 19 50       28 @tokens = grep { defined && length } @tokens;
  160         315  
243 19         62 return @tokens;
244             }
245              
246             sub is_if_token {
247 153     153 0 121 my $self = shift;
248 153   50     181 my $str = shift // '';
249 153 100       209 if ($str eq '{else}') { return 1 }
  34         50  
250 119 100       125 if ($str eq '{/if}') { return 1 }
  25         31  
251 94 100       235 if ($str =~ /^\{(?:if|elseif)\s+(.+?)\}$/) {
252 54         135 return $1;
253             }
254 40         78 return '';
255             }
256              
257             # -------------------------------------------------------------------
258             # Private methods
259             # -------------------------------------------------------------------
260              
261             sub _get_perl_file {
262 1     1   1 my $self = shift;
263 1         2 my $i = 0;
264 1         1 my $file;
265              
266 1         4 while (caller($i)) {
267 3         9 $file = (caller($i))[1];
268 3         7 $i++;
269             }
270              
271 1   50     3 return $file || __FILE__;
272             }
273              
274             sub _guess_tpl_file {
275 0     0   0 my $self = shift;
276 0         0 my $pfile = shift;
277              
278 0         0 my $base = basename($pfile);
279 0         0 $base =~ s/\.(pl|pm)$/.stpl/;
280              
281 0         0 return "tpls/$base";
282             }
283              
284             sub _get_tpl_content {
285 13     13   16 my $self = shift;
286 13   50     40 my $tpl_file = shift // '';
287 13         22 $self->{tpl_file} = $tpl_file;
288 13         22 my $tf = $tpl_file;
289              
290 13 50       30 if ($self->{perl_file_dir}) {
291 13         35 $tf = $self->{perl_file_dir} . "/$tf";
292             }
293              
294 13 50       29 if ($tpl_file eq SLUZ_INLINE) {
295 0         0 my $c = $self->_get_inline_content($self->{perl_file});
296 0 0       0 if (defined $c) { return $c }
  0         0  
297 0         0 return '';
298             }
299              
300 13 50 33     393 if ($tf && !-r $tf) {
301 0         0 $self->_error_out("Unable to load template file $tf", 42280);
302             }
303              
304 13 50       32 if ($tf) {
305 13         76 local $/;
306 13 50       558 open my $fh, '<', $tf or $self->_error_out("Cannot open $tf: $!", 42280);
307 13         300 my $str = <$fh>;
308 13         163 close $fh;
309 13   100     155 return $str // '';
310             }
311              
312 0         0 return '';
313             }
314              
315             sub _get_inline_content {
316 0     0   0 my $self = shift;
317 0         0 my $file = shift;
318 0         0 local $/;
319 0 0       0 open my $fh, '<', $file or return undef;
320 0         0 my $str = <$fh>;
321 0         0 close $fh;
322 0         0 my $idx = index($str, '__DATA__');
323 0 0       0 if ($idx < 0) { return undef }
  0         0  
324 0         0 return substr($str, $idx + 9);
325             }
326              
327             # -------------------------------------------------------------------
328             # Tokenizer
329             # -------------------------------------------------------------------
330              
331             sub _get_blocks {
332 257     257   10399 my $self = shift;
333 257   50     403 my $str = shift // '';
334 257         283 my $slen = length $str;
335 257         260 my $start = 0;
336 257         313 my $i;
337             my @blocks;
338              
339 257         324 my $z = index($str, '{');
340 257 100       402 if ($z < 0) { $z = $slen }
  44         49  
341              
342 257         458 for ($i = $z; $i < $slen; $i++) {
343 698         844 my $char = substr($str, $i, 1);
344 698         682 my $is_open = $char eq '{';
345 698         667 my $is_closed = $char eq '}';
346              
347 698 100 100     1219 if (!$is_open && !$is_closed) {
348 247         293 my $next_open = index($str, '{', $i);
349 247 100       297 if ($next_open < 0) { $next_open = $slen }
  133         121  
350 247         254 my $next_close = index($str, '}', $i);
351 247 100       282 if ($next_close < 0) { $next_close = $slen }
  16         17  
352 247 100       322 if ($next_open < $next_close) {
353 11         9 $i = $next_open - 1;
354             } else {
355 236         234 $i = $next_close - 1;
356             }
357 247         424 next;
358             }
359              
360 451         428 my $has_len = $start != $i;
361 451         429 my $is_comment = 0;
362              
363 451 100       548 if ($is_open) {
364 230         186 my $prev_c;
365 230 100       349 if ($i > 0) {
366 35         49 $prev_c = substr($str, $i - 1, 1);
367             } else {
368 195         230 $prev_c = ' ';
369             }
370 230         176 my $next_c;
371 230 50       299 if ($i + 1 < $slen) {
372 230         357 $next_c = substr($str, $i + 1, 1);
373             } else {
374 0         0 $next_c = ' ';
375             }
376 230         288 my $chk = $prev_c . $char . $next_c;
377 230 100       717 if ($chk =~ /\s[\{\}]\s/) { $is_open = 0 }
  2         3  
378 230 100       398 if ($next_c eq '*') { $is_comment = 1 }
  14         18  
379             }
380              
381 451 100 100     1147 if ($is_open && $has_len) {
    100          
382 33         66 push @blocks, [substr($str, $start, $i - $start), $i];
383 33         31 $start = $i;
384             } elsif ($is_closed) {
385 221         224 my $len = $i - $start + 1;
386 221         295 my $block = substr($str, $start, $len);
387              
388 221 100       573 if ($block =~ /^\{(if|foreach|literal)\b/) {
389 81         182 my $open_tag = $1;
390 81         103 my $close_tag = "{/$open_tag}";
391 81         185 for (my $j = $i + 1; $j < length $str; $j++) {
392 1567 100       2065 if (substr($str, $j, 1) eq '}') {
393 182         234 my $tmp = substr($str, $start, $j - $start + 1);
394 182         853 my $oc = () = $tmp =~ /\{\Q$open_tag\E/g;
395 182         474 my $cc = () = $tmp =~ m@\{\/\Q$open_tag\E\}@g;
396 182 100       289 if ($oc == $cc) {
397 79         90 $block = $tmp;
398 79         117 last;
399             }
400             }
401             }
402             }
403              
404 221 50       299 if (length $block) { push @blocks, [$block, $i] }
  221         419  
405 221         252 $start += length($block);
406 221         202 $i = $start;
407             }
408              
409 451 100       877 if ($is_comment) {
410 14         39 my $end = $self->find_ending_tag(substr($str, $start), '{*', '*}');
411 14 50       29 if (!defined $end) {
412 0         0 my ($line, $col, $file) = $self->_get_char_location($i, $self->{tpl_file});
413 0         0 $self->_error_out("Missing closing *} for comment in $file on line #$line", 48724);
414             }
415 14         13 $start += $end + 2;
416 14         25 $i = $start;
417             }
418             }
419              
420 257 100       318 if ($start < $slen) {
421 69         146 push @blocks, [substr($str, $start), $i];
422             }
423              
424             # Strip leading newline from text blocks that follow {if} or
425             # {foreach} blocks, to avoid double-newlines when the block
426             # payload already ends with \n. For {foreach}, only strip when
427             # the payload actually ends with \n — if the payload is inline
428             # (no trailing \n), the newline is structural content, not
429             # whitespace noise.
430 257         283 my $prev_is_if = 0;
431 257         1013 for my $i (0 .. $#blocks) {
432 323   50     554 my $bstr = $blocks[$i][0] // '';
433 323   100     785 my $cur_is_if = ($bstr =~ /^\{if\b/ || $bstr =~ /^\{for/);
434 323 100       358 if ($prev_is_if) {
435 4         2 my $should_strip = 1;
436 4 100       19 if ($blocks[$i-1][0] =~ /^\{foreach .+?\}(.*)\{\/foreach\}$/s) {
437 2 100       8 $should_strip = (substr($1, -1) eq "\n") ? 1 : 0;
438             }
439 4 100       7 if ($should_strip) {
440 3         5 $blocks[$i][0] = $self->ltrim_one($bstr, "\n");
441             }
442             }
443 323         394 $prev_is_if = $cur_is_if;
444             }
445              
446 257         550 return @blocks;
447             }
448              
449             sub _process_blocks {
450 284     284   252 my $self = shift;
451 284         275 my $blocks = shift;
452 284         271 my $html = '';
453              
454 284         335 for my $x (@$blocks) {
455 374         390 my $block = $x->[0];
456 374 50       440 if (!length $block) { next }
  0         0  
457 374 100       513 if (substr($block, 0, 1) eq '{') {
458 265         231 my $char_pos = $x->[1];
459 265         414 $html .= $self->_process_block($block, $char_pos);
460             } else {
461 109         169 $html .= $block;
462             }
463             }
464              
465 274         536 return $html;
466             }
467              
468             sub _process_block {
469 265     265   223 my $self = shift;
470 265   50     319 my $str = shift // '';
471 265   50     304 my $char_pos = shift // -1;
472              
473 265         351 $self->{char_pos} = $char_pos;
474              
475             # 1. Variable block {$foo} or {$foo|modifier}
476 265 100 100     859 if (substr($str, 0, 2) eq '{$' && $str =~ /^\{\$([\w|.'";\t :,!@#%^&*?_\/\\\-]+)\}$/) {
477 147         212 return $self->_variable_block($1);
478             }
479              
480             # 2. If block {if ...}{/if}
481 118 100 100     306 if (substr($str, 0, 4) eq '{if ' && substr($str, -5) eq '{/if}') {
482 51         104 return $self->_if_block($str);
483             }
484              
485             # 3. Foreach block {foreach ...}{/foreach}
486 67 100 66     257 if (substr($str, 0, 9) eq '{foreach ' && $str =~ /^\{foreach (\$\w[\w.]*) as \$(\w+)(?: => \$(\w+))?\}(.+)\{\/foreach\}$/s) {
487 30         70 return $self->_foreach_block($1, $2, $3, $4);
488             }
489              
490             # 4. Include block {include ...}
491 37 100       63 if (substr($str, 0, 9) eq '{include ') {
492 9         23 return $self->_include_block($str);
493             }
494              
495             # 5. Literal block {literal}...{/literal}
496 28 100 66     76 if (substr($str, 0, 9) eq '{literal}' && $str =~ /^\{literal\}(.+)\{\/literal\}$/s) {
497 5         15 return $1;
498             }
499              
500             # 6. Expression / function block
501 23 100       94 if ($str =~ /^\{(.+)}$/s) {
502 19         55 return $self->_expression_block($str, $1);
503             }
504              
505             # 7. Unclosed tag
506 4 100       25 if (substr($str, -1) ne '}') {
507 3         13 my ($line, $col, $file) = $self->_get_char_location($self->{char_pos}, $self->{tpl_file});
508 3         17 $self->_error_out("Unclosed tag $str in $file on line #$line", 45821);
509             }
510              
511             # 8. Fallthrough
512 1         3 return $str;
513             }
514              
515             # -------------------------------------------------------------------
516             # Block handlers
517             # -------------------------------------------------------------------
518              
519             sub _variable_block {
520 147     147   116 my $self = shift;
521 147         236 my $str = shift;
522              
523 147 100       249 if ($str =~ /(.+?)\|(.*)/) {
524 28         36 my $key = $1;
525 28         59 my $mod = $2;
526              
527 28         51 my $tmp = $self->array_dive($key, $self->{tpl_vars});
528 28   100     128 my $is_nothing = (!defined $tmp || (defined $tmp && ref $tmp eq '' && !length $tmp && $tmp ne '0'));
529 28         39 my $is_default = index($mod, 'default:') >= 0;
530              
531 28 100 100     103 if ($is_nothing && $is_default) {
    100 100        
532 4         5 my $dval = $mod;
533 4         14 $dval =~ s/^.*?default://;
534 4         11 my ($ret) = $self->_peval($dval);
535 4 50       7 if (defined $ret) { return $ret }
  4         13  
536 0         0 return '';
537             } elsif (!$is_nothing && $is_default) {
538 3   50     8 return $self->array_dive($key, $self->{tpl_vars}) // '';
539             } else {
540 21 100       32 if ($is_nothing) {
541 4         12 return '';
542             }
543 17   50     31 my $pre = $self->array_dive($key, $self->{tpl_vars}) // '';
544              
545             # Split on | not inside double or single quotes (supports chained
546             # modifiers like {$x|uc|substr:0,3})
547 17         54 my $pipe_re = qr/\|(?![^"]*"(?:(?:[^"]*"){2})*[^"]*$)(?![^']*'(?:(?:[^']*'){2})*[^']*$)/;
548 17         130 for my $m_part (split $pipe_re, $mod) {
549 18         45 my @x = split /:/, $m_part, 2;
550 18   50     29 my $func = $x[0] // '';
551 18   100     59 my $param_str = $x[1] // '';
552 18         61 my @params = ($pre);
553              
554 18 100       29 if (length $param_str) {
555             # Split on commas not inside double or single quotes
556             # (parameter separator in modifier calls like substr:2,2)
557 15         31 my $comma_re = qr/,(?=(?:[^"]*"[^"]*")*[^"]*$)(?=(?:[^']*'[^']*')*[^']*$)/;
558             my @new = map {
559 15         98 my ($v) = $self->_peval($_);
  16         44  
560 16         41 $v;
561             } split $comma_re, $param_str;
562 15         39 push @params, @new;
563             }
564              
565             {
566 1     1   11 no strict 'refs';
  1         2  
  1         4389  
  18         24  
567              
568             # Priority: main::, Template::Sluz built-ins, then CORE::
569 18   66     15 my $callable = defined &{"main::$func"} || defined &{$func} || defined &{"CORE::$func"};
570              
571 18 50       34 if (!$callable) {
572 0         0 my ($line, $col, $file) = $self->_get_char_location($self->{char_pos}, $self->{tpl_file});
573 0         0 $self->_error_out("Unknown function call $func in $file on line #$line", 47204);
574             }
575              
576 18 100       16 if (defined &{"main::$func"}) {
  18 100       43  
577 11         16 $pre = eval { &{"main::$func"}(@params) };
  11         17  
  11         42  
578 7         12 } elsif (defined &{$func}) {
579 5         5 $pre = eval { &{$func}(@params) };
  5         7  
  5         11  
580             } else {
581 2         2 $pre = eval { &{"CORE::$func"}(@params) };
  2         2  
  2         11  
582             }
583             }
584              
585 18 50       139 if ($@) {
586 0         0 $self->_error_out("Exception: $@", 79134);
587             }
588             }
589              
590 17         76 return $pre;
591             }
592             }
593              
594 119         184 my $ret = $self->array_dive($str, $self->{tpl_vars});
595 119 100       161 if (ref $ret eq 'ARRAY') { return 'ARRAY' }
  2         8  
596 117 50       128 if (ref $ret eq 'HASH') { return 'HASH' }
  0         0  
597 117 100       151 if (defined $ret) { return $ret }
  111         206  
598 6         17 return '';
599             }
600              
601             sub _if_block {
602 51     51   46 my $self = shift;
603 51         52 my $str = shift;
604              
605 51         73 my $is_simple = index($str, '{else', 7) < 0;
606 51         50 my @rules;
607              
608 51 100       67 if ($is_simple) {
609 32         119 $str =~ /\{if (.+?)}(.+)\{\/if\}/s;
610 32   50     66 my $cond = $1 // '';
611 32   50     55 my $payload = $2 // '';
612 32         65 $payload = $self->ltrim_one($payload, "\n");
613 32         62 @rules = ([$cond, $payload]);
614             } else {
615 19         36 my @toks = $self->get_tokens($str);
616 19         41 @rules = $self->_if_rules_from_tokens(\@toks);
617             }
618              
619 51         71 my $ret = '';
620 51         57 for my $rule (@rules) {
621 65         110 my $test = $self->_convert_vars($rule->[0]);
622 65         77 my $payload = $rule->[1];
623 65         100 my ($res) = $self->_peval($test);
624 65 100       104 if ($res) {
625 45         74 my @in_blocks = $self->_get_blocks($payload);
626 45         77 $ret .= $self->_process_blocks(\@in_blocks);
627 45         114 last;
628             }
629             }
630              
631 51         152 return $ret;
632             }
633              
634             sub _foreach_block {
635 30     30   25 my $self = shift;
636 30         37 my $src_expr = shift;
637 30         30 my $okey = shift;
638 30         34 my $oval = shift;
639 30         36 my $payload = shift;
640              
641 30         49 my $conv_src = $self->_convert_vars($src_expr);
642 30         50 $payload = $self->ltrim_one($payload, "\n");
643 30         48 my @blocks = $self->_get_blocks($payload);
644              
645 30         53 my ($src) = $self->_peval($conv_src);
646              
647 30 100 100     85 if (!defined $src) {
    100          
648 2         3 $src = [];
649             } elsif (ref $src ne 'ARRAY' && ref $src ne 'HASH') {
650 1         2 $src = [$src];
651             }
652              
653 30         22 my %save = %{$self->{tpl_vars}};
  30         341  
654 30         61 my $ret = '';
655 30         28 my $idx = 0;
656              
657 30         31 my $need_first = index($payload, '__FOREACH_FIRST') >= 0;
658 30         26 my $need_last = index($payload, '__FOREACH_LAST') >= 0;
659 30         26 my $need_index = index($payload, '__FOREACH_INDEX') >= 0;
660              
661 30 100       37 if (ref $src eq 'ARRAY') {
    50          
662 28         28 my $last = $#$src;
663 28         44 for my $i (0 .. $last) {
664 67 100       84 if ($need_first) {
665 3 100       6 $self->{tpl_vars}{__FOREACH_FIRST} = ($idx == 0) ? 1 : 0;
666             }
667 67 100       85 if ($need_last) {
668 3 100       7 $self->{tpl_vars}{__FOREACH_LAST} = ($idx == $last) ? 1 : 0;
669             }
670 67 100       66 if ($need_index) {
671 3         5 $self->{tpl_vars}{__FOREACH_INDEX} = $idx;
672             }
673 67 100       79 if (defined $oval) {
674 16         32 $self->{tpl_vars}{$okey} = $i;
675 16         22 $self->{tpl_vars}{$oval} = $src->[$i];
676             } else {
677 51         68 $self->{tpl_vars}{$okey} = $src->[$i];
678             }
679 67         90 $ret .= $self->_process_blocks(\@blocks);
680 67         67 $idx++;
681             }
682             } elsif (ref $src eq 'HASH') {
683 2         9 my @keys = sort keys %$src;
684 2         4 my $last = $#keys;
685 2         3 for my $i (0 .. $last) {
686 6         7 my $k = $keys[$i];
687 6 50       5 if ($need_first) {
688 0 0       0 $self->{tpl_vars}{__FOREACH_FIRST} = ($idx == 0) ? 1 : 0;
689             }
690 6 50       7 if ($need_last) {
691 0 0       0 $self->{tpl_vars}{__FOREACH_LAST} = ($idx == $last) ? 1 : 0;
692             }
693 6 50       7 if ($need_index) {
694 0         0 $self->{tpl_vars}{__FOREACH_INDEX} = $idx;
695             }
696 6 100       9 if (defined $oval) {
697 3         3 $self->{tpl_vars}{$okey} = $k;
698 3         5 $self->{tpl_vars}{$oval} = $src->{$k};
699             } else {
700 3         4 $self->{tpl_vars}{$okey} = $src->{$k};
701             }
702 6         8 $ret .= $self->_process_blocks(\@blocks);
703 6         7 $idx++;
704             }
705             }
706              
707 30         117 $self->{tpl_vars} = \%save;
708 30         113 return $ret;
709             }
710              
711             sub _include_block {
712 9     9   10 my $self = shift;
713 9         10 my $str = shift;
714              
715 9         12 my $save = $self->{tpl_vars};
716 9         23 my $inc_tpl = $self->_extract_include_file($str);
717              
718 9 50       19 if ($self->{perl_file_dir}) {
719 9         19 $inc_tpl = $self->{perl_file_dir} . "/$inc_tpl";
720             }
721              
722 9         49 while ($str =~ m/(\w+)=(['"](.+?)['"])/g) {
723 9         25 my $key = $1;
724 9         12 my $val = $2;
725 9 100       17 if ($key eq 'file') { next }
  8         36  
726 1         2 $val = $self->_convert_vars($val);
727 1         3 my ($res) = $self->_peval($val);
728 1 50       2 if (defined $res) {
729 1         4 $self->assign($key => $res);
730             } else {
731 0         0 $self->assign($key => $val);
732             }
733             }
734              
735 9 50 33     268 if (!-f $inc_tpl || !-r $inc_tpl) {
736 0         0 $self->{inc_tpl_file} = undef;
737 0         0 my ($line, $col, $file) = $self->_get_char_location($self->{char_pos}, $self->{tpl_file});
738 0         0 $self->_error_out("Unable to load include template $inc_tpl in $file on line #$line", 18485);
739             }
740              
741 9         34 local $/;
742 9 50       296 open my $fh, '<', $inc_tpl or $self->_error_out("Cannot open $inc_tpl: $!", 18485);
743 9         222 my $content = <$fh>;
744 9         73 close $fh;
745              
746 9         44 my @blocks = $self->_get_blocks($content);
747 9         23 my $r = $self->_process_blocks(\@blocks);
748              
749 9         14 $self->{tpl_vars} = $save;
750 9         13 $self->{inc_tpl_file} = undef;
751              
752 9         63 return $r;
753             }
754              
755             sub _expression_block {
756 19     19   23 my $self = shift;
757 19         23 my $str = shift;
758 19         58 my $inner = shift;
759              
760 19 100       79 if ($str !~ /["\d\$\(]/) {
761 5         21 my ($line, $col, $file) = $self->_get_char_location($self->{char_pos}, $self->{tpl_file});
762 5         29 $self->_error_out("Unknown block type $str in $file on line #$line", 73467);
763             }
764              
765 14         29 my $after = $self->_convert_vars($inner);
766 14         37 my ($ret, $err) = $self->_peval($after);
767              
768 14         18 my $valid;
769 14 100 33     55 if (defined $ret && (!ref $ret || ref $ret eq '')) {
      66        
770 12         13 $valid = 1;
771             } else {
772 2         4 $valid = 0;
773             }
774              
775 14 100 100     39 if ($err || !$valid) {
776 2         9 my ($line, $col, $file) = $self->_get_char_location($self->{char_pos}, $self->{tpl_file});
777 2         10 $self->_error_out("Unknown tag $str in $file on line #$line", 18933);
778             }
779              
780 12         55 return $ret;
781             }
782              
783             # -------------------------------------------------------------------
784             # Variable / eval engine
785             # -------------------------------------------------------------------
786              
787             sub _convert_vars {
788 119     119   105 my $self = shift;
789 119   50     202 my $str = shift // '';
790 119 100       234 if (index($str, '$') < 0) { return $str }
  24         41  
791              
792             # Step 1: $var.key -> $__S->{sluz_pfx_var}->{key}
793 95         362 $str =~ s/(\$\w[\w\.]*)/ $self->_dot_to_bracket_cb($1) /ge;
  99         176  
794              
795             # Step 2: $__S->{...}["key"] -> $__S->{...}->{key} (PHP bracket syntax)
796 95         186 $str =~ s/(\$__S(?:->\{[^}]+\})+)\[(["'])([^\]]+?)\2\]/$1 . '->{' . $3 . '}'/ge;
  1         5  
797              
798 95         150 return $str;
799             }
800              
801             sub _dot_to_bracket_cb {
802 99     99   97 my $self = shift;
803 99         127 my $match = shift;
804 99         172 my @parts = split /\./, $match;
805 99         123 my $first = shift @parts;
806 99         112 my $var = substr($first, 1);
807 99         151 my $res = "\$__S->\{$self->{var_prefix}_$var\}";
808 99         136 for my $p (@parts) {
809 8 100       17 if ($p =~ /^\d+$/) {
810 3         7 $res .= "->[$p]";
811             } else {
812 5         11 $res .= "->{$p}";
813             }
814             }
815 99         283 return $res;
816             }
817              
818             sub _micro_optimize {
819 139     139   117 my $self = shift;
820 139   50     194 my $str = shift // '';
821 139 100       391 if ($str =~ /^-?\d+(?:\.\d+)?$/) { return $str }
  15         28  
822              
823 124 100       165 if (!length $str) { return undef }
  1         2  
824 123         131 my $first = substr($str, 0, 1);
825 123         119 my $last = substr($str, -1);
826              
827 123 100 66     210 if ($first eq "'" && $last eq "'") {
828 10         17 my $tmp = substr($str, 1, length($str) - 2);
829 10 50       18 if (index($tmp, "'") < 0) { return $tmp }
  10         18  
830             }
831              
832 113 100 66     190 if ($first eq '"' && $last eq '"') {
833 14         26 my $tmp = substr($str, 1, length($str) - 2);
834 14 100 66     62 if (index($tmp, '$') < 0 && index($tmp, '"') < 0) { return $tmp }
  11         24  
835             }
836              
837 102 100       233 if ($str =~ /^\$__S->\{sluz_pfx_(\w+)\}$/) {
838 69 100       152 if (exists $self->{tpl_vars}{$1}) { return $self->{tpl_vars}{$1} }
  58         99  
839             }
840              
841 44 100       72 if ($str =~ /^!\$__S->\{sluz_pfx_(\w+)\}$/) {
842 4 100       17 if (exists $self->{tpl_vars}{$1}) { return !$self->{tpl_vars}{$1} }
  3         10  
843             }
844              
845 41 50 66     111 if ($str =~ /^(\w+)$/ && exists $self->{tpl_vars}{$1}) {
846 3         6 return $self->{tpl_vars}{$1};
847             }
848              
849 38 0 33     59 if ($str =~ /^!(\w+)$/ && exists $self->{tpl_vars}{$1}) {
850 0         0 return !$self->{tpl_vars}{$1};
851             }
852              
853 38         55 return undef;
854             }
855              
856             sub _peval {
857 139     139   132 my $self = shift;
858 139   50     214 my $str = shift // '';
859              
860 139         175 $str =~ s/===/==/g;
861              
862 139         220 my $opt = $self->_micro_optimize($str);
863 139 100       208 if (defined $opt) { return ($opt, 0) }
  98         187  
864              
865 41         45 my $__S = {};
866 41         47 while (my ($k, $v) = each %{$self->{tpl_vars}}) {
  1320         2246  
867 1279         2200 $__S->{"$self->{var_prefix}_$k"} = $v;
868             }
869              
870             # Check compiled sub cache — avoids re-parsing the same expression
871 41         84 my $sub = $self->{_sub_cache}{$str};
872 41 100       58 if (!defined $sub) {
873             # Compile in main:: first (where user functions live), then Template::Sluz
874 30         3118 $sub = eval "package main; sub { my \$__S = \$_[0]; return ($str); }";
875 30 100       97 if ($@) {
876 1         74 $sub = eval "sub { my \$__S = \$_[0]; return ($str); }";
877             }
878             # Cache the result (even undef) so we don't recompile failures
879 30         80 $self->{_sub_cache}{$str} = $sub;
880             }
881              
882 41         42 my $ret;
883 41 100       67 if ($sub) {
884 40     1   198 local $SIG{__WARN__} = sub {};
885 40         53 $ret = eval { $sub->($__S) };
  40         556  
886 40 100       116 unless ($@) { return ($ret, 0) }
  39         374  
887             # Cached sub failed (e.g. function not in main::) — evict and fall through
888 1         5 delete $self->{_sub_cache}{$str};
889             }
890              
891             {
892 2     0   3 local $SIG{__WARN__} = sub {};
  2         11  
893 2         125 $ret = eval "return ($str);";
894 2 100       14 if ($@) {
895 1         56 $ret = eval "package main; return ($str);";
896             }
897             }
898              
899 2 100       12 if ($@) {
900 1         14 return (undef, -1);
901             }
902              
903 1         12 return ($ret, 0);
904             }
905              
906             # -------------------------------------------------------------------
907             # Error handling
908             # -------------------------------------------------------------------
909              
910             sub _error_out {
911 11     11   16 my $self = shift;
912 11         16 my $msg = shift;
913 11         12 my $err_num = shift;
914 11         2126 croak "Template::Sluz error #$err_num: $msg";
915             }
916              
917             sub _get_char_location {
918 10     10   19 my $self = shift;
919 10         14 my $pos = shift;
920 10   100     28 my $tpl_file = shift // '';
921              
922 10 50       29 if ($self->{inc_tpl_file}) { $tpl_file = $self->{inc_tpl_file} }
  0         0  
923              
924 10         32 my $str = $self->_get_tpl_content($tpl_file);
925 10 50 33     49 if ($pos < 0 || !defined $str) { return (-1, -1, $tpl_file) }
  0         0  
926              
927 10         16 my $line = 1;
928 10         10 my $col = 0;
929 10         24 for (my $i = 0; $i < length $str; $i++) {
930 4         7 $col++;
931 4 50       12 if (substr($str, $i, 1) eq "\n") {
932 0         0 $line++;
933 0         0 $col = 0;
934             }
935 4 100       11 if ($pos == $i) { return ($line, $col, $tpl_file) }
  1         6  
936             }
937              
938 9 50       20 if ($pos == length $str) { return ($line, $col, $tpl_file) }
  0         0  
939 9         30 return (-1, -1, $tpl_file);
940             }
941              
942             sub _extract_include_file {
943 9     9   8 my $self = shift;
944 9         8 my $str = shift;
945              
946 9 100       45 if ($str =~ /\s(file=)(['"].+?['"])/) {
947 8         18 my $xstr = $self->_convert_vars($2);
948 8         19 my ($ret) = $self->_peval($xstr);
949 8         15 $self->{inc_tpl_file} = $ret;
950 8         13 return $ret;
951             }
952              
953 1 50       6 if ($str =~ /\s(['"].+?['"])/) {
954 1         3 my $xstr = $self->_convert_vars($1);
955 1         2 my ($ret) = $self->_peval($xstr);
956 1         3 $self->{inc_tpl_file} = $ret;
957 1         2 return $ret;
958             }
959              
960 0         0 my ($line, $col, $file) = $self->_get_char_location($self->{char_pos}, $self->{tpl_file});
961 0         0 $self->_error_out("Unable to find a file in include block $str in $file on line #$line", 68493);
962             }
963              
964             sub _if_rules_from_tokens {
965 19     19   21 my $self = shift;
966 19         16 my $toks = shift;
967 19         18 my $num = scalar @$toks;
968 19         20 my $nested = 0;
969 19         17 my @tmp;
970              
971 19         33 for my $i (0 .. $num - 1) {
972 129         122 my $item = $toks->[$i];
973 129 100       181 if ($item =~ /^\{if/) { $nested++ }
  25         22  
974 129 100       147 if ($item eq '{/if}') { $nested-- }
  25         26  
975              
976 129         107 my $yes = 0;
977 129 100       182 if ($nested == 1) {
978 90   100     133 $yes = $self->is_if_token($item) || 0;
979 90 100       138 $yes = 0 if $item eq '{/if}';
980             }
981 129         157 $tmp[$i] = $yes;
982             }
983              
984 19         26 $tmp[$num - 1] = 1;
985              
986 19         17 my @conds;
987 19         30 for my $i (0 .. $num - 1) {
988 129 100       156 if ($tmp[$i]) {
989 63         71 my $test = $self->is_if_token($toks->[$i]);
990 63 100       98 if ($i != $num - 1) { push @conds, $test }
  44         55  
991             }
992             }
993              
994 19         20 my $str = '';
995 19         16 my @payloads;
996 19         17 my $first = 1;
997 19         27 for my $i (0 .. $num - 1) {
998 129 100       132 if ($tmp[$i]) {
999 63 100       72 if (!$first) { push @payloads, $str }
  44         61  
1000 63         79 $first = 0;
1001 63         62 $str = '';
1002             } else {
1003 66         77 $str .= $toks->[$i];
1004             }
1005             }
1006              
1007 19 50       29 if (@conds != @payloads) {
1008 0         0 $self->_error_out("Error parsing {if} conditions in '$str'", 95320);
1009             }
1010              
1011 19         23 my @ret;
1012 19         84 push @ret, [$conds[$_], $payloads[$_]] for 0 .. $#conds;
1013 19         108 return @ret;
1014             }
1015              
1016             1;
1017              
1018             __END__