#!/usr/bin/perl

my $VERSION = "1.4.10";

#################################################################################

use v5.014;                                                 # Require Perl 5.14 for 'state' variables and /u in regexes
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 "/usr/share/diff-so-fancy";
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 $short_headers              = git_config_boolean("diff-so-fancy.shortHeaders","false");
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_regex       = qr/\e\[([0-9]{0,3}(;[0-9]{1,3}){0,10})[mK]/;
my $ansi_color_regex = qr/(${ansi_regex})?/;
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 $ruler_shape       = 'ruler';
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

if ($args->{rulerWidth}) {
	$ruler_width = int($args->{rulerWidth});
}

# If --short_headers [0/1] is passed at the CLI
if (defined($args->{shortHeaders})) {
	$short_headers = int($args->{shortHeaders});
}

# 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();
}

# 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}) {
		die(usage());
	} else {
		die("Missing input on STDIN\n");
	}
}

#################################################################################
#################################################################################

# 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,@_);

	# Wait until we have enough lines to process, then divide up into safe
	# parseable chunks and have d-s-f do it's magic
	if (@lines > 100) {
		my @chunks = get_diff_chunks(\@lines);
		@lines     = ();

		foreach my $chunk (@chunks) {
			do_dsf_stuff($chunk);
		}
	}
};

my $line_count = 0;
while (my $line = <STDIN>) {
	# 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();

# Process anything left over
my @chunks = get_diff_chunks(\@lines);

foreach my $chunk (@chunks) {
	do_dsf_stuff($chunk);
}

#################################################################################
#################################################################################

sub do_dsf_stuff {
	my $input     = shift();
	my $chunk_str = join("", @$input); # Store the full chunk text for lookbacks

	# If we're in debug mode we want to log the chunks we see
	if ($args->{debug}) { log_chunks($input); }

	# FIXME: There is a scenario where there are FIVE diff header lines
	# I'm not sure if we need to account for that or not. Unit tests pass so... bees?
	my $cnt = count_git_header_lines(@$input);
	if ($cnt == 4) {
		$patch_mode    = 1;
		$short_headers = 0; # shortHeaders has to be off in --patch mode
	}

	# Calculate the context lines for this chunk
	my $context_lines = calculate_context_lines(@$input);
	my $git_show_mode = 0;

	#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) {
				my $str = $meta_color . file_change_string($file_1,$file_2) . "\n";

				draw_ruler($str, $ruler_shape, $meta_color);
			}
		#########################
		# Look for the filename #
		#########################
		#                                            $4
		} elsif ($line =~ /^${ansi_color_regex}diff (.+?)$/) {
			my $extra = $4;

			# Mercurial looks like: diff --recursive -u core/app.py language/app.py
			if ($extra =~ m/(-r|--recursive) -u (.+?) (.*)/) {
				$is_mercurial = 1;
				$meta_color   = get_config_color("meta");

				$last_file_seen = basename($3 || "");
			# Git looks like: diff --git a/diff-so-fancy b/diff-so-fancy
			} elsif ($extra =~ m/--git/) {
				# Note file may contains spaces
				my ($a_file, $b_file) = $extra =~ m/(a\/.+) (b\/.+)/;
				$a_file ||= "";
				$b_file ||= "";

				$last_file_seen = $a_file;
			}

			$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";
			}
		############################################
		# In `git show` the commit always in a box #
		############################################
		} elsif ($line =~ /^${ansi_color_regex}commit/) {
			$meta_color    = $1;
			$git_show_mode = 1;

			# Rulers after this (file names) are in a box
			$ruler_shape = 'box';

			draw_ruler($line, "ruler", $meta_color);
		########################################
		# 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 ($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;
			}

			# Check if the last 'diff' line specified recursive
			my $recursive = 0;
			if ($chunk_str =~ /^${ansi_color_regex}diff .*(--recursive|-r)\b/) {
				$recursive = 1;
			}

			# In recursive mode we massage the file names so they're similar
			if ($recursive) {
				# In recursive the files have the date appended:
				# --- /var/tmp/a/index.txt        2026-03-13 21:35:20.997231861 -0700
				# so we remove the date portion
				$file_1 =~ s/\s+(\d{4}-\d{2}-\d{2}.+)//;
				$file_2 =~ s/\s+(\d{4}-\d{2}-\d{2}.+)//;

				my $common = common_prefix($file_1, $file_2);

				# Remove the common prefix from each as well as the next dir
				$file_1 =~ s/$common.+?\///;
				$file_2 =~ s/$common.+?\///;
			}

			my $str = "";
			if ($short_headers) {
				my $start_line = extract_start_line($line, $input, $context_lines);
				$str = file_change_string($file_1,$file_2) . " ${reset_color}${meta_color}\@ $start_line${reset_color}\n";
			} else {
				$str = file_change_string($file_1,$file_2) . "\n";
			}

			draw_ruler($str, $ruler_shape, $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 $frag_color  = $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;
			}

			my $start_line = extract_start_line($line, $input, $context_lines);

			# 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";
				$frag_color = get_config_color("fragment");
			}

			if (!$short_headers) {
				print $frag_color;
				print "@ $last_file_seen:$start_line \@${reset_color}${last_function_color}${remain}${reset_color}\n";
			}
		###################################
		# Remove any new file permissions #
		###################################
		} elsif ($remove_file_add_header && $line =~ /^${ansi_color_regex}new file mode [0-7]{6}/) {
			# 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 [0-7]{6}/) {
			# 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);
			my $str    = "$meta_color$change (binary)\n";

			draw_ruler($str, $ruler_shape, $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 ($action1, $file1) = $next =~ /(copy|rename) from (.+?)(\e|\t|$)/;

			$next       = shift(@$input);
			my ($action2, $file2) = $next =~ /(copy|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 = "???";
				if ($action1 eq "copy") {
					$change = "Copied $file1 to $file2";
				} else {
					$change = file_change_string($file1,$file2);
				}

				my $str = $meta_color . $change . "\n";

				draw_ruler($str, $ruler_shape, $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 2>&1";
	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

	my $result = git_config($search_key,$default_value);
	my $ret    = boolean($result);

	return $ret;
}

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, $context_lines) = @_;
	my $ret;

	if ($line_num == 0 && $diff_context == 0) {
		return 1;
	}

	# Three lines on either side, and the line itself = 7
	my $expected_context = ($context_lines * 2 + 1);

	# The first three lines
	if ($line_num == 1 && $diff_context < $expected_context) {
		$ret = $diff_context - $context_lines;
	} else {
		$ret = $line_num + $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;
}

sub file_change_string {
	my $file_1 = shift();
	my $file_2 = shift();

	my $ret = "";

	# If they're the same it's a modify
	if ($file_1 eq $file_2) {
		$ret = "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];
		$ret = "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];
		$ret = "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);
		$ret = "renamed: $old$meta_color to $new"
	# Something we haven't thought of yet
	} else {
		$ret = "$file_1 -> $file_2";
	}

	return $ret;
}

# 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;
	}
}

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, $txt) = @_;

	# If we're NOT connected to a an interactive terminal don't do color
	#if (-t STDOUT == 0) { return ''; }

	# 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 27 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{1,3})?_?(\w+)?$/g;
	my ($bc)      = $str =~ /on_(\d{1,3})$/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"; }
	if ($txt)         { $ret .= $txt . "\e[0m";   }

	return $ret;
}

