Skip to content

fix: measure the truncation tail in visible columns, not raw width - #298

Merged
djc merged 1 commit into
console-rs:mainfrom
youdie006:fix-truncate-str-ansi-tail-width
Sep 10, 2026
Merged

fix: measure the truncation tail in visible columns, not raw width#298
djc merged 1 commit into
console-rs:mainfrom
youdie006:fix-truncate-str-ansi-tail-width

Conversation

@youdie006

Copy link
Copy Markdown
Contributor

truncate_str charges the truncation tail's ANSI escape bytes against the visible-width budget, so a styled marker eats its own budget and can consume the whole thing.

The inconsistency

truncate_str measures its two string operands with two different helpers:

// src/utils.rs:916
pub fn truncate_str<'a>(s: &'a str, width: usize, tail: &str) -> Cow<'a, str> {
    if measure_text_width(s) <= width {   // :917  ansi-aware -> width is VISIBLE columns
// src/utils.rs:932, :938  (inside the `ansi-parsing` branch)
if str_width(s) + length > width.saturating_sub(str_width(tail)) {
...
let rest_width = width.saturating_sub(str_width(tail)).saturating_sub(length);

str_width (:884) is raw UnicodeWidthStr::width / chars().count(); it has no idea what an escape sequence is. Line 917 defines width in visible columns, so lines 932 and 938 are subtracting a byte-ish count from a column count.

str_width is the right helper at :126 and :957, where it is applied to a chunk AnsiCodeIterator has already classified as non-ANSI. :932/:938 are the only places in the crate where a whole user-supplied string is measured with it.

Reproduction, public API only

let s = "abcdefghijklmnop";                                  // 16 columns
let tail = style("...").red().force_styling(true).to_string();

truncate_str(s, 10, "...")   // "abcdefg..."                            width 10  ok
truncate_str(s, 10, &tail)   // "\x1b[31m...\x1b[0m"                    width  3  <-- all 16 columns of input gone
truncate_str(s, 10, "\x1b[0m") // "abcdef\x1b[0m"                       width  6  <-- a bare reset costs 4 columns

pad_str(s, 10, Alignment::Left, Some(&tail))  // "\x1b[31m...\x1b[0m"   width  3  <-- same, via :1030

pad_str_with reaches it on the same footing: it measures with measure_text_width at :1025 and then hands that width to truncate_str at :1030.

After the patch all of those come back at width 10.

The fix

Measure tail with measure_text_width, the same helper truncate_str already uses for s fifteen lines above.

The #[cfg(not(feature = "ansi-parsing"))] branch at :980 is deliberately untouched: there measure_text_width is str_width (:133), so changing it would be pure churn.

Verification

test_truncate_str_ansi_tail fails on the pristine tree (left: "\x1b[31m...\x1b[0m", right: "foo bar\x1b[31m...\x1b[0m") and passes with the patch. Mutating the fix in both directions kills it:

mutation result
revert to str_width(tail) 28 passed / 1 failed - only the new test, disjoint from the existing suite
measure_text_width(tail) + 1 23 passed / 6 failed (test_truncate_str, test_truncate_str_no_ansi, test_truncate_str_multibyte_no_panic, test_pad_str, test_pad_str_with, new)
tail_width = 0 same 6

Repo gates, run verbatim from the Makefile: make test (all seven feature rows) 0, make check 0, make lint (cargo clippy --examples --tests --all-features -- --deny warnings) 0, make format-check 0, cargo test --doc --all-features 0, cargo hack check --each-feature 7/7. The new test is gated on feature = "ansi-parsing" and its assertions are pure ASCII, so the std,ansi-parsing row (unicode-width off) agrees with the --all-features row. make check-minver needs cargo-minimal-versions, which I do not have locally; that job is continue-on-error anyway.


Disclosure: this patch was prepared with AI assistance. I ran the tests, the mutation matrix and every gate above myself and I stand behind the change.

truncate_str takes width in visible columns (line 917 measures the input
with the ansi-aware measure_text_width), but subtracted the tail budget
with the ansi-blind str_width, so escape sequences in the tail were
charged as printed columns. A styled marker could consume the entire
budget and drop the whole input.

@djc djc left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, that makes sense!

@djc
djc merged commit 4f54213 into console-rs:main Sep 10, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants