Skip to content

Proposal: sniff for invisible characters in hard-coded identifier names #1491

Description

@Otzie2023

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

  1. Where does it belong — Generic/Sniffs/NamingConventions/, or somewhere
    else? Generic/Files/ByteOrderMarkSniff looked like the closest structural
    model.
  2. 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.
  3. Should the character set be configurable, or fixed?
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions