Fix ZWJ swallowing the following character; clearer TypeErrors for bad input#230
Conversation
(cherry picked from commit a62107e0211fae99fbe7e129a6bf26a715a388fe)
(cherry picked from commit 31663a1912efd7ad2be40073602e63867d24c819)
The ZWJ (U+200D) branch advanced `idx += 2`, skipping the character
immediately after a ZWJ without measuring it. This silently swallowed
characters and produced wrong widths:
- wcswidth('a\x07b') returned 2 but must return -1: the BEL
(U+0007) after the ZWJ is a C0 control, and the docstring promises
-1 for C0/C1 controls.
- wcswidth('一') (leading ZWJ then CJK) returned 0 but must
return 2: the wide ideograph was swallowed.
The same swallow affected wcstwidth, which shares the logic.
A ZWJ legitimately continues an emoji grapheme cluster (GB11), so the
joined character is consumed without adding width -- but only when there
is a measured base to join to and the following character is not a
control. Gate the skip on a new `seen_base` flag (set when any base
character is measured) and on the next character not being a C0/C1
control (its wcwidth is negative); otherwise consume only the ZWJ and
let the loop measure the next character normally.
This preserves every currently-correct output, including emoji
ZWJ-sequence combining (e.g. '\U0001F468\U0001F469' == 2) and the
documented "ZWJ after a base consumes the next character" behaviour, and
corrects only the swallowed-control and leading-ZWJ cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 89869612407c024050d01443ab19d2f29ccd3e9b)
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #230 +/- ##
=========================================
Coverage 100.00% 100.00%
=========================================
Files 27 27
Lines 1967 1980 +13
Branches 462 465 +3
=========================================
+ Hits 1967 1980 +13 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Merging this PR will degrade performance by 26.94%
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing |
| # pylint: disable=too-complex,too-many-branches,duplicate-code,too-many-nested-blocks | ||
|
|
||
| if not isinstance(pwcs, str): | ||
| raise TypeError(f'wcswidth() expects a string, got {type(pwcs).__name__}') |
There was a problem hiding this comment.
This library uses typing and the argument is typed for str. This extra isinstance check is not wanted or necessary
The bug (most significant of the 3 commits)
In
wcswidth()/wcstwidth(), the ZWJ (U+200D) branch advancesidx += 2, unconditionally skipping the character immediately after a ZWJ without measuring it. This silently swallows characters and produces wrong widths:wcswidth('a\x07b')returns2, but must return-1: the BEL (U+0007) after the ZWJ is a C0 control, and the docstring promises-1for C0/C1 controls.wcswidth('一')(leading ZWJ then CJK) returns0, but must return2: the wide ideograph is swallowed.wcstwidth, which shares the logic.A ZWJ legitimately continues an emoji grapheme cluster (UAX #29 GB11), so the joined character should be consumed without adding width — but only when there is a measured base to join to and the following character isn't a control. The fix gates the skip on a new
seen_baseflag (set once any base character has been measured) and on the next character not being a C0/C1 control (itswcwidthis negative); otherwise it consumes only the ZWJ and lets the loop measure the next character normally.This preserves every currently-correct output, including legitimate emoji ZWJ-sequence combining (e.g.
'\U0001F468\U0001F469' == 2) — verified with a 60,000-input randomized sweep comparing base vs. fixed across ASCII, controls, CJK, combining marks, emoji, ZWJ, VS15/16, regional indicators, Fitzpatrick, and virama inputs for bothwcswidthandwcstwidth. Every changed output was wrong→right; zero changes occurred on any input without a ZWJ. Non-vacuity confirmed: reverting only the source change makes the new regression tests fail for the documented reason (assert 2 == -1 / assert 0 == 2).I recognize this touches a performance-sensitive hot path with a lot of careful prior work (virama, VS15/16, terminal overrides) — happy to adjust the gating condition or move the check if there's a cheaper way to express it that you'd prefer.
Also included (independent, smaller commits)
wcwidth(): clearTypeErrorfor a multi-character string input (previously a crypticord()error), preserving all other input types (bytes,None,0, single chars) unchanged.wcswidth(): clearTypeErrorfor non-string input (previously anAttributeErrorfrom.isascii()), add-only.Verification
Full suite green:
1322 passed, 10 skipped, 1 deselected. All three commits independently red-before-green verified (each added test fails on the parent commit for the right reason) with 100% changed-line coverage.Co-authored by Claude (Anthropic) as part of an automated hardening pass; independently re-verified (non-vacuity, backward-compatibility sweep, coverage) before submission.