From b0aca29d4df346272c0a97088566cd508b033c0e Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Mon, 25 May 2026 14:52:28 +0000 Subject: [PATCH] fix(tac): preserve unterminated last line on reversal Real GNU tac splits input by newline separator and reverses records while keeping their original separators. An unterminated last line stays unterminated when reversed, so it concatenates directly with the previous reversed record. Bashkit's reverse_lines always re-inserted a separator and appended a final newline, producing extra newlines for inputs lacking a trailing separator. Reproduced by coreutils_differential_tests::tac_no_trailing_newline, which the weekly Coreutils Args Drift workflow now flags. --- crates/bashkit/src/builtins/textrev.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/crates/bashkit/src/builtins/textrev.rs b/crates/bashkit/src/builtins/textrev.rs index 072a86e9..51bcf6b7 100644 --- a/crates/bashkit/src/builtins/textrev.rs +++ b/crates/bashkit/src/builtins/textrev.rs @@ -135,16 +135,26 @@ fn reverse_lines(raw: &str) -> String { if raw.is_empty() { return String::new(); } + // GNU tac splits input into records by newline separators; each record + // keeps its own trailing separator (the last record has none when input + // is unterminated). Reversing records preserves that — so an + // unterminated last line concatenates directly with the previous one + // without an inserted newline. let has_trailing_newline = raw.ends_with('\n'); let trimmed = if has_trailing_newline { &raw[..raw.len() - 1] } else { raw }; - let mut lines: Vec<&str> = trimmed.split('\n').collect(); - lines.reverse(); - let mut out = lines.join("\n"); - out.push('\n'); + let lines: Vec<&str> = trimmed.split('\n').collect(); + let last = lines.len() - 1; + let mut out = String::new(); + for (i, line) in lines.iter().enumerate().rev() { + out.push_str(line); + if i != last || has_trailing_newline { + out.push('\n'); + } + } out }