From 804d62f651f3a95f6f28e5b79be0eda6f040a974 Mon Sep 17 00:00:00 2001 From: stk Date: Sat, 20 Jun 2026 11:42:32 +0200 Subject: [PATCH 1/3] Emit per-line diff metadata as an OSC sequence for host applications A host that renders diff-so-fancy's output (e.g. lazygit) needs to map a rendered diff row back to its patch-space identity -- file, line type, and new/old line numbers -- to act on the line the user points at. diff-so-fancy strips the +/- markers and conveys the side by color, so that identity cannot be recovered from the painted text; the pager, which still has it at render time, has to state it. Gate the emission on the OSC1717_METADATA environment variable so output is byte-for-byte unchanged outside such a host -- harmless in a raw terminal, less, or tmux. The host advertises the protocol versions it understands and diff-so-fancy emits the highest mutually-understood one (just V1 today). See the protocol in diff-line-metadata-osc-spec.md; this is the third reference implementation after delta and difftastic. The emitter tracks its own old/new line counters, seeded from each hunk header, and classifies every content line by its leading +/- indicator -- read before the existing code strips that indicator and rewrites the line. sanitize_display strips OSC sequences, so the record is prepended to the already-sanitized line rather than embedded in it. The path prefers the new-file side, falling back to the old side for a deletion (whose new side is /dev/null), which also recovers the path for a noprefix deletion that never reaches $last_file_seen. Co-Authored-By: Claude Opus 4.8 (1M context) --- diff-so-fancy | 107 ++++++++++++++++++++++++++++++++++++++++- test/osc-metadata.bats | 98 +++++++++++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 test/osc-metadata.bats diff --git a/diff-so-fancy b/diff-so-fancy index 24c7847..ab09999 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}); @@ -367,10 +375,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 +500,10 @@ sub do_dsf_stuff { # Just a regular line, print it out # ##################################### } else { + # Classify the line for OSC 1717 metadata while its leading +/- + # indicator is still present, before we rewrite the line below. + my $osc = osc_for_content_line($line); + # Mark empty line with a red/green box indicating addition/removal if ($mark_empty_lines) { $line = mark_empty_line($line); @@ -511,7 +525,9 @@ sub do_dsf_stuff { } } - print sanitize_display($line); + # sanitize_display() strips OSC sequences, so the metadata record + # is prepended to its (already sanitized) content line, not embedded. + print $osc . sanitize_display($line); } $i++; @@ -531,6 +547,95 @@ 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 (!defined($advertised)) { + return undef; + } + + my $best; + foreach my $v (split(/,/, $advertised)) { + $v = trim($v); + next if $v !~ /^V(\d+)$/; + + my $num = int($1); + next if $num > $supported; + $best = $num if (!defined($best) || $num > $best); + } + + 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) = @_; + + return if !$osc_metadata_version; + + my $bleached = bleach_text($line); + if ($bleached =~ /^@@@/) { + $osc_skip_hunk = 1; + return; + } + + 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; + } + + # 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 = (defined($file_2) && $file_2 ne "" && $file_2 ne "/dev/null") ? $file_2 : $file_1; + + $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) = @_; + + return "" if (!$osc_metadata_version || $osc_skip_hunk); + return "" if ($line !~ /^${ansi_color_regex}([ +-])/); + 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++; + } + + return "\e]1717;${osc_metadata_version};${type};${new_line};${old_field};${osc_file}\e\\"; +} + # 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..ebeee99 --- /dev/null +++ b/test/osc-metadata.bats @@ -0,0 +1,98 @@ +#!/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. +osc_records() { + perl -ne 'while (/\e\]1717;([^\e\a]*)(?:\e\\|\a)/g) { print "$1\n"; }' +} + +# 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 "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 "" +} From ba98a8c849d8917e0bdcfa9e760df17cbe372b47 Mon Sep 17 00:00:00 2001 From: stk Date: Sat, 20 Jun 2026 14:57:55 +0200 Subject: [PATCH 2/3] Emit a version-only metadata handshake as the first output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A host can already learn diff-so-fancy's per-line diff metadata, but only from the records emitted before content lines — so a diff with no content (a binary file, or the empty diff a host would use to probe) emits nothing, and "speaks the protocol" is indistinguishable from "unsupported pager". Emit a version-only OSC 1717 record (no further fields) once, before processing, whenever a version is negotiated. It's content-independent, so a host can probe diff-so-fancy on an empty diff and get a conclusive answer. The osc_records test helper now skips this handshake record so the per-line assertions are unchanged; two new tests cover the handshake (first, and present on an empty diff). See diff-line-metadata-osc-spec.md §4.4. Co-Authored-By: Claude Opus 4.8 (1M context) --- diff-so-fancy | 8 ++++++++ test/osc-metadata.bats | 24 +++++++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/diff-so-fancy b/diff-so-fancy index ab09999..65bdcfc 100755 --- a/diff-so-fancy +++ b/diff-so-fancy @@ -121,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,@_); diff --git a/test/osc-metadata.bats b/test/osc-metadata.bats index ebeee99..8ee3228 100644 --- a/test/osc-metadata.bats +++ b/test/osc-metadata.bats @@ -25,10 +25,16 @@ teardown() { # Extract the OSC 1717 payloads (everything between "ESC ] 1717 ;" and the # string terminator) from stdin, one record per line, for line-wise assertions. -osc_records() { +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 @@ -54,6 +60,22 @@ records_for() { 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" From be03e6bd02ac569bf07967137fb8720428e7c29e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 11:30:40 +0200 Subject: [PATCH 3/3] fixup! Emit per-line diff metadata as an OSC sequence for host applications --- diff-so-fancy | 53 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/diff-so-fancy b/diff-so-fancy index 65bdcfc..bf6d106 100755 --- a/diff-so-fancy +++ b/diff-so-fancy @@ -508,9 +508,8 @@ sub do_dsf_stuff { # Just a regular line, print it out # ##################################### } else { - # Classify the line for OSC 1717 metadata while its leading +/- - # indicator is still present, before we rewrite the line below. - my $osc = osc_for_content_line($line); + # 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) { @@ -533,9 +532,13 @@ sub do_dsf_stuff { } } - # sanitize_display() strips OSC sequences, so the metadata record - # is prepended to its (already sanitized) content line, not embedded. - print $osc . sanitize_display($line); + # 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); } $i++; @@ -562,20 +565,25 @@ sub parse_hunk_header { # 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}; + my $advertised = $ENV{OSC1717_METADATA} || ""; - if (!defined($advertised)) { + if (!$advertised) { return undef; } - my $best; + # 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); - next if $v !~ /^V(\d+)$/; + if ($v !~ /^V(\d+)$/) { next; } my $num = int($1); - next if $num > $supported; - $best = $num if (!defined($best) || $num > $best); + if ($num > $supported) { next; } + + if (!defined($best) || $num > $best) { + $best = $num; + } } return $best; @@ -588,18 +596,20 @@ sub negotiated_osc_version { sub osc_seed_hunk { my ($line) = @_; - return if !$osc_metadata_version; + if (!$osc_metadata_version) { + return undef; + } my $bleached = bleach_text($line); if ($bleached =~ /^@@@/) { $osc_skip_hunk = 1; - return; + 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; + return undef; } # The record carries the new-file path, falling back to the old path for a @@ -607,7 +617,10 @@ sub osc_seed_hunk { # 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 = (defined($file_2) && $file_2 ne "" && $file_2 ne "/dev/null") ? $file_2 : $file_1; + 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; @@ -623,8 +636,8 @@ sub osc_seed_hunk { sub osc_for_content_line { my ($line) = @_; - return "" if (!$osc_metadata_version || $osc_skip_hunk); - return "" if ($line !~ /^${ansi_color_regex}([ +-])/); + if (!$osc_metadata_version || $osc_skip_hunk) { return ''; } + if ($line !~ /^${ansi_color_regex}([ +-])/) { return ''; } my $indicator = $4; my ($type, $new_line, $old_field); @@ -641,7 +654,9 @@ sub osc_for_content_line { $osc_old_line++; } - return "\e]1717;${osc_metadata_version};${type};${new_line};${old_field};${osc_file}\e\\"; + 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