diff --git a/diff-so-fancy b/diff-so-fancy index 329c68e..d27b489 100755 --- a/diff-so-fancy +++ b/diff-so-fancy @@ -36,6 +36,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})?/; @@ -57,6 +58,20 @@ 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. The +# remaining counters hold how many old-/new-file lines of the hunk are still +# to come (from the @@ counts, which are exact); they bound the records to the +# hunk, so that later lines that merely look like content -- submodule log +# lines, diffstats and indented commit messages in `git log -p` output -- are +# not tagged with the last hunk's (by then stale) identity. +my $osc_old_line = 0; +my $osc_new_line = 0; +my $osc_old_remaining = 0; +my $osc_new_remaining = 0; +my $osc_file = ""; +my $osc_skip_hunk = 1; if ($args->{rulerWidth}) { $ruler_width = int($args->{rulerWidth}); @@ -114,6 +129,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,@_); @@ -248,7 +271,7 @@ sub do_dsf_stuff { if ($file_1 && $file_2) { my $str = $meta_color . file_change_string($file_1,$file_2) . "\n"; - draw_ruler($str, $ruler_shape, $meta_color); + draw_ruler($str, $ruler_shape, $meta_color, osc_file_header_record($file_1, $file_2)); } ######################### # Look for the filename # @@ -366,16 +389,18 @@ sub do_dsf_stuff { } } - draw_ruler($str, $ruler_shape, $meta_color); + draw_ruler($str, $ruler_shape, $meta_color, osc_file_header_record($file_1, $file_2)); ######################################## # Check for "@@ -3,41 +3,63 @@" syntax # ######################################## } elsif (!$change_hunk_indicators && $line =~ /^${ansi_color_regex}(@@@* .+? @@@*)(.*)/) { $in_hunk = 1; + osc_seed_hunk($line); - print $line; + print osc_hunk_header_record() . $line; } elsif ($change_hunk_indicators && $line =~ /^${ansi_color_regex}(@@@* .+? @@@*)(.*)/) { $in_hunk = 1; + osc_seed_hunk($line); my $frag_color = $1; my $hunk_header = $4; @@ -406,7 +431,10 @@ sub do_dsf_stuff { } if (!$short_headers) { - my $str = "$frag_color@ $last_file_seen:$start_line \@${reset_color}${last_function_color}${remain}${reset_color}\n"; + # The row announcing the hunk carries the hunk's `h` record. + # (With shortHeaders on, no hunk rows are rendered at all, so + # there is nothing to tag.) + my $str = osc_hunk_header_record() . "$frag_color@ $last_file_seen:$start_line \@${reset_color}${last_function_color}${remain}${reset_color}\n"; print $str; } @@ -432,10 +460,11 @@ sub do_dsf_stuff { # Look for binary file changes # ################################ } elsif ($line =~ /^Binary files (\w\/)?(.+?) and (\w\/)?(.+?) differ/) { - my $change = file_change_string($2,$4); + my ($old_file, $new_file) = ($2, $4); + my $change = file_change_string($old_file,$new_file); my $str = "$meta_color$change (binary)\n"; - draw_ruler($str, $ruler_shape, $meta_color); + draw_ruler($str, $ruler_shape, $meta_color, osc_file_header_record($old_file, $new_file)); ##################################################### # Check if we're changing the permissions of a file # ##################################################### @@ -452,7 +481,18 @@ sub do_dsf_stuff { if ($patch_mode) { print "\n"; } - print "$last_file_seen changed file mode from $old_mode to $new_mode\n"; + + # For a mode-only change this line is the only row announcing the + # file, so it carries the file's `f` record -- that keeps files with + # no content lines visible to a host (spec §5.5). When the file also + # has content (an index/similarity line follows), the ruled file + # header drawn later is the file's header instead. + my $osc = ""; + my $peek = $input->[0] || ""; + if ($peek !~ /^${ansi_color_regex}(index |similarity index )/) { + $osc = osc_file_header_record($last_file_seen, $last_file_seen); + } + print $osc . "$last_file_seen changed file mode from $old_mode to $new_mode\n"; ############### # File rename # @@ -486,7 +526,7 @@ sub do_dsf_stuff { my $str = $meta_color . $change . "\n"; - draw_ruler($str, $ruler_shape, $meta_color); + draw_ruler($str, $ruler_shape, $meta_color, osc_file_header_record($file1, $file2)); } $i += 3; # We've consumed three lines @@ -495,6 +535,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); @@ -516,6 +559,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); } @@ -536,6 +585,153 @@ 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 (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} || ""; + + 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_old_remaining = $o_cnt; + $osc_new_remaining = $n_cnt; + $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; + + # The hunk's @@ counts are exact, so a line whose side(s) the hunk has + # already used up is not part of the hunk -- it only looks like content + # (a " > subject" submodule log line, a diffstat or an indented commit + # message in `git log -p` output). Such a line carries no record; tagging + # it would name the last hunk's file and line numbers, which it has + # nothing to do with. + my ($type, $new_line, $old_field); + if ($indicator eq "+") { + # Addition: advances the new-file side only. + if ($osc_new_remaining <= 0) { return ''; } + $osc_new_remaining--; + ($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. + if ($osc_old_remaining <= 0) { return ''; } + $osc_old_remaining--; + ($type, $new_line, $old_field) = ("d", $osc_new_line, $osc_old_line++); + } else { + # Context: advances both sides. + if ($osc_old_remaining <= 0 || $osc_new_remaining <= 0) { return ''; } + $osc_old_remaining--; + $osc_new_remaining--; + ($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; +} + +# Build the OSC 1717 `f` (file-header) record for a file-header block, or "" +# when no host negotiated the protocol. The path prefers the new-file side, +# falling back to the old side for a deletion -- the same preference +# osc_seed_hunk applies, so the header groups with its file's content records. +# An `f` record never carries line numbers (spec §5.5): a file header needs +# none, and a streaming renderer doesn't know the first hunk's line yet when +# it draws the header. +sub osc_file_header_record { + my ($old_path, $new_path) = @_; + + if (!$osc_metadata_version) { return ''; } + + my $path = $old_path; + if (defined($new_path) && $new_path ne "" && $new_path ne "/dev/null") { + $path = $new_path; + } + $path = sanitize_display(defined($path) ? $path : ""); + + return "\e]1717;${osc_metadata_version};f;;;${path}\e\\"; +} + +# Build the OSC 1717 `h` (hunk-header) record for the hunk osc_seed_hunk has +# just seeded, or "" for un-annotated hunks. Its new-line is the hunk's first +# new-file line (spec §5.2), which is exactly what the freshly seeded counter +# holds. Must be called after osc_seed_hunk and before any content line. +sub osc_hunk_header_record { + if (!$osc_metadata_version || $osc_skip_hunk) { return ''; } + + return "\e]1717;${osc_metadata_version};h;${osc_new_line};;${osc_file}\e\\"; +} + # Mark the first char of an empty line sub mark_empty_line { my $line = shift(); @@ -1457,11 +1653,14 @@ sub get_sem_info { } sub draw_ruler { - my ($str, $type, $color) = @_; + my ($str, $type, $color, $osc) = @_; $str = trim($str); $type ||= 'ruler'; $color ||= ""; + # An OSC 1717 record to attach to the block: every row of a multi-row + # header carries the same record, like a wrapped content line (spec §6.4). + $osc ||= ""; my @lines = split(/\n/, $str); my $max = 0; @@ -1493,20 +1692,26 @@ sub draw_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"; + # $str may span several rows (e.g. semantic info below the file name); + # each gets the record. + if ($osc) { + $str =~ s/\n/\n$osc/g; + } + + print $osc . $line . "\n"; + print $osc . $color . $str . $reset_color . "\n"; + print $osc . $line . "\n"; } elsif ($type eq 'box') { my $len = $max; - print $color . ($dash x $len) . $top_r . $reset_color . "\n"; + print $osc . $color . ($dash x $len) . $top_r . $reset_color . "\n"; foreach my $line (@lines) { my $padding = $len - length(bleach_text($line)); $line .= " " x $padding; - print $color . $line . $color . $right . $reset_color . "\n"; + print $osc . $color . $line . $color . $right . $reset_color . "\n"; } - print $color . ($dash x $len) . $bot_r . $reset_color . "\n"; + print $osc . $color . ($dash x $len) . $bot_r . $reset_color . "\n"; } } diff --git a/test/fixtures/log-with-stat.diff b/test/fixtures/log-with-stat.diff new file mode 100644 index 0000000..d591452 --- /dev/null +++ b/test/fixtures/log-with-stat.diff @@ -0,0 +1,22 @@ +commit aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +Author: A U Thor +Date: Mon Aug 3 09:00:00 2026 +0200 + + first commit + +diff --git a/one.txt b/one.txt +index 257cc56..5716ca5 100644 +--- a/one.txt ++++ b/one.txt +@@ -1 +1 @@ +-foo ++bar + +commit bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +Author: A U Thor +Date: Mon Aug 3 10:00:00 2026 +0200 + + second commit + + two.txt | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/fixtures/submodule-log.diff b/test/fixtures/submodule-log.diff new file mode 100644 index 0000000..38a9b57 --- /dev/null +++ b/test/fixtures/submodule-log.diff @@ -0,0 +1,10 @@ +diff --git a/one.txt b/one.txt +index 257cc56..5716ca5 100644 +--- a/one.txt ++++ b/one.txt +@@ -1 +1 @@ +-foo ++bar +Submodule sub/mod 1234567..89abcde: + > Add a feature + > Fix a bug diff --git a/test/osc-metadata.bats b/test/osc-metadata.bats new file mode 100644 index 0000000..8f7cbc9 --- /dev/null +++ b/test/osc-metadata.bats @@ -0,0 +1,196 @@ +#!/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 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=V1 "$diff_so_fancy" | osc_records +} + +# As records_for, but only the content-line records (c/a/d), skipping the f/h +# header records, so tests about content-line semantics stay independent of how +# many rows the rendered header blocks span. +content_records_for() { + records_for "$1" | perl -ne 'print if /^\d+;[cad];/;' +} + +@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=V2 "$diff_so_fancy" | osc_records ) + assert_output "" + + # Junk -> silence. + output=$( load_fixture "add_file_with_content" | OSC1717=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=V0,V1,V2 "$diff_so_fancy" | osc_records ) + run printf "%s" "$output" + assert_line --index 0 "1;f;;;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=V1 "$diff_so_fancy" | all_osc_records ) + run printf "%s" "$output" + assert_line --index 0 "1" + assert_line --index 1 "1;f;;;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=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=$( content_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=$( content_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=$( content_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=$( content_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 carry only their file header's f record" { + # A combined diff (@@@ ...) has multiple old-file sides and a different + # line-number model, so its hunks and content lines are not annotated. The + # file header still carries its `f` record (one per row of the ruled + # block), so the file stays visible to a host's file list/navigation. + output=$( records_for "complex-hunks" ) + run printf "%s" "$output" + assert_output "1;f;;;libs/header_clean/header_clean.pl +1;f;;;libs/header_clean/header_clean.pl +1;f;;;libs/header_clean/header_clean.pl" +} + +@test "file headers carry f records on every row; hunk headers carry h with the hunk's first line" { + # file-rename.diff renames Changes.new (no content change) and then + # modifies dist.ini in two hunks (@@ -1,4 +1,4 @@ and @@ -9,6 +9,7 @@). + output=$( records_for "file-rename" ) + run printf "%s" "$output" + + # The pure rename emits only its `f` records -- one per row of the ruled + # header block, no line numbers (spec §5.5) -- keeping a file with no + # content lines visible to the host. + assert_line --index 0 "1;f;;;bin/Changes.new" + assert_line --index 1 "1;f;;;bin/Changes.new" + assert_line --index 2 "1;f;;;bin/Changes.new" + + # The modified file: its own `f` block, then each hunk's row carries `h` + # with the hunk's first new-file line (the @@ new start, not the first + # changed line the rendered header displays). + assert_line --index 3 "1;f;;;dist.ini" + assert_line --index 6 "1;h;1;;dist.ini" + assert_line --index 7 "1;d;1;1;dist.ini" + assert_line "1;h;9;;dist.ini" +} + +@test "a mode-only change carries an f record on its single announcement row" { + # circle.yml only changes its mode; "circle.yml changed file mode ..." is + # the only row announcing it, so that row carries the `f`. foo.json is a + # regular added file whose header follows as its own f block. + output=$( records_for "file-perms" ) + run printf "%s" "$output" + assert_line --index 0 "1;f;;;circle.yml" + assert_line --index 1 "1;f;;;foo.json" + assert_line --index 4 "1;h;1;;foo.json" + assert_line --index 5 "1;a;1;;foo.json" +} + +@test "binary files carry an f record" { + # A binary file emits no content records; its `f` keeps it visible. + output=$( records_for "binary-modified" ) + run printf "%s" "$output" + assert_line --index 0 "1;f;;;cancel.png" +} + +@test "content records stop at the hunk's counted extent" { + # The @@ counts are exact, so the records must end with the hunk. The + # submodule log lines that follow (" > subject") begin with a space and + # would otherwise be classified as context lines carrying the previous + # hunk's (stale) file and line numbers. + output=$( content_records_for "submodule-log" ) + run printf "%s" "$output" + assert_line --index 0 "1;d;1;1;one.txt" + assert_line --index 1 "1;a;1;;one.txt" + refute_line --partial ";c;" +} + +@test "diffstat and commit-message lines after a hunk carry no records" { + # In `git log -p` output, the next commit's indented message body and its + # diffstat lines also begin with a space; none of them are content lines + # of the preceding hunk. + output=$( content_records_for "log-with-stat" ) + run printf "%s" "$output" + assert_line --index 0 "1;d;1;1;one.txt" + assert_line --index 1 "1;a;1;;one.txt" + refute_line --partial ";c;" +}