Background
PHP's scanner defines identifiers on bytes, not code points:
LABEL [a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*
Any byte >= 0x80 is accepted, so a character that renders as nothing, or as
whitespace, becomes part of the name. $x and $x followed by U+00A0 are two
distinct variables that look identical in every editor.
This came out of a concept thread on internals
(https://news-web.php.net/php.internals/132344). The conclusion there was that
a language change was not the right vehicle and that this belongs in tooling.
@jrfnl invited me to bring it here.
What it would flag
Identifier tokens whose text contains a character that is invisible or renders
as whitespace. Live example, from the Alipay OpenAPI SDK, vendored into four
unrelated projects in my sample:
$chrtext\u{00A0} = null;
// ...
openssl_public_encrypt($block, $chrtext\u{00A0}, $res);
It works only because the typo is consistent throughout the function. Anyone
typing $chrtext normally gets a silently different variable, passed by
reference, that stays null.
How common
From a survey of the 5,000 most-downloaded Packagist packages, 4,863 resolvable,
520,802 PHP files (tooling and raw data: https://github.com/Otzie2023/PHP):
|
|
| Non-ASCII identifiers |
1,447 |
| Containing an invisible character |
68 |
| Files scanned |
520,802 |
Not all 68 are mistakes. markrogoyski/math-php writes variable names such as
⟮1<U+00A0>−<U+00A0>p⟯ˣ, with U+00A0 deliberately inside the name. That
matters for the design, below.
Proposed character set
Default_Ignorable_Code_Point, plus the format, control and separator general
categories. Version-independent and small — 17 ranges:
00AD, 034F, 061C, 115F..1160, 17B4..17B5, 180B..180F, 200B..200F,
202A..202E, 2060..206F, 3164, FE00..FE0F, FEFF, FFA0, FFF0..FFF8,
1BCA0..1BCA3, 1D173..1D17A, E0000..E0FFF
plus general categories Cf, Cc, Cs, Co, Cn, Zs, Zl, Zp. That
catches U+00A0 (which is Zs, not Default_Ignorable), the zero-width joiners,
the variation selectors and U+3000 IDEOGRAPHIC SPACE, which also occurs in the
corpus.
Deliberately not included: general UAX #31 conformance, NFC, or confusable
scripts. Those are separate questions with different answers, and I would
rather not smuggle them in here.
Design points I have an opinion on
No fixer. Stripping the character from one occurrence renames that
occurrence and breaks the code, because the other occurrences carry the same
character. It has to be reported and corrected by hand.
Warning, not error. math-php's usage is deliberate. A sniff that errors on
it would be wrong about that code, and the ratio in the corpus does not justify
error severity.
ASCII fast path. The first check is for any byte >= 0x80; an identifier of
pure ASCII returns immediately. That is every identifier in almost every file.
No ext/mbstring. PHPCS requires ext-tokenizer but not ext-mbstring.
preg_match('//u', $s) validates UTF-8 through PCRE, and code point iteration
can go through preg_split('//u', ...), so no new extension dependency is
needed. I have this working in the survey scanner.
Questions for you
- Where does it belong —
Generic/Sniffs/NamingConventions/, or somewhere
else? Generic/Files/ByteOrderMarkSniff looked like the closest structural
model.
- Should it also cover hard-coded names as opposed to identifiers:
${'...'}, ->{'...'}, define(), class_alias() and friends? PHP treats
names and identifiers as different things, and a name may legitimately be
anything. In my corpus only 5 non-ASCII names of that kind exist across all
520,802 files, all deliberate, so it may not be worth the surface area.
- Should the character set be configurable, or fixed?
- Is a separate sniff for identifiers that are not well-formed UTF-8 worth
having? There are 2 in the corpus — one is symfony/cache, which declares a
class whose entire name is the single byte 0xA9.
Happy to open a PR once the shape is agreed.
Background
PHP's scanner defines identifiers on bytes, not code points:
Any byte >= 0x80 is accepted, so a character that renders as nothing, or as
whitespace, becomes part of the name.
$xand$xfollowed by U+00A0 are twodistinct variables that look identical in every editor.
This came out of a concept thread on internals
(https://news-web.php.net/php.internals/132344). The conclusion there was that
a language change was not the right vehicle and that this belongs in tooling.
@jrfnl invited me to bring it here.
What it would flag
Identifier tokens whose text contains a character that is invisible or renders
as whitespace. Live example, from the Alipay OpenAPI SDK, vendored into four
unrelated projects in my sample:
It works only because the typo is consistent throughout the function. Anyone
typing
$chrtextnormally gets a silently different variable, passed byreference, that stays
null.How common
From a survey of the 5,000 most-downloaded Packagist packages, 4,863 resolvable,
520,802 PHP files (tooling and raw data: https://github.com/Otzie2023/PHP):
Not all 68 are mistakes.
markrogoyski/math-phpwrites variable names such as⟮1<U+00A0>−<U+00A0>p⟯ˣ, with U+00A0 deliberately inside the name. Thatmatters for the design, below.
Proposed character set
Default_Ignorable_Code_Point, plus the format, control and separator generalcategories. Version-independent and small — 17 ranges:
plus general categories
Cf,Cc,Cs,Co,Cn,Zs,Zl,Zp. Thatcatches U+00A0 (which is
Zs, not Default_Ignorable), the zero-width joiners,the variation selectors and U+3000 IDEOGRAPHIC SPACE, which also occurs in the
corpus.
Deliberately not included: general
UAX #31conformance, NFC, or confusablescripts. Those are separate questions with different answers, and I would
rather not smuggle them in here.
Design points I have an opinion on
No fixer. Stripping the character from one occurrence renames that
occurrence and breaks the code, because the other occurrences carry the same
character. It has to be reported and corrected by hand.
Warning, not error. math-php's usage is deliberate. A sniff that errors on
it would be wrong about that code, and the ratio in the corpus does not justify
error severity.
ASCII fast path. The first check is for any byte >= 0x80; an identifier of
pure ASCII returns immediately. That is every identifier in almost every file.
No
ext/mbstring. PHPCS requiresext-tokenizerbut notext-mbstring.preg_match('//u', $s)validates UTF-8 through PCRE, and code point iterationcan go through
preg_split('//u', ...), so no new extension dependency isneeded. I have this working in the survey scanner.
Questions for you
Generic/Sniffs/NamingConventions/, or somewhereelse?
Generic/Files/ByteOrderMarkSnifflooked like the closest structuralmodel.
${'...'},->{'...'},define(),class_alias()and friends? PHP treatsnames and identifiers as different things, and a name may legitimately be
anything. In my corpus only 5 non-ASCII names of that kind exist across all
520,802 files, all deliberate, so it may not be worth the surface area.
having? There are 2 in the corpus — one is
symfony/cache, which declares aclass whose entire name is the single byte
0xA9.Happy to open a PR once the shape is agreed.