Bug in double negation of regex character classes?
There are some strange voodoo going on in the character class parsing code of Oracle's implementation of Pattern
class, which comes with your JRE/JDK if you downloaded it from Oracle's website or if you are using OpenJDK. I have not checked how other JVM (notably GNU Classpath) implementations parse the regex in the question.
From this point, any reference to Pattern
class and its internal working is strictly restricted to Oracle's implementation (the reference implementation).
It would take some time to read and understand how Pattern
class parses the nested negation as shown in the question. However, I have written a program1 to extract information from a Pattern
object (with Reflection API) to look at the result of compilation. The output below is from running my program on Java HotSpot Client VM version 1.7.0_51.
1: Currently, the program is an embarrassing mess. I will update this post with a link when I finished it and refactored it.
[^0-9]
Start. Start unanchored match (minLength=1)
CharProperty.complement (character class negation). Match any character NOT matched by the following character class:
Pattern.rangeFor (character range). Match any character within the range from code point U+0030 to code point U+0039 (both ends inclusive)
LastNode
Node. Accept match
Nothing surprising here.
[^[^0-9]]
Start. Start unanchored match (minLength=1)
CharProperty.complement (character class negation). Match any character NOT matched by the following character class:
Pattern.rangeFor (character range). Match any character within the range from code point U+0030 to code point U+0039 (both ends inclusive)
LastNode
Node. Accept match
[^[^[^0-9]]]
Start. Start unanchored match (minLength=1)
CharProperty.complement (character class negation). Match any character NOT matched by the following character class:
Pattern.rangeFor (character range). Match any character within the range from code point U+0030 to code point U+0039 (both ends inclusive)
LastNode
Node. Accept match
The next 2 cases above are compiled to the same program as [^0-9]
, which is counter-intuitive.
[[^0-9]2]
Start. Start unanchored match (minLength=1)
Pattern.union (character class union). Match any character matched by either character classes below:
CharProperty.complement (character class negation). Match any character NOT matched by the following character class:
Pattern.rangeFor (character range). Match any character within the range from code point U+0030 to code point U+0039 (both ends inclusive)
BitClass. Optimized character class with boolean[] to match characters in Latin-1 (code point <= 255). Match the following 1 character(s):
[U+0032]
2
LastNode
Node. Accept match
[\D2]
Start. Start unanchored match (minLength=1)
Pattern.union (character class union). Match any character matched by either character classes below:
CharProperty.complement (character class negation). Match any character NOT matched by the following character class:
Ctype. Match POSIX character class DIGIT (US-ASCII)
BitClass. Optimized character class with boolean[] to match characters in Latin-1 (code point <= 255). Match the following 1 character(s):
[U+0032]
2
LastNode
Node. Accept match
Nothing strange in the 2 cases above, as stated in the question.
[013-9]
Start. Start unanchored match (minLength=1)
Pattern.union (character class union). Match any character matched by either character classes below:
BitClass. Optimized character class with boolean[] to match characters in Latin-1 (code point <= 255). Match the following 2 character(s):
[U+0030][U+0031]
01
Pattern.rangeFor (character range). Match any character within the range from code point U+0033 to code point U+0039 (both ends inclusive)
LastNode
Node. Accept match
[^\D2]
Start. Start unanchored match (minLength=1)
Pattern.setDifference (character class subtraction). Match any character matched by the 1st character class, but NOT the 2nd character class:
CharProperty.complement (character class negation). Match any character NOT matched by the following character class:
CharProperty.complement (character class negation). Match any character NOT matched by the following character class:
Ctype. Match POSIX character class DIGIT (US-ASCII)
BitClass. Optimized character class with boolean[] to match characters in Latin-1 (code point <= 255). Match the following 1 character(s):
[U+0032]
2
LastNode
Node. Accept match
These 2 cases work as expected, as stated in the question. However, take note of how the engine takes complement of the first character class (\D
) and apply set difference to the character class consisting of the leftover.
[^[^0-9]2]
Start. Start unanchored match (minLength=1)
Pattern.setDifference (character class subtraction). Match any character matched by the 1st character class, but NOT the 2nd character class:
CharProperty.complement (character class negation). Match any character NOT matched by the following character class:
Pattern.rangeFor (character range). Match any character within the range from code point U+0030 to code point U+0039 (both ends inclusive)
BitClass. Optimized character class with boolean[] to match characters in Latin-1 (code point <= 255). Match the following 1 character(s):
[U+0032]
2
LastNode
Node. Accept match
[^[^[^0-9]]2]
Start. Start unanchored match (minLength=1)
Pattern.setDifference (character class subtraction). Match any character matched by the 1st character class, but NOT the 2nd character class:
CharProperty.complement (character class negation). Match any character NOT matched by the following character class:
Pattern.rangeFor (character range). Match any character within the range from code point U+0030 to code point U+0039 (both ends inclusive)
BitClass. Optimized character class with boolean[] to match characters in Latin-1 (code point <= 255). Match the following 1 character(s):
[U+0032]
2
LastNode
Node. Accept match
[^[^[^[^0-9]]]2]
Start. Start unanchored match (minLength=1)
Pattern.setDifference (character class subtraction). Match any character matched by the 1st character class, but NOT the 2nd character class:
CharProperty.complement (character class negation). Match any character NOT matched by the following character class:
Pattern.rangeFor (character range). Match any character within the range from code point U+0030 to code point U+0039 (both ends inclusive)
BitClass. Optimized character class with boolean[] to match characters in Latin-1 (code point <= 255). Match the following 1 character(s):
[U+0032]
2
LastNode
Node. Accept match
As confirmed via testing by Keppil in the comment, the output above shows that all 3 regex above are compiled to the same program!
[^2[^0-9]]
Start. Start unanchored match (minLength=1)
Pattern.union (character class union). Match any character matched by either character classes below:
CharProperty.complement (character class negation). Match any character NOT matched by the following character class:
BitClass. Optimized character class with boolean[] to match characters in Latin-1 (code point <= 255). Match the following 1 character(s):
[U+0032]
2
CharProperty.complement (character class negation). Match any character NOT matched by the following character class:
Pattern.rangeFor (character range). Match any character within the range from code point U+0030 to code point U+0039 (both ends inclusive)
LastNode
Node. Accept match
Instead of NOT(UNION(2, NOT(0-9))
, which is 0-13-9
, we get UNION(NOT(2), NOT(0-9))
, which is equivalent to NOT(2)
.
[^2[^[^0-9]]]
Start. Start unanchored match (minLength=1)
Pattern.union (character class union). Match any character matched by either character classes below:
CharProperty.complement (character class negation). Match any character NOT matched by the following character class:
BitClass. Optimized character class with boolean[] to match characters in Latin-1 (code point <= 255). Match the following 1 character(s):
[U+0032]
2
CharProperty.complement (character class negation). Match any character NOT matched by the following character class:
Pattern.rangeFor (character range). Match any character within the range from code point U+0030 to code point U+0039 (both ends inclusive)
LastNode
Node. Accept match
The regex [^2[^[^0-9]]]
compiles to the same program as [^2[^0-9]]
due to the same bug.
There is an unresolved bug that seems to be of the same nature: JDK-6609854.
Explanation
Preliminary
Below are implementation details of Pattern
class that one should know before reading further:
Pattern
class compiles aString
into a chain of nodes, each node is in charge of a small and well-defined responsibility, and delegates the work to the next node in the chain.Node
class is the base class of all the nodes.CharProperty
class is the base class of all character-class relatedNode
s.BitClass
class is a subclass ofCharProperty
class that uses aboolean[]
array to speed up matching for Latin-1 characters (code point <= 255). It has anadd
method, which allows characters to be added during compilation.CharProperty.complement
,Pattern.union
,Pattern.intersection
are methods corresponding to set operations. What they do is self-explanatory.Pattern.setDifference
is asymmetric set difference.
Parsing character class at first glance
Before looking at the full code of CharProperty clazz(boolean consume)
method, which is the method responsible for parsing a character class, let us look at an extremely simplified version of the code to understand the flow of the code:
private CharProperty clazz(boolean consume) {
// [Declaration and initialization of local variables - OMITTED]
BitClass bits = new BitClass();
int ch = next();
for (;;) {
switch (ch) {
case '^':
// Negates if first char in a class, otherwise literal
if (firstInClass) {
// [CODE OMITTED]
ch = next();
continue;
} else {
// ^ not first in class, treat as literal
break;
}
case '[':
// [CODE OMITTED]
ch = peek();
continue;
case '&':
// [CODE OMITTED]
continue;
case 0:
// [CODE OMITTED]
// Unclosed character class is checked here
break;
case ']':
// [CODE OMITTED]
// The only return statement in this method
// is in this case
break;
default:
// [CODE OMITTED]
break;
}
node = range(bits);
// [CODE OMITTED]
ch = peek();
}
}
The code basically reads the input (the input String
converted to null-terminated int[]
of code points) until it hits ]
or the end of the String (unclosed character class).
The code is a bit confusing with continue
and break
mixing together inside the switch
block. However, as long as you realize that continue
belongs to the outer for
loop and break
belongs to the switch
block, the code is easy to understand:
- Cases ending in
continue
will never execute the code after theswitch
statement. - Cases ending in
break
may execute the code after theswitch
statement (if it doesn'treturn
already).
With the observation above, we can see that whenever a character is found to be non-special and should be included in the character class, we will execute the code after the switch
statement, in which node = range(bits);
is the first statement.
If you check the source code, the method CharProperty range(BitClass bits)
parses "a single character or a character range in a character class". The method either returns the same BitClass
object passed in (with new character added) or return a new instance of CharProperty
class.
The gory details
Next, let us look at the full version of the code (with the part parsing character class intersection &&
omitted):
private CharProperty clazz(boolean consume) {
CharProperty prev = null;
CharProperty node = null;
BitClass bits = new BitClass();
boolean include = true;
boolean firstInClass = true;
int ch = next();
for (;;) {
switch (ch) {
case '^':
// Negates if first char in a class, otherwise literal
if (firstInClass) {
if (temp[cursor-1] != '[')
break;
ch = next();
include = !include;
continue;
} else {
// ^ not first in class, treat as literal
break;
}
case '[':
firstInClass = false;
node = clazz(true);
if (prev == null)
prev = node;
else
prev = union(prev, node);
ch = peek();
continue;
case '&':
// [CODE OMITTED]
// There are interesting things (bugs) here,
// but it is not relevant to the discussion.
continue;
case 0:
firstInClass = false;
if (cursor >= patternLength)
throw error("Unclosed character class");
break;
case ']':
firstInClass = false;
if (prev != null) {
if (consume)
next();
return prev;
}
break;
default:
firstInClass = false;
break;
}
node = range(bits);
if (include) {
if (prev == null) {
prev = node;
} else {
if (prev != node)
prev = union(prev, node);
}
} else {
if (prev == null) {
prev = node.complement();
} else {
if (prev != node)
prev = setDifference(prev, node);
}
}
ch = peek();
}
}
Looking at the code in case '[':
of the switch
statement and the code after the switch
statement:
- The
node
variable stores the result of parsing a unit (a standalone character, a character range, a shorthand character class, a POSIX/Unicode character class or a nested character class) - The
prev
variable stores the compilation result so far, and is always updated right after we compiles a unit innode
.
Since the local variable boolean include
, which records whether the character class is negated, is never passed to any method call, it can only be acted upon in this method alone. And the only place include
is read and processed is after the switch
statement.
Post under construction
According to the JavaDoc page nesting classes produces the union of the two classes, which makes it impossible to create an intersection using that notation:
To create a union, simply nest one class inside the other, such as [0-4[6-8]]. This particular union creates a single character class that matches the numbers 0, 1, 2, 3, 4, 6, 7, and 8.
To create an intersection you will have to use &&
:
To create a single character class matching only the characters common to all of its nested classes, use &&, as in [0-9&&[345]]. This particular intersection creates a single character class matching only the numbers common to both character classes: 3, 4, and 5.
The last part of your problem is still a mystery to me too. The union of [^2]
and [^0-9]
should indeed be [^2]
, so [^2[^0-9]]
behaves as expected. [^[^0-9]2]
behaving like [^0-9]
is indeed strange though.