Group
Extension

App-NDTools/lib/App/NDTools/NDDiff.pm

package App::NDTools::NDDiff;

use strict;
use warnings FATAL => 'all';
use parent 'App::NDTools::NDTool';

use Algorithm::Diff qw(compact_diff);
use JSON qw();
use JSON::Patch 0.04 qw();
use App::NDTools::Slurp qw(s_dump);
use App::NDTools::Util qw(is_number);
use Log::Log4Cli 0.18;
use Struct::Diff 0.96 qw();
use Struct::Diff::MergePatch qw();
use Struct::Path 0.80 qw(path path_delta);
use Struct::Path::PerlStyle 0.80 qw(str2path path2str);
use Term::ANSIColor qw(color);

our $VERSION = '0.61';

my $JSON = JSON->new->canonical->allow_nonref;
my %COLOR;

sub arg_opts {
    my $self = shift;

    return (
        $self->SUPER::arg_opts(),
        'A!' => \$self->{OPTS}->{diff}->{A},
        'N!' => \$self->{OPTS}->{diff}->{N},
        'O!' => \$self->{OPTS}->{diff}->{O},
        'R!' => \$self->{OPTS}->{diff}->{R},
        'U!' => \$self->{OPTS}->{diff}->{U},
        'brief' => sub { $self->{OPTS}->{ofmt} = $_[0] },
        'colors!' => \$self->{OPTS}->{colors},
        'ctx-text=i' => \$self->{OPTS}->{'ctx-text'},
        'grep=s@' => \$self->{OPTS}->{grep},
        'json' => sub { $self->{OPTS}->{ofmt} = $_[0] },
        'ignore=s@' => \$self->{OPTS}->{ignore},
        'rules' => sub { $self->{OPTS}->{ofmt} = $_[0] },
        'quiet|q' => \$self->{OPTS}->{quiet},
        'show' => \$self->{OPTS}->{show},
    )
}

sub check_args {
    my $self = shift;

    if ($self->{OPTS}->{show}) {
        die_fatal "At least one argument expected when --show used", 1
            unless (@_);
    } elsif (@_ < 2) {
        die_fatal "At least two arguments expected for diff", 1;
    }

    return $self;
}

sub configure {
    my $self = shift;

    $self->SUPER::configure();

    $self->{OPTS}->{colors} = $self->{TTY}
        unless (defined $self->{OPTS}->{colors});

    # resolve colors
    while (my ($k, $v) = each %{$self->{OPTS}->{term}->{line}}) {
        if ($self->{OPTS}->{colors}) {
            $COLOR{$k} = color($v);
            $COLOR{"B$k"} = color("bold $v");
        } else {
            $COLOR{$k} = $COLOR{"B$k"} = '';
        }
    }

    $COLOR{head} = $self->{OPTS}->{colors}
        ? color($self->{OPTS}->{term}->{head}) : "";
    $COLOR{reset} = $self->{OPTS}->{colors} ? color('reset') : "";

    # resolve paths
    for (@{$self->{OPTS}->{grep}}, @{$self->{OPTS}->{ignore}}) {
        my $tmp = eval { str2path($_) };
        die_fatal "Failed to parse '$_'", 4 if ($@);
        $_ = $tmp;
    }

    $self->{OPTS}->{ofmt} = lc($self->{OPTS}->{ofmt});

    # Use full diff (JSON Merge Patch does not provide arrays diffs)
    map { $self->{OPTS}->{diff}->{$_} = 1 } keys %{$self->{OPTS}->{diff}},
        if ($self->{OPTS}->{ofmt} eq 'jsonmergepatch');

    return $self;
}

