Skip to content

Commit 769ab99

Browse files
committed
Emit f and h records on the per-hunk header banners
difftastic renders no `@@`/`diff --git` rows; its only structural row is one banner per hunk (`path --- N/total --- Format`) announcing both the file and the hunk. Annotate it with the header records so a host can anchor file and hunk navigation on the banners themselves and can see files with no content lines: every banner carries its hunk's `h` record with the hunk's first new-file line, and the first hunk's banner -- the only row announcing the file -- additionally carries the file's `f` record before the `h`, per the spec's combined-header rule (§5.5). `f` never carries a line number; the spec fixes each header type's payload rather than leaving it to the renderer (a streaming renderer like delta cannot know the first-hunk line at its file header). Unlike delta, difftastic builds the whole diff before rendering, so the hunk's first new-file line is in hand when the banner is printed: the first aligned row with a new-file line provides it, falling back to the position the next new line would occupy for a hunk with none (a pure deletion), mirroring the deletion convention. In inline mode the banner print moves below the context calculation for the same reason. A whole-file add/delete renders through the single-column path as one hunk, so its banner carries `f` plus an `h` at line 1 (addition) or 0 (deletion, matching its `d` records). A rename's two-row banner repeats the records on each row (spec §6.4). With OSC1717 unset, output remains byte-for-byte identical to stock difftastic.
1 parent 5bf1add commit 769ab99

3 files changed

Lines changed: 132 additions & 33 deletions

File tree

src/display/diff_line_metadata.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,40 @@ impl DiffLineMetadata {
174174
}
175175
}
176176