# Get colors used for various output sections (memoized)
sub get_config_color {
	my $str = shift();

	state $static_config;

	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,
		'default' => 9, # pseudo color (39/49 = set default)
		'normal'  => -1, # placeholder color to be ignored
	};

	# Bright colors are just offsets from the "regular" color
	for my $k (keys %{ $colors }) {
		$colors->{"bright" . $k} = $colors->{$k} + 60;
	}

	my @ansi_part = ();

	if (grep { /^bold$/ } @parts) {
		push(@ansi_part, "1");
	}

	if (grep { /^dim$/ } @parts) {
		push(@ansi_part, "2");
	}

	if (grep { /^ul$/ } @parts) {
		push(@ansi_part, "4");
	}

	if (grep { /^reverse$/ } @parts) {
		push(@ansi_part, "7");
	}

	# Remove parts that aren't colors
	@parts = grep { exists $colors->{$_} || is_numeric($_) || /^\#/ } @parts;

	my $fg  = $parts[0] // "";
	my $bg  = $parts[1] // "";

	set_ansi_color($fg, 0 , \@ansi_part, $colors) if $fg;
	set_ansi_color($bg, 10, \@ansi_part, $colors) if $bg;

	#############################################

	my $ansi_str = join(";", @ansi_part);
	my $ret      = "\e[" . $ansi_str . "m";

	return $ret;
}

