Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 48 additions & 46 deletions src/main/java/org/perlonjava/runtime/regex/MultiCharFoldMapper.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
package org.perlonjava.runtime.regex;

import com.ibm.icu.lang.UCharacter;
import com.ibm.icu.lang.UProperty;
import com.ibm.icu.text.UnicodeSet;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;

/**
Expand All @@ -16,50 +24,29 @@ public class MultiCharFoldMapper {
private static final Map<Integer, String> MULTI_CHAR_FOLDS = new HashMap<>();

// Reverse map: fold string → List of characters that fold to it
// Format: fold string → character
private static final Map<String, Integer> REVERSE_FOLDS = new HashMap<>();
// Format: fold string → characters
private static final Map<String, List<Integer>> REVERSE_FOLDS = new HashMap<>();
private static final Set<Integer> FOLD_COMPONENTS = new HashSet<>();

static {
// Latin
MULTI_CHAR_FOLDS.put(0x00DF, "ss"); // ß → ss
MULTI_CHAR_FOLDS.put(0x0130, "i\u0307"); // İ → i̇ (i + combining dot above)
MULTI_CHAR_FOLDS.put(0x0149, "\u02BCn"); // ʼn → ʼn
MULTI_CHAR_FOLDS.put(0x01F0, "j\u030C"); // ǰ → ǰ
MULTI_CHAR_FOLDS.put(0x1E96, "h\u0331"); // ẖ → ẖ
MULTI_CHAR_FOLDS.put(0x1E97, "t\u0308"); // ẗ → ẗ
MULTI_CHAR_FOLDS.put(0x1E98, "w\u030A"); // ẘ → ẘ
MULTI_CHAR_FOLDS.put(0x1E99, "y\u030A"); // ẙ → ẙ
MULTI_CHAR_FOLDS.put(0x1E9A, "a\u02BE"); // ẚ → aʾ
MULTI_CHAR_FOLDS.put(0x1E9B, "\u017Fs"); // ẛ → ẛ
MULTI_CHAR_FOLDS.put(0x1F50, "\u03C5\u0313"); // ὐ → ὐ

// Greek
MULTI_CHAR_FOLDS.put(0x0390, "\u03B9\u0308\u0301"); // ΐ → ΐ
MULTI_CHAR_FOLDS.put(0x03B0, "\u03C5\u0308\u0301"); // ΰ → ΰ

// Armenian
MULTI_CHAR_FOLDS.put(0x0587, "\u0565\u0582"); // և → եւ

// Latin ligatures
MULTI_CHAR_FOLDS.put(0xFB00, "ff"); // ff → ff
MULTI_CHAR_FOLDS.put(0xFB01, "fi"); // fi → fi
MULTI_CHAR_FOLDS.put(0xFB02, "fl"); // fl → fl
MULTI_CHAR_FOLDS.put(0xFB03, "ffi"); // ffi → ffi
MULTI_CHAR_FOLDS.put(0xFB04, "ffl"); // ffl → ffl
MULTI_CHAR_FOLDS.put(0xFB05, "\u017Ft"); // ſt → ſt
MULTI_CHAR_FOLDS.put(0xFB06, "st"); // st → st

// Armenian ligatures
MULTI_CHAR_FOLDS.put(0xFB13, "\u0574\u0576"); // ﬓ → մն
MULTI_CHAR_FOLDS.put(0xFB14, "\u0574\u0565"); // ﬔ → մե
MULTI_CHAR_FOLDS.put(0xFB15, "\u0574\u056B"); // ﬕ → մի
MULTI_CHAR_FOLDS.put(0xFB16, "\u057E\u0576"); // ﬖ → վն
MULTI_CHAR_FOLDS.put(0xFB17, "\u0574\u056D"); // ﬗ → մխ
// ICU4J is already the runtime's Unicode source of truth. Derive all
// full folds instead of maintaining a partial hand-written table.
UnicodeSet foldCandidates = new UnicodeSet()
.applyIntPropertyValue(UProperty.CHANGES_WHEN_CASEFOLDED, 1);
for (String original : foldCandidates) {
if (original.codePointCount(0, original.length()) != 1) continue;
int codePoint = original.codePointAt(0);
String fold = UCharacter.foldCase(original, true);
if (fold.codePointCount(0, fold.length()) > 1) {
MULTI_CHAR_FOLDS.put(codePoint, fold);
}
}

// Build reverse map (lowercase versions only for simpler matching)
for (Map.Entry<Integer, String> entry : MULTI_CHAR_FOLDS.entrySet()) {
String fold = entry.getValue().toLowerCase();
REVERSE_FOLDS.put(fold, entry.getKey());
String fold = entry.getValue();
REVERSE_FOLDS.computeIfAbsent(fold, ignored -> new ArrayList<>()).add(entry.getKey());
fold.codePoints().forEach(FOLD_COMPONENTS::add);
}
}

Expand Down Expand Up @@ -134,19 +121,34 @@ public static String expandToAlternation(int codePoint) {
* @return true if a character folds to this string
*/
public static boolean hasReverseFold(String str) {
return REVERSE_FOLDS.containsKey(str.toLowerCase());
return REVERSE_FOLDS.containsKey(UCharacter.foldCase(str, true));
}