177+
/// Prefix every row of a header banner with its OSC record(s). difftastic
178+
/// renders no `@@`/`diff --git` rows; instead it prints one banner per hunk
179+
/// (`path --- N/total --- Format`) that announces both the file and the
180+
/// hunk. Every banner is a hunk header, so it carries that hunk's `h`
181+
/// record (`new_line` is the hunk's first new-file line); the first hunk's
182+
/// banner is also the only row announcing the file, so it additionally
183+
/// carries the file's `f` record -- before the `h`, per the combined-header
184+
/// rule (spec §5.5). An `f` never carries line numbers.
185+
///
186+
/// The banner is one row, or two when the first hunk also shows a rename's
187+
/// old path -- and spec §6.4 wants every row of a header block tagged, so
188+
/// each row gets the same record(s).
189+
pub(crate) fn header_banner(
190+
&self,
191+
is_first_hunk: bool,
192+
new_line: usize,
193+
banner: &str,
194+
) -> String {
195+
let mut prefix = String::new();
196+
if is_first_hunk {
197+
prefix.push_str(&format!(
198+
"{OSC};{version};f;;;{file}{ST}",
199+
version = self.version,
200+
file = self.file,
201+
));
202+
}
203+
prefix.push_str(&self.osc('h', new_line, None));
204+
format!(
205+
"{}{}",
206+
prefix,
207+
banner.replace('\n', &format!("\n{}", prefix))
208+
)
209+
}
210+
177211
fn osc(&self, type_char: char, new_line: usize, old_line: Option<usize>) -> String {
178212
let old_field = old_line.map_or(String::new(), |n| n.to_string());
179213
format!(
@@ -280,6 +314,34 @@ mod tests {
280314
);
281315
}
282316

317+
#[test]
318+
fn test_header_banner_file_and_hunk() {
319+
// Every banner is a hunk header (`h`, carrying the hunk's first line);
320+
// the first hunk's banner also announces the file, so it additionally
321+
// carries the `f` record -- first, and without line numbers (spec §5.5).
322+
let md = metadata();
323+
assert_eq!(
324+
md.header_banner(true, 1, "b.txt --- Rust"),
325+
"\x1b]1717;1;f;;;a/b.txt\x1b\\\x1b]1717;1;h;1;;a/b.txt\x1b\\b.txt --- Rust"
326+
);
327+
assert_eq!(
328+
md.header_banner(false, 16, "b.txt --- 2/2 --- Rust"),
329+
"\x1b]1717;1;h;16;;a/b.txt\x1b\\b.txt --- 2/2 --- Rust"
330+
);
331+
}
332+
333+
#[test]
334+
fn test_header_banner_tags_every_row() {
335+
// A rename's first-hunk banner spans two rows (path, then old path);
336+
// each row carries the same records (spec §6.4).
337+
let md = metadata();
338+
assert_eq!(
339+
md.header_banner(true, 1, "new.txt --- Rust\nrenamed from old.txt"),
340+
"\x1b]1717;1;f;;;a/b.txt\x1b\\\x1b]1717;1;h;1;;a/b.txt\x1b\\new.txt --- Rust\n\
341+
\x1b]1717;1;f;;;a/b.txt\x1b\\\x1b]1717;1;h;1;;a/b.txt\x1b\\renamed from old.txt"
342+
);
343+
}
344+
283345
#[test]
284346
fn test_row_is_novel_compares_contents_not_tokens() {
285347
let lhs_lines = ["foo(1)", "", "same"];

src/display/inline.rs

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -76,18 +76,6 @@ pub(crate) fn print(
7676
let rhs_line_nums_width = format_line_num(rhs_src.max_line()).len();
7777

7878
for (i, hunk) in hunks.iter().enumerate() {
79-
println!(
80-
"{}",
81-
style::header(
82-
display_path,
83-
extra_info.as_ref(),
84-
i + 1,
85-
hunks.len(),
86-
file_format,
87-
display_options
88-
)
89-
);
90-
9179
let hunk_lines = hunk.lines.clone();
9280

9381
let before_lines = calculate_before_context(
@@ -106,6 +94,30 @@ pub(crate) fn print(
10694
display_options.num_context_lines as usize,
10795
);
10896

97+
let banner = style::header(
98+
display_path,
99+
extra_info.as_ref(),
100+
i + 1,
101+
hunks.len(),
102+
file_format,
103+
display_options,
104+
);
105+
match &metadata {
106+
// The banner announces both the file and this hunk: every banner
107+
// carries the hunk's `h` (with the hunk's first new-file line), and
108+
// the first hunk's banner additionally carries the file's `f`.
109+
Some(metadata) => {
110+
let new_line = before_lines
111+
.iter()
112+
.chain(hunk_lines.iter())
113+
.chain(after_lines.iter())
114+
.find_map(|(_, rhs)| rhs.map(|n| n.as_usize() + 1))
115+
.unwrap_or(1);
116+
println!("{}", metadata.header_banner(i == 0, new_line, &banner));
117+
}
118+
None => println!("{}", banner),
119+
}
120+
109121
// Inline mode groups all old-side content (before-context, then
110122
// deletions) before all new-side content (additions, then
111123
// after-context). The metadata rides each line: the old-side passes

src/display/side_by_side.rs

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -81,15 +81,18 @@ fn display_single_column(
8181

8282
let mut formatted_lines = Vec::with_capacity(src_lines.len());
8383

84-
let mut header_line = String::new();
85-
header_line.push_str(&style::header(
86-
display_path,
87-
old_path,
88-
1,
89-
1,
90-
file_format,
91-
display_options,
92-
));
84+
let banner = style::header(display_path, old_path, 1, 1, file_format, display_options);
85+
let mut header_line = match metadata {
86+
// A whole-file add/delete is a single hunk, so its banner carries the
87+
// file's `f` and the hunk's `h`. A deleted file has no new-file
88+
// content, so its hunk sits at new-line 0 (as for its deletion
89+
// records); an added file's first new line is 1.
90+
Some(metadata) => {
91+
let new_line = if side == Side::Right { 1 } else { 0 };
92+
metadata.header_banner(true, new_line, &banner)
93+
}
94+
None => banner,
95+
};
9396
header_line.push('\n');
9497
formatted_lines.push(header_line);
9598

@@ -426,6 +429,21 @@ fn visible_content_max_display_width(
426429
(lhs_content_max_width, rhs_content_max_width)
427430
}
428431

432+
/// The new-file line a hunk's header banner points at: the first new-file
433+
/// (RHS) line shown in the hunk (spec §5.2). A hunk with no new-file lines at
434+
/// all (a pure deletion with no surrounding new context) falls back to the
435+
/// position the next new line would occupy, mirroring the deletion convention
436+
/// in `left_cell`.
437+
fn hunk_first_new_line(
438+
aligned_lines: &[(Option<LineNumber>, Option<LineNumber>)],
439+
prev_rhs: Option<LineNumber>,
440+
) -> usize {
441+
aligned_lines
442+
.iter()
443+
.find_map(|(_, rhs)| rhs.map(|n| n.as_usize() + 1))
444+
.unwrap_or_else(|| prev_rhs.map_or(1, |n| n.as_usize() + 2))
445+
}
446+
429447
pub(crate) fn print(
430448
hunks: &[Hunk],
431449
display_options: &DisplayOptions,
@@ -608,18 +626,6 @@ pub(crate) fn print(
608626
);
609627

610628
for (i, hunk) in hunks.iter().enumerate() {
611-
println!(
612-
"{}",
613-
style::header(
614-
display_path,
615-
old_path,
616-
i + 1,
617-
hunks.len(),
618-
file_format,
619-
display_options
620-
)
621-
);
622-
623629
let (start_i, end_i) = matched_lines_indexes_for_hunk(
624630
matched_lines_to_print,
625631
hunk,
@@ -633,6 +639,25 @@ pub(crate) fn print(
633639
// diffs.
634640
matched_lines_to_print = &matched_lines_to_print[start_i..];
635641

642+
let banner = style::header(
643+
display_path,
644+
old_path,
645+
i + 1,
646+
hunks.len(),
647+
file_format,
648+
display_options,
649+
);
650+
match &metadata {
651+
// The banner announces both the file and this hunk: every banner
652+
// carries the hunk's `h` (with the hunk's first new-file line), and
653+
// the first hunk's banner additionally carries the file's `f`.
654+
Some(metadata) => {
655+
let new_line = hunk_first_new_line(aligned_lines, prev_rhs_line_num);
656+
println!("{}", metadata.header_banner(i == 0, new_line, &banner));
657+
}
658+
None => println!("{}", banner),
659+
}
660+
636661
let no_lhs_changes = hunk.novel_lhs.is_empty();
637662
let no_rhs_changes = hunk.novel_rhs.is_empty();
638663
let same_lines = aligned_lines.iter().all(|(l, r)| l == r);

0 commit comments

Comments
 (0)