/ diff-so-fancy
diff-so-fancy
1 #!/usr/bin/env perl 2 3 # This chunk of stuff was generated by App::FatPacker. To find the original 4 # file's code, look for the end of this BEGIN block or the string 'FATPACK' 5 BEGIN { 6 my %fatpacked; 7 8 $fatpacked{"DiffHighlight.pm"} = '#line '.(1+__LINE__).' "'.__FILE__."\"\n".<<'DIFFHIGHLIGHT'; 9 package DiffHighlight; 10 11 use 5.008; 12 use warnings FATAL => 'all'; 13 use strict; 14 15 # Use the correct value for both UNIX and Windows (/dev/null vs nul) 16 use File::Spec; 17 18 my $NULL = File::Spec->devnull(); 19 20 # Highlight by reversing foreground and background. You could do 21 # other things like bold or underline if you prefer. 22 our @OLD_HIGHLIGHT = ( 23 undef, 24 "\e[7m", 25 "\e[27m", 26 ); 27 our @NEW_HIGHLIGHT = ( 28 $OLD_HIGHLIGHT[0], 29 $OLD_HIGHLIGHT[1], 30 $OLD_HIGHLIGHT[2], 31 ); 32 33 34 35 my $RESET = "\x1b[m"; 36 my $COLOR = qr/\x1b\[[0-9;]*m/; 37 my $BORING = qr/$COLOR|\s/; 38 39 my @removed; 40 my @added; 41 my $in_hunk; 42 my $graph_indent = 0; 43 44 our $line_cb = sub { print @_ }; 45 our $flush_cb = sub { local $| = 1 }; 46 47 # Count the visible width of a string, excluding any terminal color sequences. 48 sub visible_width { 49 local $_ = shift; 50 my $ret = 0; 51 while (length) { 52 if (s/^$COLOR//) { 53 # skip colors 54 } elsif (s/^.//) { 55 $ret++; 56 } 57 } 58 return $ret; 59 } 60 61 # Return a substring of $str, omitting $len visible characters from the 62 # beginning, where terminal color sequences do not count as visible. 63 sub visible_substr { 64 my ($str, $len) = @_; 65 while ($len > 0) { 66 if ($str =~ s/^$COLOR//) { 67 next 68 } 69 $str =~ s/^.//; 70 $len--; 71 } 72 return $str; 73 } 74 75 sub handle_line { 76 my $orig = shift; 77 local $_ = $orig; 78 79 # match a graph line that begins a commit 80 if (/^(?:$COLOR?\|$COLOR?[ ])* # zero or more leading "|" with space 81 $COLOR?\*$COLOR?[ ] # a "*" with its trailing space 82 (?:$COLOR?\|$COLOR?[ ])* # zero or more trailing "|" 83 [ ]* # trailing whitespace for merges 84 /x) { 85 my $graph_prefix = $&; 86 87 # We must flush before setting graph indent, since the 88 # new commit may be indented differently from what we 89 # queued. 90 flush(); 91 $graph_indent = visible_width($graph_prefix); 92 93 } elsif ($graph_indent) { 94 if (length($_) < $graph_indent) { 95 $graph_indent = 0; 96 } else { 97 $_ = visible_substr($_, $graph_indent); 98 } 99 } 100 101 if (!$in_hunk) { 102 $line_cb->($orig); 103 $in_hunk = /^$COLOR*\@\@ /; 104 } 105 elsif (/^$COLOR*-/) { 106 push @removed, $orig; 107 } 108 elsif (/^$COLOR*\+/) { 109 push @added, $orig; 110 } 111 else { 112 flush(); 113 $line_cb->($orig); 114 $in_hunk = /^$COLOR*[\@ ]/; 115 } 116 117 # Most of the time there is enough output to keep things streaming, 118 # but for something like "git log -Sfoo", you can get one early 119 # commit and then many seconds of nothing. We want to show 120 # that one commit as soon as possible. 121 # 122 # Since we can receive arbitrary input, there's no optimal 123 # place to flush. Flushing on a blank line is a heuristic that 124 # happens to match git-log output. 125 if (!length) { 126 $flush_cb->(); 127 } 128 } 129 130 sub flush { 131 # Flush any queued hunk (this can happen when there is no trailing 132 # context in the final diff of the input). 133 show_hunk(\@removed, \@added); 134 @removed = (); 135 @added = (); 136 } 137 138 sub highlight_stdin { 139 while (<STDIN>) { 140 handle_line($_); 141 } 142 flush(); 143 } 144 145 # Ideally we would feed the default as a human-readable color to 146 # git-config as the fallback value. But diff-highlight does 147 # not otherwise depend on git at all, and there are reports 148 # of it being used in other settings. Let's handle our own 149 # fallback, which means we will work even if git can't be run. 150 sub color_config { 151 my ($key, $default) = @_; 152 153 # Removing the redirect speeds up execution by about 12ms 154 #my $s = `git config --get-color $key 2>$NULL`; 155 my $s = `git config --get-color $key`; 156 157 return length($s) ? $s : $default; 158 } 159 160 sub show_hunk { 161 my ($a, $b) = @_; 162 163 # If one side is empty, then there is nothing to compare or highlight. 164 if (!@$a || !@$b) { 165 $line_cb->(@$a, @$b); 166 return; 167 } 168 169 # If we have mismatched numbers of lines on each side, we could try to 170 # be clever and match up similar lines. But for now we are simple and 171 # stupid, and only handle multi-line hunks that remove and add the same 172 # number of lines. 173 if (@$a != @$b) { 174 $line_cb->(@$a, @$b); 175 return; 176 } 177 178 my @queue; 179 for (my $i = 0; $i < @$a; $i++) { 180 my ($rm, $add) = highlight_pair($a->[$i], $b->[$i]); 181 $line_cb->($rm); 182 push @queue, $add; 183 } 184 $line_cb->(@queue); 185 } 186 187 sub highlight_pair { 188 my @a = split_line(shift); 189 my @b = split_line(shift); 190 191 # Find common prefix, taking care to skip any ansi 192 # color codes. 193 my $seen_plusminus; 194 my ($pa, $pb) = (0, 0); 195 while ($pa < @a && $pb < @b) { 196 if ($a[$pa] =~ /$COLOR/) { 197 $pa++; 198 } 199 elsif ($b[$pb] =~ /$COLOR/) { 200 $pb++; 201 } 202 elsif ($a[$pa] eq $b[$pb]) { 203 $pa++; 204 $pb++; 205 } 206 elsif (!$seen_plusminus && $a[$pa] eq '-' && $b[$pb] eq '+') { 207 $seen_plusminus = 1; 208 $pa++; 209 $pb++; 210 } 211 else { 212 last; 213 } 214 } 215 216 # Find common suffix, ignoring colors. 217 my ($sa, $sb) = ($#a, $#b); 218 while ($sa >= $pa && $sb >= $pb) { 219 if ($a[$sa] =~ /$COLOR/) { 220 $sa--; 221 } 222 elsif ($b[$sb] =~ /$COLOR/) { 223 $sb--; 224 } 225 elsif ($a[$sa] eq $b[$sb]) { 226 $sa--; 227 $sb--; 228 } 229 else { 230 last; 231 } 232 } 233 234 if (is_pair_interesting(\@a, $pa, $sa, \@b, $pb, $sb)) { 235 return highlight_line(\@a, $pa, $sa, \@OLD_HIGHLIGHT), 236 highlight_line(\@b, $pb, $sb, \@NEW_HIGHLIGHT); 237 } 238 else { 239 return join('', @a), 240 join('', @b); 241 } 242 } 243 244 # we split either by $COLOR or by character. This has the side effect of 245 # leaving in graph cruft. It works because the graph cruft does not contain "-" 246 # or "+" 247 sub split_line { 248 local $_ = shift; 249 return utf8::decode($_) ? 250 map { utf8::encode($_); $_ } 251 map { /$COLOR/ ? $_ : (split //) } 252 split /($COLOR+)/ : 253 map { /$COLOR/ ? $_ : (split //) } 254 split /($COLOR+)/; 255 } 256 257 sub highlight_line { 258 my ($line, $prefix, $suffix, $theme) = @_; 259 260 my $start = join('', @{$line}[0..($prefix-1)]); 261 my $mid = join('', @{$line}[$prefix..$suffix]); 262 my $end = join('', @{$line}[($suffix+1)..$#$line]); 263 264 # If we have a "normal" color specified, then take over the whole line. 265 # Otherwise, we try to just manipulate the highlighted bits. 266 if (defined $theme->[0]) { 267 s/$COLOR//g for ($start, $mid, $end); 268 chomp $end; 269 return join('', 270 $theme->[0], $start, $RESET, 271 $theme->[1], $mid, $RESET, 272 $theme->[0], $end, $RESET, 273 "\n" 274 ); 275 } else { 276 return join('', 277 $start, 278 $theme->[1], $mid, $theme->[2], 279 $end 280 ); 281 } 282 } 283 284 # Pairs are interesting to highlight only if we are going to end up 285 # highlighting a subset (i.e., not the whole line). Otherwise, the highlighting 286 # is just useless noise. We can detect this by finding either a matching prefix 287 # or suffix (disregarding boring bits like whitespace and colorization). 288 sub is_pair_interesting { 289 my ($a, $pa, $sa, $b, $pb, $sb) = @_; 290 my $prefix_a = join('', @$a[0..($pa-1)]); 291 my $prefix_b = join('', @$b[0..($pb-1)]); 292 my $suffix_a = join('', @$a[($sa+1)..$#$a]); 293 my $suffix_b = join('', @$b[($sb+1)..$#$b]); 294 295 return visible_substr($prefix_a, $graph_indent) !~ /^$COLOR*-$BORING*$/ || 296 visible_substr($prefix_b, $graph_indent) !~ /^$COLOR*\+$BORING*$/ || 297 $suffix_a !~ /^$BORING*$/ || 298 $suffix_b !~ /^$BORING*$/; 299 } 300 DIFFHIGHLIGHT 301 302 s/^ //mg for values %fatpacked; 303 304 my $class = 'FatPacked::'.(0+\%fatpacked); 305 no strict 'refs'; 306 *{"${class}::files"} = sub { keys %{$_[0]} }; 307 308 if ($] < 5.008) { 309 *{"${class}::INC"} = sub { 310 if (my $fat = $_[0]{$_[1]}) { 311 my $pos = 0; 312 my $last = length $fat; 313 return (sub { 314 return 0 if $pos == $last; 315 my $next = (1 + index $fat, "\n", $pos) || $last; 316 $_ .= substr $fat, $pos, $next - $pos; 317 $pos = $next; 318 return 1; 319 }); 320 } 321 }; 322 } 323 324 else { 325 *{"${class}::INC"} = sub { 326 if (my $fat = $_[0]{$_[1]}) { 327 open my $fh, '<', \$fat 328 or die "FatPacker error loading $_[1] (could be a perl installation issue?)"; 329 return $fh; 330 } 331 return; 332 }; 333 } 334 335 unshift @INC, bless \%fatpacked, $class; 336 } # END OF FATPACK CODE 337 338 339 my $VERSION = "1.4.2"; 340 341 ################################################################################# 342 343 use v5.010; # Require Perl 5.10 for 'state' variables 344 use warnings FATAL => 'all'; 345 use strict; 346 347 use File::Spec; # For catdir 348 use File::Basename; # For dirname 349 use Cwd qw(abs_path); # For realpath() 350 use lib dirname(abs_path(File::Spec->catdir($0))) . "/lib"; # Add the local lib/ to @INC 351 use DiffHighlight; 352 353 my $remove_file_add_header = 1; 354 my $remove_file_delete_header = 1; 355 my $clean_permission_changes = 1; 356 my $patch_mode = 0; 357 my $manually_color_lines = 0; # Usually git/hg colorizes the lines, but for raw patches we use this 358 my $change_hunk_indicators = git_config_boolean("diff-so-fancy.changeHunkIndicators","true"); 359 my $strip_leading_indicators = git_config_boolean("diff-so-fancy.stripLeadingSymbols","true"); 360 my $mark_empty_lines = git_config_boolean("diff-so-fancy.markEmptyLines","true"); 361 my $use_unicode_dash_for_ruler = git_config_boolean("diff-so-fancy.useUnicodeRuler","true"); 362 my $ruler_width = git_config("diff-so-fancy.rulerWidth", undef); 363 my $git_strip_prefix = git_config_boolean("diff.noprefix","false"); 364 my $has_stdin = has_stdin(); 365 366 my $ansi_color_regex = qr/(\e\[([0-9]{1,3}(;[0-9]{1,3}){0,10})[mK])?/; 367 my $reset_color = color("reset"); 368 my $bold = color("bold"); 369 my $meta_color = ""; 370 371 # Set the diff highlight colors from the config 372 init_diff_highlight_colors(); 373 374 my ($file_1,$file_2); 375 my $args = argv(); # Hashref of all the ARGV stuff 376 my $last_file_seen = ""; 377 my $last_file_mode = ""; 378 my $i = 0; 379 my $in_hunk = 0; 380 my $columns_to_remove = 0; 381 my $is_mercurial = 0; 382 my $color_forced = 0; # Has the color been forced on/off 383 384 # We try and be smart about whether we need to do line coloring, but 385 # this is an option to force it on/off 386 if ($args->{color_on}) { 387 $manually_color_lines = 1; 388 $color_forced = 1; 389 } elsif ($args->{color_off}) { 390 $manually_color_lines = 0; 391 $color_forced = 1; 392 } 393 394 if ($args->{debug}) { 395 show_debug_info(); 396 exit(); 397 } 398 399 # `git add --patch` requires our output to match the number of lines from the 400 # input. So, when patch mode is active, we print out empty lines to pad our 401 # output to match any lines we've consumed. 402 if ($args->{patch}) { 403 $patch_mode = 1; 404 } 405 406 # We only process ARGV if we don't have STDIN 407 if (!$has_stdin) { 408 if ($args->{v} || $args->{version}) { 409 die(version()); 410 } elsif ($args->{'set-defaults'}) { 411 my $ok = set_defaults(); 412 exit; 413 } elsif ($args->{colors}) { 414 # We print this to STDOUT so we can redirect to bash to auto-set the colors 415 print get_default_colors(); 416 exit; 417 } elsif (!%$args || $args->{help} || $args->{h}) { 418 my $first = check_first_run(); 419 420 if (!$first) { 421 die(usage()); 422 } 423 } else { 424 die("Missing input on STDIN\n"); 425 } 426 } 427 428 ################################################################################# 429 ################################################################################# 430 431 # Check to see if were using default settings 432 check_first_run(); 433 434 # The logic here is that we run all the lines through DiffHighlight first. This 435 # highlights all the intra-word changes. Then we take those lines and send them 436 # to do_dsf_stuff() to convert the diff to human readable d-s-f output and add 437 # appropriate fanciness 438 439 my @lines; 440 local $DiffHighlight::line_cb = sub { 441 push(@lines,@_); 442 443 my $last_line = $lines[-1]; 444 445 # Buffer X lines before we try and output anything 446 # Also make sure we're sending enough data to d-s-f to do it's magic. 447 # Certain things require a look-ahead line or two to function so 448 # we make sure we don't break on those sections prematurely 449 if (@lines > 24 && ($last_line !~ /^${ansi_color_regex}(---|index|old mode|similarity index|rename (from|to))/)) { 450 do_dsf_stuff(\@lines); 451 @lines = (); 452 } 453 }; 454 455 my $line_count = 0; 456 while (my $line = <STDIN>) { 457 # If the very first line of the diff doesn't start with ANSI color we're assuming 458 # it's a raw patch file, and we have to color the added/removed lines ourself 459 if (!$color_forced && $line_count == 0 && starts_with_ansi($line)) { 460 $manually_color_lines = 1; 461 } 462 463 my $ok = DiffHighlight::handle_line($line); 464 $line_count++; 465 } 466 467 # If we're mid hunk above process anything still pending 468 DiffHighlight::flush(); 469 do_dsf_stuff(\@lines); 470 471 ################################################################################# 472 ################################################################################# 473 474 sub do_dsf_stuff { 475 my $input = shift(); 476 477 #print STDERR "START -------------------------------------------------\n"; 478 #print STDERR join("",@$input); 479 #print STDERR "END ---------------------------------------------------\n"; 480 481 while (my $line = shift(@$input)) { 482 ###################################################### 483 # Pre-process the line before we do any other markup # 484 ###################################################### 485 486 # If the first line of the input is a blank line, skip that 487 if ($i == 0 && $line =~ /^\s*$/) { 488 next; 489 } 490 491 ###################### 492 # End pre-processing # 493 ###################### 494 495 ####################################################################### 496 497 #################################################################### 498 # Look for git index and replace it horizontal line (header later) # 499 #################################################################### 500 if ($line =~ /^${ansi_color_regex}index /) { 501 # Print the line color and then the actual line 502 $meta_color = $1 || get_config_color("meta"); 503 504 # Get the next line without incrementing counter while loop 505 my $next = $input->[0] || ""; 506 my ($file_1,$file_2); 507 508 # The line immediately after the "index" line should be the --- file line 509 # If it's not it's an empty file add/delete 510 if ($next !~ /^$ansi_color_regex(---|Binary files)/) { 511 512 # We fake out the file names since it's a raw add/delete 513 if ($last_file_mode eq "add") { 514 $file_1 = "/dev/null"; 515 $file_2 = $last_file_seen; 516 } elsif ($last_file_mode eq "delete") { 517 $file_1 = $last_file_seen; 518 $file_2 = "/dev/null"; 519 } 520 } 521 522 if ($file_1 && $file_2) { 523 print horizontal_rule($meta_color); 524 print $meta_color . file_change_string($file_1,$file_2) . "\n"; 525 print horizontal_rule($meta_color); 526 } 527 ######################### 528 # Look for the filename # 529 ######################### 530 # $4 $5 531 } elsif ($line =~ /^${ansi_color_regex}diff (-r|--git|--cc) (.*?)(\e| b\/|$)/) { 532 533 # Mercurial looks like: diff -r 82e55d328c8c hello.c 534 if ($4 eq "-r") { 535 $is_mercurial = 1; 536 $meta_color = get_config_color("meta"); 537 # Git looks like: diff --git a/diff-so-fancy b/diff-so-fancy 538 } else { 539 $last_file_seen = $5; 540 } 541 542 $last_file_seen =~ s|^\w/||; # Remove a/ (and handle diff.mnemonicPrefix). 543 $in_hunk = 0; 544 if ($patch_mode) { 545 # we are consuming one line, and the debt must be paid 546 print "\n"; 547 } 548 ######################################## 549 # Find the first file: --- a/README.md # 550 ######################################## 551 } elsif (!$in_hunk && $line =~ /^$ansi_color_regex--- (\w\/)?(.+?)(\e|\t|$)/) { 552 $meta_color = get_config_color("meta"); 553 554 if ($git_strip_prefix) { 555 my $file_dir = $4 || ""; 556 $file_1 = $file_dir . $5; 557 } else { 558 $file_1 = $5; 559 } 560 561 # Find the second file on the next line: +++ b/README.md 562 my $next = shift(@$input); 563 $next =~ /^$ansi_color_regex\+\+\+ (\w\/)?(.+?)(\e|\t|$)/; 564 if ($1) { 565 print $1; # Print out whatever color we're using 566 } 567 if ($git_strip_prefix) { 568 my $file_dir = $4 || ""; 569 $file_2 = $file_dir . $5; 570 } else { 571 $file_2 = $5; 572 } 573 574 if ($file_2 ne "/dev/null") { 575 $last_file_seen = $file_2; 576 } 577 578 # Print out the top horizontal line of the header 579 print $reset_color; 580 print horizontal_rule($meta_color); 581 582 # Mercurial coloring is slightly different so we need to hard reset colors 583 if ($is_mercurial) { 584 print $reset_color; 585 } 586 587 print $meta_color; 588 print file_change_string($file_1,$file_2) . "\n"; 589 590 # Print out the bottom horizontal line of the header 591 print horizontal_rule($meta_color); 592 ######################################## 593 # Check for "@@ -3,41 +3,63 @@" syntax # 594 ######################################## 595 } elsif (!$change_hunk_indicators && $line =~ /^${ansi_color_regex}(@@@* .+? @@@*)(.*)/) { 596 $in_hunk = 1; 597 598 print $line; 599 } elsif ($change_hunk_indicators && $line =~ /^${ansi_color_regex}(@@@* .+? @@@*)(.*)/) { 600 $in_hunk = 1; 601 602 my $hunk_header = $4; 603 my $remain = bleach_text($5); 604 605 # The number of colums to remove (1 or 2) is based on how many commas in the hunk header 606 $columns_to_remove = (char_count(",",$hunk_header)) - 1; 607 # On single line removes there is NO comma in the hunk so we force one 608 if ($columns_to_remove <= 0) { 609 $columns_to_remove = 1; 610 } 611 612 if ($1) { 613 print $1; # Print out whatever color we're using 614 } 615 616 my ($orig_offset, $orig_count, $new_offset, $new_count) = parse_hunk_header($hunk_header); 617 #$last_file_seen = basename($last_file_seen); 618 619 # Figure out the start line 620 my $start_line = start_line_calc($new_offset,$new_count); 621 622 # Last function has it's own color 623 my $last_function_color = ""; 624 if ($remain) { 625 $last_function_color = get_config_color("last_function"); 626 } 627 628 # Check to see if we have the color for the fragment from git 629 if ($5 =~ /\e\[\d/) { 630 #print "Has ANSI color for fragment\n"; 631 } else { 632 # We don't have the ANSI sequence so we shell out to get it 633 #print "No ANSI color for fragment\n"; 634 my $frag_color = get_config_color("fragment"); 635 print $frag_color; 636 } 637 638 print "@ $last_file_seen:$start_line \@${bold}${last_function_color}${remain}${reset_color}\n"; 639 ################################### 640 # Remove any new file permissions # 641 ################################### 642 } elsif ($remove_file_add_header && $line =~ /^${ansi_color_regex}.*new file mode/) { 643 # Don't print the line (i.e. remove it from the output); 644 $last_file_mode = "add"; 645 if ($patch_mode) { 646 print "\n"; 647 } 648 ###################################### 649 # Remove any delete file permissions # 650 ###################################### 651 } elsif ($remove_file_delete_header && $line =~ /^${ansi_color_regex}deleted file mode/) { 652 # Don't print the line (i.e. remove it from the output); 653 $last_file_mode = "delete"; 654 if ($patch_mode) { 655 print "\n"; 656 } 657 ################################ 658 # Look for binary file changes # 659 ################################ 660 } elsif ($line =~ /^Binary files (\w\/)?(.+?) and (\w\/)?(.+?) differ/) { 661 my $change = file_change_string($2,$4); 662 print horizontal_rule($meta_color); 663 print "$meta_color$change (binary)\n"; 664 print horizontal_rule($meta_color); 665 ##################################################### 666 # Check if we're changing the permissions of a file # 667 ##################################################### 668 } elsif ($clean_permission_changes && $line =~ /^${ansi_color_regex}old mode (\d+)/) { 669 my ($old_mode) = $4; 670 my $next = shift(@$input); 671 672 if ($1) { 673 print $1; # Print out whatever color we're using 674 } 675 676 my ($new_mode) = $next =~ m/new mode (\d+)/; 677 678 if ($patch_mode) { 679 print "\n"; 680 } 681 print "$last_file_seen changed file mode from $old_mode to $new_mode\n"; 682 683 ############### 684 # File rename # 685 ############### 686 } elsif ($line =~ /^${ansi_color_regex}similarity index (\d+)%/) { 687 my $simil = $4; 688 689 # If it's a move with content change we ignore this and the next two lines 690 if ($simil != 100) { 691 shift(@$input); 692 shift(@$input); 693 next; 694 } 695 696 my $next = shift(@$input); 697 my ($file1) = $next =~ /rename from (.+?)(\e|\t|$)/; 698 699 $next = shift(@$input); 700 my ($file2) = $next =~ /rename to (.+?)(\e|\t|$)/; 701 702 if ($file1 && $file2) { 703 # We may not have extracted this yet, so we pull from the config if not 704 $meta_color = get_config_color("meta"); 705 706 my $change = file_change_string($file1,$file2); 707 708 print horizontal_rule($meta_color); 709 print $meta_color . $change . "\n"; 710 print horizontal_rule($meta_color); 711 } 712 713 $i += 3; # We've consumed three lines 714 next; 715 ##################################### 716 # Just a regular line, print it out # 717 ##################################### 718 } else { 719 # Mark empty line with a red/green box indicating addition/removal 720 if ($mark_empty_lines) { 721 $line = mark_empty_line($line); 722 } 723 724 # Remove the correct number of leading " " or "+" or "-" 725 if ($strip_leading_indicators) { 726 $line = strip_leading_indicators($line,$columns_to_remove); 727 } 728 print $line; 729 } 730 731 $i++; 732 } 733 } 734 735 ###################################################################################################### 736 # End regular code, begin functions 737 ###################################################################################################### 738 739 # Courtesy of github.com/git/git/blob/ab5d01a/git-add--interactive.perl#L798-L805 740 sub parse_hunk_header { 741 my ($line) = @_; 742 my ($o_ofs, $o_cnt, $n_ofs, $n_cnt) = $line =~ /^\@\@+(?: -(\d+)(?:,(\d+))?)+ \+(\d+)(?:,(\d+))? \@\@+/; 743 $o_cnt = 1 unless defined $o_cnt; 744 $n_cnt = 1 unless defined $n_cnt; 745 return ($o_ofs, $o_cnt, $n_ofs, $n_cnt); 746 } 747 748 # Mark the first char of an empty line 749 sub mark_empty_line { 750 my $line = shift(); 751 752 my $reset_color = "\e\\[0?m"; 753 my $reset_escape = "\e\[m"; 754 my $invert_color = "\e\[7m"; 755 my $add_color = $DiffHighlight::NEW_HIGHLIGHT[1]; 756 my $del_color = $DiffHighlight::OLD_HIGHLIGHT[1]; 757 758 # This captures lines that do not have any ANSI in them (raw vanilla diff) 759 if ($line eq "+\n") { 760 $line = $invert_color . $add_color . " " . color('reset') . "\n"; 761 # This captures lines that do not have any ANSI in them (raw vanilla diff) 762 } elsif ($line eq "-\n") { 763 $line = $invert_color . $del_color . " " . color('reset') . "\n"; 764 # This handles everything else 765 } else { 766 $line =~ s/^($ansi_color_regex)[+-]$reset_color\s*$/$invert_color$1 $reset_escape\n/; 767 } 768 769 return $line; 770 } 771 772 # String to boolean 773 sub boolean { 774 my $str = shift(); 775 $str = trim($str); 776 777 if ($str eq "" || $str =~ /^(no|false|0)$/i) { 778 return 0; 779 } else { 780 return 1; 781 } 782 } 783 784 # Get the git config 785 sub git_config_raw { 786 my $cmd = "git config --list"; 787 my @out = `$cmd`; 788 789 return \@out; 790 } 791 792 # Memoize fetching a textual item from the git config 793 sub git_config { 794 my $search_key = lc($_[0] || ""); 795 my $default_value = lc($_[1] || ""); 796 797 state $raw = {}; 798 if (%$raw && $search_key) { 799 return $raw->{$search_key} || $default_value; 800 } 801 802 if ($args->{debug}) { 803 print "Parsing git config\n"; 804 } 805 806 my $out = git_config_raw(); 807 808 foreach my $line (@$out) { 809 if ($line =~ /=/) { 810 my ($key,$value) = split("=",$line,2); 811 $value =~ s/\s+$//; 812 $raw->{$key} = $value; 813 } 814 } 815 816 # If we're given a search key return that, else return the hash 817 if ($search_key) { 818 return $raw->{$search_key} || $default_value; 819 } else { 820 return $raw; 821 } 822 } 823 824 # Fetch a boolean item from the git config 825 sub git_config_boolean { 826 my $search_key = lc($_[0] || ""); 827 my $default_value = lc($_[1] || 0); # Default to false 828 829 # If we're in a unit test, use the default (don't read the users config) 830 if (in_unit_test()) { 831 return boolean($default_value); 832 } 833 834 my $result = git_config($search_key,$default_value); 835 my $ret = boolean($result); 836 837 return $ret; 838 } 839 840 # Check if we're inside of BATS 841 sub in_unit_test { 842 if ($ENV{BATS_CWD}) { 843 return 1; 844 } else { 845 return 0; 846 } 847 } 848 849 sub get_less_charset { 850 my @less_char_vars = ("LESSCHARSET", "LESSCHARDEF", "LC_ALL", "LC_CTYPE", "LANG"); 851 foreach my $key (@less_char_vars) { 852 my $val = $ENV{$key}; 853 854 if (defined $val) { 855 return ($key, $val); 856 } 857 } 858 859 return (); 860 } 861 862 sub should_print_unicode { 863 if (-t STDOUT) { 864 # Always print unicode chars if we're not piping stuff, e.g. to less(1) 865 return 1; 866 } 867 868 # Otherwise, assume we're piping to less(1) 869 my ($less_env_var, $less_charset) = get_less_charset(); 870 if ($less_charset && $less_charset =~ /utf-?8/i) { 871 return 1; 872 } 873 874 return 0; 875 } 876 877 # Try and be smart about what line the diff hunk starts on 878 sub start_line_calc { 879 my ($line_num,$diff_context) = @_; 880 my $ret; 881 882 if ($line_num == 0 && $diff_context == 0) { 883 return 1; 884 } 885 886 # Git defaults to three lines of context 887 my $default_context_lines = 3; 888 # Three lines on either side, and the line itself = 7 889 my $expected_context = ($default_context_lines * 2 + 1); 890 891 # The first three lines 892 if ($line_num == 1 && $diff_context < $expected_context) { 893 $ret = $diff_context - $default_context_lines; 894 } else { 895 $ret = $line_num + $default_context_lines; 896 } 897 898 if ($ret < 1) { 899 $ret = 1; 900 } 901 902 return $ret; 903 } 904 905 # Remove + or - at the beginning of the lines 906 sub strip_leading_indicators { 907 my $line = shift(); # Array passed in by reference 908 my $columns_to_remove = shift(); # Don't remove any lines by default 909 910 if ($columns_to_remove == 0) { 911 return $line; # Nothing to do 912 } 913 914 $line =~ s/^(${ansi_color_regex})([ +-]){${columns_to_remove}}/$1/; 915 916 if ($manually_color_lines) { 917 if (defined($5) && $5 eq "+") { 918 my $add_line_color = get_config_color("add_line"); 919 $line = $add_line_color . insert_reset_at_line_end($line); 920 } elsif (defined($5) && $5 eq "-") { 921 my $remove_line_color = get_config_color("remove_line"); 922 $line = $remove_line_color . insert_reset_at_line_end($line); 923 } 924 } 925 926 return $line; 927 } 928 929 # Insert the color reset code at end of line, but before any newlines 930 sub insert_reset_at_line_end { 931 my $line = shift(); 932 $line =~ s/^(.*)([\n\r]+)?$/${1}${reset_color}${2}/; 933 return $line; 934 } 935 936 # Count the number of a given char in a string 937 # https://www.perturb.org/display/1010_Perl_Count_occurrences_of_substring.html 938 sub char_count { 939 my ($needle, $haystack) = @_; 940 941 my $count = () = ($haystack =~ /$needle/g); 942 943 return $count; 944 } 945 946 # Remove all ANSI codes from a string 947 sub bleach_text { 948 my $str = shift(); 949 $str =~ s/\e\[\d*(;\d+)*m//mg; 950 951 return $str; 952 } 953 954 # Remove all trailing and leading spaces 955 sub trim { 956 my $s = shift(); 957 if (!$s) { return ""; } 958 959 $s =~ s/^\s*//u; 960 $s =~ s/\s*$//u; 961 962 return $s; 963 } 964 965 # Print a line of em-dash or line-drawing chars the full width of the screen 966 sub horizontal_rule { 967 my $color = $_[0] || ""; 968 my $width = get_terminal_width(); 969 970 # em-dash http://www.fileformat.info/info/unicode/char/2014/index.htm 971 #my $dash = "\x{2014}"; 972 # BOX DRAWINGS LIGHT HORIZONTAL http://www.fileformat.info/info/unicode/char/2500/index.htm 973 my $dash; 974 if ($use_unicode_dash_for_ruler && should_print_unicode()) { 975 #$dash = Encode::encode('UTF-8', "\x{2500}"); 976 $dash = "\xE2\x94\x80"; 977 } else { 978 $dash = "-"; 979 } 980 981 # Draw the line 982 my $ret = $color . ($dash x $width) . "$reset_color\n"; 983 984 return $ret; 985 } 986 987 sub file_change_string { 988 my $file_1 = shift(); 989 my $file_2 = shift(); 990 991 # If they're the same it's a modify 992 if ($file_1 eq $file_2) { 993 return "modified: $file_1"; 994 # If the first is /dev/null it's a new file 995 } elsif ($file_1 eq "/dev/null") { 996 my $add_color = $DiffHighlight::NEW_HIGHLIGHT[1]; 997 return "added: $add_color$file_2$reset_color"; 998 # If the second is /dev/null it's a deletion 999 } elsif ($file_2 eq "/dev/null") { 1000 my $del_color = $DiffHighlight::OLD_HIGHLIGHT[1]; 1001 return "deleted: $del_color$file_1$reset_color"; 1002 # If the files aren't the same it's a rename 1003 } elsif ($file_1 ne $file_2) { 1004 my ($old, $new) = DiffHighlight::highlight_pair($file_1,$file_2,{only_diff => 1}); 1005 # highlight_pair already includes reset_color, but adds newline characters that need to be trimmed off 1006 $old = trim($old); 1007 $new = trim($new); 1008 return "renamed: $old$meta_color to $new" 1009 # Something we haven't thought of yet 1010 } else { 1011 return "$file_1 -> $file_2"; 1012 } 1013 } 1014 1015 # Check to see if STDIN is connected to an interactive terminal 1016 sub has_stdin { 1017 my $i = -t STDIN; 1018 my $ret = int(!$i); 1019 1020 return $ret; 1021 } 1022 1023 # We use this instead of Getopt::Long because it's faster and we're not parsing any 1024 # crazy arguments 1025 # Borrowed from: https://www.perturb.org/display/1153_Perl_Quick_extract_variables_from_ARGV.html 1026 sub argv { 1027 my $ret = {}; 1028 1029 for (my $i = 0; $i < scalar(@ARGV); $i++) { 1030 1031 # If the item starts with "-" it's a key 1032 if ((my ($key) = $ARGV[$i] =~ /^--?([a-zA-Z_-]*\w)$/) && ($ARGV[$i] !~ /^-\w\w/)) { 1033 # If the next item does not start with "--" it's the value for this item 1034 if (defined($ARGV[$i + 1]) && ($ARGV[$i + 1] !~ /^--?\D/)) { 1035 $ret->{$key} = $ARGV[$i + 1]; 1036 # Bareword like --verbose with no options 1037 } else { 1038 $ret->{$key}++; 1039 } 1040 } 1041 } 1042 1043 # We're looking for a certain item 1044 if ($_[0]) { return $ret->{$_[0]}; } 1045 1046 return $ret; 1047 } 1048 1049 # Output the command line usage for d-s-f 1050 sub usage { 1051 my $out = color("white_bold") . version() . color("reset") . "\n"; 1052 1053 $out .= "Usage: 1054 1055 git diff --color | diff-so-fancy # Use d-s-f on one diff 1056 cat diff.txt | diff-so-fancy # Use d-s-f on a diff/patch file 1057 diff -u one.txt two.txt | diff-so-fancy # Use d-s-f on unified diff output 1058 1059 diff-so-fancy --colors # View the commands to set the recommended colors 1060 diff-so-fancy --set-defaults # Configure git-diff to use diff-so-fancy and suggested colors 1061 diff-so-fancy --patch # Use diff-so-fancy in patch mode (interoperable with `git add --patch`) 1062 1063 # Configure git to use d-s-f for *all* diff operations 1064 git config --global core.pager \"diff-so-fancy | less --tabs=4 -RFX\" 1065 1066 # Configure git to use d-s-f for `git add --patch` 1067 git config --global interactive.diffFilter \"diff-so-fancy --patch\"\n"; 1068 1069 return $out; 1070 } 1071 1072 sub get_default_colors { 1073 my $out = "# Recommended default colors for diff-so-fancy\n"; 1074 $out .= "# --------------------------------------------\n"; 1075 $out .= 'git config --global color.ui true 1076 1077 git config --global color.diff-highlight.oldNormal "red bold" 1078 git config --global color.diff-highlight.oldHighlight "red bold 52" 1079 git config --global color.diff-highlight.newNormal "green bold" 1080 git config --global color.diff-highlight.newHighlight "green bold 22" 1081 1082 git config --global color.diff.meta "yellow" 1083 git config --global color.diff.frag "magenta bold" 1084 git config --global color.diff.commit "yellow bold" 1085 git config --global color.diff.old "red bold" 1086 git config --global color.diff.new "green bold" 1087 git config --global color.diff.whitespace "red reverse" 1088 '; 1089 1090 return $out; 1091 } 1092 1093 # Output the current version string 1094 sub version { 1095 my $ret = "Diff-so-fancy: https://github.com/so-fancy/diff-so-fancy\n"; 1096 $ret .= "Version : $VERSION\n"; 1097 1098 return $ret; 1099 } 1100 1101 sub is_windows { 1102 if ($^O eq 'MSWin32' or $^O eq 'dos' or $^O eq 'os2' or $^O eq 'cygwin' or $^O eq 'msys') { 1103 return 1; 1104 } else { 1105 return 0; 1106 } 1107 } 1108 1109 # Return value is whether this is the first time they've run d-s-f 1110 sub check_first_run { 1111 my $ret = 0; 1112 1113 # If first-run is not set, or it's set to "true" 1114 my $first_run = git_config_boolean('diff-so-fancy.first-run'); 1115 # See if they're previously set SOME diff-highlight colors 1116 my $has_dh_colors = git_config_boolean('color.diff-highlight.oldnormal') || git_config_boolean('color.diff-highlight.newnormal'); 1117 1118 #$first_run = 1; $has_dh_colors = 0; 1119 1120 if (!$first_run || $has_dh_colors) { 1121 return 0; 1122 } else { 1123 print "This appears to be the first time you've run diff-so-fancy, please note\n"; 1124 print "that the default git colors are not ideal. Diff-so-fancy recommends the\n"; 1125 print "following colors.\n\n"; 1126 1127 print get_default_colors(); 1128 1129 # Set the first run flag to false 1130 my $cmd = 'git config --global diff-so-fancy.first-run false'; 1131 system($cmd); 1132 1133 exit; 1134 } 1135 1136 return 1; 1137 } 1138 1139 sub set_defaults { 1140 my $color_config = get_default_colors(); 1141 my $git_config = 'git config --global core.pager "diff-so-fancy | less --tabs=4 -RFX"'; 1142 my $first_cmd = 'git config --global diff-so-fancy.first-run false'; 1143 1144 my @cmds = split(/\n/,$color_config); 1145 push(@cmds,$git_config); 1146 push(@cmds,$first_cmd); 1147 1148 # Remove all comments from the commands 1149 foreach my $x (@cmds) { 1150 $x =~ s/#.*//g; 1151 } 1152 1153 # Remove any empty commands 1154 @cmds = grep($_,@cmds); 1155 1156 foreach my $cmd (@cmds) { 1157 system($cmd); 1158 my $exit = ($? >> 8); 1159 1160 if ($exit != 0) { 1161 die("Error running: '$cmd' (error #18941)\n"); 1162 } 1163 } 1164 1165 return 1; 1166 } 1167 1168 # Borrowed from: https://www.perturb.org/display/1167_Perl_ANSI_colors.html 1169 # String format: '115', '165_bold', '10_on_140', 'reset', 'on_173', 'red', 'white_on_blue' 1170 sub color { 1171 my $str = shift(); 1172 1173 # No string sent in, so we just reset 1174 if (!length($str) || $str eq 'reset') { return "\e[0m"; } 1175 1176 # Some predefined colors 1177 my %color_map = qw(red 160 blue 21 green 34 yellow 226 orange 214 purple 93 white 15 black 0); 1178 $str =~ s|([A-Za-z]+)|$color_map{$1} // $1|eg; 1179 1180 # Get foreground/background and any commands 1181 my ($fc,$cmd) = $str =~ /(\d+)?_?(\w+)?/g; 1182 my ($bc) = $str =~ /on_?(\d+)/g; 1183 1184 # Some predefined commands 1185 my %cmd_map = qw(bold 1 italic 3 underline 4 blink 5 inverse 7); 1186 my $cmd_num = $cmd_map{$cmd // 0}; 1187 1188 my $ret = ''; 1189 if ($cmd_num) { $ret .= "\e[${cmd_num}m"; } 1190 if (defined($fc)) { $ret .= "\e[38;5;${fc}m"; } 1191 if (defined($bc)) { $ret .= "\e[48;5;${bc}m"; } 1192 1193 return $ret; 1194 } 1195 1196 # Get colors used for various output sections (memoized) 1197 { 1198 my $static_config; 1199 1200 sub get_config_color { 1201 my $str = shift(); 1202 1203 my $ret = ""; 1204 if ($static_config->{$str}) { 1205 return $static_config->{$str}; 1206 } 1207 1208 #print color(15) . "Shelling out for color: '$str'\n" . color('reset'); 1209 1210 if ($str eq "meta") { 1211 # Default ANSI yellow 1212 $ret = git_ansi_color(git_config('color.diff.meta')) || color(11); 1213 } elsif ($str eq "reset") { 1214 $ret = color("reset"); 1215 } elsif ($str eq "add_line") { 1216 # Default ANSI green 1217 $ret = git_ansi_color(git_config('color.diff.new')) || color("2_bold"); 1218 } elsif ($str eq "remove_line") { 1219 # Default ANSI red 1220 $ret = git_ansi_color(git_config('color.diff.old')) || color("1_bold"); 1221 } elsif ($str eq "fragment") { 1222 $ret = git_ansi_color(git_config('color.diff.frag')) || color("13_bold"); 1223 } elsif ($str eq "last_function") { 1224 $ret = git_ansi_color(git_config('color.diff.func')) || color("146_bold"); 1225 } 1226 1227 # Cache (memoize) the entry for later 1228 $static_config->{$str} = $ret; 1229 1230 return $ret; 1231 } 1232 } 1233 1234 # https://www.git-scm.com/book/en/v2/Customizing-Git-Git-Configuration#_colors_in_git 1235 sub git_ansi_color { 1236 my $str = shift(); 1237 my @parts = split(' ', $str); 1238 1239 if (!@parts) { 1240 return ''; 1241 } 1242 my $colors = { 1243 'black' => 0, 1244 'red' => 1, 1245 'green' => 2, 1246 'yellow' => 3, 1247 'blue' => 4, 1248 'magenta' => 5, 1249 'cyan' => 6, 1250 'white' => 7, 1251 }; 1252 1253 my @ansi_part = (); 1254 1255 if (grep { /bold/ } @parts) { 1256 push(@ansi_part, "1"); 1257 @parts = grep { !/bold/ } @parts; # Remove from array 1258 } 1259 1260 if (grep { /reverse/ } @parts) { 1261 push(@ansi_part, "7"); 1262 @parts = grep { !/reverse/ } @parts; # Remove from array 1263 } 1264 1265 my $fg = $parts[0] // ""; 1266 my $bg = $parts[1] // ""; 1267 1268 ############################################# 1269 1270 # It's an numeric value, so it's an 8 bit color 1271 if (is_numeric($fg)) { 1272 if ($fg < 8) { 1273 push(@ansi_part, $fg + 30); 1274 } elsif ($fg < 16) { 1275 push(@ansi_part, $fg + 82); 1276 } else { 1277 push(@ansi_part, "38;5;$fg"); 1278 } 1279 # It's a simple 16 color OG ansi 1280 } elsif ($fg) { 1281 my $bright = $fg =~ s/bright//; 1282 my $color_num = $colors->{$fg} + 30; 1283 1284 if ($bright) { $color_num += 60; } # Set bold 1285 1286 push(@ansi_part, $color_num); 1287 } 1288 1289 ############################################# 1290 1291 # It's an numeric value, so it's an 8 bit color 1292 if (is_numeric($bg)) { 1293 if ($bg < 8) { 1294 push(@ansi_part, $bg + 40); 1295 } elsif ($bg < 16) { 1296 push(@ansi_part, $bg + 92); 1297 } else { 1298 push(@ansi_part, "48;5;$bg"); 1299 } 1300 # It's a simple 16 color OG ansi 1301 } elsif ($bg) { 1302 my $bright = $bg =~ s/bright//; 1303 my $color_num = $colors->{$bg} + 40; 1304 1305 if ($bright) { $color_num += 60; } # Set bold 1306 1307 push(@ansi_part, $color_num); 1308 } 1309 1310 ############################################# 1311 1312 my $ansi_str = join(";", @ansi_part); 1313 my $ret = "\e[" . $ansi_str . "m"; 1314 1315 return $ret; 1316 } 1317 1318 sub is_numeric { 1319 my $s = shift(); 1320 1321 if ($s =~ /^\d+$/) { 1322 return 1; 1323 } 1324 1325 return 0; 1326 } 1327 1328 sub starts_with_ansi { 1329 my $str = shift(); 1330 1331 if ($str =~ /^$ansi_color_regex/) { 1332 return 1; 1333 } else { 1334 return 0; 1335 } 1336 } 1337 1338 sub get_terminal_width { 1339 # Make width static so we only calculate it once 1340 state $width; 1341 1342 if ($width) { 1343 return $width; 1344 } 1345 1346 # If there is a ruler width in the config we use that 1347 if ($ruler_width) { 1348 $width = $ruler_width; 1349 # Otherwise we check the terminal width using tput 1350 } else { 1351 my $tput = `tput cols`; 1352 1353 if ($tput) { 1354 $width = int($tput); 1355 1356 if (is_windows()) { 1357 $width--; 1358 } 1359 } else { 1360 print color('orange') . "Warning: `tput cols` did not return numeric input" . color('reset') . "\n"; 1361 $width = 80; 1362 } 1363 } 1364 1365 return $width; 1366 } 1367 1368 sub show_debug_info { 1369 my @less = get_less_charset(); 1370 my $git_ver = trim(`git --version`); 1371 $git_ver =~ s/[^\d.]//g; 1372 1373 print "Diff-so-fancy : v$VERSION\n"; 1374 print "Git : v$git_ver\n"; 1375 print "Perl : $^V\n"; 1376 print "\n"; 1377 1378 print "Terminal width : " . get_terminal_width() . "\n"; 1379 print "Terminal \$LANG : " . ($ENV{LANG} || "") . "\n"; 1380 print "\n"; 1381 print "Supports Unicode: " . yes_no(should_print_unicode()) . "\n"; 1382 print "Unicode Ruler : " . yes_no($use_unicode_dash_for_ruler) . "\n"; 1383 print "\n"; 1384 print "Less Charset Var: " . ($less[0] // "") . "\n"; 1385 print "Less Charset : " . ($less[1] // "") . "\n"; 1386 print "\n"; 1387 print "Is Windows : " . yes_no(is_windows()) . "\n"; 1388 print "Operating System: $^O\n"; 1389 } 1390 1391 sub yes_no { 1392 my $val = shift(); 1393 1394 if ($val) { 1395 return "Yes"; 1396 } else { 1397 return "No"; 1398 } 1399 } 1400 1401 # If there are colors set in the gitconfig use those, otherwise leave the defaults 1402 sub init_diff_highlight_colors { 1403 $DiffHighlight::NEW_HIGHLIGHT[0] = git_ansi_color(git_config('color.diff-highlight.newnormal')) || $DiffHighlight::NEW_HIGHLIGHT[0]; 1404 $DiffHighlight::NEW_HIGHLIGHT[1] = git_ansi_color(git_config('color.diff-highlight.newhighlight')) || $DiffHighlight::NEW_HIGHLIGHT[1]; 1405 1406 $DiffHighlight::OLD_HIGHLIGHT[0] = git_ansi_color(git_config('color.diff-highlight.oldnormal')) || $DiffHighlight::OLD_HIGHLIGHT[0]; 1407 $DiffHighlight::OLD_HIGHLIGHT[1] = git_ansi_color(git_config('color.diff-highlight.oldhighlight')) || $DiffHighlight::OLD_HIGHLIGHT[1]; 1408 } 1409 1410 sub debug_log { 1411 my $log_line = shift(); 1412 my $file = "/tmp/diff-so-fancy.debug.log"; 1413 1414 state $fh; 1415 if (!$fh) { 1416 printf("%sDebug log enabled:%s $file\n", color('orange'), color()); 1417 open ($fh, ">", $file) or die("Cannot write to $file"); 1418 } 1419 1420 print $fh trim($log_line) . "\n"; 1421 1422 return 1; 1423 } 1424 1425 # Enable k() and kd() if there is a DSF_DEBUG environment variable 1426 BEGIN { 1427 if ($ENV{"DSF_DEBUG"}) { 1428 require Data::Dump::Color; 1429 *k = sub { Data::Dump::Color::dd(@_) }; 1430 *kd = sub { 1431 k(@_); 1432 1433 printf("Died at %2\$s line #%3\$s\n",caller()); 1434 exit(15); 1435 } 1436 } else { 1437 *k = sub {}; 1438 *kd = sub {}; 1439 } 1440 } 1441 1442 # vim: tabstop=4 shiftwidth=4 noexpandtab autoindent softtabstop=4