/**
* Get the character that folds to this string.
* For example: "ss" → ß
* Get the characters that fold to this string.
* For example: "ss" → [ß, ẞ]
*
* @param str The string to look up (will be lowercased)
* @return The character code point, or null if no reverse fold exists
* @return The character code points, or an empty list if no reverse fold exists
*/
public static Integer getReverseFold(String str) {
return REVERSE_FOLDS.get(str.toLowerCase());
public static List<Integer> getReverseFolds(String str) {
List<Integer> folds = REVERSE_FOLDS.get(UCharacter.foldCase(str, true));
return folds == null ? List.of() : folds;
}
}

/** Whether a literal code point can participate in a reverse multi-char fold. */
public static boolean isFoldComponent(int codePoint) {
if (FOLD_COMPONENTS.contains(codePoint)) {
return true;
}

// Escaped literals can themselves have a simple fold into a component
// of a full fold. For example, U+017F (long s) folds to "s", and two
// escaped long-s characters must therefore be eligible for the same
// reverse expansion as "ss" (including U+00DF).
String original = new String(Character.toChars(codePoint));
String folded = UCharacter.foldCase(original, true);
return folded.codePointCount(0, folded.length()) == 1
&& FOLD_COMPONENTS.contains(folded.codePointAt(0));
}
}
28 changes: 20 additions & 8 deletions src/main/java/org/perlonjava/runtime/regex/RegexFlags.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@
* @param preservesMatch p flag - preserve match after failed matches
* @param isUnicode u flag - Unicode semantics (\w, \d, \s match Unicode)
* @param isAscii a flag - ASCII-restrict (\w, \d, \s match only ASCII)
* @param isAsciiStrict aa flags - also forbid ASCII/non-ASCII case-fold crossings
*/
public record RegexFlags(boolean isGlobalMatch, boolean keepCurrentPosition, boolean isNonDestructive,
boolean isMatchExactlyOnce, boolean useGAssertion, boolean isExtendedWhitespace,
boolean isNonCapturing, boolean isOptimized, boolean isCaseInsensitive, boolean isMultiLine,
boolean isDotAll, boolean isExtended, boolean preservesMatch, boolean isUnicode,
boolean isAscii, boolean allowEvalGroup, boolean taintResults) {
boolean isAscii, boolean isAsciiStrict, boolean allowEvalGroup, boolean taintResults) {

public static RegexFlags fromModifiers(String modifiers, String patternString) {
// m?PAT? is encoded by StringParser as an extra trailing '?' on the modifier string
Expand All @@ -48,6 +49,8 @@ public static RegexFlags fromModifiers(String modifiers, String patternString) {
modifiers.contains("p"),
modifiers.contains("u"),
modifiers.contains("a"),
modifiers.indexOf('a') >= 0
&& modifiers.indexOf('a', modifiers.indexOf('a') + 1) >= 0,
modifiers.contains("E"),
modifiers.contains("T")
);
Expand Down Expand Up @@ -87,9 +90,9 @@ public int toPatternFlags() {

if (isCaseInsensitive) {
flags |= CASE_INSENSITIVE;
// For Unicode case-insensitive matching, add UNICODE_CASE
// But NOT if /a flag (ASCII-restrict) is set - /a restricts case folding to ASCII
if (!isAscii) {
// /a restricts character classes such as \w but retains Unicode
// case folding. Only /aa forbids ASCII/non-ASCII fold crossings.
if (!isAsciiStrict) {
flags |= UNICODE_CASE;
}
}
Expand All @@ -114,6 +117,7 @@ public RegexFlags with(String positiveFlags, String negativeFlags) {
boolean newPreservesMatch = this.preservesMatch;
boolean newIsUnicode = this.isUnicode;
boolean newIsAscii = this.isAscii;
boolean newIsAsciiStrict = this.isAsciiStrict;

// Handle positive flags
if (positiveFlags.indexOf('n') >= 0) newFlagN = true;
Expand All @@ -123,7 +127,11 @@ public RegexFlags with(String positiveFlags, String negativeFlags) {
if (positiveFlags.indexOf('x') >= 0) newIsExtended = true;
if (positiveFlags.indexOf('p') >= 0) newPreservesMatch = true;
if (positiveFlags.indexOf('u') >= 0) newIsUnicode = true;
if (positiveFlags.indexOf('a') >= 0) newIsAscii = true;
if (positiveFlags.indexOf('a') >= 0) {
newIsAscii = true;
int firstA = positiveFlags.indexOf('a');
if (positiveFlags.indexOf('a', firstA + 1) >= 0) newIsAsciiStrict = true;
}

// Handle negative flags
if (negativeFlags.indexOf('n') >= 0) newFlagN = false;
Expand All @@ -132,7 +140,10 @@ public RegexFlags with(String positiveFlags, String negativeFlags) {
if (negativeFlags.indexOf('s') >= 0) newIsDotAll = false;
if (negativeFlags.indexOf('x') >= 0) newIsExtended = false;
if (negativeFlags.indexOf('u') >= 0) newIsUnicode = false;
if (negativeFlags.indexOf('a') >= 0) newIsAscii = false;
if (negativeFlags.indexOf('a') >= 0) {
newIsAscii = false;
newIsAsciiStrict = false;
}

return new RegexFlags(
this.isGlobalMatch,
Expand All @@ -150,6 +161,7 @@ public RegexFlags with(String positiveFlags, String negativeFlags) {
newPreservesMatch,
newIsUnicode,
newIsAscii,
newIsAsciiStrict,
this.allowEvalGroup,
this.taintResults
);
Expand All @@ -160,7 +172,7 @@ public String toFlagString() {

if (isGlobalMatch) flagString.append('g');
if (preservesMatch) flagString.append('p');
if (isAscii) flagString.append('a');
if (isAscii) flagString.append(isAsciiStrict ? "aa" : "a");
if (isUnicode) flagString.append('u');
if (isMultiLine) flagString.append('m');
if (isDotAll) flagString.append('s');
Expand All @@ -180,7 +192,7 @@ public String toFlagString() {
*/
public String toModifierString() {
StringBuilder sb = new StringBuilder();
if (isAscii) sb.append('a');
if (isAscii) sb.append(isAsciiStrict ? "aa" : "a");
if (isUnicode) sb.append('u');
if (isMultiLine) sb.append('m');
if (isDotAll) sb.append('s');
Expand Down
119 changes: 110 additions & 9 deletions src/main/java/org/perlonjava/runtime/regex/RegexPreprocessor.java
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,9 @@ private static String preProcessRegexInternal(String s, RegexFlags regexFlags) {
s = optimizeTerminatedLazyNegatedClasses(s);

// Expand multi-character case folds when case-insensitive flag is set
if (regexFlags.isCaseInsensitive()) {
s = expandMultiCharFolds(s);
if (regexFlags.isCaseInsensitive() && !regexFlags.isAsciiStrict()
&& !containsInlineAsciiStrictModifier(s)) {
s = expandMultiCharFolds(materializeFoldableHexEscapes(s), regexFlags);
}

StringBuilder sb = new StringBuilder();
Expand Down Expand Up @@ -781,7 +782,7 @@ private static boolean closesValidQuantifier(StringBuilder result, String patter
* For example: ß → (?:ß|ss|SS|Ss|sS)
* This is needed because Java's UNICODE_CASE flag doesn't handle multi-char folds.
*/
private static String expandMultiCharFolds(String pattern) {
private static String expandMultiCharFolds(String pattern, RegexFlags regexFlags) {
StringBuilder result = new StringBuilder();
int i = 0;
int len = pattern.length();
Expand Down Expand Up @@ -823,6 +824,16 @@ private static String expandMultiCharFolds(String pattern) {
// Track if we're inside a character class [...]
if (!escaped) {
if (ch == '[') {
int classEnd = findRegularCharacterClassEnd(pattern, i);
if (classEnd > i) {
String classExpansion = expandMultiCharFoldClass(
pattern.substring(i, classEnd + 1), regexFlags);
if (classExpansion != null) {
result.append(classExpansion);
i = classEnd + 1;
continue;
}
}
inCharClass = true;
} else if (ch == ']') {
inCharClass = false;
Expand Down Expand Up @@ -956,7 +967,7 @@ private static String expandMultiCharFolds(String pattern) {

// Check if this character has a multi-char fold
int codePoint = pattern.codePointAt(i);
if (MultiCharFoldMapper.hasMultiCharFold(codePoint)) {
if (!regexFlags.isAsciiStrict() && MultiCharFoldMapper.hasMultiCharFold(codePoint)) {
// Expand it (e.g., ß → (?:ß|ss|SS|Ss|sS))
String expansion = MultiCharFoldMapper.expandToAlternation(codePoint);
result.append(expansion);
Expand All @@ -968,13 +979,14 @@ private static String expandMultiCharFolds(String pattern) {
for (int seqLen = 3; seqLen >= 2 && !foundReverseFold; seqLen--) {
if (i + seqLen <= len) {
String sequence = pattern.substring(i, i + seqLen);
if (MultiCharFoldMapper.hasReverseFold(sequence)) {
Integer reverseFoldChar = MultiCharFoldMapper.getReverseFold(sequence);
if (!regexFlags.isAsciiStrict() && MultiCharFoldMapper.hasReverseFold(sequence)) {
// Expand to include both the sequence and its reverse fold
result.append("(?:");
result.append(sequence);
result.append("|");
result.appendCodePoint(reverseFoldChar);
for (int reverseFoldChar : MultiCharFoldMapper.getReverseFolds(sequence)) {
result.append("|");
result.appendCodePoint(reverseFoldChar);
}
result.append(")");
i += seqLen;
foundReverseFold = true;
Expand All @@ -983,7 +995,8 @@ private static String expandMultiCharFolds(String pattern) {
}

if (!foundReverseFold) {
String specialExpansion = expandSpecialSingleCharFold(codePoint);
String specialExpansion = regexFlags.isAsciiStrict()
? null : expandSpecialSingleCharFold(codePoint);
if (specialExpansion != null) {
result.append(specialExpansion);
} else {
Expand All @@ -999,6 +1012,94 @@ private static String expandMultiCharFolds(String pattern) {
return result.toString();
}

private static String expandMultiCharFoldClass(String charClass, RegexFlags regexFlags) {
if (regexFlags.isAsciiStrict() || charClass.length() < 3 || charClass.charAt(1) == '^') return null;

LinkedHashSet<String> folds = new LinkedHashSet<>();
boolean escaped = false;
for (int i = 1; i < charClass.length() - 1; ) {
int codePoint = charClass.codePointAt(i);
if (!escaped && codePoint == '\\') {
escaped = true;
i++;
continue;
}
if (escaped) {
escaped = false;
i += Character.charCount(codePoint);
continue;
}
if (codePoint == '-' || codePoint == '[' || codePoint == ']') return null;
String fold = MultiCharFoldMapper.getMultiCharFold(codePoint);
if (fold != null) folds.add(Pattern.quote(fold));
i += Character.charCount(codePoint);
}
if (folds.isEmpty()) return null;

StringBuilder expansion = new StringBuilder("(?:").append(charClass);
for (String fold : folds) expansion.append('|').append(fold);
return expansion.append(')').toString();
}

/**
* Materialize only hex escapes relevant to full case folds. Keeping all
* other escapes intact avoids turning escaped regex metacharacters into
* syntax while allowing sequences such as \x{73}\x{73} to be recognized
* as the reverse fold of sharp-s.
*/
private static String materializeFoldableHexEscapes(String pattern) {
StringBuilder result = new StringBuilder(pattern.length());
for (int i = 0; i < pattern.length(); ) {
if (pattern.charAt(i) == '\\' && i + 3 < pattern.length()
&& pattern.charAt(i + 1) == 'x' && pattern.charAt(i + 2) == '{') {
int close = pattern.indexOf('}', i + 3);
if (close > i + 3) {
try {
int codePoint = Integer.parseInt(pattern.substring(i + 3, close), 16);
if (Character.isValidCodePoint(codePoint)
&& (MultiCharFoldMapper.hasMultiCharFold(codePoint)
|| MultiCharFoldMapper.isFoldComponent(codePoint))) {
result.appendCodePoint(codePoint);
i = close + 1;
continue;
}
} catch (NumberFormatException ignored) {
// Leave malformed escapes to the normal regex parser.
}
}
}
int codePoint = pattern.codePointAt(i);
result.appendCodePoint(codePoint);
i += Character.charCount(codePoint);
}
return result.toString();
}

/**
* Full-fold rewriting currently operates on the whole pattern. Avoid
* applying it across a scoped (?aa:...) boundary, where Perl forbids
* ASCII/non-ASCII fold crossings. The regular parser still handles the
* scoped modifier itself.
*/
private static boolean containsInlineAsciiStrictModifier(String pattern) {
for (int i = 0; i + 2 < pattern.length(); i++) {
if (pattern.charAt(i) != '(' || pattern.charAt(i + 1) != '?') continue;
int asciiModifiers = 0;
for (int j = i + 2; j < pattern.length(); j++) {
char modifier = pattern.charAt(j);
if (modifier == 'a') {
asciiModifiers++;
if (asciiModifiers == 2) return true;
} else if (modifier == ':' || modifier == ')' || modifier == '-') {
break;
} else if (!Character.isLetter(modifier) && modifier != '^') {
break;
}
}
}
return false;
}

private static String expandSpecialSingleCharFold(int codePoint) {
// Compute full case fold for this code point.
int folded = UCharacter.foldCase(codePoint, true);
Expand Down
Loading
Loading