sub set_ansi_color {
	my ($color, $increment, $ansi_part, $colors) = @_;

	my $base_code  = 30 + $increment;
	my $base8_code = 38 + $increment;
	my $ext_code   = 82 + $increment;

	if (is_numeric($color)) {
		if ($color < 8) {
			push(@$ansi_part, $color + $base_code);
		} elsif ($color < 16) {
			push(@$ansi_part, $color + $ext_code);
		} else {
			push(@$ansi_part, "$base8_code;5;$color");
		}
	# It's a full rgb code
	} elsif ($color =~ /^#/) {
		my ($rgbr, $rgbg, $rgbb) = $color =~ /.(..)(..)(..)/;
		push(@$ansi_part, "$base8_code;2;" . hex($rgbr) . ";" . hex($rgbg) . ";" . hex($rgbb));
	# It's a simple 16 color OG ansi
	} elsif ($color ne "normal") {
		my $color_num = $colors->{$color} + $base_code;
		push(@$ansi_part, $color_num);
	}
}

# Is the string 100% numeric
sub is_numeric {
	my $s = shift();

	if ($s =~ /^\d+$/) {
		return 1;
	}

	return 0;
}

# Does the string start with ANSI
sub starts_with_ansi {
	my $str = shift();

	# NOTE: This is not `ansi_color_regex`, which includes "no ANSI sequences".
	if ($str =~ /^$ansi_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 2>&1`);
	$git_ver    =~ s/[^\d.]//g;

	if ($git_ver !~ /git/) {
		$git_ver = "Unknown";
	} else {
		$git_ver = "v" . $git_ver;
	}

	my $out .= "Diff-so-fancy   : v$VERSION\n";
	$out    .= "Git             : $git_ver\n";
	$out    .= "Perl            : $^V\n";
	$out    .= "\n";
	$out    .= "Terminal width  : " . get_terminal_width() . "\n";
	$out    .= "Terminal \$LANG  : " . ($ENV{LANG} || "") . "\n";
	$out    .= "\n";
	$out    .= "Supports Unicode: " . yes_no(should_print_unicode()) . "\n";
	$out    .= "Unicode Ruler   : " . yes_no($use_unicode_dash_for_ruler) . "\n";
	$out    .= "\n";
	$out    .= "Less Charset Var: " . ($less[0] // "") . "\n";
	$out    .= "Less Charset    : " . ($less[1] // "") . "\n";
	$out    .= "\n";
	$out    .= "Is Windows      : " . yes_no(is_windows()) . "\n";
	$out    .= "Operating System: $^O\n";

	print $out;

	my @lines = split(/\n/, $out);
	debug_log(@lines);
}

# Boolean to yes/no string
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 {
	my $nn = git_config('color.diff-highlight.newnormal');
	my $nh = git_config('color.diff-highlight.newhighlight');
	my $nr = git_config('color.diff-highlight.newreset');

	my $on = git_config('color.diff-highlight.oldnormal');
	my $oh = git_config('color.diff-highlight.oldhighlight');
	my $or = git_config('color.diff-highlight.oldreset');

	$DiffHighlight::NEW_HIGHLIGHT[0] = git_ansi_color($nn) || "\e[1;32m";
	$DiffHighlight::NEW_HIGHLIGHT[1] = git_ansi_color($nh) || "\e[1;32;48;5;22m";
	$DiffHighlight::NEW_HIGHLIGHT[2] = git_ansi_color($nr) || "\e[0m";

	$DiffHighlight::OLD_HIGHLIGHT[0] = git_ansi_color($on) || "\e[1;31m";
	$DiffHighlight::OLD_HIGHLIGHT[1] = git_ansi_color($oh) || "\e[1;31;48;5;52m";
	$DiffHighlight::OLD_HIGHLIGHT[2] = git_ansi_color($or) || "\e[0m";
}

# Human readable datetime string with milliseconds
sub date_str {
	require Time::HiRes;

	my ($sec, $usec) = Time::HiRes::gettimeofday();
	my @t            = localtime($sec);

	my $ret = sprintf(
        "%04d-%02d-%02d %02d:%02d:%02d.%03d",
        $t[5] + 1900,
        $t[4] + 1,
        $t[3],
        $t[2],
        $t[1],
        $t[0],
        int($usec / 1000),
    );

	return $ret;
}

# Write a line to the debug log which is opened on the fly as-needed
# Usage: debug_log($str) or debug_log(@lines)
sub debug_log {
	my @lines = @_;
	my $file     = "/tmp/diff-so-fancy.debug.log";
	my $date_str = date_str();

	state $fh;
	if (!$fh) {
		printf("%sDebug log enabled:%s $file\n", color('orange'), color());
		open ($fh, ">", $file) or die("Cannot write to $file");
	}

	foreach my $log_line (@lines) {
		$log_line = trim($log_line);
		print $fh "$date_str: $log_line\n";
	}

	return 1;
}

# Find the commom prefix chars between two strings
# common_prefix("foobar", "food is yummy"); # 'foo'
sub common_prefix {
	my ($a, $b) = @_;
	my $len = length($a) < length($b) ? length($a) : length($b);

	my $i = 0;
	$i++ while $i < $len && substr($a,$i,1) eq substr($b,$i,1);

	return substr($a, 0, $i);
}

# Count the number of context lines in the diff
sub calculate_context_lines {
	my @lines     = @_;
	my $count     = 0;
	my $hunk_line = 0;

	# Count the number of lines between the hunk line and the
	# first + or - line
	foreach my $line (@lines) {
		# Look for the hunk line before we start
		if ($line =~ /^${ansi_color_regex}(@@@* .+? @@@*)(.*)/) {
			$hunk_line = $count;
		} elsif ($hunk_line && $line =~ /^${ansi_color_regex}[+-]/) {
			my $diff = $count - $hunk_line - 1;
			#print "Hunk: $hunk_line, ChangedLine: $count ($diff context)\n";
			return $diff;
		}

		$count++;
	};

	# If for some reason we can't figure it out, assume 3
	return 3;
}

# This function divides Git/Diff strings into smaller chunks that d-s-f can
# process. The special sauce is not splitting on the Git headers, or any of
# the lines that require context awareness of the next two lines
sub get_diff_chunks {
	my ($line_ref) = @_;

	my @lines       = @$line_ref;
	my @end_lines   = (); # Array of line numbers of the END of each chunk
	my $total_lines = scalar(@lines);
	my $prev        = 0;

	# Loop through all the lines and build end line numbers for each "chunk"
	for (my $i = 0; $i < $total_lines; $i++) {
		my $line           = $lines[$i];
		my $is_header_line = $line =~ m/^${ansi_color_regex}(diff|index|Submodule|---|\+\+\+|@@?|new|deleted|old)\s/;
		$is_header_line    = int($is_header_line);

		#if ($is_header_line) {
		#    printf("H%02d: %s", $i, $line);
		#} else {
		#    printf("N%02d: %s", $i, $line);
		#}

		# If this line is a header, and the previous line was NOT that means
		# the previous line was the prev line of this chunk
		if (($i > 0) && ($is_header_line && !$prev)) {
			# It's a transition point
			push(@end_lines, $i - 1);
		}

		$prev = $is_header_line;
	}

	# The final end point is the last line
	my $last_idx = $end_lines[-1] || 0;
	if (($last_idx == 0) || ($last_idx != ($total_lines - 1))) {
		push(@end_lines, $total_lines - 1);
	}

	# Now that we know where all the end points are we divide the big array
	# into smaller chunks
	my @ret = ();
	$prev   = 0;
	foreach my $idx (@end_lines) {
		# Array splice the lines into a smaller chuck and put them in @ret
		my @tmp = @lines[$prev .. $idx];
		push(@ret, \@tmp);
		$prev = $idx + 1;
	}

	return @ret;
}

# Log each chunk of diff for debugging purposes
sub log_chunks {
	my $line_ref = shift();

	debug_log("-------------- Chunk --------------");
	debug_log(@$line_ref);
}

# In patch mode we have to keep the line ratio 1:1 so we need to know how many
# header lines there are. Usually three or four.
sub count_git_header_lines {
	my @lines = @_;
	my $ret   = 0;

	# If we're not in --patch mode then bail out early
	if (!$args->{patch} && !$ENV{DSF_PATCH_MODE}) {
		return 0;
	}

	# Textual diff should be four lines of header
	#diff --git a/diff-so-fancy b/diff-so-fancy
	#index d952098..424d8a3 100755
	#--- a/diff-so-fancy
	#+++ b/diff-so-fancy

	# Binary diff should be three lines of header
	#diff --git a/file.txt b/file.txt
	#new file mode 100644
	#index 0000000..e69de29

	#diff --git a/hello.txt b/hello.txt
	#new file mode 100644
	#index 0000000..0c767bc
	#--- /dev/null
	#+++ b/hello.txt

	my $count = 1;
	foreach my $line (@lines) {
		if ($line =~ /^${ansi_color_regex}index/) {
			last;
		}

		$count++;
	}

	if ($count == 2) {
		$count = 4;
	}

	$ret = $count;

	# No Git/Diff header should be more than four lines
	if ($ret > 4) {
		$ret = 0;
	}

	return $ret;
}

# Take a chunk of diff header and find the starting line of the first change
sub extract_start_line {
	my ($line, $input, $context_lines) = @_;

	# Take the current line, and a couple lines after to build a header context that we can search for hunk information later
	my $header = $line;
	for my $num (0 .. 3) {
		my $line = $input->[$num] || "";

		$header .= $line;
	}

	#print "-----\n$header\n-----\n";

	my $start_line = 0;
	if ($header =~ m/(\@+ (.+?) \@+)/) {
		my $hunk_header = $1 || "";
		my ($orig_offset, $orig_count, $new_offset, $new_count) = parse_hunk_header($hunk_header);

		# Figure out the start line
		$start_line = start_line_calc($new_offset, $new_count, $context_lines);
	}

	return $start_line;
}

sub draw_ruler {
	my ($str, $type, $color) = @_;

	$str     = trim($str);
	$type  ||= 'ruler';
	$color ||= "";

	# Default to ASCII chars
	my $dash  = "-";
	my $top_r = "+";
	my $bot_r = "+";
	my $right = "|";

	# If we're in unicode mode we use unicode chars instead
	if ($use_unicode_dash_for_ruler && should_print_unicode()) {
		# perl -E 'use Dump::Krumo; use Encode; kx(Encode::encode("UTF-8", "\x{2503}"));'
		$dash  = "\x{E2}\x{94}\x{80}";
		$top_r = "\x{E2}\x{94}\x{90}";
		$bot_r = "\x{E2}\x{94}\x{98}";
		$right = "\x{E2}\x{94}\x{82}";
	}

	if ($type eq 'ruler') {
		my $width = get_terminal_width();
		my $line  = $color . $dash x $width . $reset_color;

		print $line  . "\n";
		print $color . $str . $reset_color . "\n";
		print $line  . "\n";
	} elsif ($type eq 'box') {
		my $len = length(bleach_text($str));

		print $color . ($dash x $len) . $top_r . $reset_color . "\n";
		print $color . $str . $right           . $reset_color . "\n";
		print $color . ($dash x $len) . $bot_r . $reset_color . "\n";
	}
}

# Borrowed from: https://www.perturb.org/display/1097_Perl_detect_if_a_module_is_installed_before_using_it.html
# Creates methods k() and kd() to print, and print & die respectively
BEGIN {
	if (!defined(&trim)) {
		*trim = sub {
			my ($s) = (@_, $_); # Passed in var, or default to $_
			if (length($s) == 0) { return ""; }
			$s =~ s/^\s*//;
			$s =~ s/\s*$//;

			return $s;
		}
	}

	# If you're me you load a debug library
	if ($ENV{USER} && ($ENV{USER} eq "bakers")) {
		require Dump::Krumo;
		Dump::Krumo->import(qw(k kd));
	}
}

################################################################################

=pod

=encoding utf8

=head1 NAME

diff-so-fancy makes your diffs human readable instead of machine readable. This
helps improve code quality and helps you spot defects faster.

=head1 USAGE

=head2 Git

Configure git to use C<diff-so-fancy> for all diff output:

    git config --global core.pager "diff-so-fancy | less --tabs=4 -RF"
    git config --global interactive.diffFilter "diff-so-fancy --patch"

=head2 Diff

Use C<-u> with diff for unified output, and pipe the output to C<diff-so-fancy>:

    diff -u file_a file_b | diff-so-fancy

We also support recursive mode with C<-r> or C<--recursive>

    diff --recursive -u /path/folder_a /path/folder_b | diff-so-fancy

=head1 OPTIONS

B<markEmptyLines:> Colorize the first block of an empty line. (Default: true)

    git config --bool --global diff-so-fancy.markEmptyLines false

B<changeHunkIndicators:> Simplify Git header chunks to a human readable format. (Default: true)

    git config --bool --global diff-so-fancy.changeHunkIndicators false

B<stripLeadingSymbols:> Should the + or - symbols at line-start be removed. (Default: true)

    git config --bool --global diff-so-fancy.stripLeadingSymbols false

B<useUnicodeRuler:> By default, the separator for the file header uses Unicode line-drawing
characters. If this is causing output errors on your terminal, set this to false to use ASCII
characters instead. (Default: true)

    git config --bool --global diff-so-fancy.useUnicodeRuler false

B<rulerWidth:> By default, the separator for the file header spans the full width of the
terminal. Use rulerWidth to set the width of the file header manually.

    git config --global diff-so-fancy.rulerWidth 80

=head1 HOMEPAGE

- https://github.com/so-fancy/diff-so-fancy

=head1 SEE ALSO

=head2 Delta

- https://github.com/dandavison/delta

=head2 Lazygit with diff-so-fancy integration

- https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md#diff-so-fancy

# vim: tabstop=4 shiftwidth=4 noexpandtab autoindent softtabstop=4
