File Coverage

lib/Yote/Spiderpup/Transform.pm
Criterion Covered Total %
statement 206 207 99.5
branch 75 92 81.5
condition 45 64 70.3
subroutine 12 12 100.0
pod 0 5 0.0
total 338 380 88.9


line stmt bran cond sub pod time code
1             package Yote::Spiderpup::Transform;
2              
3 2     2   124818 use strict;
  2         5  
  2         95  
4 2     2   11 use warnings;
  2         12  
  2         118  
5 2     2   13 use Exporter 'import';
  2         4  
  2         17547  
6              
7             our $VERSION = '0.06';
8              
9             our @EXPORT_OK = qw(
10             transform_dollar_vars
11             transform_expression
12             extract_arrow_params
13             add_implicit_this
14             parse_html
15             );
16              
17             # Transform $var syntax to this.get_var() / this.set_var()
18             sub transform_dollar_vars {
19 58     58 0 170386 my ($expr) = @_;
20 58 50       144 return $expr unless defined $expr;
21              
22             # Track positions to avoid transforming inside strings
23 58         81 my @protected_ranges;
24              
25             # Find string literals (both single and double quoted)
26             # Handle escaped quotes properly: match \\ (escaped backslash) or \' \" (escaped quote) or any non-quote char
27 58         277 while ($expr =~ /("(?:[^"\\]|\\.)*")/g) {
28 11         74 push @protected_ranges, [$-[0], $+[0]];
29             }
30 58         212 while ($expr =~ /('(?:[^'\\]|\\.)*')/g) {
31 6         67 push @protected_ranges, [$-[0], $+[0]];
32             }
33             # Find template literals (handle escaped backticks)
34 58         361 while ($expr =~ /(`(?:[^`\\]|\\.)*`)/g) {
35 8         70 push @protected_ranges, [$-[0], $+[0]];
36             }
37              
38             my $is_protected = sub {
39 42     42   72 my ($pos) = @_;
40 42         71 for my $range (@protected_ranges) {
41 22 100 100     110 return 1 if $pos >= $range->[0] && $pos < $range->[1];
42             }
43 34         80 return 0;
44 58         344 };
45              
46             # Process assignments: $var = value (but not == or ===)
47             # Need to handle complex RHS with nested $vars
48 58         111 my $result = '';
49 58         90 my $pos = 0;
50              
51 58         194 while ($expr =~ /\$(\w+)\s*=(?!=)/g) {
52 9         21 my $var_name = $1;
53 9         24 my $match_start = $-[0];
54 9         22 my $match_end = $+[0];
55              
56 9 50       22 if (!$is_protected->($match_start)) {
57             # Find the end of the assignment (semicolon, comma, or closing paren/bracket at depth 0)
58 9         36 my $rhs_start = $match_end;
59 9         13 my $depth = 0;
60 9         49 my $rhs_end = length($expr);
61 9         16 my $in_string = '';
62              
63 9         27 for my $i ($rhs_start .. length($expr) - 1) {
64 97         161 my $char = substr($expr, $i, 1);
65 97 50       213 my $prev = $i > 0 ? substr($expr, $i - 1, 1) : '';
66              
67             # Track string state (skip if escaped)
68 97 100 66     331 if (!$in_string && $prev ne '\\') {
    100 66        
      100        
69 81 50 66     393 if ($char eq '"' || $char eq "'" || $char eq '`') {
      66        
