001package gudusoft.gsqlparser.sqlenv.unicode; 002 003import java.io.DataInputStream; 004import java.io.IOException; 005import java.io.InputStream; 006 007/** 008 * GSP-pinned Unicode case operations — the JVM-independent fold engine of the 009 * persistent canonical-identity program 010 * (docs/designs/persistent-canonical-identity-implementation-plan.md §3). 011 * 012 * <p>All data comes from the committed binary resource {@code gsp-case-data.bin}, 013 * generated from vendored UCD {@value #UNICODE_VERSION} files. Nothing here consults 014 * {@code String.toUpperCase}/{@code toLowerCase} or any other JVM Unicode table, so the 015 * results are identical on every JVM — the whole point: JDK 8 ships Unicode 6.2 while 016 * JDK 21 ships 15.x, and identifier identity must not depend on which one happens to 017 * run the export. 018 * 019 * <p>Three operations, deliberately nothing else: 020 * <ul> 021 * <li>{@link #toUpperFull}/{@link #toLowerFull} — FULL case mappings (length may 022 * change: {@code ß→SS}), plus Final_Sigma as the single context rule, implemented 023 * per the UCD definition (NOT any JDK's quirk — the JDKs disagree with each other 024 * on Case_Ignorable contexts, so "match the JVM" is not even well-defined). 025 * Locale-conditional rules (tr/az/lt) are excluded by design: no database folds 026 * identifiers by session locale, and including them corrupts catalog identity.</li> 027 * <li>{@link #simpleFoldCollapse} — CaseFolding.txt C+S simple fold, code-point-wise. 028 * Successor of the char-wise {@code caseCollapse} (fixes supplementary planes).</li> 029 * </ul> 030 * 031 * <p>Unpaired surrogates pass through unchanged in every operation — identity must be 032 * total on arbitrary UTF-16, and substitution would merge distinct malformed inputs. 033 * Inputs the operation does not change are returned as the SAME instance, including 034 * surrogate-bearing ones (zero allocation). 035 * 036 * <p>Thread-safe: one immutable table holder, safely published via the class 037 * initializer, whose body is straight-line code delegating to ordinary methods. 038 * (Historical note: this class's first obfuscated build failed verification because 039 * proguard.cfg once disabled preverification while emitting v52 class files — that 040 * affected EVERY branchy method product-wide and was fixed by re-enabling 041 * preverification; the branch-free initializer is retained as cheap hygiene.) 042 * Deployment notes: the resource is looked up relative to this class, so package 043 * RELOCATION (consumer-side shading) relocates the lookup with it, but the relocator 044 * must also move the resource directory; GraalVM native-image users must register 045 * {@code gudusoft/gsqlparser/sqlenv/unicode/gsp-case-data.bin} as a resource. 046 * Missing/corrupt resource fails fast with {@link ExceptionInInitializerError} on 047 * first use. 048 */ 049public final class GspCaseData { 050 051 /** Exact UCD version the tables are generated from. Part of every policyId. */ 052 public static final String UNICODE_VERSION = "16.0.0"; 053 054 /** Bumped on ANY behavioral change of these operations. Part of every policyId. */ 055 public static final int ALGORITHM_REV = 1; 056 057 private static final int SIGMA = 0x03A3; // Σ 058 private static final int FINAL_SIGMA = 0x03C2; // ς 059 060 // value 0 = no mapping (identity); MULTI = consult the sorted arrays. 061 // U+FFFF is a noncharacter and never a mapping target (asserted by the 062 // conformance suite against the audit dump), so it is a safe sentinel. 063 private static final char MULTI = (char) 0xFFFF; 064 065 /** Immutable table holder — loaded by ordinary (verifiable) methods; the 066 * initializer below is pure straight-line code (no branches, no frames), and the 067 * static-final aliases restore JIT constant folding lost to instance-field 068 * indirection. */ 069 private static final Data D = Data.load(); 070 private static final int[] UPPER_KEYS = D.upperKeys; 071 private static final String[] UPPER_VALUES = D.upperValues; 072 private static final int[] LOWER_KEYS = D.lowerKeys; 073 private static final String[] LOWER_VALUES = D.lowerValues; 074 private static final int[] FOLD_KEYS = D.foldKeys; 075 private static final String[] FOLD_VALUE_STRINGS = D.foldValueStrings; 076 private static final int[] UPPER_BLOCKS = D.upperBlocks; 077 private static final int[] LOWER_BLOCKS = D.lowerBlocks; 078 private static final int[] FOLD_BLOCKS = D.foldBlocks; 079 080 /** 081 * Reusable mapping buffer for the 1:1 fast path (U5 fork-pair benchmark: 082 * the per-call {@code char[n]} plus {@code String}'s internal copy tripled 083 * allocation vs the JDK's in-{@code java.lang} zero-copy path). The buffer 084 * never escapes — {@code new String(buf, 0, n)} copies — and {@code map} 085 * neither recurses nor calls back into itself, so per-thread reuse is 086 * safe. Inputs longer than the buffer allocate as before. 087 */ 088 private static final int SCRATCH_LIMIT = 256; 089 private static final ThreadLocal<char[]> SCRATCH = new ThreadLocal<char[]>() { 090 @Override 091 protected char[] initialValue() { 092 return new char[SCRATCH_LIMIT]; 093 } 094 }; 095 private static final char[] UPPER_BMP = D.upperBmp; 096 private static final char[] LOWER_BMP = D.lowerBmp; 097 private static final char[] FOLD_BMP = D.foldBmp; 098 private static final int[] CASED_STARTS = D.casedStarts; 099 private static final int[] CASED_ENDS = D.casedEnds; 100 private static final int[] IGNORABLE_STARTS = D.ignorableStarts; 101 private static final int[] IGNORABLE_ENDS = D.ignorableEnds; 102 103 private GspCaseData() { 104 } 105 106 /** Full uppercase mapping (unconditional UCD data; length may grow, e.g. ß→SS). */ 107 public static String toUpperFull(String s) { 108 return map(s, UPPER_BMP, UPPER_KEYS, UPPER_BLOCKS, UPPER_VALUES, false); 109 } 110 111 /** 112 * Full lowercase mapping with the UCD Final_Sigma rule: Σ lowers to ς iff preceded 113 * by a Cased code point (skipping Case_Ignorable) and NOT followed by one (skipping 114 * Case_Ignorable). Everything else uses unconditional data. 115 */ 116 public static String toLowerFull(String s) { 117 return map(s, LOWER_BMP, LOWER_KEYS, LOWER_BLOCKS, LOWER_VALUES, true); 118 } 119 120 /** 121 * CaseFolding C+S simple fold applied per code point — the canonical-equality 122 * collapse for INSENSITIVE + no-fold cells. Code-point-wise, so supplementary-plane 123 * pairs fold correctly (unlike the char-wise legacy collapse). 124 */ 125 public static String simpleFoldCollapse(String s) { 126 return map(s, FOLD_BMP, FOLD_KEYS, FOLD_BLOCKS, FOLD_VALUE_STRINGS, false); 127 } 128 129 // ---- the mapper: ASCII tight path, then general code-point path ---- 130 131 /** 132 * One mapper for all three operations; {@code finalSigma} enables the Σ context 133 * rule (lowercasing only). Two-tier shape (U5 gate 3): fixed points (nothing 134 * maps — the dominant identifier case) return the SAME instance with zero 135 * allocation, including surrogate-bearing inputs (valid pairs are probed 136 * against the supplementary keys; unpaired units are passthrough by policy). 137 * Strings whose mappings are all 1:1 in UTF-16 units — BMP single-char 138 * mappings, supplementary pairs mapping to one code point, and Σ (σ and ς are 139 * both one unit; the context scan reads the ORIGINAL string, so mapped output 140 * never affects it) — are built in a fixed {@code char[n]}. Only a length- 141 * changing mapping (MULTI expansions like ß→SS) drops to the StringBuilder 142 * walk, resuming exactly where the fast loop stopped. 143 */ 144 private static String map(String s, char[] bmp, int[] keys, int[] blocks, 145 String[] values, boolean finalSigma) { 146 if (s == null || s.isEmpty()) { 147 return s; 148 } 149 int n = s.length(); 150 int first = firstMappedIndex(s, 0, bmp, keys, blocks); 151 if (first == n) { 152 return s; // fixed point: same instance, zero allocation 153 } 154 char[] out = n <= SCRATCH_LIMIT ? SCRATCH.get() : new char[n]; 155 s.getChars(0, first, out, 0); 156 int j = first; 157 while (j < n) { 158 char d = s.charAt(j); 159 if (d < Character.MIN_SURROGATE || d > Character.MAX_SURROGATE) { 160 if (finalSigma && d == (char) SIGMA && isFinalSigmaContext(s, j)) { 161 out[j] = (char) FINAL_SIGMA; 162 j++; 163 continue; 164 } 165 char md = bmp[d]; 166 if (md == MULTI) { 167 break; // length-changing expansion: finish on the slow path 168 } 169 out[j] = md == 0 ? d : md; 170 j++; 171 continue; 172 } 173 int cp = codePointOrUnit(s, j); 174 if (cp <= 0xFFFF) { 175 out[j] = d; // unpaired surrogate passthrough 176 j++; 177 continue; 178 } 179 int idx = lookup(keys, blocks, cp); 180 if (idx < 0) { 181 out[j] = d; 182 out[j + 1] = s.charAt(j + 1); 183 j += 2; 184 continue; 185 } 186 String v = values[idx]; 187 if (v.length() != 2) { 188 break; // supplementary mapping that changes unit count: slow path 189 } 190 out[j] = v.charAt(0); 191 out[j + 1] = v.charAt(1); 192 j += 2; 193 } 194 if (j == n) { 195 return new String(out, 0, n); 196 } 197 StringBuilder b = new StringBuilder(n + 4); 198 b.append(out, 0, j); 199 return mapTail(s, j, b, bmp, keys, blocks, values, finalSigma); 200 } 201 202 /** 203 * Code-point lookup in a sorted keys table via its per-256-codepoint block 204 * index: jump straight to the (contiguous, cache-local) block slice and 205 * binary-search only inside it — replaces full-table binary search, whose 206 * ~11 scattered probes dominated supplementary-plane rows in the U5 207 * fork-pair benchmark (the "sparse per-plane lookup" review round 2 208 * suggested). 209 */ 210 private static int lookup(int[] keys, int[] blocks, int cp) { 211 int b = cp >>> 8; 212 return java.util.Arrays.binarySearch(keys, blocks[b], blocks[b + 1], cp); 213 } 214 215 /** General walk from {@code i} appending to {@code b} (the pre-{@code i} text is 216 * already appended). Handles MULTI expansions, supplementary keys, unpaired 217 * surrogates, and — when {@code finalSigma} — the Σ context rule. */ 218 private static String mapTail(String s, int i, StringBuilder b, 219 char[] bmp, int[] keys, int[] blocks, 220 String[] values, boolean finalSigma) { 221 int n = s.length(); 222 while (i < n) { 223 char c = s.charAt(i); 224 if (c < Character.MIN_SURROGATE || c > Character.MAX_SURROGATE) { 225 if (finalSigma && c == (char) SIGMA && isFinalSigmaContext(s, i)) { 226 b.append((char) FINAL_SIGMA); 227 i++; 228 continue; 229 } 230 char m = bmp[c]; 231 if (m == 0) { 232 b.append(c); 233 } else if (m != MULTI) { 234 b.append(m); 235 } else { 236 b.append(values[lookup(keys, blocks, c)]); 237 } 238 i++; 239 continue; 240 } 241 int cp = codePointOrUnit(s, i); 242 if (cp > 0xFFFF) { 243 int idx = lookup(keys, blocks, cp); 244 if (idx >= 0) { 245 b.append(values[idx]); 246 } else { 247 b.appendCodePoint(cp); 248 } 249 i += 2; 250 } else { 251 b.append((char) cp); // unpaired surrogate passthrough 252 i++; 253 } 254 } 255 return b.toString(); 256 } 257 258 /** 259 * Index of the first UTF-16 position at or after {@code from} whose content the 260 * operation would map, or {@code s.length()} when the rest is a fixed point. 261 * Σ needs no special case: {@code LOWER_BMP[Σ]} is non-zero (σ), so a Σ always 262 * reports as mapped and the tail walk decides between σ and ς. 263 */ 264 private static int firstMappedIndex(String s, int from, char[] bmp, int[] keys, 265 int[] blocks) { 266 int n = s.length(); 267 int i = from; 268 while (i < n) { 269 char c = s.charAt(i); 270 if (c < Character.MIN_SURROGATE || c > Character.MAX_SURROGATE) { 271 if (bmp[c] != 0) { 272 return i; 273 } 274 i++; 275 continue; 276 } 277 int cp = codePointOrUnit(s, i); 278 if (cp > 0xFFFF) { 279 if (lookup(keys, blocks, cp) >= 0) { 280 return i; 281 } 282 i += 2; 283 } else { 284 i++; // unpaired surrogate: passthrough, keep scanning 285 } 286 } 287 return n; 288 } 289 290 // ---- Final_Sigma context (UCD definition, Cased/Case_Ignorable from DCP) ---- 291 292 private static boolean isFinalSigmaContext(String s, int sigmaIndex) { 293 // Before: scanning backwards, skip Case_Ignorable; require Cased. Note the 294 // sets OVERLAP (U+0345 is both) — per the spec's possessive skipping, a 295 // code point that is Case_Ignorable is skipped regardless of Cased-ness. 296 int i = sigmaIndex; 297 boolean casedBefore = false; 298 while (i > 0) { 299 int cp = codePointBefore(s, i); 300 i -= cp > 0xFFFF ? 2 : 1; 301 if (isCaseIgnorable(cp)) { 302 continue; 303 } 304 casedBefore = isCased(cp); 305 break; 306 } 307 if (!casedBefore) { 308 return false; 309 } 310 // After: scanning forwards, skip Case_Ignorable; require NOT Cased. 311 i = sigmaIndex + 1; // Σ is a BMP char 312 while (i < s.length()) { 313 int cp = codePointOrUnit(s, i); 314 i += cp > 0xFFFF ? 2 : 1; 315 if (isCaseIgnorable(cp)) { 316 continue; 317 } 318 return !isCased(cp); 319 } 320 return true; // end of string after skipping ignorables 321 } 322 323 static boolean isCased(int cp) { 324 return inRanges(cp, CASED_STARTS, CASED_ENDS); 325 } 326 327 static boolean isCaseIgnorable(int cp) { 328 return inRanges(cp, IGNORABLE_STARTS, IGNORABLE_ENDS); 329 } 330 331 private static boolean inRanges(int cp, int[] starts, int[] ends) { 332 int idx = java.util.Arrays.binarySearch(starts, cp); 333 if (idx >= 0) { 334 return true; 335 } 336 int insertion = -idx - 2; // candidate range that starts before cp 337 return insertion >= 0 && cp <= ends[insertion]; 338 } 339 340 // ---- UTF-16 walking that is total on malformed input ---- 341 342 /** Code point at i, or the lone surrogate unit itself when unpaired. */ 343 private static int codePointOrUnit(String s, int i) { 344 char c = s.charAt(i); 345 if (Character.isHighSurrogate(c) && i + 1 < s.length() 346 && Character.isLowSurrogate(s.charAt(i + 1))) { 347 return Character.toCodePoint(c, s.charAt(i + 1)); 348 } 349 return c; // BMP char, or unpaired surrogate passed through as its unit value 350 } 351 352 private static int codePointBefore(String s, int i) { 353 char c = s.charAt(i - 1); 354 if (Character.isLowSurrogate(c) && i - 2 >= 0 355 && Character.isHighSurrogate(s.charAt(i - 2))) { 356 return Character.toCodePoint(s.charAt(i - 2), c); 357 } 358 return c; 359 } 360 361 // ---- the table holder: loaded by ordinary (verifiable) methods ---- 362 363 private static final class Data { 364 final int[] upperKeys; 365 final String[] upperValues; 366 final int[] lowerKeys; 367 final String[] lowerValues; 368 final int[] foldKeys; 369 final String[] foldValueStrings; 370 final char[] upperBmp = new char[0x10000]; 371 final char[] lowerBmp = new char[0x10000]; 372 final char[] foldBmp = new char[0x10000]; 373 final int[] casedStarts; 374 final int[] casedEnds; 375 final int[] ignorableStarts; 376 final int[] ignorableEnds; 377 final int[] upperBlocks; 378 final int[] lowerBlocks; 379 final int[] foldBlocks; 380 381 private Data(int[] upperKeys, String[] upperValues, 382 int[] lowerKeys, String[] lowerValues, 383 int[] foldKeys, int[] foldValues, 384 int[] casedStarts, int[] casedEnds, 385 int[] ignorableStarts, int[] ignorableEnds) { 386 this.upperKeys = upperKeys; 387 this.upperValues = upperValues; 388 this.lowerKeys = lowerKeys; 389 this.lowerValues = lowerValues; 390 this.foldKeys = foldKeys; 391 this.casedStarts = casedStarts; 392 this.casedEnds = casedEnds; 393 this.ignorableStarts = ignorableStarts; 394 this.ignorableEnds = ignorableEnds; 395 this.foldValueStrings = new String[foldValues.length]; 396 for (int i = 0; i < foldValues.length; i++) { 397 StringBuilder fv = new StringBuilder(2); 398 fv.appendCodePoint(foldValues[i]); 399 this.foldValueStrings[i] = fv.toString(); 400 } 401 deriveBmp(upperKeys, upperValues, upperBmp); 402 deriveBmp(lowerKeys, lowerValues, lowerBmp); 403 for (int i = 0; i < foldKeys.length; i++) { 404 if (foldKeys[i] <= 0xFFFF) { 405 foldBmp[foldKeys[i]] = foldValues[i] <= 0xFFFF 406 ? (char) foldValues[i] : MULTI; 407 } 408 } 409 this.upperBlocks = blockIndex(upperKeys); 410 this.lowerBlocks = blockIndex(lowerKeys); 411 this.foldBlocks = blockIndex(foldKeys); 412 } 413 414 /** blocks[b] = index of the first key >= b<<8; blocks[b+1] bounds the 415 * slice, so a lookup binary-searches only its own 256-codepoint block. */ 416 private static int[] blockIndex(int[] keys) { 417 int blockCount = (0x110000 >>> 8); 418 int[] blocks = new int[blockCount + 1]; 419 int k = 0; 420 for (int b = 0; b <= blockCount; b++) { 421 int floor = b << 8; 422 while (k < keys.length && keys[k] < floor) { 423 k++; 424 } 425 blocks[b] = k; 426 } 427 return blocks; 428 } 429 430 private static void deriveBmp(int[] keys, String[] values, char[] bmp) { 431 for (int i = 0; i < keys.length; i++) { 432 if (keys[i] <= 0xFFFF) { 433 String v = values[i]; 434 bmp[keys[i]] = v.length() == 1 ? v.charAt(0) : MULTI; 435 } 436 } 437 } 438 439 /** All loading logic lives here, in ordinary verifiable methods. */ 440 static Data load() { 441 InputStream raw = GspCaseData.class.getResourceAsStream("gsp-case-data.bin"); 442 if (raw == null) { 443 throw new IllegalStateException("Missing case-data resource " 444 + "gsp-case-data.bin next to " + GspCaseData.class.getName()); 445 } 446 try { 447 DataInputStream in = new DataInputStream(raw); 448 try { 449 byte[] magic = new byte[8]; 450 in.readFully(magic); 451 if (!"GSPCASE1".equals(new String(magic, "US-ASCII"))) { 452 throw new IllegalStateException("Bad case-data magic"); 453 } 454 String version = in.readUTF(); 455 if (!UNICODE_VERSION.equals(version)) { 456 throw new IllegalStateException("Case-data version " + version 457 + " does not match compiled-in " + UNICODE_VERSION); 458 } 459 int rev = in.readInt(); 460 if (rev != ALGORITHM_REV) { 461 throw new IllegalStateException("Case-data algorithm rev " + rev 462 + " does not match compiled-in " + ALGORITHM_REV); 463 } 464 int n = in.readInt(); 465 int[] upperKeys = new int[n]; 466 String[] upperValues = new String[n]; 467 readMappingTable(in, n, upperKeys, upperValues); 468 n = in.readInt(); 469 int[] lowerKeys = new int[n]; 470 String[] lowerValues = new String[n]; 471 readMappingTable(in, n, lowerKeys, lowerValues); 472 n = in.readInt(); 473 int[] foldKeys = new int[n]; 474 int[] foldValues = new int[n]; 475 for (int i = 0; i < n; i++) { 476 foldKeys[i] = in.readInt(); 477 foldValues[i] = in.readInt(); 478 } 479 n = in.readInt(); 480 int[] casedStarts = new int[n]; 481 int[] casedEnds = new int[n]; 482 for (int i = 0; i < n; i++) { 483 casedStarts[i] = in.readInt(); 484 casedEnds[i] = in.readInt(); 485 } 486 n = in.readInt(); 487 int[] ignorableStarts = new int[n]; 488 int[] ignorableEnds = new int[n]; 489 for (int i = 0; i < n; i++) { 490 ignorableStarts[i] = in.readInt(); 491 ignorableEnds[i] = in.readInt(); 492 } 493 return new Data(upperKeys, upperValues, lowerKeys, lowerValues, 494 foldKeys, foldValues, casedStarts, casedEnds, 495 ignorableStarts, ignorableEnds); 496 } finally { 497 in.close(); 498 } 499 } catch (IOException e) { 500 throw new IllegalStateException("Failed to load gsp-case-data.bin", e); 501 } 502 } 503 504 private static void readMappingTable(DataInputStream in, int n, 505 int[] keys, String[] values) throws IOException { 506 for (int i = 0; i < n; i++) { 507 keys[i] = in.readInt(); 508 int len = in.readUnsignedByte(); 509 char[] chars = new char[len]; 510 for (int j = 0; j < len; j++) { 511 chars[j] = in.readChar(); 512 } 513 values[i] = new String(chars); 514 } 515 } 516 } 517}