File Coverage

blib/lib/Data/TOON/Decoder.pm
Criterion Covered Total %
statement 170 217 78.3
branch 70 104 67.3
condition 22 45 48.8
subroutine 10 10 100.0
pod 0 2 0.0
total 272 378 71.9


line stmt bran cond sub pod time code
1             package Data::TOON::Decoder;
2 10     10   179 use 5.014;
  10         42  
3 10     10   53 use strict;
  10         19  
  10         286  
4 10     10   42 use warnings;
  10         14  
  10         34224  
5              
6             sub new {
7 47     47 0 129 my ($class, %opts) = @_;
8             return bless {
9             strict => $opts{strict} // 1,
10             lines => [],
11             pos => 0,
12 47   50     682 max_depth => $opts{max_depth} // 100, # Prevent DoS from deep nesting
      50        
13             current_depth => 0,
14             }, $class;
15             }
16              
17             sub decode {
18 47     47 0 132 my ($self, $toon_text) = @_;
19            
20             # Split into lines and remove trailing newlines
21 47         574 my @lines = split /\r?\n/, $toon_text;
22 47 50 33     244 pop @lines if @lines && $lines[-1] eq '';
23            
24 47         138 $self->{lines} = \@lines;
25 47         89 $self->{pos} = 0;
26            
27             # Determine root form and decode
28 47         132 return $self->_decode_root();
29             }
30              
31             sub _decode_root {
32 47     47   93 my ($self) = @_;
33            
34             # Initialize position
35 47         81 $self->{pos} = 0;
36            
37             # Find first non-empty line at depth 0
38 47         90 my @non_empty = ();
39 47         79 foreach my $line (@{$self->{lines}}) {
  47         139  
40 234 50 33     870 next if !defined $line || $line =~ /^\s*$/;
41 234 100       371 next if $self->_get_depth($line) != 0;
42 53         155 push @non_empty, $line;
43             }
44            
45             # Empty document → empty object
46 47 50       122 return {} if !@non_empty;
47            
48 47         93 my $first = $non_empty[0];
49            
50             # Check if first line is a root array header: [N], [N|], [N\t]
51 47 100       184 if ($first =~ /^\s*\[(\d+)([\t|]?)?\]\s*:\s*(.*)$/) {
52             # Root array header
53 3         13 my $count = $1;
54 3 100       13 my $delimiter = $2 ? $2 : ',';
55 3         10 my $rest = $3;
56            
57             # Inline values
58 3 50       10 if ($rest) {
59 3         62 my @values = split /\Q$delimiter\E/, $rest;
60 3         12 return [map { $self->_parse_primitive($_) } @values];
  9         21  
61             }
62            
63             # Or read list items below
64 0         0 $self->{pos} = 0;
65 0         0 my @items;
66 0         0 while ($self->{pos} < @{$self->{lines}}) {
  0         0  
67 0         0 my $line = $self->{lines}->[$self->{pos}];
68            
69 0 0 0     0 if (!$line || $line =~ /^\s*$/) {
70 0         0 $self->{pos}++;
71 0         0 next;
72             }
73            
74 0         0 my $depth = $self->_get_depth($line);
75 0 0       0 if ($depth == 0) {
    0          
76             # Still at root - check if it's the header
77 0 0       0 if ($line =~ /^\[/) {
78 0         0 $self->{pos}++;
79 0         0 next; # Skip header
80             }
81             } elsif ($depth > 0) {
82 0         0 my $trimmed = $line;
83 0         0 $trimmed =~ s/^\s+//;
84 0 0       0 if ($trimmed =~ /^-/) {
85             # List item
86 0         0 $self->{pos}++;
87 0         0 $trimmed =~ s/^-\s+//;
88 0         0 push @items, $self->_parse_primitive($trimmed);
89 0         0 next;
90             }
91             } else {
92 0         0 last;
93             }
94 0         0 $self->{pos}++;
95             }
96 0 0       0 return \@items if @items;
97 0         0 return [];
98             }
99            
100             # Check if first non-empty line is an array header with key (has [N]{...}: pattern)
101 44 100       142 if ($first =~ /^\w+\[/) {
102             # Object with array field - use object mode
103 14         55 return $self->_decode_object(0);
104             }
105            
106             # Single line and it's a primitive (no colon = not key-value)
107 30 100 100     174 if (@non_empty == 1 && $first !~ /:/) {
108 10         31 return $self->_parse_primitive($first);
109             }
110            
111             # Otherwise, decode as object with depth 0
112 20         58 return $self->_decode_object(0);
113             }
114              
115             sub _decode_object {
116 140     140   312 my ($self, $target_depth) = @_;
117 140   50     290 $target_depth //= 0;
118            
119             # Check max depth to prevent DoS
120 140 100       272 if ($target_depth > $self->{max_depth}) {
121 1         54 die "Maximum nesting depth exceeded (max: $self->{max_depth})\n";
122             }
123            
124 139         177 my $obj = {};
125            
126 139         213 while ($self->{pos} < @{$self->{lines}}) {
  187         390  
127 149         258 my $line = $self->{lines}->[$self->{pos}];
128            
129             # Skip empty lines
130 149 50 33     547 if (!$line || $line =~ /^\s*$/) {
131 0         0 $self->{pos}++;
132 0         0 next;
133             }
134            
135 149         238 my $depth = $self->_get_depth($line);
136            
137             # If depth is less than or equal to target, we're done with this object
138 149 50       243 if ($depth < $target_depth) {
139 0         0 last;
140             }
141            
142             # If depth is greater than target (but not target+1), skip (shouldn't happen in well-formed TOON)
143 149 50       257 if ($depth > $target_depth + 1) {
144 0         0 $self->{pos}++;
145 0         0 next;
146             }
147            
148             # If depth is greater than target, it's a child of a nested key
149 149 50       219 if ($depth > $target_depth) {
150 0         0 last;
151             }
152            
153 149         285 $self->{pos}++;
154            
155             # Parse key-value line
156 149         180 my $trimmed = $line;
157 149         267 $trimmed =~ s/^\s+//;
158            
159             # Match patterns like:
160             # key: value
161             # key[N]: ...
162             # key[N]{fields}: ...
163             # key: (empty - nested object)
164 149 100       559 if ($trimmed =~ /^(\w+)(\[[^\]]*\])?(\{[^}]*\})?\s*:\s*(.*)$/) {
165 147         640 my ($key, $bracket, $fields, $rest) = ($1, $2, $3, $4);
166            
167             # If there's a bracket segment, it's an array
168 147 100 66     502 if ($bracket) {
    100          
169 13         74 $obj->{$key} = $self->_decode_array_value($bracket, $fields, $rest);
170             } elsif (!$rest || $rest =~ /^\s*$/) {
171             # Empty value after colon = nested object
172 106         380 $obj->{$key} = $self->_decode_object($target_depth + 1);
173             } else {
174             # Primitive value
175 28         96 $obj->{$key} = $self->_parse_primitive($rest);
176             }
177             }
178             }
179            
180 38         326 return $obj;
181             }
182              
183             sub _decode_array_value {
184 13     13   46 my ($self, $bracket_part, $fields_part, $rest) = @_;
185            
186             # Parse bracket part: [N] or [N\t] or [N|]
187 13         26 my $delimiter = ','; # default
188 13 100       111 if ($bracket_part =~ /^\[(\d+)([\t|])?\]/) {
189 12         31 my $count = $1;
190 12 100       45 if (defined $2) {
191 7         12 $delimiter = $2;
192             }
193            
194 12 100       33 if ($fields_part) {
195             # Tabular format: extract field names and parse rows
196 7         14 my $fields_str = $fields_part;
197 7         52 $fields_str =~ s/^{|}$//g;
198 7         148 my @fields = split /\Q$delimiter\E/, $fields_str;
199            
200 7         19 my @rows;
201 7         17 while ($self->{pos} < @{$self->{lines}}) {
  21         62  
202 14         33 my $line = $self->{lines}->[$self->{pos}];
203            
204 14 50 33     108 if (!$line || $line =~ /^\s*$/) {
205 0         0 $self->{pos}++;
206 0         0 next;
207             }
208            
209 14         35 my $depth = $self->_get_depth($line);
210 14 50       37 if ($depth <= 0) {
211 0         0 last;
212             }
213            
214             # Check for list items (- prefix)
215 14         55 my $trimmed = $line;
216 14         52 $trimmed =~ s/^\s+//;
217 14 50       40 if ($trimmed =~ /^-\s/) {
218 0         0 last; # List format starts here
219             }
220            
221 14         32 $self->{pos}++;
222            
223 14         139 my @values = split /\Q$delimiter\E/, $trimmed;
224 14         30 my $obj = {};
225 14   100     87 for (my $i = 0; $i < @fields && $i < @values; $i++) {
226 28         71 $obj->{$fields[$i]} = $self->_parse_primitive($values[$i]);
227             }
228 14         53 push @rows, $obj;
229             }
230 7         44 return \@rows;
231             } else {
232             # Check for list format (items starting with -)
233             # Peek ahead to see if next line (at depth+1) starts with "-"
234 5         13 my $has_list_format = 0;
235 5         15 my $peek_pos = $self->{pos};
236            
237 5         14 while ($peek_pos < @{$self->{lines}}) {
  5         23  
238 3         12 my $peek_line = $self->{lines}->[$peek_pos];
239            
240 3 50 33     30 if (!$peek_line || $peek_line =~ /^\s*$/) {
241 0         0 $peek_pos++;
242 0         0 next;
243             }
244            
245 3         11 my $peek_depth = $self->_get_depth($peek_line);
246 3 50       11 if ($peek_depth <= 0) {
247 0         0 last;
248             }
249            
250 3         9 my $peek_trimmed = $peek_line;
251 3         13 $peek_trimmed =~ s/^\s+//;
252            
253 3 50       14 if ($peek_trimmed =~ /^-/) {
254 3         6 $has_list_format = 1;
255 3         10 last;
256             }
257            
258             # If it's not empty and doesn't start with -, it's inline or not list format
259 0         0 last;
260             }
261            
262 5 100       16 if ($has_list_format) {
263             # Parse list format items (rest of the implementation)
264 3         8 my @items;
265 3         8 while ($self->{pos} < @{$self->{lines}}) {
  10         72  
266 7         21 my $line = $self->{lines}->[$self->{pos}];
267            
268 7 50 33     45 if (!$line || $line =~ /^\s*$/) {
269 0         0 $self->{pos}++;
270 0         0 next;
271             }
272            
273 7         17 my $depth = $self->_get_depth($line);
274 7 50       20 if ($depth <= 0) {
275 0         0 last;
276             }
277            
278 7         13 my $trimmed = $line;
279 7         60 $trimmed =~ s/^\s+//;
280            
281 7 50       48 if ($trimmed =~ /^-\s(.*)$/) {
282 7         17 $self->{pos}++;
283 7         17 my $item_content = $1;
284            
285             # Parse first field or value
286 7 100       27 if ($item_content =~ /^(\w+):\s*(.*)$/) {
287             # Object item: first field on hyphen line
288 4         14 my ($first_key, $first_value) = ($1, $2);
289 4         9 my $item = {};
290            
291 4 50       17 if ($first_value =~ /^\s*$/) {
292             # Nested object - parse remaining fields at depth+2
293 0         0 $item->{$first_key} = $self->_decode_object($depth + 2);
294             } else {
295             # Primitive value
296 4         14 $item->{$first_key} = $self->_parse_primitive($first_value);
297            
298             # Parse remaining fields at depth+1
299 4         11 while ($self->{pos} < @{$self->{lines}}) {
  6         30  
300 4         11 my $next_line = $self->{lines}->[$self->{pos}];
301            
302 4 50 33     43 if (!$next_line || $next_line =~ /^\s*$/) {
303 0         0 $self->{pos}++;
304 0         0 next;
305             }
306            
307 4         12 my $next_depth = $self->_get_depth($next_line);
308 4 100 66     22 if ($next_depth < $depth + 1 || $next_depth > $depth + 1) {
309 2         6 last;
310             }
311            
312 2         3 my $next_trimmed = $next_line;
313 2         21 $next_trimmed =~ s/^\s+//;
314            
315 2 50       8 if ($next_trimmed =~ /^-/) {
316             # Next list item
317 0         0 last;
318             }
319            
320 2 50       9 if ($next_trimmed =~ /^(\w+):\s*(.*)$/) {
321 2         5 $self->{pos}++;
322 2         6 $item->{$1} = $self->_parse_primitive($2);
323             } else {
324 0         0 last;
325             }
326             }
327             }
328            
329 4         15 push @items, $item;
330             } else {
331             # Primitive item
332 3         28 push @items, $self->_parse_primitive($item_content);
333             }
334             } else {
335 0         0 last;
336             }
337             }
338 3         21 return \@items;
339             } else {
340             # Inline primitive array format: parse inline values with correct delimiter
341 2         42 my @values = split /\Q$delimiter\E/, $rest;
342 2         7 my @result = map { $self->_parse_primitive($_) } @values;
  6         17  
343 2         16 return \@result;
344             }
345             }
346             }
347            
348 1         7 return [];
349             }
350             sub _parse_primitive {
351 90     90   202 my ($self, $value) = @_;
352            
353 90 50       299 return undef unless defined $value;
354            
355 90         12805 $value =~ s/^\s+|\s+$//g;
356            
357             # Handle quoted strings
358 90 100       320 if ($value =~ /^"(.*)"$/) {
359 13         228 my $str = $1;
360             # Unescape
361 13         34 $str =~ s/\\"/"/g;
362 13         31 $str =~ s/\\\\/\\/g;
363 13         29 $str =~ s/\\n/\n/g;
364 13         33 $str =~ s/\\r/\r/g;
365 13         26 $str =~ s/\\t/\t/g;
366 13         87 return $str;
367             }
368            
369 77 100       294 return undef if $value eq 'null';
370 76 100       174 return 1 if $value eq 'true';
371 74 100       397 return 0 if $value eq 'false';
372            
373             # Number parsing with canonical form
374 73 100       329 if ($value =~ /^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/) {
375             # Reject leading zeros (except 0 itself and 0.x)
376 30 100 66     101 if ($value =~ /^[+-]?0\d/ && $value !~ /^[+-]?0\./) {
377             # Leading zero - treat as string
378 1         7 return $value;
379             }
380            
381             # Parse and normalize
382 29         117 my $num = 0 + $value;
383            
384             # Normalize: -0 → 0, remove trailing zeros
385 29 100       79 if ($num == 0) {
386 2         15 return 0;
387             }
388            
389             # Return normalized number
390 27 100       74 if ($num != int($num)) {
391             # Float - remove trailing zeros
392 2         15 my $str = sprintf("%.15g", $num);
393 2         18 return 0 + $str;
394             }
395            
396 25         196 return $num;
397             }
398            
399 43         245 return $value;
400             }
401              
402             sub _get_depth {
403 411     411   606 my ($self, $line) = @_;
404 411 100       655 return 0 unless $line;
405 410         866 my $spaces = length($line) - length($line =~ s/^ +//r);
406 410         881 return int($spaces / 2);
407             }
408              
409             1;