File Coverage

blib/lib/App/NDTools/NDDiff.pm
Criterion Covered Total %
statement 294 298 98.6
branch 102 102 100.0
condition 39 39 100.0
subroutine 39 43 90.7
pod 0 21 0.0
total 474 503 94.2


line stmt bran cond sub pod time code
1             package App::NDTools::NDDiff;
2              
3 3     3   1984 use strict;
  3         6  
  3         123  
4 3     3   19 use warnings FATAL => 'all';
  3         5  
  3         141  
5 3     3   19 use parent 'App::NDTools::NDTool';
  3         5  
  3         78  
6              
7 3     3   1457 use Algorithm::Diff qw(compact_diff);
  3         10312  
  3         180  
8 3     3   23 use JSON qw();
  3         6  
  3         66  
9 3     3   1402 use JSON::Patch 0.04 qw();
  3         29777  
  3         90  
10 3     3   25 use App::NDTools::Slurp qw(s_dump);
  3         9  
  3         184  
11 3     3   20 use App::NDTools::Util qw(is_number);
  3         8  
  3         151  
12 3     3   19 use Log::Log4Cli 0.18;
  3         49  
  3         219  
13 3     3   18 use Struct::Diff 0.96 qw();
  3         41  
  3         130  
14 3     3   1402 use Struct::Diff::MergePatch qw();
  3         2274  
  3         90  
15 3     3   21 use Struct::Path 0.80 qw(path path_delta);
  3         46  
  3         164  
16 3     3   1407 use Struct::Path::PerlStyle 0.80 qw(str2path path2str);
  3         155907  
  3         231  
17 3     3   36 use Term::ANSIColor qw(color);
  3         9  
  3         11387  
