From 98bfd5f030d1272ef3ebf4548194c295397ed603 Mon Sep 17 00:00:00 2001 From: jemmix Date: Thu, 17 Sep 2026 20:21:52 +0200 Subject: [PATCH] Make simpleFold follow only symmetric case mappings. simpleFold's table-miss fallback follows toLower/toUpper, assuming the mappings form closed two-element orbits with the rune. That holds only for symmetric pairs: runes whose asymmetric case mappings postdate the generated tables' Unicode version (e.g. U+1C80..U+1C88, Cyrillic historic letters, added in Unicode 9.0 vs tables at 6.0) step into the partner's own orbit and never cycle back, hanging Pattern.compile and the match-time fold walks (minFoldRune, appendFoldedRange, equalsIgnoreCase). The fallback now follows a mapping only when the partner maps back (a verified symmetric pair), so such runes are fold-inert and every walk terminates structurally -- no hop cap, control-flow only, zero data changes. Symmetric pairs, including post-6.0 ones reached via the runtime's mappings, fold exactly as before. Adds CaseFoldTerminationTest: every U+1C80..U+1C88 compiles under (?i) (used to hang), the letters are consistently fold-inert in literal and class form in both directions, and symmetric pairs (z/Z, Cyrillic two-element pairs, the three-element K/k/Kelvin-sign orbit) keep folding. Regenerating the tables from current Unicode is the fuller fix (the universe then matches the runtime again); this change makes every walk terminate regardless of table/runtime skew, so it also guards whatever skew the next regeneration misses. --- java/com/google/re2j/Unicode.java | 17 +++- .../google/re2j/CaseFoldTerminationTest.java | 82 +++++++++++++++++++ 2 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 javatests/com/google/re2j/CaseFoldTerminationTest.java diff --git a/java/com/google/re2j/Unicode.java b/java/com/google/re2j/Unicode.java index 8ec0288a..439168c5 100644 --- a/java/com/google/re2j/Unicode.java +++ b/java/com/google/re2j/Unicode.java @@ -115,11 +115,24 @@ static int simpleFold(int r) { // No folding specified. This is a one- or two-element // equivalence class containing rune and toLower(rune) // and toUpper(rune) if they are different from rune. + // A mapping is only followed when the partner maps back: every walk + // that consults simpleFold (class-range folding, literal + // canonicalization, case-insensitive comparison) assumes its + // next-pointer graph cycles back to the walk's start, which holds + // only for symmetric pairs. Runes whose asymmetric case mappings + // postdate the table's Unicode version (e.g. U+1C80..U+1C88, Cyrillic + // historic letters, Unicode 9.0 vs tables at 6.0) would otherwise + // step into the partner's own orbit and never cycle back, hanging + // Pattern.compile; those runes are fold-inert instead. int l = Characters.toLowerCase(r); - if (l != r) { + if (l != r && Characters.toUpperCase(l) == r) { return l; } - return Characters.toUpperCase(r); + int u = Characters.toUpperCase(r); + if (u != r && Characters.toLowerCase(u) == r) { + return u; + } + return r; } // equalsIgnoreCase performs case-insensitive equality comparison diff --git a/javatests/com/google/re2j/CaseFoldTerminationTest.java b/javatests/com/google/re2j/CaseFoldTerminationTest.java new file mode 100644 index 00000000..6b21baca --- /dev/null +++ b/javatests/com/google/re2j/CaseFoldTerminationTest.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2020 The Go Authors. All rights reserved. + * + * Use of this source code is governed by a BSD-style + * license that can be found in the LICENSE file. + */ +package com.google.re2j; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Tests that simple-fold orbit walks terminate and stay shape-consistent for runes whose case + * mappings postdate the generated table's Unicode version. + * + *

+ * {@code Unicode.simpleFold}'s fallback follows {@code toLower}/{@code toUpper} on table misses, + * assuming they form closed two-element orbits with the rune. That holds for symmetric pairs, but + * runes whose asymmetric mappings postdate the table (U+1C80..U+1C88, Cyrillic historic letters, + * Unicode 9.0 vs tables at 6.0) step into the partner's own orbit and never cycle back: compiling + * or matching such patterns used to hang forever. The fallback now follows a mapping only when the + * partner maps back (a verified symmetric pair), so those runes are fold-inert — consistently in + * literal and class form, in both directions — and every walk terminates. + */ +@RunWith(JUnit4.class) +public class CaseFoldTerminationTest { + + private static Matcher matcher(String pattern, String input) { + return Pattern.compile(pattern).matcher(input); + } + + @Test + public void foldWalkTerminatesOnHistoricCyrillicLiterals() { + // Every one of U+1C80..U+1C88 used to hang the compile under (?i). + for (char c = '\u1C80'; c <= '\u1C88'; c++) { + Pattern.compile("(?i)" + c); + } + Pattern.compile("(?i)[\u1C80-\u1C88]"); + } + + @Test + public void historicCyrillicLettersAreFoldInert() { + // The fallback declines the asymmetric mappings, so the letters fold + // nowhere — same answer from every pattern shape, in both directions. + for (char c = '\u1C80'; c <= '\u1C88'; c++) { + assertTrue(matcher("(?i)" + c, String.valueOf(c)).find()); + assertTrue(matcher("(?i)[" + c + "]", String.valueOf(c)).find()); + assertFalse(matcher("(?i)" + c, "x").find()); + } + // U+1C80 (Ꚁ) case-maps onto В/в; none of the three folds onto the others. + assertFalse(matcher("(?i)\u1C80", "\u0412").find()); + assertFalse(matcher("(?i)\u1C80", "\u0432").find()); + assertFalse(matcher("(?i)\u0432", "\u1C80").find()); + assertFalse(matcher("(?i)[\u1C80]", "\u0432").find()); + assertFalse(matcher("(?i)[\u0432]", "\u1C80").find()); + // The partner pair itself keeps folding (symmetric, table-era data). + assertTrue(matcher("(?i)\u0432", "\u0412").find()); + assertTrue(matcher("(?i)\u0412", "\u0432").find()); + } + + @Test + public void symmetricPairsStillFold() { + // Plain two-element orbits via the fallback ... + assertTrue(matcher("(?i)z", "Z").find()); + assertTrue(matcher("(?i)\u0442", "\u0422").find()); + // ... and a three-element orbit from the table (K, k, Kelvin sign). + assertTrue(matcher("(?i)k", "K").find()); + assertTrue(matcher("(?i)k", "\u212A").find()); + assertTrue(matcher("(?i)\u212A", "k").find()); + } + + @Test + public void matchTimeFoldComparisonTerminates() { + // equalsIgnoreCase walks the same orbit graph at match time. + assertTrue(matcher("(?i)\u0442", "\u0442").find()); + assertFalse(matcher("(?i)\u0442", "x").find()); + } +}