Regex Grapheme Cluster In Character Class
ID |
javascript.regex_grapheme_cluster_in_class |
Severity |
high |
Remediation Complexity |
trivial |
Remediation Risk |
low |
Remediation Effort |
low |
Resource |
Reliability |
Language |
JavaScript |
Tags |
code-style |
Description
Reports a regex character class that combines a base character with a following Unicode combining mark — visually one grapheme, encoded as two separate class members.
Rationale
Some visible characters are not a single Unicode code point. ö, for instance, is the letter o
followed by the combining diaeresis U+0308; it looks like one character on screen but is two in
the source. Text pasted from a document, or normalised to NFD, arrives in exactly that form.
A character class is an enumeration of its members, never a sequence, so writing that pair inside
[…] does not name the combined grapheme — it names two independent alternatives, the base
letter or the bare mark:
// "ö" and "ñ" are written decomposed: o + U+0308, n + U+0303
"nöñn".replace(/[öñ]/g, "X");
// intent: replace the accented "ö" and "ñ"
// actual: [öñ] enumerates o, the diaeresis, n and the tilde, so every plain "o",
// every plain "n" and every bare combining mark is replaced too
Remediation
Use an alternation instead of a class — ö|ñ matches the two-code-point grapheme as a unit, the
way the class was meant to. Normalising the input first makes the intent explicit and lets the
precomposed characters be written directly:
"nöñn".replace(/ö|ñ/g, "X");
// or, composing the input first so the class holds precomposed "ö" and "ñ" (one code point each)
"nöñn".normalize("NFC").replace(/[öñ]/g, "X");
What is not reported
A base+mark pair outside a character class — for example already written as an alternation — is the documented fix and is never reported. A run of marks with no base character in front of them, or a class holding a single character on its own, is not a cluster.
A mark spelled out as an explicit four-hex-digit Unicode escape inside a new RegExp("…") string
argument is treated as a deliberate, individually-enumerated code point rather than an accidental
grapheme, and is not reported. Inside a regex literal that distinction is not available: the
escape is resolved before the pattern is analysed, so an escaped mark and a raw one are reported
alike. The pattern behaves identically either way.