Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions diff-so-fancy
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ my $ruler_width = git_config("diff-so-fancy.rulerWidth", undef);
my $git_strip_prefix = git_config_boolean("diff.noprefix","false");
my $do_sem_stuff = git_config_boolean("diff-so-fancy.semIntegration","false");
my $has_stdin = has_stdin();
my $osc_metadata_version = negotiated_osc_version();

my $ansi_regex = qr/\e\[([0-9]{0,3}(;[0-9]{1,3}){0,10})[mK]/;
my $ansi_color_regex = qr/(${ansi_regex})?/;
Expand All @@ -56,6 +57,13 @@ my $i = 0;
my $in_hunk = 0;
my $columns_to_remove = 0;
my $is_mercurial = 0;
# OSC 1717 state: the new-/old-file line counters and the file path, all seeded
# at each hunk header (osc_seed_hunk) and advanced per content line
# (osc_for_content_line), plus a flag for hunks we don't annotate.
my $osc_old_line = 0;
my $osc_new_line = 0;
my $osc_file = "";
my $osc_skip_hunk = 1;

if ($args->{rulerWidth}) {
$ruler_width = int($args->{rulerWidth});
Expand Down Expand Up @@ -113,6 +121,14 @@ if ($args->{debug}) {
debug_log("Ruler width : $ruler_width");
}

# Announce protocol support as the first output: a version-only OSC 1717 record
# (the handshake). It lets a host probe diff-so-fancy on an empty diff — which emits
# no per-line records — and tell "speaks the protocol" apart from "unsupported
# pager". See diff-line-metadata-osc-spec.md §4.4.
if ($osc_metadata_version) {
print "\e]1717;${osc_metadata_version}\e\\";
}

my @lines;
local $DiffHighlight::line_cb = sub {
push(@lines,@_);
Expand Down Expand Up @@ -367,10 +383,12 @@ sub do_dsf_stuff {
########################################
} elsif (!$change_hunk_indicators && $line =~ /^${ansi_color_regex}(@@@* .+? @@@*)(.*)/) {
$in_hunk = 1;
osc_seed_hunk($line);

print $line;
} elsif ($change_hunk_indicators && $line =~ /^${ansi_color_regex}(@@@* .+? @@@*)(.*)/) {
$in_hunk = 1;
osc_seed_hunk($line);

my $frag_color = $1;
my $hunk_header = $4;
Expand Down Expand Up @@ -490,6 +508,9 @@ sub do_dsf_stuff {
# Just a regular line, print it out #
#####################################
} else {
# Save the line before we muck around with it
my $orig = $line;

# Mark empty line with a red/green box indicating addition/removal
if ($mark_empty_lines) {
$line = mark_empty_line($line);
Expand All @@ -511,6 +532,12 @@ sub do_dsf_stuff {
}
}

# Classify the line for OSC 1717 metadata while its leading +/-
# indicator is still present, before we rewrite the line below.
if ($osc_metadata_version) {
print osc_for_content_line($orig);
}

print sanitize_display($line);
}

Expand All @@ -531,6 +558,107 @@ sub parse_hunk_header {
return ($o_ofs, $o_cnt, $n_ofs, $n_cnt);
}

# Negotiate the OSC 1717 diff-line-metadata protocol version against the host's
# advertised list in OSC1717_METADATA (e.g. "V1" or "V1,V2"). Returns the
# highest version we both understand, or undef when no host is asking (the
# variable is unset), in which case nothing is emitted and the output is
# byte-for-byte unchanged. See diff-line-metadata-osc-spec.md for the protocol.
sub negotiated_osc_version {
my $supported = 1; # Highest version this build knows how to emit
my $advertised = $ENV{OSC1717_METADATA} || "";

if (!$advertised) {
return undef;
}

# Loop through all the OSC params looking for 'V1' and pull out
# the highest number
my $best = undef;
foreach my $v (split(/,/, $advertised)) {
$v = trim($v);
if ($v !~ /^V(\d+)$/) { next; }

my $num = int($1);
if ($num > $supported) { next; }

if (!defined($best) || $num > $best) {
$best = $num;
}
}

return $best;
}

# Seed the OSC 1717 line counters at a hunk header. Combined/merge diffs (@@@)
# carry multiple old-file sides and a different line-number model, so we don't
# annotate them (matching the delta/difftastic prototypes); the same goes for an
# unparseable header.
sub osc_seed_hunk {
my ($line) = @_;

if (!$osc_metadata_version) {
return undef;
}

my $bleached = bleach_text($line);
if ($bleached =~ /^@@@/) {
$osc_skip_hunk = 1;
return undef;
}

my ($o_ofs, $o_cnt, $n_ofs, $n_cnt) = parse_hunk_header($bleached);
if (!defined($o_ofs) || !defined($n_ofs)) {
$osc_skip_hunk = 1;
return undef;
}

# The record carries the new-file path, falling back to the old path for a
# deletion (where the new side is /dev/null) -- the same preference the host
# applies. $file_1/$file_2 are this file's old/new paths from its ---/+++
# lines; preferring them over $last_file_seen keeps the path for a noprefix
# deletion, which never reaches $last_file_seen.
my $path = $file_1;
if (defined($file_2) && $file_2 ne "" && $file_2 ne "/dev/null") {
$path = $file_2;
}

$osc_old_line = $o_ofs;
$osc_new_line = $n_ofs;
$osc_file = sanitize_display(defined($path) ? $path : "");
$osc_skip_hunk = 0;
}

# Build the OSC 1717 record for a content line and advance the line counters.
# Returns the escape sequence to print before the line, or "" for non-content
# lines (e.g. "\ No newline at end of file") and un-annotated hunks. Must be
# called before the leading +/- indicator is stripped, as that is how the line
# is classified; the indicator survives DiffHighlight, after any leading ANSI.
sub osc_for_content_line {
my ($line) = @_;

if (!$osc_metadata_version || $osc_skip_hunk) { return ''; }
if ($line !~ /^${ansi_color_regex}([ +-])/) { return ''; }
my $indicator = $4;

my ($type, $new_line, $old_field);
if ($indicator eq "+") {
# Addition: advances the new-file side only.
($type, $new_line, $old_field) = ("a", $osc_new_line++, "");
} elsif ($indicator eq "-") {
# Deletion: sits at the current new-file position (which does not
# advance) and carries its own old-file line; advances the old side.
($type, $new_line, $old_field) = ("d", $osc_new_line, $osc_old_line++);
} else {
# Context: advances both sides.
($type, $new_line, $old_field) = ("c", $osc_new_line++, "");
$osc_old_line++;
}

my $ret = "\e]1717;${osc_metadata_version};${type};${new_line};${old_field};${osc_file}\e\\";

return $ret;
}

# Mark the first char of an empty line
sub mark_empty_line {
my $line = shift();
Expand Down
120 changes: 120 additions & 0 deletions test/osc-metadata.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/usr/bin/env bats

# Tests for the OSC 1717 diff-line-metadata protocol (see
# diff-line-metadata-osc-spec.md). diff-so-fancy strips the +/- markers and
# conveys the side by color, so a host that renders its output cannot recover a
# row's patch identity by parsing it -- the pager states it inline instead. The
# protocol is gated on the OSC1717_METADATA handshake and is strictly
# additive: with the variable unset, output is byte-for-byte unchanged.

__load_imports__() {
load 'test_helper/bats-support/load'
load 'test_helper/bats-assert/load'
load 'test_helper/util'
}

setup() {
__load_imports__
set_env
setup_default_dsf_git_config
}

teardown() {
teardown_default_dsf_git_config
}

# Extract the OSC 1717 payloads (everything between "ESC ] 1717 ;" and the
# string terminator) from stdin, one record per line, for line-wise assertions.
all_osc_records() {
perl -ne 'while (/\e\]1717;([^\e\a]*)(?:\e\\|\a)/g) { print "$1\n"; }'
}

# As all_osc_records, but skips the version-only handshake record (no fields; see
# the dedicated handshake tests), so per-line assertions stay focused on content.
osc_records() {
all_osc_records | perl -ne 'print if /;/;'
}

# Render a fixture with the host handshake set to V1, returning just the records.
records_for() {
load_fixture "$1" | OSC1717_METADATA=V1 "$diff_so_fancy" | osc_records
}

@test "no metadata is emitted without the handshake" {
output=$( load_fixture "ls-function" | $diff_so_fancy | osc_records )
assert_output ""
}

@test "the handshake negotiates the protocol version" {
# A version we don't emit -> silence (the advertised set is disjoint).
output=$( load_fixture "add_file_with_content" | OSC1717_METADATA=V2 "$diff_so_fancy" | osc_records )
assert_output ""

# Junk -> silence.
output=$( load_fixture "add_file_with_content" | OSC1717_METADATA=nonsense "$diff_so_fancy" | osc_records )
assert_output ""

# A list that includes V1 -> V1 records (we emit the highest we both know).
output=$( load_fixture "add_file_with_content" | OSC1717_METADATA=V0,V1,V2 "$diff_so_fancy" | osc_records )
run printf "%s" "$output"
assert_line --index 0 "1;a;1;;newfile.txt"
}

@test "a version-only handshake is emitted first, before any per-line record" {
# The handshake (just the version, no further fields) announces protocol support;
# it precedes the per-line records so a host sees it up front.
output=$( load_fixture "add_file_with_content" | OSC1717_METADATA=V1 "$diff_so_fancy" | all_osc_records )
run printf "%s" "$output"
assert_line --index 0 "1"
assert_line --index 1 "1;a;1;;newfile.txt"
}

@test "the handshake is emitted even for an empty diff, so a host can probe" {
# An empty diff has no content lines and so no per-line records; the handshake is
# still emitted, letting a host probe diff-so-fancy with empty input.
output=$( printf "" | OSC1717_METADATA=V1 "$diff_so_fancy" | all_osc_records )
assert_output "1"
}

@test "added lines carry the new-file line and an empty old-file field" {
output=$( records_for "add_file_with_content" )
run printf "%s" "$output"
assert_line --index 0 "1;a;1;;newfile.txt"
assert_line --index 1 "1;a;2;;newfile.txt"
assert_line --index 2 "1;a;3;;newfile.txt"
}

@test "deleted lines carry both line numbers; a whole-file delete sits at new-line 0" {
output=$( records_for "delete_file_with_content" )
run printf "%s" "$output"
assert_line --index 0 "1;d;0;1;oldfile.txt"
assert_line --index 1 "1;d;0;2;oldfile.txt"
assert_line --index 2 "1;d;0;3;oldfile.txt"
}

@test "the path falls back to the old side for a noprefix deletion" {
# This fixture's "diff --git" line has no a/ b/ prefix and the +++ side is
# /dev/null, so the path is recoverable only from the --- (old) side.
output=$( records_for "single-line-remove" )
assert_output "1;d;0;1;test/data/readywaitasset.js"
}

@test "context, deletion and addition interleave; the no-newline marker is skipped" {
# one/two are context; "three" is modified (delete then add at new-line 3).
# The trailing "\ No newline at end of file" carries no record and does not
# advance the counters.
output=$( records_for "remove_slashn_eof" )
run printf "%s" "$output"
assert_line --index 0 "1;c;1;;test.txt"
assert_line --index 1 "1;c;2;;test.txt"
assert_line --index 2 "1;d;3;3;test.txt"
assert_line --index 3 "1;a;3;;test.txt"
refute_line "1;c;4;;test.txt"
}

@test "combined (merge) diffs are not annotated" {
# A combined diff (@@@ ...) has multiple old-file sides and a different
# line-number model, so no records are emitted for it.
output=$( records_for "complex-hunks" )
assert_output ""
}