Grapheme Cluster In Character Class
ID |
kotlin.regex_grapheme_cluster_in_class |
Severity |
high |
Remediation Complexity |
trivial |
Remediation Risk |
low |
Remediation Effort |
low |
Resource |
Reliability |
Language |
Kotlin |
Tags |
code-style |
Description
Reports a regex character class that combines a base character with a following Unicode combining mark. What looks like one accented letter is encoded as two code points — the base letter, then the mark — and a character class is an enumeration of its members, never a sequence.
Rationale
// Bad — written as if it held one accented letter
val re = Regex("[c̈]")
re.containsMatchIn("c̈") // false — the class never matches the pair
re.containsMatchIn("c") // true — it matches the bare base letter
re.containsMatchIn("̈") // true — or the bare combining mark
-
The class does not mean what it looks like.
[c̈]is read exactly like[c+ mark +]: two independent members, so it matches either code point alone and never the grapheme the source appears to name. -
The defect is invisible in review. Most editors render the base and its mark as a single glyph, so the class looks like it holds one character and the diff shows nothing unusual.
-
A validation or sanitising class built this way silently accepts a lone combining mark, and silently rejects the accented character it was meant to allow.
Remediation
Match the grapheme as a unit — with an alternation, or with a group when it has to sit inside a larger class-like choice. Using the precomposed single code point (é, ñ) where one exists is simpler still.
// Good
val re = Regex("c̈|ö") // an alternation matches each grapheme as a unit
val precomposed = Regex("[éñ]") // one code point each, no combining mark
If the individual code point really was intended, say so explicitly by writing the mark as an escape in a raw string:
val re = Regex("""[n\u0303]""") // deliberately the base letter or that code point
What is not reported
An alternation or a group is never reported — only a character class, because only a class turns a sequence into a choice. A precomposed character is a single code point and carries no mark. A combining mark alone in a class has no base character to combine with. A code-point escape inside a raw string breaks the pair and is treated as deliberate.
That last carve-out does not extend to an ordinary string literal, where "\u0303" is a Kotlin escape: it is resolved while the literal’s value is reconstructed, so an escaped mark and a typed one are the same text by the time the pattern is analysed, and both are reported. The pattern behaves identically either way, so the finding is accurate — write the escape in a raw string to mark it as intentional.
A pattern that is not statically known — built by concatenation, interpolated, or held in a variable — is not reported. Neither is String.replace("[c̈]", "-"), which takes a plain string and replaces it literally.