File Coverage

blib/lib/TOON/PP.pm
Criterion Covered Total %
statement 345 376 91.7
branch 84 144 58.3
condition 31 52 59.6
subroutine 28 28 100.0
pod 3 3 100.0
total 491 603 81.4


line stmt bran cond sub pod time code
1             package TOON::PP;
2              
3 6     6   82 use v5.40;
  6         23  
4 6     6   42 use feature 'signatures';
  6         17  
  6         903  
5              
6 6     6   47 use Scalar::Util qw(looks_like_number blessed);
  6         12  
  6         622  
7 6     6   3206 use TOON::Error;
  6         18  
  6         40740  
8              
9             our $VERSION = '0.1.0';
10              
11 7     7 1 14 sub new ($class, %opts) {
  7         9  
  7         14  
  7         9  
12             return bless {
13             pretty => $opts{pretty} // 0,
14             canonical => $opts{canonical} // 0,
15 7   100     76 indent => $opts{indent} // 2,
      100        
      100        
16             }, $class;
17             }
18              
19 3     3 1 10 sub encode ($self, $data) {
  3         5  
  3         4  
  3         4  
20 3         13 return $self->_encode_value($data, 0);
21             }
22              
23 4     4 1 6 sub decode ($self, $text) {
  4         8  
  4         4  
  4         8  
24 4         16 my $state = {
25             text => $text,
26             len => length($text),
27             pos => 0,
28             };
29              
30 4         18 $self->_skip_ws($state);
31 4         13 my $value = $self->_parse_value($state);
32 3         10 $self->_skip_ws($state);
33              
34 3 50       16 if ($state->{pos} < $state->{len}) {
35 0         0 $self->_throw($state, 'Trailing characters after document');
36             }
37              
38 3         26 return $value;
39             }
40              
41 8     8   8 sub _encode_value ($self, $value, $level) {
  8         9  
  8         9  
  8         7  
  8         10  
42 8 50       14 return 'null' if !defined $value;
43              
44 8 50       16 if (blessed($value)) {
45 0         0 die TOON::Error->new(
46             message => 'Encoding blessed references is not supported',
47             line => 1,
48             column => 1,
49             offset => 0,
50             );
51             }
52              
53 8         14 my $ref = ref $value;
54              
55 8 100       43 if (!$ref) {
56 4 50       7 return 'true' if $value eq 'true';
57 4 50       7 return 'false' if $value eq 'false';
58              
59 4 100 66     21 if ($value =~ /\A(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\z/ || looks_like_number($value)) {
60 3         10 return 0 + $value;
61             }
62              
63 1         4 return $self->_quote_string($value);
64             }
65              
66 4 100       19 if ($ref eq 'ARRAY') {
67 1         3 return $self->_encode_array($value, $level);
68             }
69              
70 3 50       10 if ($ref eq 'HASH') {
71 3         12 return $self->_encode_hash($value, $level);
72             }
73              
74 0         0 die TOON::Error->new(
75             message => "Encoding $ref references is not supported",
76             line => 1,
77             column => 1,
78             offset => 0,
79             );
80             }
81              
82 1     1   1 sub _encode_array ($self, $array, $level) {
  1         10  
  1         1  
  1         1  
  1         2  
83 1 50       2 return '[]' unless @$array;
84              
85 1         2 my @items = map { $self->_encode_value($_, $level + 1) } @$array;
  2         26  
86              
87             return '[' . join(', ', @items) . ']'
88 1 50       6 unless $self->{pretty};
89              
90 1         3 my $pad = ' ' x ($self->{indent} * $level);
91 1         19 my $childpad = ' ' x ($self->{indent} * ($level + 1));
92              
93             return "[\n"
94 1         2 . join(",\n", map { $childpad . $_ } @items)
  2         9  
95             . "\n$pad]";
96             }
97              
98 3     3   5 sub _is_tabular_encodable ($self, $hash) {
  3         4  
  3         4  
  3         6  
99 3 50       14 return 0 unless %$hash;
100              
101 3         10 for my $key (keys %$hash) {
102 3 50       19 return 0 unless $key =~ /\A[A-Za-z_][A-Za-z0-9_-]*\z/;
103              
104 3         7 my $val = $hash->{$key};
105 3 100 66     45 return 0 unless ref $val eq 'ARRAY' && @$val > 0;
106              
107 1         3 my $first = $val->[0];
108 1 50 33     8 return 0 unless ref $first eq 'HASH' && %$first;
109              
110 1         7 my @fields = sort keys %$first;
111 1         3 for my $f (@fields) {
112 3 50       13 return 0 unless $f =~ /\A[A-Za-z_][A-Za-z0-9_-]*\z/;
113             }
114              
115 1         3 for my $row (@$val) {
116 2 50       6 return 0 unless ref $row eq 'HASH';
117 2 50       13 return 0 unless join(',', sort keys %$row) eq join(',', @fields);
118 2         6 for my $cell (values %$row) {
119 6 50       24 return 0 unless defined $cell;
120 6 50 66     50 return 0 if !looks_like_number($cell) && $cell =~ /[,\n\r]/;
121             }
122             }
123             }
124              
125 1         6 return 1;
126             }
127              
128 1     1   3 sub _encode_tabular ($self, $hash) {
  1         2  
  1         2  
  1         2  
129 1         4 my @keys = sort keys %$hash;
130              
131 1         2 my @sections;
132 1         2 for my $key (@keys) {
133 1         3 my $arr = $hash->{$key};
134 1         2 my $count = scalar @$arr;
135 1         3 my @fields = sort keys %{ $arr->[0] };
  1         5  
136              
137 1         6 my $section = "$key\[$count\]{" . join(',', @fields) . "}:\n";
138 1         3 for my $row (@$arr) {
139 2         4 $section .= ' ' . join(',', map { $self->_encode_tabular_value($row->{$_}) } @fields) . "\n";
  6         15  
140             }
141 1         4 push @sections, $section;
142             }
143              
144 1         9 return join('', @sections);
145             }
146              
147 6     6   9 sub _encode_tabular_value ($self, $value) {
  6         9  
  6         10  
  6         9  
148 6 50       13 return '' unless defined $value;
149 6 100       22 return 0 + $value if looks_like_number($value);
150 4         16 return "$value";
151             }
152              
153 3     3   6 sub _encode_hash ($self, $hash, $level) {
  3         6  
  3         4  
  3         4  
  3         5  
154 3 50       14 return '{}' unless %$hash;
155              
156 3 100 66     18 if ($level == 0 && $self->_is_tabular_encodable($hash)) {
157 1         16 return $self->_encode_tabular($hash);
158             }
159              
160 2         5 my @keys = keys %$hash;
161 2 50       19 @keys = sort @keys if $self->{canonical};
162              
163             my @pairs = map {
164 2 50       7 my $key = $_ =~ /\A[A-Za-z_][A-Za-z0-9_-]*\z/
  3         12  
165             ? $_
166             : $self->_quote_string($_);
167 3         10 $key . ': ' . $self->_encode_value($hash->{$_}, $level + 1);
168             } @keys;
169              
170             return '{' . join(', ', @pairs) . '}'
171 2 100       10 unless $self->{pretty};
172              
173 1         3 my $pad = ' ' x ($self->{indent} * $level);
174 1         2 my $childpad = ' ' x ($self->{indent} * ($level + 1));
175              
176             return "{\n"
177 1         2 . join(",\n", map { $childpad . $_ } @pairs)
  2         8  
178             . "\n$pad}";
179             }
180              
181 1     1   2 sub _quote_string ($self, $string) {
  1         2  
  1         1  
  1         1  
182 1         2 $string =~ s/\\/\\\\/g;
183 1         2 $string =~ s/"/\\"/g;
184 1         1 $string =~ s/\n/\\n/g;
185 1         2 $string =~ s/\r/\\r/g;
186 1         18 $string =~ s/\t/\\t/g;
187 1         19 $string =~ s/\f/\\f/g;
188 1         2 $string =~ s/\x08/\\b/g;
189 1         4 return qq{"$string"};
190             }
191              
192 17     17   18 sub _parse_value ($self, $state) {
  17         18  
  17         15  
  17         16  
193 17         26 $self->_skip_ws($state);
194              
195 17         29 my $ch = $self->_peek($state);
196 17 50       74 $self->_throw($state, 'Unexpected end of input') unless defined $ch;
197              
198 17 100       45 return $self->_parse_object($state) if $ch eq '{';
199 14 100       24 return $self->_parse_array($state) if $ch eq '[';
200 11 100       28 return $self->_parse_string($state) if $ch eq '"';
201              
202 10 50       36 if ($self->_consume_literal($state, 'null')) { return undef }
  0         0  
203 10 100       18 if ($self->_consume_literal($state, 'true')) { return 1 }
  1         15  
204 9 50       26 if ($self->_consume_literal($state, 'false')) { return 0 }
  0         0  
205              
206 9 100       23 if ($ch =~ /[-0-9]/) {
207 8         21 return $self->_parse_number($state);
208             }
209              
210 1 50       5 if ($ch =~ /[A-Za-z_]/) {
211 1         5 return $self->_parse_tabular($state);
212             }
213              
214 0         0 $self->_throw($state, "Unexpected character '$ch'");
215             }
216              
217 3     3   5 sub _parse_object ($self, $state) {
  3         6  
  3         4  
  3         4  
218 3         10 $self->_expect($state, '{');
219 3         6 $self->_skip_ws($state);
220              
221 3         3 my %hash;
222              
223 3 50 50     7 if (($self->_peek($state) // '') eq '}') {
224 0         0 $state->{pos}++;
225 0         0 return \%hash;
226             }
227              
228 3         11 while (1) {
229 6         13 $self->_skip_ws($state);
230 6         13 my $key = $self->_parse_key($state);
231 6         14 $self->_skip_ws($state);
232 6         10 $self->_expect($state, ':');
233 6         11 $self->_skip_ws($state);
234 6         33 $hash{$key} = $self->_parse_value($state);
235 5         9 $self->_skip_ws($state);
236              
237 5         6 my $ch = $self->_peek($state);
238 5 100 66     20 if (defined $ch && $ch eq ',') {
239 3         9 $state->{pos}++;
240 3         5 next;
241             }
242 2         2 last;
243             }
244              
245 2         4 $self->_skip_ws($state);
246 2         8 $self->_expect($state, '}');
247 2         4 return \%hash;
248             }
249              
250 3     3   6 sub _parse_array ($self, $state) {
  3         11  
  3         4  
  3         3  
251 3         14 $self->_expect($state, '[');
252 3         5 $self->_skip_ws($state);
253              
254 3         8 my @array;
255              
256 3 50 50     5 if (($self->_peek($state) // '') eq ']') {
257 0         0 $state->{pos}++;
258 0         0 return \@array;
259             }
260              
261 3         5 while (1) {
262 7         13 push @array, $self->_parse_value($state);
263 7         17 $self->_skip_ws($state);
264              
265 7         9 my $ch = $self->_peek($state);
266 7 100 66     25 if (defined $ch && $ch eq ',') {
267 4         17 $state->{pos}++;
268 4         6 next;
269             }
270 3         4 last;
271             }
272              
273 3         5 $self->_skip_ws($state);
274 3         6 $self->_expect($state, ']');
275 2         5 return \@array;
276             }
277              
278 1     1   2 sub _parse_tabular ($self, $state) {
  1         2  
  1         2  
  1         1  
279 1         3 my %result;
280              
281 1         5 while ($state->{pos} < $state->{len}) {
282             # Skip blank lines and leading whitespace between sections
283 2         6 $self->_skip_ws($state);
284 2 100       7 last if $state->{pos} >= $state->{len};
285              
286 1         4 my $ch = $self->_peek($state);
287 1 50 33     11 last unless defined $ch && $ch =~ /[A-Za-z_]/;
288              
289             # Parse key name
290 1         4 my $remaining = substr($state->{text}, $state->{pos});
291 1 50       33 $self->_throw($state, 'Expected identifier')
292             unless $remaining =~ /\A([A-Za-z_][A-Za-z0-9_-]*)/;
293 1         6 my $key = $1;
294 1         3 $state->{pos} += length $key;
295              
296             # Parse [count]
297 1         6 $self->_expect($state, '[');
298 1         3 $remaining = substr($state->{text}, $state->{pos});
299 1 50       7 $self->_throw($state, 'Expected count in [...]')
300             unless $remaining =~ /\A([0-9]+)/;
301 1         3 my $count = int($1);
302 1         3 $state->{pos} += length $1;
303 1         4 $self->_expect($state, ']');
304              
305             # Parse {field1,field2,...}
306 1         6 $self->_expect($state, '{');
307 1         2 my @fields;
308 1         3 while (1) {
309 3         9 $remaining = substr($state->{text}, $state->{pos});
310 3 50       13 $self->_throw($state, 'Expected field name')
311             unless $remaining =~ /\A([A-Za-z_][A-Za-z0-9_-]*)/;
312 3         10 push @fields, $1;
313 3         8 $state->{pos} += length $1;
314 3         7 my $c = $self->_peek($state);
315 3 100 66     61 if (defined $c && $c eq ',') {
316 2         6 $state->{pos}++;
317 2         3 next;
318             }
319 1         2 last;
320             }
321 1         5 $self->_expect($state, '}');
322              
323             # Expect ':'
324 1         3 $self->_expect($state, ':');
325              
326             # Parse count rows of comma-separated values
327 1         1 my @rows;
328 1         5 for (1 .. $count) {
329             # Skip to the start of the next line
330 2         7 while ($state->{pos} < $state->{len}) {
331 2         7 my $c = substr($state->{text}, $state->{pos}, 1);
332 2         3 $state->{pos}++;
333 2 50       6 last if $c eq "\n";
334             }
335              
336             # Skip leading whitespace (indentation) on this data line
337 2         7 while ($state->{pos} < $state->{len}) {
338 6         23 my $c = substr($state->{text}, $state->{pos}, 1);
339 6 100 66     22 last unless $c eq ' ' || $c eq "\t";
340 4         10 $state->{pos}++;
341             }
342             # Parse comma-separated field values
343 2         3 my %row;
344 2         8 for my $fi (0 .. $#fields) {
345 6 100       12 if ($fi > 0) {
346 4         11 my $c = $self->_peek($state);
347 4 50 33     20 $self->_throw($state, 'Expected comma between row values')
348             unless defined $c && $c eq ',';
349 4         7 $state->{pos}++;
350             }
351 6         17 $row{$fields[$fi]} = $self->_parse_tabular_value($state);
352             }
353 2         16 push @rows, \%row;
354             }
355              
356 1         7 $result{$key} = \@rows;
357             }
358              
359 1         5 return \%result;
360             }
361              
362 6     6   17 sub _parse_tabular_value ($self, $state) {
  6         10  
  6         7  
  6         8  
363 6         16 my $remaining = substr($state->{text}, $state->{pos});
364 6         21 $remaining =~ /\A([^,\n\r]*)/;
365 6         16 my $raw = $1;
366 6         12 $raw =~ s/\s+\z//; # right-trim
367 6         13 $state->{pos} += length $1;
368              
369 6 100       56 if ($raw =~ /\A-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\z/) {
370 2         14 return 0 + $raw;
371             }
372 4         33 return $raw;
373             }
374              
375 6     6   6 sub _parse_key ($self, $state) {
  6         6  
  6         8  
  6         5  
376 6         9 my $ch = $self->_peek($state);
377 6 50 33     21 return $self->_parse_string($state) if defined $ch && $ch eq '"';
378              
379 6         12 my $remaining = substr($state->{text}, $state->{pos});
380 6 50       26 if ($remaining =~ /\A([A-Za-z_][A-Za-z0-9_-]*)/) {
381 6         11 $state->{pos} += length $1;
382 6         18 return $1;
383             }
384              
385 0         0 $self->_throw($state, 'Expected object key');
386             }
387              
388 1     1   1 sub _parse_string ($self, $state) {
  1         1  
  1         2  
  1         1  
389 1         2 $self->_expect($state, '"');
390 1         1 my $out = '';
391              
392 1         3 while ($state->{pos} < $state->{len}) {
393 4         5 my $ch = substr($state->{text}, $state->{pos}, 1);
394 4         5 $state->{pos}++;
395              
396 4 100       21 return $out if $ch eq '"';
397              
398 3 50       4 if ($ch eq '\\') {
399             $self->_throw($state, 'Unexpected end of input in string escape')
400 0 0       0 if $state->{pos} >= $state->{len};
401              
402 0         0 my $esc = substr($state->{text}, $state->{pos}, 1);
403 0         0 $state->{pos}++;
404              
405 0 0       0 if ($esc eq '"') { $out .= '"' }
  0 0       0  
    0          
    0          
    0          
    0          
    0          
    0          
    0          
406 0         0 elsif ($esc eq '\\') { $out .= '\\' }
407 0         0 elsif ($esc eq '/') { $out .= '/' }
408 0         0 elsif ($esc eq 'n') { $out .= "\n" }
409 0         0 elsif ($esc eq 'r') { $out .= "\r" }
410 0         0 elsif ($esc eq 't') { $out .= "\t" }
411 0         0 elsif ($esc eq 'f') { $out .= "\f" }
412 0         0 elsif ($esc eq 'b') { $out .= "\b" }
413             elsif ($esc eq 'u') {
414 0         0 my $hex = substr($state->{text}, $state->{pos}, 4);
415 0 0       0 $self->_throw($state, 'Invalid unicode escape')
416             unless $hex =~ /\A[0-9A-Fa-f]{4}\z/;
417 0         0 $state->{pos} += 4;
418 0         0 $out .= chr(hex($hex));
419             }
420             else {
421 0         0 $self->_throw($state, "Unknown escape sequence \\$esc");
422             }
423              
424 0         0 next;
425             }
426              
427 3         4 $out .= $ch;
428             }
429              
430 0         0 $self->_throw($state, 'Unterminated string');
431             }
432              
433 8     8   6 sub _parse_number ($self, $state) {
  8         7  
  8         9  
  8         7  
434 8         12 my $remaining = substr($state->{text}, $state->{pos});
435 8 50       27 if ($remaining =~ /\A(-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)/) {
436 8         18 $state->{pos} += length $1;
437 8         20 return 0 + $1;
438             }
439              
440 0         0 $self->_throw($state, 'Invalid number');
441             }
442              
443 29     29   43 sub _consume_literal ($self, $state, $literal) {
  29         69  
  29         28  
  29         44  
  29         27  
444 29 100       90 return 0 unless substr($state->{text}, $state->{pos}, length($literal)) eq $literal;
445              
446 1         2 my $next = substr($state->{text}, $state->{pos} + length($literal), 1);
447 1 50 33     6 return 0 if defined($next) && $next =~ /[A-Za-z0-9_-]/;
448              
449 1         1 $state->{pos} += length($literal);
450 1         2 return 1;
451             }
452              
453 23     23   25 sub _expect ($self, $state, $char) {
  23         49  
  23         24  
  23         23  
  23         21  
454 23         32 my $got = $self->_peek($state);
455 23 100 66     80 $self->_throw($state, "Expected '$char'") unless defined $got && $got eq $char;
456 22         32 $state->{pos}++;
457             }
458              
459 72     72   65 sub _peek ($self, $state) {
  72         68  
  72         66  
  72         68  
460 72 50       130 return undef if $state->{pos} >= $state->{len};
461 72         135 return substr($state->{text}, $state->{pos}, 1);
462             }
463              
464 67     67   61 sub _skip_ws ($self, $state) {
  67         59  
  67         63  
  67         53  
465 67         108 while ($state->{pos} < $state->{len}) {
466 95         116 my $ch = substr($state->{text}, $state->{pos}, 1);
467 95 100       148 if ($ch =~ /[\x20\x09\x0A\x0D]/) {
468 32         51 $state->{pos}++;
469 32         46 next;
470             }
471 63         60 last;
472             }
473             }
474              
475 1     1   6 sub _throw ($self, $state, $message) {
  1         1  
  1         2  
  1         1  
  1         1  
476 1         4 my ($line, $column) = $self->_line_and_column($state->{text}, $state->{pos});
477             die TOON::Error->new(
478             message => $message,
479             line => $line,
480             column => $column,
481             offset => $state->{pos},
482 1         8 );
483             }
484              
485 1     1   1 sub _line_and_column ($self, $text, $pos) {
  1         1  
  1         2  
  1         1  
  1         1  
486 1         2 my $prefix = substr($text, 0, $pos);
487 1         3 my $line = 1 + ($prefix =~ tr/\n//);
488 1         29 my $last_nl = rindex($prefix, "\n");
489 1         3 my $column = $pos - $last_nl;
490 1         3 return ($line, $column);
491             }
492              
493             1;
494              
495             __END__