File Coverage

blib/lib/Data/Hash/Diff/Smart/Engine.pm
Criterion Covered Total %
statement 137 173 79.1
branch 58 96 60.4
condition 37 79 46.8
subroutine 16 17 94.1
pod 1 1 100.0
total 249 366 68.0


line stmt bran cond sub pod time code
1             package Data::Hash::Diff::Smart::Engine;
2              
3 10     10   68 use strict;
  10         24  
  10         363  
4 10     10   48 use warnings;
  10         17  
  10         562  
5              
6 10     10   108 use Scalar::Util qw(reftype blessed refaddr);
  10         34  
  10         750  
7 10     10   4896 use Data::Hash::Diff::Smart::Path ();
  10         31  
  10         27395  
8              
9             =pod
10              
11             =head1 NAME
12              
13             Data::Hash::Diff::Smart::Engine - Internal diff engine for Data::Hash::Diff::Smart
14              
15             =head1 DESCRIPTION
16              
17             This module implements the recursive diff algorithm used by
18             L. It is not intended to be used directly.
19              
20             Features include:
21              
22             =over 4
23              
24             =item * recursive comparison of scalars, hashes, arrays, and objects
25              
26             =item * cycle detection to avoid infinite recursion
27              
28             =item * ignore rules (exact, regex, wildcard)
29              
30             =item * custom comparators per path
31              
32             =item * array diff modes: index, LCS, unordered
33              
34             =back
35              
36             =head1 INTERNAL METHODS
37              
38             =head2 diff($old, $new, %opts)
39              
40             Entry point for computing a diff.
41              
42             =head2 _diff($old, $new, $path, $changes, $ctx)
43              
44             Recursive comparison routine.
45              
46             =head2 _diff_scalar, _diff_hash, _diff_array
47              
48             Type-specific comparison helpers.
49              
50             =head2 _diff_array_index, _diff_array_lcs, _diff_array_unordered
51              
52             Array diffing strategies.
53              
54             =head2 _normalize_ignore, _is_ignored
55              
56             Ignore rule processing.
57              
58             =head2 _reftype, _eq
59              
60             Utility helpers.
61              
62             =cut
63              
64             # -------------------------------------------------------------------------
65             # Public entry point
66             # -------------------------------------------------------------------------
67              
68             sub diff {
69 26     26 1 97 my ($old, $new, %opts) = @_;
70              
71 26         76 my $changes = [];
72              
73             my $ctx = {
74             ignore => _normalize_ignore($opts{ignore}),
75             compare => $opts{compare} || {},
76 26   50     175 array_mode => $opts{array_mode} || 'index',
      100        
77             };
78              
79 26         130 _diff($old, $new, '', $changes, $ctx);
80              
81 26         310 return $changes;
82             }
83              
84             # -------------------------------------------------------------------------
85             # Core recursive diff
86             # -------------------------------------------------------------------------
87              
88             sub _diff {
89 56     56   184 my ($old, $new, $path, $changes, $ctx) = @_;
90              
91             # Ignore rules
92 56 100       156 return if _is_ignored($path, $ctx->{ignore});
93              
94             # ------------------------------------------------------------------
95             # Cycle detection
96             # ------------------------------------------------------------------
97 52 100 66     271 if (ref($old) && ref($new)) {
98 34         84 my $ro = refaddr($old);
99 34         70 my $rn = refaddr($new);
100              
101             # If we've seen this pair before, stop recursion
102 34 100       223 if ($ctx->{seen}{$ro}{$rn}++) {
103 1         4 return;
104             }
105             }
106              
107 51         161 my $rt_old = _reftype($old);
108 51         134 my $rt_new = _reftype($new);
109              
110             # ------------------------------------------------------------------
111             # Both scalars
112             # ------------------------------------------------------------------
113 51 50 66     194 if (!$rt_old && !$rt_new) {
114 18         70 return _diff_scalar($old, $new, $path, $changes, $ctx);
115             }
116              
117             # ------------------------------------------------------------------
118             # Type mismatch
119             # ------------------------------------------------------------------
120 33 50 33     246 if ($rt_old && $rt_new && $rt_old ne $rt_new) {
      33        
121 0         0 push @$changes, {
122             op => 'change',
123             path => $path,
124             from => $old,
125             to => $new,
126             };
127 0         0 return;
128             }
129              
130             # ------------------------------------------------------------------
131             # One ref, one scalar
132             # ------------------------------------------------------------------
133 33 50 33     141 if ($rt_old && !$rt_new) {
134 0         0 push @$changes, {
135             op => 'change',
136             path => $path,
137             from => $old,
138             to => $new,
139             };
140 0         0 return;
141             }
142              
143 33 50 33     131 if (!$rt_old && $rt_new) {
144 0         0 push @$changes, {
145             op => 'change',
146             path => $path,
147             from => $old,
148             to => $new,
149             };
150 0         0 return;
151             }
152              
153             # ------------------------------------------------------------------
154             # Both refs, same type
155             # ------------------------------------------------------------------
156 33 100       93 if ($rt_old eq 'HASH') {
157 31         120 return _diff_hash($old, $new, $path, $changes, $ctx);
158             }
159              
160 2 50       6 if ($rt_old eq 'ARRAY') {
161 2         9 return _diff_array($old, $new, $path, $changes, $ctx);
162             }
163              
164             # ------------------------------------------------------------------
165             # Fallback: stringify
166             # ------------------------------------------------------------------
167 0         0 return _diff_scalar("$old", "$new", $path, $changes, $ctx);
168             }
169              
170             # -------------------------------------------------------------------------
171             # Scalar comparison
172             # -------------------------------------------------------------------------
173              
174             sub _diff_scalar {
175 18     18   76 my ($old, $new, $path, $changes, $ctx) = @_;
176              
177             # Custom comparator?
178 18 50       82 if (my $cmp = $ctx->{compare}{$path}) {
179 0         0 my $same = eval { $cmp->($old, $new) };
  0         0  
180 0 0       0 if ($@) {
181 0         0 push @$changes, {
182             op => 'change',
183             path => $path,
184             from => $old,
185             to => $new,
186             error => "$@",
187             };
188 0         0 return;
189             }
190 0 0       0 return if $same;
191             }
192             else {
193 18 100       66 return if _eq($old, $new);
194             }
195              
196 8         95 push @$changes, {
197             op => 'change',
198             path => $path,
199             from => $old,
200             to => $new,
201             };
202             }
203              
204             # -------------------------------------------------------------------------
205             # Hash comparison
206             # -------------------------------------------------------------------------
207              
208             sub _diff_hash {
209 31     31   104 my ($old, $new, $path, $changes, $ctx) = @_;
210              
211 31         59 my %keys;
212 31         147 $keys{$_}++ for keys %$old;
213 31         108 $keys{$_}++ for keys %$new;
214              
215 31         108 for my $k (sort keys %keys) {
216 36         128 my $subpath = Data::Hash::Diff::Smart::Path::join($path, $k);
217              
218 36 100 100     227 if (exists $old->{$k} && exists $new->{$k}) {
    100          
219 26         130 _diff($old->{$k}, $new->{$k}, $subpath, $changes, $ctx);
220             }
221             elsif (exists $old->{$k}) {
222             push @$changes, {
223             op => 'remove',
224             path => $subpath,
225 5         46 from => $old->{$k},
226             };
227             }
228             else {
229             push @$changes, {
230             op => 'add',
231             path => $subpath,
232 5         83 value => $new->{$k},
233             };
234             }
235             }
236             }
237              
238             # -------------------------------------------------------------------------
239             # Array comparison dispatcher
240             # -------------------------------------------------------------------------
241              
242             sub _diff_array {
243 2     2   7 my ($old, $new, $path, $changes, $ctx) = @_;
244              
245 2   50     9 my $mode = $ctx->{array_mode} || 'index';
246              
247 2 50       75 if ($mode eq 'index') {
    100          
    50          
248 0         0 return _diff_array_index($old, $new, $path, $changes, $ctx);
249             }
250             elsif ($mode eq 'lcs') {
251 1         6 return _diff_array_lcs($old, $new, $path, $changes, $ctx);
252             }
253             elsif ($mode eq 'unordered') {
254 1         10 return _diff_array_unordered($old, $new, $path, $changes, $ctx);
255             }
256              
257 0         0 die "Unsupported array_mode: $mode";
258             }
259              
260             # -------------------------------------------------------------------------
261             # Array mode: index
262             # -------------------------------------------------------------------------
263              
264             sub _diff_array_index {
265 0     0   0 my ($old, $new, $path, $changes, $ctx) = @_;
266              
267 0 0       0 my $max = @$old > @$new ? @$old : @$new;
268              
269 0         0 for my $i (0 .. $max - 1) {
270 0         0 my $subpath = Data::Hash::Diff::Smart::Path::join($path, $i);
271              
272 0 0 0     0 if ($i <= $#$old && $i <= $#$new) {
    0          
273 0         0 _diff($old->[$i], $new->[$i], $subpath, $changes, $ctx);
274             }
275             elsif ($i <= $#$old) {
276 0         0 push @$changes, {
277             op => 'remove',
278             path => $subpath,
279             from => $old->[$i],
280             };
281             }
282             else {
283 0         0 push @$changes, {
284             op => 'add',
285             path => $subpath,
286             value => $new->[$i],
287             };
288             }
289             }
290             }
291              
292             # -------------------------------------------------------------------------
293             # Array mode: LCS (Longest Common Subsequence)
294             # -------------------------------------------------------------------------
295              
296             sub _diff_array_lcs {
297 1     1   3 my ($old, $new, $path, $changes, $ctx) = @_;
298              
299 1         4 my @a = @$old;
300 1         3 my @b = @$new;
301              
302 1         2 my $m = @a;
303 1         1 my $n = @b;
304              
305             # DP table
306 1         7 my @dp;
307 1         3 for my $i (0 .. $m) {
308 5         8 for my $j (0 .. $n) {
309 35         42 $dp[$i][$j] = 0;
310             }
311             }
312              
313 1         2 for my $i (1 .. $m) {
314 4         6 for my $j (1 .. $n) {
315 24 100       42 if (_eq($a[$i-1], $b[$j-1])) {
316 4         9 $dp[$i][$j] = $dp[$i-1][$j-1] + 1;
317             } else {
318 20 100       33 $dp[$i][$j] = $dp[$i-1][$j] > $dp[$i][$j-1]
319             ? $dp[$i-1][$j]
320             : $dp[$i][$j-1];
321             }
322             }
323             }
324              
325             # Extract LCS
326 1         18 my @lcs;
327 1         2 my ($i, $j) = ($m, $n);
328              
329 1   66     61 while ($i > 0 && $j > 0) {
330 6 100       15 if (_eq($a[$i-1], $b[$j-1])) {
    50          
331 4         12 unshift @lcs, $a[$i-1];
332 4         8 $i--; $j--;
  4         15  
333             }
334             elsif ($dp[$i-1][$j] >= $dp[$i][$j-1]) {
335 0         0 $i--;
336             }
337             else {
338 2         8 $j--;
339             }
340             }
341              
342             # Walk arrays and LCS
343 1         5 my ($ai, $bi, $li) = (0, 0, 0);
344              
345 1   66     7 while ($ai < @a || $bi < @b) {
346 6 50       16 my $l = $li < @lcs ? $lcs[$li] : undef;
347              
348 6 100 33     22 if ($ai < @a && $bi < @b && _eq($a[$ai], $b[$bi])) {
    50 66        
    0 33        
      33        
      0        
      0        
349 4         13 my $subpath = Data::Hash::Diff::Smart::Path::join($path, $bi);
350 4         30 _diff($a[$ai], $b[$bi], $subpath, $changes, $ctx);
351 4         7 $ai++; $bi++;
  4         5  
352             }
353             elsif ($ai < @a && defined $l && _eq($a[$ai], $l)) {
354 2         18 my $subpath = Data::Hash::Diff::Smart::Path::join($path, $bi);
355 2         19 push @$changes, {
356             op => 'add',
357             path => $subpath,
358             value => $b[$bi],
359             };
360 2         3 $bi++;
361             }
362             elsif ($bi < @b && defined $l && _eq($b[$bi], $l)) {
363 0         0 my $subpath = Data::Hash::Diff::Smart::Path::join($path, $ai);
364 0         0 push @$changes, {
365             op => 'remove',
366             path => $subpath,
367             from => $a[$ai],
368             };
369 0         0 $ai++;
370             }
371             else {
372 0         0 my $subpath = Data::Hash::Diff::Smart::Path::join($path, $bi);
373 0         0 _diff($a[$ai], $b[$bi], $subpath, $changes, $ctx);
374 0         0 $ai++; $bi++;
  0         0  
375             }
376              
377 6 100 33     31 if ($li < @lcs && $ai > 0 && $bi > 0 && _eq($a[$ai-1], $lcs[$li])) {
      33        
      66        
378 4         29 $li++;
379             }
380             }
381             }
382              
383             # -------------------------------------------------------------------------
384             # Array mode: unordered (multiset)
385             # -------------------------------------------------------------------------
386              
387             sub _diff_array_unordered {
388 1     1   4 my ($old, $new, $path, $changes, $ctx) = @_;
389              
390 1         2 my %count_old;
391             my %count_new;
392              
393 1         6 $count_old{_key($_)}++ for @$old;
394 1         4 $count_new{_key($_)}++ for @$new;
395              
396 1         3 my %keys;
397 1         5 $keys{$_}++ for keys %count_old;
398 1         5 $keys{$_}++ for keys %count_new;
399              
400 1         6 for my $k (sort keys %keys) {
401 4   100     14 my $o = $count_old{$k} || 0;
402 4   100     15 my $n = $count_new{$k} || 0;
403              
404 4 100       12 if ($n > $o) {
    50          
405 2         5 for (1 .. $n - $o) {
406 2         13 push @$changes, {
407             op => 'add',
408             path => "$path/*",
409             value => $k,
410             };
411             }
412             }
413             elsif ($o > $n) {
414 2         7 for (1 .. $o - $n) {
415 2         13 push @$changes, {
416             op => 'remove',
417             path => "$path/*",
418             from => $k,
419             };
420             }
421             }
422             }
423             }
424              
425             sub _key {
426 8     8   16 my ($v) = @_;
427 8 50       28 return ref($v) ? "$v" : $v;
428             }
429              
430             # -------------------------------------------------------------------------
431             # Helpers
432             # -------------------------------------------------------------------------
433              
434             sub _reftype {
435 102     102   196 my ($v) = @_;
436 102 100       376 return unless ref $v;
437 66   50     257 return reftype($v) || 'SCALAR';
438             }
439              
440             sub _eq {
441 62     62   105 my ($a, $b) = @_;
442 62 0 33     116 return 1 if !defined($a) && !defined($b);
443 62 50 25     187 return 0 if defined($a) xor defined($b);
444 62         243 return $a eq $b;
445             }
446              
447             sub _normalize_ignore {
448 26     26   116 my ($ignore) = @_;
449 26 100       360 return [] unless $ignore;
450              
451 2         9 my @rules;
452              
453 2         6 for my $r (@$ignore) {
454              
455             # Regex rule
456 2 50       7 if (ref($r) eq 'Regexp') {
457 0         0 push @rules, { type => 'regex', re => $r };
458 0         0 next;
459             }
460              
461             # String rule: check for wildcard
462 2 50       11 if ($r =~ /\*/) {
463 2         9 my @parts = grep { length $_ } split m{/}, $r;
  8         29  
464 2         14 push @rules, { type => 'wildcard', parts => \@parts };
465             }
466             else {
467 0         0 push @rules, { type => 'exact', path => $r };
468             }
469             }
470              
471 2         24 return \@rules;
472             }
473              
474             sub _is_ignored {
475 56     56   130 my ($path, $rules) = @_;
476 56 100 66     311 return 0 unless $rules && @$rules;
477              
478             # Split current path into parts
479 14         50 my @path_parts = grep { length $_ } split m{/}, $path;
  36         77  
480              
481             RULE:
482 14         36 for my $rule (@$rules) {
483              
484 14 50       78 if ($rule->{type} eq 'exact') {
    50          
    50          
485 0 0       0 return 1 if $path eq $rule->{path};
486             }
487              
488             elsif ($rule->{type} eq 'regex') {
489 0 0       0 return 1 if $path =~ $rule->{re};
490             }
491              
492             elsif ($rule->{type} eq 'wildcard') {
493 14         21 my @r = @{ $rule->{parts} };
  14         54  
494              
495 14 100       55 next RULE unless @r == @path_parts;
496              
497 4         30 for my $i (0 .. $#r) {
498 12 100       30 next if $r[$i] eq '*';
499 8 50       28 next RULE if $r[$i] ne $path_parts[$i];
500             }
501              
502 4         35 return 1;
503             }
504             }
505              
506 10         33 return 0;
507             }
508              
509             1;
510              
511             =head1 AUTHOR
512              
513             Nigel Horne
514              
515             =cut