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   1906 use strict;
  3         6  
  3         113  
4 3     3   19 use warnings FATAL => 'all';
  3         6  
  3         131  
5 3     3   19 use parent 'App::NDTools::NDTool';
  3         6  
  3         76  
6              
7 3     3   1338 use Algorithm::Diff qw(compact_diff);
  3         10055  
  3         192  
8 3     3   23 use JSON qw();
  3         6  
  3         68  
9 3     3   1357 use JSON::Patch 0.04 qw();
  3         29792  
  3         92  
10 3     3   22 use App::NDTools::Slurp qw(s_dump);
  3         7  
  3         165  
11 3     3   21 use App::NDTools::Util qw(is_number);
  3         6  
  3         137  
12 3     3   20 use Log::Log4Cli 0.18;
  3         56  
  3         231  
13 3     3   21 use Struct::Diff 0.96 qw();
  3         41  
  3         70  
14 3     3   1371 use Struct::Diff::MergePatch qw();
  3         2161  
  3         143  
15 3     3   21 use Struct::Path 0.80 qw(path path_delta);
  3         45  
  3         169  
16 3     3   1379 use Struct::Path::PerlStyle 0.80 qw(str2path path2str);
  3         158472  
  3         290  
17 3     3   32 use Term::ANSIColor qw(color);
  3         8  
  3         11163  
