diff --git a/diff-so-fancy b/diff-so-fancy index 24c7847..bf6d106 100755 --- a/diff-so-fancy +++ b/diff-so-fancy @@ -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})?/; @@ -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}); @@ -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,@_); @@ -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; @@ -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); @@ -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); } @@ -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(); diff --git a/test/osc-metadata.bats b/test/osc-metadata.bats new file mode 100644 index 0000000..8ee3228 --- /dev/null +++ b/test/osc-metadata.bats @@ -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 "" +}