sub defaults {
    my $self = shift;

    my $out = {
        %{$self->SUPER::defaults()},
        'ctx-text' => 3,
        'diff' => {
            'A' => 1,
            'N' => 1,
            'O' => 1,
            'R' => 1,
            'U' => 0,
        },
        'ofmt' => 'term',
        'term' => {
            'head' => 'yellow',
            'indt' => '  ',
            'line' => {
                'A' => 'green',
                'D' => 'yellow',
                'N' => 'green',
                'O' => 'red',
                'U' => 'white',
                'R' => 'red',
                '@' => 'magenta',
            },
            'sign' => {
                'A' => '+ ',
                'D' => '! ',
                'N' => '+ ',
                'O' => '- ',
                'U' => '  ',
                'R' => '- ',
                '@' => '  ',
            },
        },
    };

    return $out;
}

sub diff {
    my ($self, $old, $new) = @_;

    log_debug { "Calculating diff for structure" };
    my $diff = Struct::Diff::diff(
        $old, $new,
        map { ("no$_" => 1) } grep { !$self->{OPTS}->{diff}->{$_} }
            keys %{$self->{OPTS}->{diff}},
    );

    # retrieve result from wrapper (see load() for more info)
    if (exists $diff->{D}) {
        $diff = $diff->{D}->[0];
    } elsif (exists $diff->{U}) {
        $diff->{U} = $diff->{U}->[0];
    }

    return $diff;
}

sub diff_term {
    my ($self, $diff) = @_;

    log_debug { "Calculating diffs for text values" };

    my $dref; # ref to diff
    my @list = Struct::Diff::list_diff($diff);

    while (@list) {
        (undef, $dref) = splice @list, 0, 2;

        next unless (exists ${$dref}->{N});
        next unless (defined ${$dref}->{O} and defined ${$dref}->{N});
        next if (ref ${$dref}->{O} or ref ${$dref}->{N});
        next if (is_number(${$dref}->{O}) or is_number(${$dref}->{N}));

        my @old = split($/, ${$dref}->{O}, -1);
        my @new = split($/, ${$dref}->{N}, -1);

        if (@old > 1 or @new > 1) {
            delete ${$dref}->{O};
            delete ${$dref}->{N};

            if ($old[-1] eq '' and $new[-1] eq '') {
                pop @old; # because split by newline and -1 for LIMIT
                pop @new; # -"-
            }

            my @cdiff = compact_diff(\@old, \@new);
            my ($match, $header);

            while (@cdiff > 2) {
                my @del = @old[$cdiff[0] .. $cdiff[2] - 1];
                my @add = @new[$cdiff[1] .. $cdiff[3] - 1];

                if ($match = !$match) {
                    # trailing context
                    if ($header) {
                        my @tail = splice @del, 0, $self->{OPTS}->{'ctx-text'};
                        push @{${$dref}->{T}}, 'U', \@tail;

                        $header->[1] += @tail;
                        $header->[3] += @tail;
                    }

                    # leading context
                    if (@cdiff > 4) {
                        my @rest = splice @del, 0, $self->{OPTS}->{'ctx-text'}
                            ? $self->{OPTS}->{'ctx-text'} * -1 : scalar @del;

                        if (@rest or !$header) {
                            push @{${$dref}->{T}}, '@', $header = [
                                $cdiff[2] - @del + 1, 0,
                                $cdiff[3] - @del + 1, 0,
                            ];
                        }

                        if (@del) {
                            push @{${$dref}->{T}}, 'U', \@del;
                            $header->[1] += @del;
                            $header->[3] += @del;
                        }
                    }
                } else {
                    if (@del) {
                        push @{${$dref}->{T}}, 'R', \@del;
                        $header->[1] += @del;
                    }

                    if (@add) {
                        push @{${$dref}->{T}}, 'A', \@add;
                        $header->[3] += @add;
                    }
                }

                splice @cdiff, 0, 2;
            }
        }
    }

    return $self;
}

sub dump {
    my ($self, $diff) = @_;

    log_debug { "Dumping results" };

    my %formats = (
        brief           => \&dump_brief,
        jsonmergepatch  => \&dump_json_merge_patch,
        jsonpatch       => \&dump_json_patch,
        rules           => \&dump_rules,
        term            => \&dump_term,
    );

    if (my $dump = $formats{$self->{OPTS}->{ofmt}}) {
        $dump->($self, $diff);
    } else {
        s_dump(\*STDOUT, $self->{OPTS}->{ofmt},
            {pretty => $self->{OPTS}->{pretty}}, $diff);
    }

    return $self;
}