18              
19             our $VERSION = '0.60';
20              
21             my $JSON = JSON->new->canonical->allow_nonref;
22             my %COLOR;
23              
24             sub arg_opts {
25 80     80 0 159 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   5658 '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   12347 'json' => sub { $self->{OPTS}->{ofmt} = $_[0] },
39             'ignore=s@' => \$self->{OPTS}->{ignore},
40 1     1   1334 'rules' => sub { $self->{OPTS}->{ofmt} = $_[0] },
41             'quiet|q' => \$self->{OPTS}->{quiet},
42             'show' => \$self->{OPTS}->{show},
43             )
44 80         232 }
45              
46             sub check_args {
47 79     79 0 129 my $self = shift;
48              
49 79 100       269 if ($self->{OPTS}->{show}) {
    100          
50 6 100       20 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 77         143 return $self;
57             }
58              
59             sub configure {
60 79     79 0 1584 my $self = shift;
61              
62 79         255 $self->SUPER::configure();
63              
64             $self->{OPTS}->{colors} = $self->{TTY}
65 77 100       270 unless (defined $self->{OPTS}->{colors});
66              
67             # resolve colors
68 77         127 while (my ($k, $v) = each %{$self->{OPTS}->{term}->{line}}) {
  616         2326  
69 539 100       906 if ($self->{OPTS}->{colors}) {
70 28         97 $COLOR{$k} = color($v);
71 28         526 $COLOR{"B$k"} = color("bold $v");
72             } else {
73 511         1126 $COLOR{$k} = $COLOR{"B$k"} = '';
74             }
75             }
76              
77             $COLOR{head} = $self->{OPTS}->{colors}
78 77 100       197 ? color($self->{OPTS}->{term}->{head}) : "";
79 77 100       248 $COLOR{reset} = $self->{OPTS}->{colors} ? color('reset') : "";
80              
81             # resolve paths
82 77         192 for (@{$self->{OPTS}->{grep}}, @{$self->{OPTS}->{ignore}}) {
  77         160  
  77         205  
83 7         13 my $tmp = eval { str2path($_) };
  7         24  
84 7 100       5815 die_fatal "Failed to parse '$_'", 4 if ($@);
85 6         16 $_ = $tmp;
86             }
87              
88 76         254 $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         5  
92 76 100       526 if ($self->{OPTS}->{ofmt} eq 'jsonmergepatch');
93              
94 76         144 return $self;
95             }
96              
97             sub defaults {
98 80     80 0 136 my $self = shift;
99              
100             my $out = {
101 80         116 %{$self->SUPER::defaults()},
  80         221  
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 80         360 return $out;
136             }
137              
138             sub diff {
139 69     69 0 148 my ($self, $old, $new) = @_;
140              
141 69     0   416 log_debug { "Calculating diff for structure" };
  0         0  
142             my $diff = Struct::Diff::diff(
143             $old, $new,
144 71         308 map { ("no$_" => 1) } grep { !$self->{OPTS}->{diff}->{$_} }
  345         719  
145 69         329 keys %{$self->{OPTS}->{diff}},
  69         267  
146             );
147              
148             # retrieve result from wrapper (see load() for more info)
149 69 100       63702 if (exists $diff->{D}) {
    100          
150 64         184 $diff = $diff->{D}->[0];
151             } elsif (exists $diff->{U}) {
152 1         4 $diff->{U} = $diff->{U}->[0];
153             }
154              
155 69         158 return $diff;
156             }
157              
158             sub diff_term {
159 55     55 0 123 my ($self, $diff) = @_;
160              
161 55     0   305 log_debug { "Calculating diffs for text values" };
  0         0  
162              
163 55         238 my $dref; # ref to diff
164 55         150 my @list = Struct::Diff::list_diff($diff);
165              
166 55         3055 while (@list) {
167 100         200 (undef, $dref) = splice @list, 0, 2;
168              
169 100 100       198 next unless (exists ${$dref}->{N});
  100         265  
170 66 100 100     94 next unless (defined ${$dref}->{O} and defined ${$dref}->{N});
  66         210  
  62         198  
171 61 100 100     95 next if (ref ${$dref}->{O} or ref ${$dref}->{N});
  61         178  
  54         148  
172 53 100 100     87 next if (is_number(${$dref}->{O}) or is_number(${$dref}->{N}));
  53         164  
  42         109  
173              
174 40         554 my @old = split($/, ${$dref}->{O}, -1);
  40         228  
175 40         264 my @new = split($/, ${$dref}->{N}, -1);
  40         171  
176              
177 40 100 100     218 if (@old > 1 or @new > 1) {
178 25         41 delete ${$dref}->{O};
  25         63  
179 25         43 delete ${$dref}->{N};
  25         48  
180              
181 25 100 100     100 if ($old[-1] eq '' and $new[-1] eq '') {
182 14         29 pop @old; # because split by newline and -1 for LIMIT
183 14         28 pop @new; # -"-
184             }
185              
186 25         111 my @cdiff = compact_diff(\@old, \@new);
187 25         6172 my ($match, $header);
188              
189 25         79 while (@cdiff > 2) {
190 110         272 my @del = @old[$cdiff[0] .. $cdiff[2] - 1];
191 110         227 my @add = @new[$cdiff[1] .. $cdiff[3] - 1];
192              
193 110 100       235 if ($match = !$match) {
194             # trailing context
195 59 100       167 if ($header) {
196 34         74 my @tail = splice @del, 0, $self->{OPTS}->{'ctx-text'};
197 34         52 push @{${$dref}->{T}}, 'U', \@tail;
  34         44  
  34         126  
198              
199 34         62 $header->[1] += @tail;
200 34         53 $header->[3] += @tail;
201             }
202              
203             # leading context
204 59 100       121 if (@cdiff > 4) {
205             my @rest = splice @del, 0, $self->{OPTS}->{'ctx-text'}
206 51 100       156 ? $self->{OPTS}->{'ctx-text'} * -1 : scalar @del;
207              
208 51 100 100     177 if (@rest or !$header) {
209 32         52 push @{${$dref}->{T}}, '@', $header = [
  32         45  
  32         135  
210             $cdiff[2] - @del + 1, 0,
211             $cdiff[3] - @del + 1, 0,
212             ];
213             }
214              
215 51 100       122 if (@del) {
216 18         26 push @{${$dref}->{T}}, 'U', \@del;
  18         25  
  18         44  
217 18         46 $header->[1] += @del;
218 18         37 $header->[3] += @del;
219             }
220             }
221             } else {
222 51 100       107 if (@del) {
223 43         56 push @{${$dref}->{T}}, 'R', \@del;
  43         60  
  43         111  
224 43         74 $header->[1] += @del;
225             }
226              
227 51 100       124 if (@add) {
228 42         57 push @{${$dref}->{T}}, 'A', \@add;
  42         52  
  42         100  
229 42         70 $header->[3] += @add;
230             }
231             }
232              
233 110         328 splice @cdiff, 0, 2;
234             }
235             }
236             }
237              
238 55         124 return $self;
239             }
240              
241             sub dump {
242 70     70 0 134 my ($self, $diff) = @_;
243              
244 70     0   343 log_debug { "Dumping results" };
  0         0  
245              
246 70         544 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 70 100       232 if (my $dump = $formats{$self->{OPTS}->{ofmt}}) {
255 60         151 $dump->($self, $diff);
256             } else {
257             s_dump(\*STDOUT, $self->{OPTS}->{ofmt},
258 10         54 {pretty => $self->{OPTS}->{pretty}}, $diff);
259             }
260              
261 70         444 return $self;
262             }
263              
264             sub dump_brief {
265 4     4 0 11 my ($self, $diff) = @_;
266              
267 4         9 my ($path, $dref, $tag);
268 4         15 my @list = Struct::Diff::list_diff($diff, sort => 1);
269              
270 4         274 while (@list) {
271 10         241 ($path, $dref) = splice @list, 0, 2;
272 10         26 for $tag (qw{R N A}) {
273             $self->print_brief_block($path, $tag)
274 30 100       587 if (exists ${$dref}->{$tag});
  30         100  
275             }
276             }
277             }
278              
279             sub dump_json_merge_patch {
280 1     1 0 4 my ($self, $diff) = @_;
281              
282             s_dump(
283             \*STDOUT, 'JSON',
284             {pretty => $self->{OPTS}->{pretty}},
285 1         10 Struct::Diff::MergePatch::diff($diff)
286             );
287             }
288              
289             sub dump_json_patch {
290 1     1 0 3 my ($self, $diff) = @_;
291              
292             s_dump(
293             \*STDOUT, 'JSON',
294             {pretty => $self->{OPTS}->{pretty}},
295 1         9 JSON::Patch::diff($diff)
296             );
297             }
298              
299              
300             sub dump_rules {
301 1     1 0 4 my ($self, $diff) = @_;
302              
303 1         3 my ($path, $dref, $item, @out);
304 1         5 my @list = Struct::Diff::list_diff($diff, sort => 1);
305              
306 1         93 while (@list) {
307 4         10 ($path, $dref) = splice @list, 0, 2;
308 4         11 for (qw{R N A}) {
309 12 100       19 next unless (exists ${$dref}->{$_});
  12         26  
310             unshift @out, {
311             modname => $_ eq "R" ? "Remove" : "Insert",
312             path => $self->dump_rules_path($path),
313 4 100       16 value => ${$dref}->{$_}
  4         265  
314             };
315             }
316             }
317              
318 1         8 s_dump(\*STDOUT, 'JSON', {pretty => $self->{OPTS}->{pretty}}, \@out);
319             }
320              
321             sub dump_rules_path { # to be able to override
322 4     4 0 14 return path2str($_[1]);
323             }
324              
325             sub dump_term {
326 53     53 0 83 my ($self, $diff) = @_;
327              
328 53         96 my ($path, $dref, $tag);
329 53         121 my @list = Struct::Diff::list_diff($diff, sort => 1);
330              
331 53         2964 while (@list) {
332 98         234 ($path, $dref) = splice @list, 0, 2;
333 98         208 for $tag (qw{R O N A T U}) {
334 134         380 $self->print_term_block(${$dref}->{$tag}, $path, $tag)
335 588 100       892 if (exists ${$dref}->{$tag});
  588         1459  
336             }
337             }
338             }
339              
340             sub exec {
341 71     71 0 123 my $self = shift;
342 71         121 my (@diffs, @files);
343              
344 71         96 for (@{$self->{ARGV}}) {
  71         144  
345 137         379 push @files, { data => $self->load($_), name => $_ };
346              
347 137 100       326 if ($self->{OPTS}->{show}) {
348 5 100       20 if (ref $files[0]->{data}->[0] eq 'ARRAY') { # ndproc's blame
349 1         3 for (@{$files[0]->{data}->[0]}) {
  1         4  
350             push @diffs, $_->{diff},
351 2         11 [ $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 132 100       357 next unless (@files > 1);
358 66         207 push @diffs, $self->diff($files[0]->{data}, $files[1]->{data});
359 66         195 push @diffs, [ $files[0]->{name}, $files[1]->{name} ];
360             }
361              
362 71         117 shift @files;
363              
364 71         349 while (@diffs) {
365 72         187 my ($diff, $hdrs) = splice @diffs, 0, 2;
366              
367 72         110 $self->print_term_header(@{$hdrs});
  72         248  
368              
369 72 100 100     202 if (
370             $self->{OPTS}->{show} and
371             my @errs = Struct::Diff::valid_diff($diff)
372             ) {
373 1         73 while (@errs) {
374 2         96 my ($path, $type) = splice @errs, 0, 2;
375 2     2   15 log_error { "$type " . path2str($path) };
  2         85  
376             }
377              
378 1         74 die_fatal "Diff validation failed", 1;
379             }
380              
381 71 100       514 unless ($self->{OPTS}->{quiet}) {
382 70 100       256 $self->diff_term($diff) if ($self->{OPTS}->{ofmt} eq 'term');
383 70         197 $self->dump($diff);
384             }
385              
386             $self->{status} = 8
387 71 100 100     104 unless (not keys %{$diff} or exists $diff->{U});
  71         708  
388             }
389             }
390              
391 70 100       185 die_info "All done, no difference found", 0 unless ($self->{status});
392 66         228 die_info "Difference found", 8;
393             }
394              
395             sub load {
396 143     143 0 786 my $self = shift;
397              
398 143         501 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 143 100       234 if (@{$self->{OPTS}->{grep}});
  143         464  
403              
404 143 100 100     600 if (@data and ref $data[0]) {
405 126         207 map { path($data[0], $_, delete => 1) } @{$self->{OPTS}->{ignore}}
  4         19  
  126         259  
406             }
407              
408 143         1532 return \@data;
409             }
410              
411             sub print_brief_block {
412 10     10 0 22 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         38 path2str([splice @{$path}, 0, -1]) . $COLOR{reset} .
418 10         33 $COLOR{"B$status"} . path2str($path) . $COLOR{reset} . "\n";
419             }
420              
421             sub print_term_block {
422 134     134 0 441 my ($self, $value, $path, $status) = @_;
423              
424 134     0   797 log_trace { "'" . path2str($path) . "' ($status)" };
  0         0  
425              
426 134         625 my @lines;
427 134         343 my $dsign = $self->{OPTS}->{term}->{sign}->{$status};
428 134         245 my $indent = $self->{OPTS}->{term}->{indt};
429              
430             # diff for path
431 134 100 100     179 if (@{$path} and my @delta = path_delta($self->{'hdr_path'}, $path)) {
  134         661  
432 91         3288 $self->{'hdr_path'} = [@{$path}];
  91         264  
433 91 100       217 my $s = $self->{OPTS}->{pretty} ? @{$path} - @delta : 0;
  86         166  
434              
435 91         149 while ($s < @{$path}) {
  227         486  
436 136         496 my $line = $indent x $s . path2str([$path->[$s]]);
437              
438 136 100 100     6848 if (($status eq 'A' or $status eq 'R') and $s == $#{$path}) {
  44   100     122  
439 22         76 $line = $COLOR{"B$status"} . $dsign . $line . $COLOR{reset};
440             } else {
441 114         267 substr($line, 0, 0, $indent);
442             }
443              
444 136         232 push @lines, $line;
445 136         215 $s++;
446             }
447             }
448              
449             # diff for value
450 134         2575 push @lines, $self->term_value_diff($value, $status, $indent x @{$path});
  134         461  
451              
452 134         3538 print join("\n", @lines) . "\n";
453             }
454              
455             sub print_term_header {
456 77     77 0 17813 my ($self, @names) = @_;
457              
458 77 100 100     282 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       122 $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       320 return $self->term_value_diff_text($value, $indent)
469             if ($status eq 'T');
470              
471 111         260 return $self->term_value_diff_default($value, $status, $indent);
472             }
473              
474             sub term_value_diff_default {
475 111     111 0 215 my ($self, $value, $status, $indent) = @_;
476 111         175 my @out;
477              
478 111 100       289 $value = $JSON->pretty($self->{OPTS}->{pretty})->encode($value)
479             unless (is_number($value));
480              
481 111         1476 for my $line (split($/, $value)) {
482 130         557 substr($line, 0, 0, $self->{OPTS}->{term}->{sign}->{$status} . $indent);
483 130         448 push @out, $COLOR{$status} . $line . $COLOR{reset};
484             }
485              
486 111         343 return @out;
487             }
488              
489             sub term_value_diff_text {
490 23     23 0 60 my ($self, $diff, $indent) = @_;
491 23         39 my (@hdr, $lines, @out, $pfx, $sfx, $status);
492              
493 23         45 $sfx = $COLOR{reset};
494              
495 23         39 while (@{$diff}) {
  186         342  
496 163         218 ($status, $lines) = splice @{$diff}, 0, 2;
  163         314  
497              
498 163         375 $pfx = $COLOR{$status} . $self->{OPTS}->{term}->{sign}->{$status} .
499             $indent;
500              
501 163 100       342 if ($status eq '@') {
502 30         48 @hdr = splice @{$lines};
  30         55  
503 30 100       173 $lines->[0] = "@@ -$hdr[0]" . ($hdr[1] > 1 ? ",$hdr[1] " : "") .
    100          
504             " +$hdr[2]" . ($hdr[3] > 1 ? ",$hdr[3] @@" : " @@");
505             }
506              
507 163         199 map { substr($_ , 0, 0, $pfx); $_ .= $sfx; push @out, $_ } @{$lines};
  267         494  
  267         374  
  267         472  
  163         294  
508             }
509              
510 23         133 return @out;
511             }
512              
513             1; # End of App::NDTools::NDDiff