70 1         2 $in_string = $char;
71 1         2 next;
72             }
73             } elsif ($in_string && $char eq $in_string && $prev ne '\\') {
74 1         2 $in_string = '';
75 1         1 next;
76             }
77              
78 95 100       174 next if $in_string;
79              
80 80 100 66     799 if ($char eq '(' || $char eq '[' || $char eq '{') {
    100 66        
    100 66        
      100        
      66        
      66        
81 2         5 $depth++;
82             } elsif ($char eq ')' || $char eq ']' || $char eq '}') {
83 5 100       17 if ($depth == 0) {
84 3         6 $rhs_end = $i;
85 3         9 last;
86             }
87 2         4 $depth--;
88             } elsif (($char eq ';' || $char eq ',') && $depth == 0) {
89 2         5 $rhs_end = $i;
90 2         5 last;
91             }
92             }
93              
94 9         20 my $rhs = substr($expr, $rhs_start, $rhs_end - $rhs_start);
95             # Trim leading whitespace from RHS
96 9         40 $rhs =~ s/^\s+//;
97             # Recursively transform $vars in RHS
98 9         31 $rhs = transform_dollar_vars($rhs);
99              
100 9         23 $result .= substr($expr, $pos, $match_start - $pos);
101 9         22 $result .= "this.set_$var_name($rhs)";
102 9         13 $pos = $rhs_end;
103              
104             # Reset regex position
105 9         47 pos($expr) = $pos;
106             }
107             }
108 58         164 $result .= substr($expr, $pos);
109 58         109 $expr = $result;
110              
111             # Process reads: $var (not followed by =)
112             # Need to be careful not to transform inside strings
113 58         86 $result = '';
114 58         79 $pos = 0;
115 58         111 @protected_ranges = (); # Recalculate for the modified expression
116              
117 58         177 while ($expr =~ /("(?:[^"\\]|\\.)*")/g) {
118 11         51 push @protected_ranges, [$-[0], $+[0]];
119             }
120 58         186 while ($expr =~ /('(?:[^'\\]|\\.)*')/g) {
121 6         38 push @protected_ranges, [$-[0], $+[0]];
122             }
123 58         185 while ($expr =~ /(`(?:[^`\\]|\\.)*`)/g) {
124 8         45 push @protected_ranges, [$-[0], $+[0]];
125             }
126              
127 58         253 while ($expr =~ /\$(\w+)(?!\s*=(?!=))/g) {
128 33         75 my $var_name = $1;
129 33         75 my $match_start = $-[0];
130 33         70 my $match_end = $+[0];
131              
132 33 100       70 if (!$is_protected->($match_start)) {
133 25         58 $result .= substr($expr, $pos, $match_start - $pos);
134 25         41 $result .= "this.get_$var_name()";
135 25         70 $pos = $match_end;
136             }
137             }
138 58         125 $result .= substr($expr, $pos);
139              
140 58         409 return $result;
141             }
142              
143             # Extract parameter names from arrow function expressions
144             sub extract_arrow_params {
145 32     32 0 5871 my ($expr) = @_;
146 32         53 my %params;
147              
148             # Match arrow functions: (param1, param2) => or param =>
149 32         272 while ($expr =~ /\(([^)]*)\)\s*=>/g) {
150 12         35 my $param_str = $1;
151 12         67 for my $param (split /,/, $param_str) {
152 8         44 $param =~ s/^\s+|\s+$//g;
153 8         20 $param =~ s/\s*=.*//; # Remove default values
154 8 100       50 $params{$param} = 1 if $param =~ /^\w+$/;
155             }
156             }
157              
158             # Single param without parens: x =>
159 32         166 while ($expr =~ /\b(\w+)\s*=>/g) {
160 4         52 $params{$1} = 1;
161             }
162              
163             # Match regular function params: function(param1, param2)
164 32         237 while ($expr =~ /\bfunction\s*\(([^)]*)\)/g) {
165 21         60 my $param_str = $1;
166 21         135 for my $param (split /,/, $param_str) {
167 10         38 $param =~ s/^\s+|\s+$//g;
168 10         22 $param =~ s/\s*=.*//; # Remove default values
169 10 50       69 $params{$param} = 1 if $param =~ /^\w+$/;
170             }
171             }
172              
173 32         98 return \%params;
174             }
175              
176             # Add implicit this. prefix to known method calls (whitelist approach)
177             sub add_implicit_this {
178 29     29 0 70 my ($expr, $local_vars, $known_methods) = @_;
179 29 50       69 return $expr unless defined $expr;
180 29   50     67 $local_vars //= {};
181              
182             # Track string positions to avoid transforming inside strings
183 29         36 my @protected_ranges;
184 29         113 while ($expr =~ /("(?:[^"\\]|\\.)*")/g) {
185 2         16 push @protected_ranges, [$-[0], $+[0]];
186             }
187 29         112 while ($expr =~ /('(?:[^'\\]|\\.)*')/g) {
188 4         22 push @protected_ranges, [$-[0], $+[0]];
189             }
190 29         112 while ($expr =~ /(`(?:[^`\\]|\\.)*`)/g) {
191 6         32 push @protected_ranges, [$-[0], $+[0]];
192             }
193              
194             my $is_protected = sub {
195 63     63   125 my ($pos) = @_;
196 63         123 for my $range (@protected_ranges) {
197 44 100 100     130 return 1 if $pos >= $range->[0] && $pos < $range->[1];
198             }
199 62         1122 return 0;
200 29         135 };
201              
202             # Replace bare method calls: only add this. for known methods
203 29         53 my $result = '';
204 29         48 my $last_end = 0;
205              
206 29         200 while ($expr =~ /\b(\w+)\s*\(/g) {
207 63         166 my $name = $1;
208 63         177 my $match_start = $-[1];
209 63         151 my $match_end = $+[0];
210              
211 63 100       151 next if $is_protected->($match_start);
212              
213             # Check what precedes this identifier
214 62         143 my $before = substr($expr, 0, $match_start);
215              
216             # Skip if preceded by . (already a method call on something)
217 62 100       345 next if $before =~ /\.\s*$/;
218              
219             # Skip if preceded by 'new '
220 31 50       73 next if $before =~ /\bnew\s+$/;
221              
222             # Skip if preceded by 'function '
223 31 50       79 next if $before =~ /\bfunction\s+$/;
224              
225             # Skip if already has this.
226 31 50       77 next if $before =~ /\bthis\.\s*$/;
227              
228             # Skip local variables (arrow params, for loop vars)
229 31 50       76 next if $local_vars->{$name};
230              
231             # Only add this. if it's a known method (whitelist approach)
232 31 100 66     246 next unless $known_methods && $known_methods->{$name};
233              
234             # Add this. prefix
235 5         18 $result .= substr($expr, $last_end, $match_start - $last_end);
236 5         12 $result .= "this.$name(";
237 5         17 $last_end = $match_end;
238             }
239              
240 29         94 $result .= substr($expr, $last_end);
241 29         194 return $result;
242             }
243              
244             # Main transformation function that applies all shorthand transformations
245             sub transform_expression {
246 29     29 0 6677 my ($expr, $known_methods) = @_;
247 29 50 33     180 return $expr unless defined $expr && $expr =~ /\S/;
248              
249             # Wrap as regular function so .call(scope) can rebind 'this' for slot scoping
250             # Don't wrap expressions that are already complete (async, function)
251 29 100 66     282 if ($expr =~ /^async\s/ || $expr =~ /^function[\s(]/) {
    100          
    50          
    100          
252             # Already a complete expression, just apply transforms below
253             }
254             elsif ($expr !~ /^\(/) {
255 14         41 $expr = "function(){return $expr}";
256             }
257             elsif($expr =~ /^\s*\(\s*([^)]+)\s*\)\s*=>\s*([{].*)/s) {
258             # Arrow with block body — convert to regular function
259 0         0 $expr = "function($1)$2";
260             }
261             elsif($expr =~ /^\s*\(\s*([^)]+)\s*\)\s*=>\s*(.*)/s) {
262             # Arrow with expression body — convert to regular function
263 5         32 $expr = "function($1){return $2}";
264             }
265              
266 29         76 my $local_vars = extract_arrow_params($expr);
267 29         102 $expr = transform_dollar_vars($expr);
268 29         79 $expr = add_implicit_this($expr, $local_vars, $known_methods);
269 29         161 return $expr;
270             }
271              
272             # Parse HTML into hierarchical structure
273             sub parse_html {
274 22     22 0 74036 my ($html, $known_methods) = @_;
275              
276 22         84 my %result = (
277             children => [],
278             );
279              
280             # Self-closing tags
281 22         66 my %void_tags = map { $_ => 1 } qw(area base br col embed hr img input meta param source track wbr);
  286         727  
282              
283 22         82 my @stack = (\%result);
284 22         44 my $pos = 0;
285              
286 22         453 while ($html =~ /(<(?:[^>"']|"[^"]*"|'[^']*')+>|[^<]+)/g) {
287 201         566 my $token = $1;
288              
289 201 100       1528 if ($token =~ /^<\/([\w.!]+)>$/) {
    100          
    100          
290 56         136 my $tag = lc($1);
291 56 50       273 pop @stack if @stack > 1;
292             }
293             elsif ($token =~ /^<([\w.!]+)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>$/) {
294 64         199 my $full_tag = lc($1);
295 64   50     224 my $attr_str = $2 // '';
296 64         159 my $self_close = $3;
297              
298 64 50       176 next if $full_tag =~ /^!/;
299              
300 64         256 my ($tag, $variant) = split(/!/, $full_tag, 2);
301              
302 64         242 my $element = {
303             tag => $tag,
304             children => [],
305             };
306 64 100       197 $element->{variant} = $variant if defined $variant;
307              
308 64         173 my %attrs;
309             # First pass: match attributes with values (attr="value")
310 64         319 while ($attr_str =~ /([\w:@!*]+)="([^"]*)"/g) {
311 38         124 my ($attr, $value) = ($1, $2);
312              
313             # Parentheses required: 'and' has lower precedence than '||'
314 38 100 100     339 if (($tag eq 'if' || $tag eq 'elseif') and $attr eq 'condition') {
    100 66        
    100          
    100          
    100          
315 4         63 $attrs{"*condition"} = transform_expression($value, $known_methods);
316             }
317             elsif ($attr eq 'for') {
318 4 100       18 if ($value =~ /^\[/) {
319 2         8 $value = "function(){return $value}";
320             }
321 4         13 $attrs{"*for"} = transform_expression($value, $known_methods);
322             }
323             elsif ($attr =~ /^@(\w+)$/) {
324 3         11 $attr = "*on" . lc($1);
325 3         16 $attrs{$attr} = transform_expression($value, $known_methods);
326             }
327             elsif ($value =~ /^\(/) {
328 4         13 $value = transform_expression($value, $known_methods);
329 4         28 $attrs{"*".lc($attr)} = $value;
330             }
331             elsif ($value =~ /\$/) {
332 3         12 $value = _transform_dollar_interpolation($value);
333 3         20 $attrs{"*".lc($attr)} = "function(){return `$value`}";
334             }
335             else {
336 20         116 $attrs{lc($attr)} = $value;
337             }
338             }
339              
340             # Second pass: match bare attributes without values (e.g.,
341             # Remove already-matched attributes with values first
342 64         153 my $bare_attr_str = $attr_str;
343 64         390 $bare_attr_str =~ s/[\w:@*]+="[^"]*"//g;
344 64         205 while ($bare_attr_str =~ /\b([\w-]+)\b/g) {
345 1         5 my $attr = lc($1);
346             # Only add if not already set
347 1 50 33     10 unless (exists $attrs{$attr} || exists $attrs{"*$attr"}) {
348 1         6 $attrs{$attr} = 'true';
349             }
350             }
351              
352 64         162 $element->{attributes} = \%attrs;
353              
354 64         122 my $parent = $stack[-1];
355 64         96 push @{$parent->{children}}, $element;
  64         180  
356              
357 64 50 66     352 unless ($self_close || $void_tags{$tag}) {
358 56         376 push @stack, $element;
359             }
360             }
361             elsif ($token !~ /^\s*$/) {
362 32         115 my $text = $token;
363 32         232 $text =~ s/^\s+|\s+$//g;
364 32         57 my $text_node = {};
365 32 50       110 if ($text ne '') {
366 32 100       126 if ($text =~ /^\s*(\([^)]*\)\s*=>)(.*)/) {
    100          
367 1         15 $text_node->{'*content'} = transform_expression($text, $known_methods);
368             } elsif ($text =~ /\$/) {
369 10         26 $text = _transform_dollar_interpolation($text);
370 10         33 $text_node->{'*content'} = "function(){return `$text`}";
371             } else {
372 21         70 $text_node->{content} = $text;
373             }
374              
375 32         59 my $parent = $stack[-1];
376 32         57 push @{$parent->{children}}, $text_node;
  32         232  
377             }
378             }
379             }
380              
381 22         185 return \%result;
382             }
383              
384             # Transform $var references in template text/attributes.
385             # Handles ${$var.prop}, ${$var}, and bare $var patterns.
386             # Inside ${...} blocks, $var is replaced without adding another ${} wrapper.
387             sub _transform_dollar_interpolation {
388 13     13   32 my ($text) = @_;
389              
390             # First: transform ${...} blocks that contain $var references
391 13         47 $text =~ s/\$\{([^}]*)\}/"\${" . _transform_inner_dollars($1) . "}"/ge;
  5         15  
392              
393             # Then: transform bare $var (not inside ${})
394 13         109 $text =~ s/\$([\w]+)/\${this.get_$1()}/g;
395              
396 13         43 return $text;
397             }
398              
399             # Transform $var references inside a ${...} expression.
400             # $var becomes this.get_var(), no extra ${} wrapping.
401             sub _transform_inner_dollars {
402 5     5   13 my ($expr) = @_;
403 5         12 $expr =~ s/\$(\w+)/this.get_$1()/g;
404 5         25 return $expr;
405             }
406              
407             1;