#!/usr/bin/env perl # This chunk of stuff was generated by App::FatPacker. To find the original # file's code, look for the end of this BEGIN block or the string 'FATPACK' BEGIN { my %fatpacked; $fatpacked{"DiffHighlight.pm"} = '#line '.(1+__LINE__).' "'.__FILE__."\"\n".<<'DIFFHIGHLIGHT'; package DiffHighlight; use 5.008; use warnings FATAL => 'all'; use strict; # Use the correct value for both UNIX and Windows (/dev/null vs nul) use File::Spec; my $NULL = File::Spec->devnull(); # Highlight by reversing foreground and background. You could do # other things like bold or underline if you prefer. our @OLD_HIGHLIGHT = ( undef, "\e[7m", "\e[27m", ); our @NEW_HIGHLIGHT = ( $OLD_HIGHLIGHT[0], $OLD_HIGHLIGHT[1], $OLD_HIGHLIGHT[2], ); my $RESET = "\x1b[m"; my $COLOR = qr/\x1b\[[0-9;]*m/; my $BORING = qr/$COLOR|\s/; my @removed; my @added; my $in_hunk; my $graph_indent = 0; our $line_cb = sub { print @_ }; our $flush_cb = sub { local $| = 1 }; # Count the visible width of a string, excluding any terminal color sequences. sub visible_width { local $_ = shift; my $ret = 0; while (length) { if (s/^$COLOR//) { # skip colors } elsif (s/^.//) { $ret++; } } return $ret; } # Return a substring of $str, omitting $len visible characters from the # beginning, where terminal color sequences do not count as visible. sub visible_substr { my ($str, $len) = @_; while ($len > 0) { if ($str =~ s/^$COLOR//) { next } $str =~ s/^.//; $len--; } return $str; } sub handle_line { my $orig = shift; local $_ = $orig; # match a graph line that begins a commit if (/^(?:$COLOR?\|$COLOR?[ ])* # zero or more leading "|" with space $COLOR?\*$COLOR?[ ] # a "*" with its trailing space (?:$COLOR?\|$COLOR?[ ])* # zero or more trailing "|" [ ]* # trailing whitespace for merges /x) { my $graph_prefix = $&; # We must flush before setting graph indent, since the # new commit may be indented differently from what we # queued. flush(); $graph_indent = visible_width($graph_prefix); } elsif ($graph_indent) { if (length($_) < $graph_indent) { $graph_indent = 0; } else { $_ = visible_substr($_, $graph_indent); } } if (!$in_hunk) { $line_cb->($orig); $in_hunk = /^$COLOR*\@\@ /; } elsif (/^$COLOR*-/) { push @removed, $orig; } elsif (/^$COLOR*\+/) { push @added, $orig; } else { flush(); $line_cb->($orig); $in_hunk = /^$COLOR*[\@ ]/; } # Most of the time there is enough output to keep things streaming, # but for something like "git log -Sfoo", you can get one early # commit and then many seconds of nothing. We want to show # that one commit as soon as possible. # # Since we can receive arbitrary input, there's no optimal # place to flush. Flushing on a blank line is a heuristic that # happens to match git-log output. if (!length) { $flush_cb->(); } } sub flush { # Flush any queued hunk (this can happen when there is no trailing # context in the final diff of the input). show_hunk(\@removed, \@added); @removed = (); @added = (); } sub highlight_stdin { while () { handle_line($_); } flush(); } # Ideally we would feed the default as a human-readable color to # git-config as the fallback value. But diff-highlight does # not otherwise depend on git at all, and there are reports # of it being used in other settings. Let's handle our own # fallback, which means we will work even if git can't be run. sub color_config { my ($key, $default) = @_; # Removing the redirect speeds up execution by about 12ms #my $s = `git config --get-color $key 2>$NULL`; my $s = `git config --get-color $key`; return length($s) ? $s : $default; } sub show_hunk { my ($a, $b) = @_; # If one side is empty, then there is nothing to compare or highlight. if (!@$a || !@$b) { $line_cb->(@$a, @$b); return; } # If we have mismatched numbers of lines on each side, we could try to # be clever and match up similar lines. But for now we are simple and # stupid, and only handle multi-line hunks that remove and add the same # number of lines. if (@$a != @$b) { $line_cb->(@$a, @$b); return; } my @queue; for (my $i = 0; $i < @$a; $i++) { my ($rm, $add) = highlight_pair($a->[$i], $b->[$i]); $line_cb->($rm); push @queue, $add; } $line_cb->(@queue); } sub highlight_pair { my @a = split_line(shift); my @b = split_line(shift); # Find common prefix, taking care to skip any ansi # color codes. my $seen_plusminus; my ($pa, $pb) = (0, 0); while ($pa < @a && $pb < @b) { if ($a[$pa] =~ /$COLOR/) { $pa++; } elsif ($b[$pb] =~ /$COLOR/) { $pb++; } elsif ($a[$pa] eq $b[$pb]) { $pa++; $pb++; } elsif (!$seen_plusminus && $a[$pa] eq '-' && $b[$pb] eq '+') { $seen_plusminus = 1; $pa++; $pb++; } else { last; } } # Find common suffix, ignoring colors. my ($sa, $sb) = ($#a, $#b); while ($sa >= $pa && $sb >= $pb) { if ($a[$sa] =~ /$COLOR/) { $sa--; } elsif ($b[$sb] =~ /$COLOR/) { $sb--; } elsif ($a[$sa] eq $b[$sb]) { $sa--; $sb--; } else { last; } } if (is_pair_interesting(\@a, $pa, $sa, \@b, $pb, $sb)) { return highlight_line(\@a, $pa, $sa, \@OLD_HIGHLIGHT), highlight_line(\@b, $pb, $sb, \@NEW_HIGHLIGHT); } else { return join('', @a), join('', @b); } } # we split either by $COLOR or by character. This has the side effect of # leaving in graph cruft. It works because the graph cruft does not contain "-" # or "+" sub split_line { local $_ = shift; return utf8::decode($_) ? map { utf8::encode($_); $_ } map { /$COLOR/ ? $_ : (split //) } split /($COLOR+)/ : map { /$COLOR/ ? $_ : (split //) } split /($COLOR+)/; } sub highlight_line { my ($line, $prefix, $suffix, $theme) = @_; my $start = join('', @{$line}[0..($prefix-1)]); my $mid = join('', @{$line}[$prefix..$suffix]); my $end = join('', @{$line}[($suffix+1)..$#$line]); # If we have a "normal" color specified, then take over the whole line. # Otherwise, we try to just manipulate the highlighted bits. if (defined $theme->[0]) { s/$COLOR//g for ($start, $mid, $end); chomp $end; return join('', $theme->[0], $start, $RESET, $theme->[1], $mid, $RESET, $theme->[0], $end, $RESET, "\n" ); } else { return join('', $start, $theme->[1], $mid, $theme->[2], $end ); } } # Pairs are interesting to highlight only if we are going to end up # highlighting a subset (i.e., not the whole line). Otherwise, the highlighting # is just useless noise. We can detect this by finding either a matching prefix # or suffix (disregarding boring bits like whitespace and colorization). sub is_pair_interesting { my ($a, $pa, $sa, $b, $pb, $sb) = @_; my $prefix_a = join('', @$a[0..($pa-1)]); my $prefix_b = join('', @$b[0..($pb-1)]); my $suffix_a = join('', @$a[($sa+1)..$#$a]); my $suffix_b = join('', @$b[($sb+1)..$#$b]); return visible_substr($prefix_a, $graph_indent) !~ /^$COLOR*-$BORING*$/ || visible_substr($prefix_b, $graph_indent) !~ /^$COLOR*\+$BORING*$/ || $suffix_a !~ /^$BORING*$/ || $suffix_b !~ /^$BORING*$/; } DIFFHIGHLIGHT s/^ //mg for values %fatpacked; my $class = 'FatPacked::'.(0+\%fatpacked); no strict 'refs'; *{"${class}::files"} = sub { keys %{$_[0]} }; if ($] < 5.008) { *{"${class}::INC"} = sub { if (my $fat = $_[0]{$_[1]}) { my $pos = 0; my $last = length $fat; return (sub { return 0 if $pos == $last; my $next = (1 + index $fat, "\n", $pos) || $last; $_ .= substr $fat, $pos, $next - $pos; $pos = $next; return 1; }); } }; } else { *{"${class}::INC"} = sub { if (my $fat = $_[0]{$_[1]}) { open my $fh, '<', \$fat or die "FatPacker error loading $_[1] (could be a perl installation issue?)"; return $fh; } return; }; } unshift @INC, bless \%fatpacked, $class; } # END OF FATPACK CODE my $VERSION = "1.4.3"; ################################################################################# use v5.010; # Require Perl 5.10 for 'state' variables use warnings FATAL => 'all'; use strict; use File::Spec; # For catdir use File::Basename; # For dirname use Cwd qw(abs_path); # For realpath() use lib dirname(abs_path(File::Spec->catdir($0))) . "/lib"; # Add the local lib/ to @INC use DiffHighlight; my $remove_file_add_header = 1; my $remove_file_delete_header = 1; my $clean_permission_changes = 1; my $patch_mode = 0; my $manually_color_lines = 0; # Usually git/hg colorizes the lines, but for raw patches we use this my $change_hunk_indicators = git_config_boolean("diff-so-fancy.changeHunkIndicators","true"); my $strip_leading_indicators = git_config_boolean("diff-so-fancy.stripLeadingSymbols","true"); my $mark_empty_lines = git_config_boolean("diff-so-fancy.markEmptyLines","true"); my $use_unicode_dash_for_ruler = git_config_boolean("diff-so-fancy.useUnicodeRuler","true"); my $ruler_width = git_config("diff-so-fancy.rulerWidth", undef); my $git_strip_prefix = git_config_boolean("diff.noprefix","false"); my $has_stdin = has_stdin(); my $ansi_color_regex = qr/(\e\[([0-9]{1,3}(;[0-9]{1,3}){0,10})[mK])?/; my $reset_color = color("reset"); my $bold = color("bold"); my $meta_color = ""; # Set the diff highlight colors from the config init_diff_highlight_colors(); my ($file_1,$file_2); my $args = argv(); # Hashref of all the ARGV stuff my $last_file_seen = ""; my $last_file_mode = ""; my $i = 0; my $in_hunk = 0; my $columns_to_remove = 0; my $is_mercurial = 0; my $color_forced = 0; # Has the color been forced on/off # We try and be smart about whether we need to do line coloring, but # this is an option to force it on/off if ($args->{color_on}) { $manually_color_lines = 1; $color_forced = 1; } elsif ($args->{color_off}) { $manually_color_lines = 0; $color_forced = 1; } if ($args->{debug}) { show_debug_info(); exit(); } # `git add --patch` requires our output to match the number of lines from the # input. So, when patch mode is active, we print out empty lines to pad our # output to match any lines we've consumed. if ($args->{patch}) { $patch_mode = 1; } # We only process ARGV if we don't have STDIN if (!$has_stdin) { if ($args->{v} || $args->{version}) { die(version()); } elsif ($args->{'set-defaults'}) { my $ok = set_defaults(); exit; } elsif ($args->{colors}) { # We print this to STDOUT so we can redirect to bash to auto-set the colors print get_default_colors(); exit; } elsif (!%$args || $args->{help} || $args->{h}) { my $first = check_first_run(); if (!$first) { die(usage()); } } else { die("Missing input on STDIN\n"); } } ################################################################################# ################################################################################# # Check to see if were using default settings check_first_run(); # The logic here is that we run all the lines through DiffHighlight first. This # highlights all the intra-word changes. Then we take those lines and send them # to do_dsf_stuff() to convert the diff to human readable d-s-f output and add # appropriate fanciness my @lines; local $DiffHighlight::line_cb = sub { push(@lines,@_); my $last_line = $lines[-1]; # Buffer X lines before we try and output anything # Also make sure we're sending enough data to d-s-f to do it's magic. # Certain things require a look-ahead line or two to function so # we make sure we don't break on those sections prematurely if (@lines > 24 && ($last_line !~ /^${ansi_color_regex}(---|index|old mode|similarity index|rename (from|to))/)) { do_dsf_stuff(\@lines); @lines = (); } }; my $line_count = 0; while (my $line = ) { # If the very first line of the diff doesn't start with ANSI color we're assuming # it's a raw patch file, and we have to color the added/removed lines ourself if (!$color_forced && $line_count == 0 && starts_with_ansi($line)) { $manually_color_lines = 1; } my $ok = DiffHighlight::handle_line($line); $line_count++; } # If we're mid hunk above process anything still pending DiffHighlight::flush(); do_dsf_stuff(\@lines); ################################################################################# ################################################################################# sub do_dsf_stuff { my $input = shift(); #print STDERR "START -------------------------------------------------\n"; #print STDERR join("",@$input); #print STDERR "END ---------------------------------------------------\n"; while (my $line = shift(@$input)) { ###################################################### # Pre-process the line before we do any other markup # ###################################################### # If the first line of the input is a blank line, skip that if ($i == 0 && $line =~ /^\s*$/) { next; } ###################### # End pre-processing # ###################### ####################################################################### #################################################################### # Look for git index and replace it horizontal line (header later) # #################################################################### if ($line =~ /^${ansi_color_regex}index /) { # Print the line color and then the actual line $meta_color = $1 || get_config_color("meta"); # Get the next line without incrementing counter while loop my $next = $input->[0] || ""; my ($file_1,$file_2); # The line immediately after the "index" line should be the --- file line # If it's not it's an empty file add/delete if ($next !~ /^$ansi_color_regex(---|Binary files)/) { # We fake out the file names since it's a raw add/delete if ($last_file_mode eq "add") { $file_1 = "/dev/null"; $file_2 = $last_file_seen; } elsif ($last_file_mode eq "delete") { $file_1 = $last_file_seen; $file_2 = "/dev/null"; } } if ($file_1 && $file_2) { print horizontal_rule($meta_color); print $meta_color . file_change_string($file_1,$file_2) . "\n"; print horizontal_rule($meta_color); } ######################### # Look for the filename # ######################### # $4 $5 } elsif ($line =~ /^${ansi_color_regex}diff (-r|--git|--cc) (.*?)(\e| b\/|$)/) { # Mercurial looks like: diff -r 82e55d328c8c hello.c if ($4 eq "-r") { $is_mercurial = 1; $meta_color = get_config_color("meta"); # Git looks like: diff --git a/diff-so-fancy b/diff-so-fancy } else { $last_file_seen = $5; } $last_file_seen =~ s|^\w/||; # Remove a/ (and handle diff.mnemonicPrefix). $in_hunk = 0; if ($patch_mode) { # we are consuming one line, and the debt must be paid print "\n"; } ######################################## # Find the first file: --- a/README.md # ######################################## } elsif (!$in_hunk && $line =~ /^$ansi_color_regex--- (\w\/)?(.+?)(\e|\t|$)/) { $meta_color = get_config_color("meta"); if ($git_strip_prefix) { my $file_dir = $4 || ""; $file_1 = $file_dir . $5; } else { $file_1 = $5; } # Find the second file on the next line: +++ b/README.md my $next = shift(@$input); $next =~ /^$ansi_color_regex\+\+\+ (\w\/)?(.+?)(\e|\t|$)/; if ($1) { print $1; # Print out whatever color we're using } if ($git_strip_prefix) { my $file_dir = $4 || ""; $file_2 = $file_dir . $5; } else { $file_2 = $5; } if ($file_2 ne "/dev/null") { $last_file_seen = $file_2; } # Print out the top horizontal line of the header print $reset_color; print horizontal_rule($meta_color); # Mercurial coloring is slightly different so we need to hard reset colors if ($is_mercurial) { print $reset_color; } print $meta_color; print file_change_string($file_1,$file_2) . "\n"; # Print out the bottom horizontal line of the header print horizontal_rule($meta_color); ######################################## # Check for "@@ -3,41 +3,63 @@" syntax # ######################################## } elsif (!$change_hunk_indicators && $line =~ /^${ansi_color_regex}(@@@* .+? @@@*)(.*)/) { $in_hunk = 1; print $line; } elsif ($change_hunk_indicators && $line =~ /^${ansi_color_regex}(@@@* .+? @@@*)(.*)/) { $in_hunk = 1; my $hunk_header = $4; my $remain = bleach_text($5); # The number of colums to remove (1 or 2) is based on how many commas in the hunk header $columns_to_remove = (char_count(",",$hunk_header)) - 1; # On single line removes there is NO comma in the hunk so we force one if ($columns_to_remove <= 0) { $columns_to_remove = 1; } if ($1) { print $1; # Print out whatever color we're using } my ($orig_offset, $orig_count, $new_offset, $new_count) = parse_hunk_header($hunk_header); #$last_file_seen = basename($last_file_seen); # Figure out the start line my $start_line = start_line_calc($new_offset,$new_count); # Last function has it's own color my $last_function_color = ""; if ($remain) { $last_function_color = get_config_color("last_function"); } # Check to see if we have the color for the fragment from git if ($5 =~ /\e\[\d/) { #print "Has ANSI color for fragment\n"; } else { # We don't have the ANSI sequence so we shell out to get it #print "No ANSI color for fragment\n"; my $frag_color = get_config_color("fragment"); print $frag_color; } print "@ $last_file_seen:$start_line \@${bold}${last_function_color}${remain}${reset_color}\n"; ################################### # Remove any new file permissions # ################################### } elsif ($remove_file_add_header && $line =~ /^${ansi_color_regex}.*new file mode/) { # Don't print the line (i.e. remove it from the output); $last_file_mode = "add"; if ($patch_mode) { print "\n"; } ###################################### # Remove any delete file permissions # ###################################### } elsif ($remove_file_delete_header && $line =~ /^${ansi_color_regex}deleted file mode/) { # Don't print the line (i.e. remove it from the output); $last_file_mode = "delete"; if ($patch_mode) { print "\n"; } ################################ # Look for binary file changes # ################################ } elsif ($line =~ /^Binary files (\w\/)?(.+?) and (\w\/)?(.+?) differ/) { my $change = file_change_string($2,$4); print horizontal_rule($meta_color); print "$meta_color$change (binary)\n"; print horizontal_rule($meta_color); ##################################################### # Check if we're changing the permissions of a file # ##################################################### } elsif ($clean_permission_changes && $line =~ /^${ansi_color_regex}old mode (\d+)/) { my ($old_mode) = $4; my $next = shift(@$input); if ($1) { print $1; # Print out whatever color we're using } my ($new_mode) = $next =~ m/new mode (\d+)/; if ($patch_mode) { print "\n"; } print "$last_file_seen changed file mode from $old_mode to $new_mode\n"; ############### # File rename # ############### } elsif ($line =~ /^${ansi_color_regex}similarity index (\d+)%/) { my $simil = $4; # If it's a move with content change we ignore this and the next two lines if ($simil != 100) { shift(@$input); shift(@$input); next; } my $next = shift(@$input); my ($file1) = $next =~ /rename from (.+?)(\e|\t|$)/; $next = shift(@$input); my ($file2) = $next =~ /rename to (.+?)(\e|\t|$)/; if ($file1 && $file2) { # We may not have extracted this yet, so we pull from the config if not $meta_color = get_config_color("meta"); my $change = file_change_string($file1,$file2); print horizontal_rule($meta_color); print $meta_color . $change . "\n"; print horizontal_rule($meta_color); } $i += 3; # We've consumed three lines next; ##################################### # Just a regular line, print it out # ##################################### } else { # Mark empty line with a red/green box indicating addition/removal if ($mark_empty_lines) { $line = mark_empty_line($line); } # Remove the correct number of leading " " or "+" or "-" if ($strip_leading_indicators) { $line = strip_leading_indicators($line,$columns_to_remove); } print $line; } $i++; } } ###################################################################################################### # End regular code, begin functions ###################################################################################################### # Courtesy of github.com/git/git/blob/ab5d01a/git-add--interactive.perl#L798-L805 sub parse_hunk_header { my ($line) = @_; my ($o_ofs, $o_cnt, $n_ofs, $n_cnt) = $line =~ /^\@\@+(?: -(\d+)(?:,(\d+))?)+ \+(\d+)(?:,(\d+))? \@\@+/; $o_cnt = 1 unless defined $o_cnt; $n_cnt = 1 unless defined $n_cnt; return ($o_ofs, $o_cnt, $n_ofs, $n_cnt); } # Mark the first char of an empty line sub mark_empty_line { my $line = shift(); my $reset_color = "\e\\[0?m"; my $reset_escape = "\e\[m"; my $invert_color = "\e\[7m"; my $add_color = $DiffHighlight::NEW_HIGHLIGHT[1]; my $del_color = $DiffHighlight::OLD_HIGHLIGHT[1]; # This captures lines that do not have any ANSI in them (raw vanilla diff) if ($line eq "+\n") { $line = $invert_color . $add_color . " " . color('reset') . "\n"; # This captures lines that do not have any ANSI in them (raw vanilla diff) } elsif ($line eq "-\n") { $line = $invert_color . $del_color . " " . color('reset') . "\n"; # This handles everything else } else { $line =~ s/^($ansi_color_regex)[+-]$reset_color\s*$/$invert_color$1 $reset_escape\n/; } return $line; } # String to boolean sub boolean { my $str = shift(); $str = trim($str); if ($str eq "" || $str =~ /^(no|false|0)$/i) { return 0; } else { return 1; } } # Get the git config sub git_config_raw { my $cmd = "git config --list"; my @out = `$cmd`; return \@out; } # Memoize fetching a textual item from the git config sub git_config { my $search_key = lc($_[0] || ""); my $default_value = lc($_[1] || ""); state $raw = {}; if (%$raw && $search_key) { return $raw->{$search_key} || $default_value; } if ($args->{debug}) { print "Parsing git config\n"; } my $out = git_config_raw(); foreach my $line (@$out) { if ($line =~ /=/) { my ($key,$value) = split("=",$line,2); $value =~ s/\s+$//; $raw->{$key} = $value; } } # If we're given a search key return that, else return the hash if ($search_key) { return $raw->{$search_key} || $default_value; } else { return $raw; } } # Fetch a boolean item from the git config sub git_config_boolean { my $search_key = lc($_[0] || ""); my $default_value = lc($_[1] || 0); # Default to false # If we're in a unit test, use the default (don't read the users config) if (in_unit_test()) { return boolean($default_value); } my $result = git_config($search_key,$default_value); my $ret = boolean($result); return $ret; } # Check if we're inside of BATS sub in_unit_test { if ($ENV{BATS_CWD}) { return 1; } else { return 0; } } sub get_less_charset { my @less_char_vars = ("LESSCHARSET", "LESSCHARDEF", "LC_ALL", "LC_CTYPE", "LANG"); foreach my $key (@less_char_vars) { my $val = $ENV{$key}; if (defined $val) { return ($key, $val); } } return (); } sub should_print_unicode { if (-t STDOUT) { # Always print unicode chars if we're not piping stuff, e.g. to less(1) return 1; } # Otherwise, assume we're piping to less(1) my ($less_env_var, $less_charset) = get_less_charset(); if ($less_charset && $less_charset =~ /utf-?8/i) { return 1; } return 0; } # Try and be smart about what line the diff hunk starts on sub start_line_calc { my ($line_num,$diff_context) = @_; my $ret; if ($line_num == 0 && $diff_context == 0) { return 1; } # Git defaults to three lines of context my $default_context_lines = 3; # Three lines on either side, and the line itself = 7 my $expected_context = ($default_context_lines * 2 + 1); # The first three lines if ($line_num == 1 && $diff_context < $expected_context) { $ret = $diff_context - $default_context_lines; } else { $ret = $line_num + $default_context_lines; } if ($ret < 1) { $ret = 1; } return $ret; } # Remove + or - at the beginning of the lines sub strip_leading_indicators { my $line = shift(); # Array passed in by reference my $columns_to_remove = shift(); # Don't remove any lines by default if ($columns_to_remove == 0) { return $line; # Nothing to do } $line =~ s/^(${ansi_color_regex})([ +-]){${columns_to_remove}}/$1/; if ($manually_color_lines) { if (defined($5) && $5 eq "+") { my $add_line_color = get_config_color("add_line"); $line = $add_line_color . insert_reset_at_line_end($line); } elsif (defined($5) && $5 eq "-") { my $remove_line_color = get_config_color("remove_line"); $line = $remove_line_color . insert_reset_at_line_end($line); } } return $line; } # Insert the color reset code at end of line, but before any newlines sub insert_reset_at_line_end { my $line = shift(); $line =~ s/^(.*)([\n\r]+)?$/${1}${reset_color}${2}/; return $line; } # Count the number of a given char in a string # https://www.perturb.org/display/1010_Perl_Count_occurrences_of_substring.html sub char_count { my ($needle, $haystack) = @_; my $count = () = ($haystack =~ /$needle/g); return $count; } # Remove all ANSI codes from a string sub bleach_text { my $str = shift(); $str =~ s/\e\[\d*(;\d+)*m//mg; return $str; } # Remove all trailing and leading spaces sub trim { my $s = shift(); if (!$s) { return ""; } $s =~ s/^\s*//u; $s =~ s/\s*$//u; return $s; } # Print a line of em-dash or line-drawing chars the full width of the screen sub horizontal_rule { my $color = $_[0] || ""; my $width = get_terminal_width(); # em-dash http://www.fileformat.info/info/unicode/char/2014/index.htm #my $dash = "\x{2014}"; # BOX DRAWINGS LIGHT HORIZONTAL http://www.fileformat.info/info/unicode/char/2500/index.htm my $dash; if ($use_unicode_dash_for_ruler && should_print_unicode()) { #$dash = Encode::encode('UTF-8', "\x{2500}"); $dash = "\xE2\x94\x80"; } else { $dash = "-"; } # Draw the line my $ret = $color . ($dash x $width) . "$reset_color\n"; return $ret; } sub file_change_string { my $file_1 = shift(); my $file_2 = shift(); # If they're the same it's a modify if ($file_1 eq $file_2) { return "modified: $file_1"; # If the first is /dev/null it's a new file } elsif ($file_1 eq "/dev/null") { my $add_color = $DiffHighlight::NEW_HIGHLIGHT[1]; return "added: $add_color$file_2$reset_color"; # If the second is /dev/null it's a deletion } elsif ($file_2 eq "/dev/null") { my $del_color = $DiffHighlight::OLD_HIGHLIGHT[1]; return "deleted: $del_color$file_1$reset_color"; # If the files aren't the same it's a rename } elsif ($file_1 ne $file_2) { my ($old, $new) = DiffHighlight::highlight_pair($file_1,$file_2,{only_diff => 1}); # highlight_pair already includes reset_color, but adds newline characters that need to be trimmed off $old = trim($old); $new = trim($new); return "renamed: $old$meta_color to $new" # Something we haven't thought of yet } else { return "$file_1 -> $file_2"; } } # Check to see if STDIN is connected to an interactive terminal sub has_stdin { my $i = -t STDIN; my $ret = int(!$i); return $ret; } # We use this instead of Getopt::Long because it's faster and we're not parsing any # crazy arguments # Borrowed from: https://www.perturb.org/display/1153_Perl_Quick_extract_variables_from_ARGV.html sub argv { my $ret = {}; for (my $i = 0; $i < scalar(@ARGV); $i++) { # If the item starts with "-" it's a key if ((my ($key) = $ARGV[$i] =~ /^--?([a-zA-Z_-]*\w)$/) && ($ARGV[$i] !~ /^-\w\w/)) { # If the next item does not start with "--" it's the value for this item if (defined($ARGV[$i + 1]) && ($ARGV[$i + 1] !~ /^--?\D/)) { $ret->{$key} = $ARGV[$i + 1]; # Bareword like --verbose with no options } else { $ret->{$key}++; } } } # We're looking for a certain item if ($_[0]) { return $ret->{$_[0]}; } return $ret; } # Output the command line usage for d-s-f sub usage { my $out = color("white_bold") . version() . color("reset") . "\n"; $out .= "Usage: git diff --color | diff-so-fancy # Use d-s-f on one diff cat diff.txt | diff-so-fancy # Use d-s-f on a diff/patch file diff -u one.txt two.txt | diff-so-fancy # Use d-s-f on unified diff output diff-so-fancy --colors # View the commands to set the recommended colors diff-so-fancy --set-defaults # Configure git-diff to use diff-so-fancy and suggested colors diff-so-fancy --patch # Use diff-so-fancy in patch mode (interoperable with `git add --patch`) # Configure git to use d-s-f for *all* diff operations git config --global core.pager \"diff-so-fancy | less --tabs=4 -RFX\" # Configure git to use d-s-f for `git add --patch` git config --global interactive.diffFilter \"diff-so-fancy --patch\"\n"; return $out; } sub get_default_colors { my $out = "# Recommended default colors for diff-so-fancy\n"; $out .= "# --------------------------------------------\n"; $out .= 'git config --global color.ui true git config --global color.diff-highlight.oldNormal "red bold" git config --global color.diff-highlight.oldHighlight "red bold 52" git config --global color.diff-highlight.newNormal "green bold" git config --global color.diff-highlight.newHighlight "green bold 22" git config --global color.diff.meta "yellow" git config --global color.diff.frag "magenta bold" git config --global color.diff.commit "yellow bold" git config --global color.diff.old "red bold" git config --global color.diff.new "green bold" git config --global color.diff.whitespace "red reverse" '; return $out; } # Output the current version string sub version { my $ret = "Diff-so-fancy: https://github.com/so-fancy/diff-so-fancy\n"; $ret .= "Version : $VERSION\n"; return $ret; } sub is_windows { if ($^O eq 'MSWin32' or $^O eq 'dos' or $^O eq 'os2' or $^O eq 'cygwin' or $^O eq 'msys') { return 1; } else { return 0; } } # Return value is whether this is the first time they've run d-s-f sub check_first_run { my $ret = 0; # If first-run is not set, or it's set to "true" my $first_run = git_config_boolean('diff-so-fancy.first-run'); # See if they're previously set SOME diff-highlight colors my $has_dh_colors = git_config_boolean('color.diff-highlight.oldnormal') || git_config_boolean('color.diff-highlight.newnormal'); #$first_run = 1; $has_dh_colors = 0; if (!$first_run || $has_dh_colors) { return 0; } else { print "This appears to be the first time you've run diff-so-fancy, please note\n"; print "that the default git colors are not ideal. Diff-so-fancy recommends the\n"; print "following colors.\n\n"; print get_default_colors(); # Set the first run flag to false my $cmd = 'git config --global diff-so-fancy.first-run false'; system($cmd); exit; } return 1; } sub set_defaults { my $color_config = get_default_colors(); my $git_config = 'git config --global core.pager "diff-so-fancy | less --tabs=4 -RFX"'; my $first_cmd = 'git config --global diff-so-fancy.first-run false'; my @cmds = split(/\n/,$color_config); push(@cmds,$git_config); push(@cmds,$first_cmd); # Remove all comments from the commands foreach my $x (@cmds) { $x =~ s/#.*//g; } # Remove any empty commands @cmds = grep($_,@cmds); foreach my $cmd (@cmds) { system($cmd); my $exit = ($? >> 8); if ($exit != 0) { die("Error running: '$cmd' (error #18941)\n"); } } return 1; } # Borrowed from: https://www.perturb.org/display/1167_Perl_ANSI_colors.html # String format: '115', '165_bold', '10_on_140', 'reset', 'on_173', 'red', 'white_on_blue' sub color { my $str = shift(); # No string sent in, so we just reset if (!length($str) || $str eq 'reset') { return "\e[0m"; } # Some predefined colors my %color_map = qw(red 160 blue 21 green 34 yellow 226 orange 214 purple 93 white 15 black 0); $str =~ s|([A-Za-z]+)|$color_map{$1} // $1|eg; # Get foreground/background and any commands my ($fc,$cmd) = $str =~ /(\d+)?_?(\w+)?/g; my ($bc) = $str =~ /on_?(\d+)/g; # Some predefined commands my %cmd_map = qw(bold 1 italic 3 underline 4 blink 5 inverse 7); my $cmd_num = $cmd_map{$cmd // 0}; my $ret = ''; if ($cmd_num) { $ret .= "\e[${cmd_num}m"; } if (defined($fc)) { $ret .= "\e[38;5;${fc}m"; } if (defined($bc)) { $ret .= "\e[48;5;${bc}m"; } return $ret; } # Get colors used for various output sections (memoized) { my $static_config; sub get_config_color { my $str = shift(); my $ret = ""; if ($static_config->{$str}) { return $static_config->{$str}; } #print color(15) . "Shelling out for color: '$str'\n" . color('reset'); if ($str eq "meta") { # Default ANSI yellow $ret = git_ansi_color(git_config('color.diff.meta')) || color(11); } elsif ($str eq "reset") { $ret = color("reset"); } elsif ($str eq "add_line") { # Default ANSI green $ret = git_ansi_color(git_config('color.diff.new')) || color("2_bold"); } elsif ($str eq "remove_line") { # Default ANSI red $ret = git_ansi_color(git_config('color.diff.old')) || color("1_bold"); } elsif ($str eq "fragment") { $ret = git_ansi_color(git_config('color.diff.frag')) || color("13_bold"); } elsif ($str eq "last_function") { $ret = git_ansi_color(git_config('color.diff.func')) || color("146_bold"); } # Cache (memoize) the entry for later $static_config->{$str} = $ret; return $ret; } } # https://www.git-scm.com/book/en/v2/Customizing-Git-Git-Configuration#_colors_in_git sub git_ansi_color { my $str = shift(); my @parts = split(' ', $str); if (!@parts) { return ''; } my $colors = { 'black' => 0, 'red' => 1, 'green' => 2, 'yellow' => 3, 'blue' => 4, 'magenta' => 5, 'cyan' => 6, 'white' => 7, }; my @ansi_part = (); if (grep { /bold/ } @parts) { push(@ansi_part, "1"); @parts = grep { !/bold/ } @parts; # Remove from array } if (grep { /reverse/ } @parts) { push(@ansi_part, "7"); @parts = grep { !/reverse/ } @parts; # Remove from array } my $fg = $parts[0] // ""; my $bg = $parts[1] // ""; ############################################# # It's an numeric value, so it's an 8 bit color if (is_numeric($fg)) { if ($fg < 8) { push(@ansi_part, $fg + 30); } elsif ($fg < 16) { push(@ansi_part, $fg + 82); } else { push(@ansi_part, "38;5;$fg"); } # It's a simple 16 color OG ansi } elsif ($fg) { my $bright = $fg =~ s/bright//; my $color_num = $colors->{$fg} + 30; if ($bright) { $color_num += 60; } # Set bold push(@ansi_part, $color_num); } ############################################# # It's an numeric value, so it's an 8 bit color if (is_numeric($bg)) { if ($bg < 8) { push(@ansi_part, $bg + 40); } elsif ($bg < 16) { push(@ansi_part, $bg + 92); } else { push(@ansi_part, "48;5;$bg"); } # It's a simple 16 color OG ansi } elsif ($bg) { my $bright = $bg =~ s/bright//; my $color_num = $colors->{$bg} + 40; if ($bright) { $color_num += 60; } # Set bold push(@ansi_part, $color_num); } ############################################# my $ansi_str = join(";", @ansi_part); my $ret = "\e[" . $ansi_str . "m"; return $ret; } sub is_numeric { my $s = shift(); if ($s =~ /^\d+$/) { return 1; } return 0; } sub starts_with_ansi { my $str = shift(); if ($str =~ /^$ansi_color_regex/) { return 1; } else { return 0; } } sub get_terminal_width { # Make width static so we only calculate it once state $width; if ($width) { return $width; } # If there is a ruler width in the config we use that if ($ruler_width) { $width = $ruler_width; # Otherwise we check the terminal width using tput } else { my $tput = `tput cols`; if ($tput) { $width = int($tput); if (is_windows()) { $width--; } } else { print color('orange') . "Warning: `tput cols` did not return numeric input" . color('reset') . "\n"; $width = 80; } } return $width; } sub show_debug_info { my @less = get_less_charset(); my $git_ver = trim(`git --version`); $git_ver =~ s/[^\d.]//g; print "Diff-so-fancy : v$VERSION\n"; print "Git : v$git_ver\n"; print "Perl : $^V\n"; print "\n"; print "Terminal width : " . get_terminal_width() . "\n"; print "Terminal \$LANG : " . ($ENV{LANG} || "") . "\n"; print "\n"; print "Supports Unicode: " . yes_no(should_print_unicode()) . "\n"; print "Unicode Ruler : " . yes_no($use_unicode_dash_for_ruler) . "\n"; print "\n"; print "Less Charset Var: " . ($less[0] // "") . "\n"; print "Less Charset : " . ($less[1] // "") . "\n"; print "\n"; print "Is Windows : " . yes_no(is_windows()) . "\n"; print "Operating System: $^O\n"; } sub yes_no { my $val = shift(); if ($val) { return "Yes"; } else { return "No"; } } # If there are colors set in the gitconfig use those, otherwise leave the defaults sub init_diff_highlight_colors { $DiffHighlight::NEW_HIGHLIGHT[0] = git_ansi_color(git_config('color.diff-highlight.newnormal')) || $DiffHighlight::NEW_HIGHLIGHT[0]; $DiffHighlight::NEW_HIGHLIGHT[1] = git_ansi_color(git_config('color.diff-highlight.newhighlight')) || $DiffHighlight::NEW_HIGHLIGHT[1]; $DiffHighlight::OLD_HIGHLIGHT[0] = git_ansi_color(git_config('color.diff-highlight.oldnormal')) || $DiffHighlight::OLD_HIGHLIGHT[0]; $DiffHighlight::OLD_HIGHLIGHT[1] = git_ansi_color(git_config('color.diff-highlight.oldhighlight')) || $DiffHighlight::OLD_HIGHLIGHT[1]; } sub debug_log { my $log_line = shift(); my $file = "/tmp/diff-so-fancy.debug.log"; state $fh; if (!$fh) { printf("%sDebug log enabled:%s $file\n", color('orange'), color()); open ($fh, ">", $file) or die("Cannot write to $file"); } print $fh trim($log_line) . "\n"; return 1; } # Enable k() and kd() if there is a DSF_DEBUG environment variable BEGIN { if ($ENV{"DSF_DEBUG"}) { require Data::Dump::Color; *k = sub { Data::Dump::Color::dd(@_) }; *kd = sub { k(@_); printf("Died at %2\$s line #%3\$s\n",caller()); exit(15); } } else { *k = sub {}; *kd = sub {}; } } # vim: tabstop=4 shiftwidth=4 noexpandtab autoindent softtabstop=4