sub dump_brief {
    my ($self, $diff) = @_;

    my ($path, $dref, $tag);
    my @list = Struct::Diff::list_diff($diff, sort => 1);

    while (@list) {
        ($path, $dref) = splice @list, 0, 2;
        for $tag (qw{R N A}) {
            $self->print_brief_block($path, $tag)
                if (exists ${$dref}->{$tag});
        }
    }
}

sub dump_json_merge_patch {
    my ($self, $diff) = @_;

    s_dump(
        \*STDOUT, 'JSON',
        {pretty => $self->{OPTS}->{pretty}},
        Struct::Diff::MergePatch::diff($diff)
    );
}

sub dump_json_patch {
    my ($self, $diff) = @_;

    s_dump(
        \*STDOUT, 'JSON',
        {pretty => $self->{OPTS}->{pretty}},
        JSON::Patch::diff($diff)
    );
}


sub dump_rules {
    my ($self, $diff) = @_;

    my ($path, $dref, $item, @out);
    my @list = Struct::Diff::list_diff($diff, sort => 1);

    while (@list) {
        ($path, $dref) = splice @list, 0, 2;
        for (qw{R N A}) {
            next unless (exists ${$dref}->{$_});
            unshift @out, {
                modname => $_ eq "R" ? "Remove" : "Insert",
                path => $self->dump_rules_path($path),
                value => ${$dref}->{$_}
            };
        }
    }

    s_dump(\*STDOUT, 'JSON', {pretty => $self->{OPTS}->{pretty}}, \@out);
}

sub dump_rules_path { # to be able to override
    return path2str($_[1]);
}

sub dump_term {
    my ($self, $diff) = @_;

    my ($path, $dref, $tag);
    my @list = Struct::Diff::list_diff($diff, sort => 1);

    while (@list) {
        ($path, $dref) = splice @list, 0, 2;
        for $tag (qw{R O N A T U}) {
            $self->print_term_block(${$dref}->{$tag}, $path, $tag)
                if (exists ${$dref}->{$tag});
        }
    }
}

sub exec {
    my $self = shift;
    my (@diffs, @files);

    for (@{$self->{ARGV}}) {
        push @files, { data => $self->load($_), name => $_ };

        if ($self->{OPTS}->{show}) {
            if (ref $files[0]->{data}->[0] eq 'ARRAY') { # ndproc's blame
                for (@{$files[0]->{data}->[0]}) {
                    push @diffs, $_->{diff},
                        [ $files[0]->{name} . ', rule #' . $_->{rule_id} ];
                }
            } else { # regular diff dump
                push @diffs, $files[0]->{data}->[0], [ $files[0]->{name} ];
            }
        } else { # one of the files to diff
            next unless (@files > 1);
            push @diffs, $self->diff($files[0]->{data}, $files[1]->{data});
            push @diffs, [ $files[0]->{name}, $files[1]->{name} ];
        }

        shift @files;

        while (@diffs) {
            my ($diff, $hdrs) = splice @diffs, 0, 2;

            $self->print_term_header(@{$hdrs});

            if (
                $self->{OPTS}->{show} and
                my @errs = Struct::Diff::valid_diff($diff)
            ) {
                while (@errs) {
                    my ($path, $type) = splice @errs, 0, 2;
                    log_error { "$type " . path2str($path) };
                }

                die_fatal "Diff validation failed", 1;
            }

            unless ($self->{OPTS}->{quiet}) {
                $self->diff_term($diff) if ($self->{OPTS}->{ofmt} eq 'term');
                $self->dump($diff);
            }

            $self->{status} = 8
                unless (not keys %{$diff} or exists $diff->{U});
        }
    }

    die_info "All done, no difference found", 0 unless ($self->{status});
    die_info "Difference found", 8;
}