18              
19             our $VERSION = '0.61';
20              
21             my $JSON = JSON->new->canonical->allow_nonref;
22             my %COLOR;
23              
24             sub arg_opts {
25 81     81 0 148 my $self = shift;
26              
27             return (
28             $self->SUPER::arg_opts(),
29             'A!' => \$self->{OPTS}->{diff}->{A},
30             'N!' => \$self->{OPTS}->{diff}->{N},
31             'O!' => \$self->{OPTS}->{diff}->{O},
32             'R!' => \$self->{OPTS}->{diff}->{R},
33             'U!' => \$self->{OPTS}->{diff}->{U},
34 4     4   5555 'brief' => sub { $self->{OPTS}->{ofmt} = $_[0] },
35             'colors!' => \$self->{OPTS}->{colors},
36             'ctx-text=i' => \$self->{OPTS}->{'ctx-text'},
37             'grep=s@' => \$self->{OPTS}->{grep},
38 9     9   12722 'json' => sub { $self->{OPTS}->{ofmt} = $_[0] },
39             'ignore=s@' => \$self->{OPTS}->{ignore},
40 1     1   1322 'rules' => sub { $self->{OPTS}->{ofmt} = $_[0] },
41             'quiet|q' => \$self->{OPTS}->{quiet},
42             'show' => \$self->{OPTS}->{show},
43             )
44 81         289 }
45              
46             sub check_args {
47 80     80 0 123 my $self = shift;
48              
49 80 100       293 if ($self->{OPTS}->{show}) {
    100          
50 6 100       30 die_fatal "At least one argument expected when --show used", 1
51             unless (@_);
52             } elsif (@_ < 2) {
53 1         6 die_fatal "At least two arguments expected for diff", 1;
54             }
55              
56 78         154 return $self;
57             }
58              
59             sub configure {
60 80     80 0 1554 my $self = shift;
61              
62 80         270 $self->SUPER::configure();
63              
64             $self->{OPTS}->{colors} = $self->{TTY}
65 78 100       292 unless (defined $self->{OPTS}->{colors});
66              
67             # resolve colors
68 78         156 while (my ($k, $v) = each %{$self->{OPTS}->{term}->{line}}) {
  624         2280  
69 546 100       917 if ($self->{OPTS}->{colors}) {
70 28         59 $COLOR{$k} = color($v);
71 28         505 $COLOR{"B$k"} = color("bold $v");
72             } else {
73 518         1190 $COLOR{$k} = $COLOR{"B$k"} = '';
74             }
75             }
76              
77             $COLOR{head} = $self->{OPTS}->{colors}
78 78 100       216 ? color($self->{OPTS}->{term}->{head}) : "";
79 78 100       255 $COLOR{reset} = $self->{OPTS}->{colors} ? color('reset') : "";
80              
81             # resolve paths
82 78         181 for (@{$self->{OPTS}->{grep}}, @{$self->{OPTS}->{ignore}}) {
  78         185  
  78         202  
83 7         15 my $tmp = eval { str2path($_) };
  7         26  
84 7 100       5918 die_fatal "Failed to parse '$_'", 4 if ($@);
85 6         16 $_ = $tmp;
86             }
87              
88 77         265 $self->{OPTS}->{ofmt} = lc($self->{OPTS}->{ofmt});
89              
90             # Use full diff (JSON Merge Patch does not provide arrays diffs)
91 5         10 map { $self->{OPTS}->{diff}->{$_} = 1 } keys %{$self->{OPTS}->{diff}},
  1         4  
92 77 100       597 if ($self->{OPTS}->{ofmt} eq 'jsonmergepatch');
93              
94 77         191 return $self;
95             }
96              
97             sub defaults {
98 81     81 0 169 my $self = shift;
99              
100             my $out = {
101 81         140 %{$self->SUPER::defaults()},
  81         235  
102             'ctx-text' => 3,
103             'diff' => {
104             'A' => 1,
105             'N' => 1,
106             'O' => 1,
107             'R' => 1,
108             'U' => 0,
109             },
110             'ofmt' => 'term',
111             'term' => {
112             'head' => 'yellow',
113             'indt' => ' ',
114             'line' => {
115             'A' => 'green',
116             'D' => 'yellow',
117             'N' => 'green',
118             'O' => 'red',
119             'U' => 'white',
120             'R' => 'red',
121             '@' => 'magenta',
122             },
123             'sign' => {
124             'A' => '+ ',
125             'D' => '! ',
126             'N' => '+ ',
127             'O' => '- ',
128             'U' => ' ',
129             'R' => '- ',
130             '@' => ' ',
131             },
132             },
133             };
134              
135 81         406 return $out;
136             }
137              
138             sub diff {
139 70     70 0 164 my ($self, $old, $new) = @_;
140              
141 70     0   382 log_debug { "Calculating diff for structure" };
  0         0  
142             my $diff = Struct::Diff::diff(
143             $old, $new,
144 72         359 map { ("no$_" => 1) } grep { !$self->{OPTS}->{diff}->{$_} }
  350         742  
145 70         371 keys %{$self->{OPTS}->{diff}},
  70         270  
146             );
147              
148             # retrieve result from wrapper (see load() for more info)
149 70 100       66296 if (exists $diff->{D}) {
    100          
150 65         191 $diff = $diff->{D}->[0];
151             } elsif (exists $diff->{U}) {
152 1         3 $diff->{U} = $diff->{U}->[0];
153             }
154              
155 70         159 return $diff;
156             }
157              
158             sub diff_term {
159 55     55 0 112 my ($self, $diff) = @_;
160              
161 55     0   300 log_debug { "Calculating diffs for text values" };
  0         0  
162              
163 55         253 my $dref; # ref to diff
164 55         167 my @list = Struct::Diff::list_diff($diff);
165              
166 55         3100 while (@list) {
167 100         197 (undef, $dref) = splice @list, 0, 2;
168              
169 100 100       207 next unless (exists ${$dref}->{N});
  100         263  
170 66 100 100     91 next unless (defined ${$dref}->{O} and defined ${$dref}->{N});
  66         192  
  62         210  
171 61 100 100     114 next if (ref ${$dref}->{O} or ref ${$dref}->{N});
  61         196  
  54         153  
172 53 100 100     128 next if (is_number(${$dref}->{O}) or is_number(${$dref}->{N}));
  53         190  
  42         107  
173              
174 40         502 my @old = split($/, ${$dref}->{O}, -1);
  40         229  
175 40         273 my @new = split($/, ${$dref}->{N}, -1);
  40         164  
176              
177 40 100 100     221 if (@old > 1 or @new > 1) {
178 25         43 delete ${$dref}->{O};
  25         60  
179 25         45 delete ${$dref}->{N};
  25         40  
180              
181 25 100 100     126 if ($old[-1] eq '' and $new[-1] eq '') {
182 14         24 pop @old; # because split by newline and -1 for LIMIT
183 14         25 pop @new; # -"-
184             }
185              
186 25         95 my @cdiff = compact_diff(\@old, \@new);
187 25         6209 my ($match, $header);
188              
189 25         67 while (@cdiff > 2) {
190 110         262 my @del = @old[$cdiff[0] .. $cdiff[2] - 1];
191 110         219 my @add = @new[$cdiff[1] .. $cdiff[3] - 1];
192              
193 110 100       230 if ($match = !$match) {
194             # trailing context
195 59 100       132 if ($header) {
196 34         70 my @tail = splice @del, 0, $self->{OPTS}->{'ctx-text'};
197 34         49 push @{${$dref}->{T}}, 'U', \@tail;
  34         42  
  34         77  
198              
199 34         56 $header->[1] += @tail;
200 34         56 $header->[3] += @tail;
201             }
202              
203             # leading context
204 59 100       119 if (@cdiff > 4) {
205             my @rest = splice @del, 0, $self->{OPTS}->{'ctx-text'}
206 51 100       167 ? $self->{OPTS}->{'ctx-text'} * -1 : scalar @del;
207              
208 51 100 100     212 if (@rest or !$header) {
209 32         53 push @{${$dref}->{T}}, '@', $header = [
  32         44  
  32         143  
210             $cdiff[2] - @del + 1, 0,
211             $cdiff[3] - @del + 1, 0,
212             ];
213             }
214              
215 51 100       119 if (@del) {
216 18         29 push @{${$dref}->{T}}, 'U', \@del;
  18         25  
  18         40  
217 18         46 $header->[1] += @del;
218 18         37 $header->[3] += @del;
219             }
220             }
221             } else {
222 51 100       107 if (@del) {
223 43         61 push @{${$dref}->{T}}, 'R', \@del;
  43         58  
  43         106  
224 43         72 $header->[1] += @del;
225             }
226              
227 51 100       101 if (@add) {
228 42         55 push @{${$dref}->{T}}, 'A', \@add;
  42         60  
  42         97  
229 42         68 $header->[3] += @add;
230             }
231             }
232              
233 110         326 splice @cdiff, 0, 2;
234             }
235             }
236             }
237              
238 55         113 return $self;
239             }
240              
241             sub dump {
242 71     71 0 134 my ($self, $diff) = @_;
243              
244 71     0   365 log_debug { "Dumping results" };
  0         0  
245              
246 71         583 my %formats = (
247             brief => \&dump_brief,
248             jsonmergepatch => \&dump_json_merge_patch,
249             jsonpatch => \&dump_json_patch,
250             rules => \&dump_rules,
251             term => \&dump_term,
252             );
253              
254 71 100       248 if (my $dump = $formats{$self->{OPTS}->{ofmt}}) {
255 61         146 $dump->($self, $diff);
256             } else {
257             s_dump(\*STDOUT, $self->{OPTS}->{ofmt},
258 10         48 {pretty => $self->{OPTS}->{pretty}}, $diff);
259             }
260              
261 71         443 return $self;
262             }
263              
264             sub dump_brief {
265 4     4 0 10 my ($self, $diff) = @_;
266              
267 4         6 my ($path, $dref, $tag);
268 4         14 my @list = Struct::Diff::list_diff($diff, sort => 1);
269              
270 4         272 while (@list) {
271 10         245 ($path, $dref) = splice @list, 0, 2;
272 10         31 for $tag (qw{R N A}) {
273             $self->print_brief_block($path, $tag)
274 30 100       549 if (exists ${$dref}->{$tag});
  30         101  
275             }
276             }
277             }
278              
279             sub dump_json_merge_patch {
280 1     1 0 3 my ($self, $diff) = @_;
281              
282             s_dump(
283             \*STDOUT, 'JSON',
284             {pretty => $self->{OPTS}->{pretty}},
285 1         8 Struct::Diff::MergePatch::diff($diff)
286             );
287             }
288              
289             sub dump_json_patch {
290 1     1 0 5 my ($self, $diff) = @_;
291              
292             s_dump(
293             \*STDOUT, 'JSON',
294             {pretty => $self->{OPTS}->{pretty}},
295 1         8 JSON::Patch::diff($diff)
296             );
297             }
298              
299              
300             sub dump_rules {
301 2     2 0 7 my ($self, $diff) = @_;
302              
303 2         5 my ($path, $dref, $item, @out);
304 2         6 my @list = Struct::Diff::list_diff($diff, sort => 1);
305              
306 2         185 while (@list) {
307 8         17 ($path, $dref) = splice @list, 0, 2;
308 8         20 for (qw{R N A}) {
309 24 100       32 next unless (exists ${$dref}->{$_});
  24         56  
310             unshift @out, {
311             modname => $_ eq "R" ? "Remove" : "Insert",
312             path => $self->dump_rules_path($path),
313 8 100       26 value => ${$dref}->{$_}
  8         415  
314             };
315             }
316             }
317              
318 2         12 s_dump(\*STDOUT, 'JSON', {pretty => $self->{OPTS}->{pretty}}, \@out);
319             }
320              
321             sub dump_rules_path { # to be able to override
322 8     8 0 21 return path2str($_[1]);
323             }
324              
325             sub dump_term {
326 53     53 0 119 my ($self, $diff) = @_;
327              
328 53         85 my ($path, $dref, $tag);
329 53         144 my @list = Struct::Diff::list_diff($diff, sort => 1);
330              
331 53         2908 while (@list) {
332 98         228 ($path, $dref) = splice @list, 0, 2;
333 98         238 for $tag (qw{R O N A T U}) {
334 134         376 $self->print_term_block(${$dref}->{$tag}, $path, $tag)
335 588 100       902 if (exists ${$dref}->{$tag});
  588         1520  
336             }
337             }
338             }
339              
340             sub exec {
341 72     72 0 130 my $self = shift;
342 72         123 my (@diffs, @files);
343              
344 72         118 for (@{$self->{ARGV}}) {
  72         152  
345 139         400 push @files, { data => $self->load($_), name => $_ };
346              
347 139 100       346 if ($self->{OPTS}->{show}) {
348 5 100       20 if (ref $files[0]->{data}->[0] eq 'ARRAY') { # ndproc's blame
349 1         4 for (@{$files[0]->{data}->[0]}) {
  1         6  
350             push @diffs, $_->{diff},
351 2         10 [ $files[0]->{name} . ', rule #' . $_->{rule_id} ];
352             }
353             } else { # regular diff dump
354 4         17 push @diffs, $files[0]->{data}->[0], [ $files[0]->{name} ];
355             }
356             } else { # one of the files to diff
357 134 100       359 next unless (@files > 1);
358 67         247 push @diffs, $self->diff($files[0]->{data}, $files[1]->{data});
359 67         201 push @diffs, [ $files[0]->{name}, $files[1]->{name} ];
360             }
361              
362 72         156 shift @files;
363              
364 72         322 while (@diffs) {
365 73         180 my ($diff, $hdrs) = splice @diffs, 0, 2;
366              
367 73         135 $self->print_term_header(@{$hdrs});
  73         276  
368              
369 73 100 100     213 if (
370             $self->{OPTS}->{show} and
371             my @errs = Struct::Diff::valid_diff($diff)
372             ) {
373 1         71 while (@errs) {
374 2         83 my ($path, $type) = splice @errs, 0, 2;
375 2     2   14 log_error { "$type " . path2str($path) };
  2         93  
376             }
377              
378 1         76 die_fatal "Diff validation failed", 1;
379             }
380              
381 72 100       546 unless ($self->{OPTS}->{quiet}) {
382 71 100       277 $self->diff_term($diff) if ($self->{OPTS}->{ofmt} eq 'term');
383 71         183 $self->dump($diff);
384             }
385              
386             $self->{status} = 8
387 72 100 100     96 unless (not keys %{$diff} or exists $diff->{U});
  72         1168  
388             }
389             }
390              
391 71 100       225 die_info "All done, no difference found", 0 unless ($self->{status});
392 67         259 die_info "Difference found", 8;
393             }
394              
395             sub load {
396 145     145 0 680 my $self = shift;
397              
398 145         477 my @data = $self->load_struct($_[0], $self->{OPTS}->{ifmt});
399              
400             # array used to indicate absent value for grep result
401             @data = $self->grep($self->{OPTS}->{grep}, $data[0])
402 145 100       238 if (@{$self->{OPTS}->{grep}});
  145         432  
403              
404 145 100 100     580 if (@data and ref $data[0]) {
405 128         198 map { path($data[0], $_, delete => 1) } @{$self->{OPTS}->{ignore}}
  4         16  
  128         315  
406             }
407              
408 145         1654 return \@data;
409             }
410              
411             sub print_brief_block {
412 10     10 0 21 my ($self, $path, $status) = @_;
413              
414 10 100       41 $status = 'D' if ($status eq 'N');
415              
416             print $self->{OPTS}->{term}->{sign}->{$status} . $COLOR{U} .
417 10         40 path2str([splice @{$path}, 0, -1]) . $COLOR{reset} .
418 10         37 $COLOR{"B$status"} . path2str($path) . $COLOR{reset} . "\n";
419             }
420              
421             sub print_term_block {
422 134     134 0 327 my ($self, $value, $path, $status) = @_;
423              
424 134     0   805 log_trace { "'" . path2str($path) . "' ($status)" };
  0         0  
425              
426 134         662 my @lines;
427 134         349 my $dsign = $self->{OPTS}->{term}->{sign}->{$status};
428 134         228 my $indent = $self->{OPTS}->{term}->{indt};
429              
430             # diff for path
431 134 100 100     177 if (@{$path} and my @delta = path_delta($self->{'hdr_path'}, $path)) {
  134         604  
432 91         3394 $self->{'hdr_path'} = [@{$path}];
  91         266  
433 91 100       226 my $s = $self->{OPTS}->{pretty} ? @{$path} - @delta : 0;
  86         178  
434              
435 91         144 while ($s < @{$path}) {
  227         458  
436 136         462 my $line = $indent x $s . path2str([$path->[$s]]);
437              
438 136 100 100     6262 if (($status eq 'A' or $status eq 'R') and $s == $#{$path}) {
  44   100     119  
439 22         73 $line = $COLOR{"B$status"} . $dsign . $line . $COLOR{reset};
440             } else {
441 114         258 substr($line, 0, 0, $indent);
442             }
443              
444 136         241 push @lines, $line;
445 136         223 $s++;
446             }
447             }
448              
449             # diff for value
450 134         2800 push @lines, $self->term_value_diff($value, $status, $indent x @{$path});
  134         427  
451              
452 134         3739 print join("\n", @lines) . "\n";
453             }
454              
455             sub print_term_header {
456 78     78 0 10134 my ($self, @names) = @_;
457              
458 78 100 100     276 if ($self->{TTY} and not $self->{OPTS}->{quiet}) {
459             print $COLOR{head} .
460             (@names == 1 ? "!!! $names[0]" : "--- $names[0]\n+++ $names[1]") .
461 3 100       111 $COLOR{reset}. "\n";
462             }
463             }
464              
465             sub term_value_diff {
466 134     134 0 290 my ($self, $value, $status, $indent) = @_;
467              
468 134 100       336 return $self->term_value_diff_text($value, $indent)
469             if ($status eq 'T');
470              
471 111         239 return $self->term_value_diff_default($value, $status, $indent);
472             }
473              
474             sub term_value_diff_default {
475 111     111 0 235 my ($self, $value, $status, $indent) = @_;
476 111         165 my @out;
477              
478 111 100       353 $value = $JSON->pretty($self->{OPTS}->{pretty})->encode($value)
479             unless (is_number($value));
480              
481 111         1502 for my $line (split($/, $value)) {
482 130         534 substr($line, 0, 0, $self->{OPTS}->{term}->{sign}->{$status} . $indent);
483 130         433 push @out, $COLOR{$status} . $line . $COLOR{reset};
484             }
485              
486 111         339 return @out;
487             }
488              
489             sub term_value_diff_text {
490 23     23 0 54 my ($self, $diff, $indent) = @_;
491 23         42 my (@hdr, $lines, @out, $pfx, $sfx, $status);
492              
493 23         41 $sfx = $COLOR{reset};
494              
495 23         41 while (@{$diff}) {
  186         378  
496 163         203 ($status, $lines) = splice @{$diff}, 0, 2;
  163         337  
497              
498 163         372 $pfx = $COLOR{$status} . $self->{OPTS}->{term}->{sign}->{$status} .
499             $indent;
500              
501 163 100       302 if ($status eq '@') {
502 30         46 @hdr = splice @{$lines};
  30         59  
503 30 100       158 $lines->[0] = "@@ -$hdr[0]" . ($hdr[1] > 1 ? ",$hdr[1] " : "") .
    100          
504             " +$hdr[2]" . ($hdr[3] > 1 ? ",$hdr[3] @@" : " @@");
505             }
506              
507 163         197 map { substr($_ , 0, 0, $pfx); $_ .= $sfx; push @out, $_ } @{$lines};
  267         541  
  267         354  
  267         478  
  163         245  
508             }
509              
510 23         135 return @out;
511             }
512              
513             1; # End of App::NDTools::NDDiff