File Coverage

blib/lib/Data/TOON/Encoder.pm
Criterion Covered Total %
statement 172 190 90.5
branch 68 94 72.3
condition 14 22 63.6
subroutine 14 14 100.0
pod 0 2 0.0
total 268 322 83.2


line stmt bran cond sub pod time code
1             package Data::TOON::Encoder;
2 10     10   219 use 5.014;
  10         38  
3 10     10   73 use strict;
  10         64  
  10         361  
4 10     10   46 use warnings;
  10         20  
  10         31248  
5              
6             sub new {
7 33     33 0 6566 my ($class, %opts) = @_;
8             return bless {
9             indent => $opts{indent} || 2,
10             delimiter => $opts{delimiter} || ',',
11             strict => $opts{strict} // 1,
12             depth => 0,
13             max_depth => $opts{max_depth} // 100,
14             seen => {}, # Track object references for circular detection
15 33   50     750 column_priority => $opts{column_priority} || [], # Column names to prioritize
      100        
      50        
      100        
      100        
16             }, $class;
17             }
18              
19             sub encode {
20 33     33 0 103 my ($self, $data) = @_;
21            
22 33         90 $self->{depth} = 0;
23 33         133 $self->{seen} = {}; # Reset seen references
24 33         105 my $result = $self->_encode_value($data);
25            
26 31         248 return $result;
27             }
28              
29             sub _encode_value {
30 48     48   97 my ($self, $value) = @_;
31            
32 48 50       121 return undef unless defined $value;
33            
34 48         107 my $ref = ref $value;
35            
36 48 100       144 if ($ref eq 'HASH') {
    100          
37             # Check for circular reference
38 27         70 my $ref_addr = "$value"; # Stringify reference to get address
39 27 50       100 if ($self->{seen}->{$ref_addr}) {
40 0         0 die "Circular reference detected\n";
41             }
42 27         71 $self->{seen}->{$ref_addr} = 1;
43            
44             # Check max depth
45 27 50       78 if ($self->{depth} > $self->{max_depth}) {
46 0         0 die "Maximum nesting depth exceeded (max: $self->{max_depth})\n";
47             }
48            
49 27         80 return $self->_encode_object($value);
50             } elsif ($ref eq 'ARRAY') {
51             # Check for circular reference
52 2         6 my $ref_addr = "$value";
53 2 50       8 if ($self->{seen}->{$ref_addr}) {
54 0         0 die "Circular reference detected\n";
55             }
56 2         7 $self->{seen}->{$ref_addr} = 1;
57            
58             # Check max depth
59 2 50       6 if ($self->{depth} > $self->{max_depth}) {
60 0         0 die "Maximum nesting depth exceeded (max: $self->{max_depth})\n";
61             }
62            
63 2         9 return $self->_encode_array($value);
64             } else {
65 19         48 return $self->_encode_primitive($value);
66             }
67             }
68              
69             sub _encode_object {
70 53     53   95 my ($self, $obj) = @_;
71            
72             # Check max depth - allow one level deep
73 53 100       136 if ($self->{depth} >= $self->{max_depth}) {
74 2         33 die "Maximum nesting depth exceeded (max: $self->{max_depth})\n";
75             }
76            
77 51         79 my @lines;
78 51         169 foreach my $key ($self->_sort_fields(keys %$obj)) {
79 75         174 my $value = $obj->{$key};
80 75         273 my $indent = ' ' x ($self->{depth} * $self->{indent});
81            
82 75         137 my $ref = ref $value;
83            
84 75 100       213 if ($ref eq 'ARRAY') {
    100          
85 16         57 push @lines, $self->_encode_object_with_array($indent, $key, $value);
86             } elsif ($ref eq 'HASH') {
87 26         75 push @lines, $indent . "$key:";
88 26         64 local $self->{depth} = $self->{depth} + 1;
89 26         101 push @lines, $self->_encode_object($value);
90             } else {
91 33         80 my $encoded = $self->_encode_primitive($value);
92 33         126 push @lines, "$indent$key: $encoded";
93             }
94             }
95            
96 39         168 return join "\n", @lines;
97             }
98              
99             sub _encode_object_with_array {
100 16     16   45 my ($self, $indent, $key, $array) = @_;
101            
102 16 50       51 return undef unless ref $array eq 'ARRAY';
103            
104 16         35 my @lines;
105 16         39 my $array_len = scalar(@$array);
106            
107             # Check if array contains only objects (for potential tabular format)
108 16         24 my $all_objects = 1;
109 16         37 foreach my $item (@$array) {
110 26 100       81 if (ref $item ne 'HASH') {
111 1         1 $all_objects = 0;
112 1         2 last;
113             }
114             }
115            
116 16 100 66     69 if ($all_objects && $array_len > 0) {
117             # Check if tabular format is possible
118             # Requires: all objects have same keys and all values are primitives
119 15         27 my $first = $array->[0];
120 15         75 my @first_keys = sort keys %$first;
121 15         37 my $can_tabular = 1;
122            
123             # Check all objects have same keys and all values are primitives
124 15         28 foreach my $obj (@$array) {
125 25         83 my @obj_keys = sort keys %$obj;
126            
127             # Different key set = can't use tabular
128 25 100       85 if (@obj_keys != @first_keys) {
129 1         3 $can_tabular = 0;
130 1         4 last;
131             }
132            
133 24         132 for (my $i = 0; $i < @first_keys; $i++) {
134 60 50       212 if ($first_keys[$i] ne $obj_keys[$i]) {
135 0         0 $can_tabular = 0;
136 0         0 last;
137             }
138             }
139            
140 24 50       58 if (!$can_tabular) {
141 0         0 last;
142             }
143            
144             # Check all values are primitives
145 24         58 foreach my $val (values %$obj) {
146 58 100       145 if (ref $val) {
147 4         5 $can_tabular = 0;
148 4         5 last;
149             }
150             }
151            
152 24 100       121 if (!$can_tabular) {
153 4         29 last;
154             }
155             }
156            
157 15 100       42 if ($can_tabular) {
158             # Tabular format: extract field names from first object
159 10         40 my @fields = $self->_sort_fields(keys %$first);
160            
161             # Add delimiter indicator in bracket
162 10         45 my $delim_indicator = '';
163 10 100       56 if ($self->{delimiter} eq "\t") {
    100          
164 1         4 $delim_indicator = "\t";
165             } elsif ($self->{delimiter} eq '|') {
166 3         8 $delim_indicator = '|';
167             }
168            
169 10         42 my $field_list = join($self->{delimiter}, @fields);
170 10         37 my $header = $indent . $key . '[' . $array_len . $delim_indicator . ']{' . $field_list . '}:';
171 10         70 push @lines, $header;
172            
173 10         33 local $self->{depth} = $self->{depth} + 1;
174 10         37 my $row_indent = ' ' x ($self->{depth} * $self->{indent});
175            
176 10         24 foreach my $obj (@$array) {
177 19         47 my @values = map { $self->_encode_primitive($obj->{$_}) } @fields;
  49         220  
178 19         136 push @lines, $row_indent . join($self->{delimiter}, @values);
179             }
180             } else {
181             # List format: use - items
182 5         12 my $header = $indent . $key . '[' . $array_len . ']:';
183 5         8 push @lines, $header;
184            
185 5         15 local $self->{depth} = $self->{depth} + 1;
186 5         23 my $item_indent = ' ' x ($self->{depth} * $self->{indent});
187 5         12 my $field_indent = ' ' x (($self->{depth} + 1) * $self->{indent});
188            
189 5         9 foreach my $obj (@$array) {
190             # First field on hyphen line, remaining fields at depth+2
191 6         21 my @keys = $self->_sort_fields(keys %$obj);
192 6 50       14 if (@keys > 0) {
193             # First key with hyphen at current depth
194 6         11 my $first_key = $keys[0];
195 6         14 local $self->{depth} = $self->{depth} + 1;
196 6         17 my $first_val = $self->_encode_value($obj->{$first_key});
197 6         20 push @lines, $item_indent . "- $first_key: $first_val";
198            
199             # Remaining keys at depth+2 (one deeper)
200 6         15 for (my $i = 1; $i < @keys; $i++) {
201 7         13 my $k = $keys[$i];
202 7         18 my $v = $self->_encode_value($obj->{$k});
203 7         43 push @lines, $field_indent . "$k: $v";
204             }
205             } else {
206             # Empty object
207 0         0 push @lines, $item_indent . "-";
208             }
209             }
210             }
211             } else {
212             # List format: mixed types or not objects
213 1         3 my $header = $indent . $key . '[' . $array_len . ']:';
214 1         2 push @lines, $header;
215            
216 1         2 local $self->{depth} = $self->{depth} + 1;
217 1         3 my $item_indent = ' ' x ($self->{depth} * $self->{indent});
218            
219 1         2 foreach my $item (@$array) {
220 2         31 my $encoded = $self->_encode_value($item);
221 2 50       8 push @lines, $item_indent . "- $encoded" if defined $encoded;
222             }
223             }
224            
225 16         80 return join "\n", @lines;
226             }
227              
228             sub _encode_array {
229 2     2   7 my ($self, $array) = @_;
230            
231             # Check if all primitives
232 2         4 my $all_primitives = 1;
233 2         7 foreach my $item (@$array) {
234 6 50       15 if (ref $item) {
235 0         0 $all_primitives = 0;
236 0         0 last;
237             }
238             }
239            
240 2         5 my $array_len = scalar(@$array);
241            
242 2 50       5 if ($all_primitives) {
243             # Primitive array: inline format with delimiter indicator
244 2         7 my @values = map { $self->_encode_primitive($_) } @$array;
  6         16  
245 2         5 my $delim_indicator = '';
246 2 50       13 if ($self->{delimiter} eq "\t") {
    50          
247 0         0 $delim_indicator = "\t";
248             } elsif ($self->{delimiter} eq '|') {
249 0         0 $delim_indicator = '|';
250             }
251 2         14 return '[' . $array_len . $delim_indicator . ']: ' . join($self->{delimiter}, @values);
252             } else {
253             # Mixed/object array: list format
254 0         0 my @lines = ('[' . $array_len . ']:');
255            
256 0         0 local $self->{depth} = $self->{depth} + 1;
257 0         0 foreach my $item (@$array) {
258 0         0 my $encoded = $self->_encode_value($item);
259 0 0       0 push @lines, " - $encoded" if defined $encoded;
260             }
261            
262 0         0 return join "\n", @lines;
263             }
264             }
265              
266             sub _encode_primitive {
267 107     107   270 my ($self, $value) = @_;
268            
269 107 50       262 return 'null' if !defined $value;
270            
271             # Check if it's numeric (but not a boolean-like string)
272 107 100 66     722 if ($value =~ /^[+-]?\d+(?:\.\d+)?$/ && $value !~ /^(true|false|null)$/i) {
273             # Canonical form: normalize numbers
274 53         195 my $normalized = $self->_canonicalize_number($value);
275 53         204 return $normalized;
276             }
277            
278             # Check if needs quoting
279 54 100       140 if ($self->_needs_quoting($value)) {
280 10         33 return '"' . $self->_escape_string($value) . '"';
281             }
282            
283 44         172 return $value;
284             }
285              
286             sub _canonicalize_number {
287 53     53   118 my ($self, $num) = @_;
288            
289             # -0 becomes 0
290 53 100       140 if ($num == 0) {
291 1         5 return '0';
292             }
293            
294             # Convert to numeric form to normalize
295 52         143 my $n = 0 + $num;
296            
297             # Remove trailing zeros from decimals
298 52 100       164 if ($n =~ /\./) {
299 3         12 $n =~ s/0+$//;
300 3         40 $n =~ s/\.$//;
301             }
302            
303 52         175 return "$n";
304             }
305              
306             sub _needs_quoting {
307 54     54   110 my ($self, $value) = @_;
308            
309 54 50       118 return 1 if $value eq '';
310 54 50       162 return 1 if $value =~ /^\s/;
311 54 50       165 return 1 if $value =~ /\s$/;
312 54 50 33     363 return 1 if $value eq 'true' || $value eq 'false' || $value eq 'null';
      33        
313 54 50       189 return 1 if $value =~ /^-?\d+(?:\.\d+)?(?:e[+-]?\d+)?$/i;
314 54 50       138 return 1 if $value =~ /^0\d+$/;
315 54 50       166 return 1 if $value =~ /[:"\\\[\]{}-]/;
316 54 50       150 return 1 if $value =~ /[\r\n\t]/;
317 54 100       438 return 1 if $value =~ /$self->{delimiter}/;
318 44 50       177 return 1 if $value =~ /^-/;
319            
320 44         133 return 0;
321             }
322              
323             sub _escape_string {
324 10     10   22 my ($self, $str) = @_;
325            
326 10         20 $str =~ s/\\/\\\\/g;
327 10         20 $str =~ s/"/\\"/g;
328 10         18 $str =~ s/\n/\\n/g;
329 10         18 $str =~ s/\r/\\r/g;
330 10         16 $str =~ s/\t/\\t/g;
331            
332 10         45 return $str;
333             }
334              
335             sub _sort_fields {
336 67     67   174 my ($self, @fields) = @_;
337            
338             # If no priority specified, use alphabetical sort (backward compatibility)
339 67 100       96 return sort @fields unless @{$self->{column_priority}};
  67         277  
340            
341 13         22 my %priority;
342 13         22 my $index = 0;
343 13         21 foreach my $col (@{$self->{column_priority}}) {
  13         31  
344 28         109 $priority{$col} = $index++;
345             }
346            
347             # Sort: priority columns first (by priority order), then remaining columns alphabetically
348             return sort {
349 13 100       55 my $a_priority = exists $priority{$a} ? $priority{$a} : 999999;
  16         50  
350 16 100       59 my $b_priority = exists $priority{$b} ? $priority{$b} : 999999;
351            
352 16 100       35 if ($a_priority != $b_priority) {
353 13         55 return $a_priority <=> $b_priority;
354             }
355 3         16 return $a cmp $b;
356             } @fields;
357             }
358              
359             1;
360              
361