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

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
kodiakhq Bot pushed a commit to pdylanross/fatigue that referenced this pull request Sep 14, 2026
Bumps console from 0.16.4 to 0.16.6.

Release notes
Sourced from console's releases.

0.16.6
What's Changed

Fix truncate_str panicking mid-character without ansi-parsing by @​lenamonj in console-rs/console#296
perf: accelerate printable ASCII text width by @​dexhunter in console-rs/console#297
fix: measure the truncation tail in visible columns, not raw width by @​youdie006 in console-rs/console#298
Prepare 0.16.6 by @​djc in console-rs/console#299

0.16.5
What's Changed

Strip OSC and DCS sequences to support e.g. OSC 8 hyperlinks over tmux. by @​khoek in console-rs/console#280




Commits

4329b77 Bump version to 0.16.6
bdf46b0 utils: wrap tests in module
4f54213 fix: measure the truncation tail in visible columns
ed342d0 test: consolidate text width regression coverage
48b99e9 perf: accelerate printable ASCII text width
abf0358 Fix truncate_str panicking mid-character without ansi-parsing
ac3cb73 Bump version to 0.16.5
97a91ae ansi: strip OSC and DCS sequences
See full diff in compare view




Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting @dependabot rebase.


Dependabot commands and options

You can trigger Dependabot actions by commenting on this PR:

@dependabot rebase will rebase this PR
@dependabot recreate will recreate this PR, overwriting any edits that have been made to it
@dependabot show <dependency name> ignore conditions will show all of the ignore conditions of the specified dependency
@dependabot ignore this major version will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
@dependabot ignore this minor version will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
@dependabot ignore this dependency will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
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