sub load {
    my $self = shift;

    my @data = $self->load_struct($_[0], $self->{OPTS}->{ifmt});

    # array used to indicate absent value for grep result
    @data = $self->grep($self->{OPTS}->{grep}, $data[0])
        if (@{$self->{OPTS}->{grep}});

    if (@data and ref $data[0]) {
        map { path($data[0], $_, delete => 1) } @{$self->{OPTS}->{ignore}}
    }

    return \@data;
}

sub print_brief_block {
    my ($self, $path, $status) = @_;

    $status = 'D' if ($status eq 'N');

    print $self->{OPTS}->{term}->{sign}->{$status} . $COLOR{U} .
        path2str([splice @{$path}, 0, -1]) . $COLOR{reset} .
        $COLOR{"B$status"} . path2str($path) . $COLOR{reset} . "\n";
}

sub print_term_block {
    my ($self, $value, $path, $status) = @_;

    log_trace { "'" . path2str($path) . "' ($status)" };

    my @lines;
    my $dsign = $self->{OPTS}->{term}->{sign}->{$status};
    my $indent = $self->{OPTS}->{term}->{indt};

    # diff for path
    if (@{$path} and my @delta = path_delta($self->{'hdr_path'}, $path)) {
        $self->{'hdr_path'} = [@{$path}];
        my $s = $self->{OPTS}->{pretty} ? @{$path} - @delta : 0;

        while ($s < @{$path}) {
            my $line = $indent x $s . path2str([$path->[$s]]);

            if (($status eq 'A' or $status eq 'R') and $s == $#{$path}) {
                $line = $COLOR{"B$status"} . $dsign . $line . $COLOR{reset};
            } else {
                substr($line, 0, 0, $indent);
            }

            push @lines, $line;
            $s++;
        }
    }

    # diff for value
    push @lines, $self->term_value_diff($value, $status, $indent x @{$path});

    print join("\n", @lines) . "\n";
}

sub print_term_header {
    my ($self, @names) = @_;

    if ($self->{TTY} and not $self->{OPTS}->{quiet}) {
        print $COLOR{head} .
            (@names == 1 ? "!!! $names[0]" : "--- $names[0]\n+++ $names[1]") .
            $COLOR{reset}. "\n";
    }
}

sub term_value_diff {
    my ($self, $value, $status, $indent) = @_;

    return $self->term_value_diff_text($value, $indent)
        if ($status eq 'T');

    return $self->term_value_diff_default($value, $status, $indent);
}

sub term_value_diff_default {
    my ($self, $value, $status, $indent) = @_;
    my @out;

    $value = $JSON->pretty($self->{OPTS}->{pretty})->encode($value)
        unless (is_number($value));

    for my $line (split($/, $value)) {
        substr($line, 0, 0, $self->{OPTS}->{term}->{sign}->{$status} . $indent);
        push @out, $COLOR{$status} . $line . $COLOR{reset};
    }

    return @out;
}

sub term_value_diff_text {
    my ($self, $diff, $indent) = @_;
    my (@hdr, $lines, @out, $pfx, $sfx, $status);

    $sfx = $COLOR{reset};

    while (@{$diff}) {
        ($status, $lines) = splice @{$diff}, 0, 2;

        $pfx = $COLOR{$status} . $self->{OPTS}->{term}->{sign}->{$status} .
            $indent;

        if ($status eq '@') {
            @hdr = splice @{$lines};
            $lines->[0] = "@@ -$hdr[0]" . ($hdr[1] > 1 ? ",$hdr[1] " : "") .
                " +$hdr[2]" . ($hdr[3] > 1 ? ",$hdr[3] @@" : " @@");
        }

        map { substr($_ , 0, 0, $pfx); $_ .= $sfx; push @out, $_ } @{$lines};
    }

    return @out;
}

1; # End of App::NDTools::NDDiff


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.