From 759ec5a90105fa1c0cef01883136b4534dd8218c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 16:22:51 +0000 Subject: [PATCH] fix(data): correct TOON escape decoding and double-hashing step for MIN_VALUE Two latent correctness bugs in the data module: - ToonSyntax.processEscapeSequences decoded escapes with a chain of String.replace calls whose order let an already-unescaped backslash be reinterpreted: a value such as "a\\nb" (escaped backslash + literal n) wrongly became a newline instead of a backslash followed by n. Replace the chained substitutions with a single left-to-right pass that consumes each escape exactly once and leaves unknown escapes untouched. - DoubleHashing.probe derived the probe step from Math.abs(hashCode), which stays negative for Integer.MIN_VALUE and produced a non-positive step that folded the probe sequence onto a subset of slots (e.g. 12 of 13 on a prime-sized table). Use Math.abs(hashCode % (length - 1)), which is identical for every other hashCode but overflow-safe for MIN_VALUE. Add regression tests for both. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PUdwoDrPdkLATaTkPcnpm3 --- .../tools/data/source/file/ToonSyntax.java | 42 ++++++++++++++++--- .../structure/internal/DoubleHashing.java | 12 +++++- .../data/source/file/ToonSyntaxTest.java | 13 ++++++ .../data/structure/DoubleHashingTest.java | 23 ++++++++++ 4 files changed, 82 insertions(+), 8 deletions(-) diff --git a/data/src/main/java/io/github/adamw7/tools/data/source/file/ToonSyntax.java b/data/src/main/java/io/github/adamw7/tools/data/source/file/ToonSyntax.java index 96437e17..63dc2280 100644 --- a/data/src/main/java/io/github/adamw7/tools/data/source/file/ToonSyntax.java +++ b/data/src/main/java/io/github/adamw7/tools/data/source/file/ToonSyntax.java @@ -135,11 +135,41 @@ private static boolean isQuotedString(String value) { } private static String processEscapeSequences(String value) { - return value - .replace("\\\"", "\"") - .replace("\\\\", "\\") - .replace("\\n", "\n") - .replace("\\r", "\r") - .replace("\\t", "\t"); + StringBuilder result = new StringBuilder(value.length()); + int i = 0; + while (i < value.length()) { + i = appendChar(result, value, i); + } + return result.toString(); + } + + private static int appendChar(StringBuilder result, String value, int i) { + char c = value.charAt(i); + if (c != '\\' || i + 1 >= value.length()) { + result.append(c); + return i + 1; + } + return appendEscaped(result, value.charAt(i + 1), i); + } + + private static int appendEscaped(StringBuilder result, char escaped, int i) { + String replacement = replacementFor(escaped); + if (replacement == null) { + result.append('\\'); + return i + 1; + } + result.append(replacement); + return i + 2; + } + + private static String replacementFor(char escaped) { + return switch (escaped) { + case '"' -> "\""; + case '\\' -> "\\"; + case 'n' -> "\n"; + case 'r' -> "\r"; + case 't' -> "\t"; + default -> null; + }; } } diff --git a/data/src/main/java/io/github/adamw7/tools/data/structure/internal/DoubleHashing.java b/data/src/main/java/io/github/adamw7/tools/data/structure/internal/DoubleHashing.java index 6bf0ce12..93a9f3e2 100644 --- a/data/src/main/java/io/github/adamw7/tools/data/structure/internal/DoubleHashing.java +++ b/data/src/main/java/io/github/adamw7/tools/data/structure/internal/DoubleHashing.java @@ -43,10 +43,18 @@ public static int grownSize(int currentLength) { return Math.max((int) (currentLength * MULTIPLIER), currentLength + 1); } - /** The slot index probed on the given {@code iteration} of the sequence. */ + /** + * The slot index probed on the given {@code iteration} of the sequence. + * + *

The step {@code h2} is derived from {@code Math.abs(hashCode % (length - 1))} + * rather than {@code Math.abs(hashCode) % (length - 1)}: the two agree for every + * {@code hashCode} except {@link Integer#MIN_VALUE}, whose {@code Math.abs} stays + * negative and would otherwise yield a non-positive step that folds the probe + * sequence back onto a handful of slots. + */ public static int probe(int hashCode, int prime, int length, int iteration) { int h1 = prime - (hashCode % prime); - int h2 = 1 + (Math.abs(hashCode) % (length - 1)); + int h2 = 1 + Math.abs(hashCode % (length - 1)); return Math.abs((h1 + (iteration * h2)) % length); } } diff --git a/data/src/test/java/io/github/adamw7/tools/data/source/file/ToonSyntaxTest.java b/data/src/test/java/io/github/adamw7/tools/data/source/file/ToonSyntaxTest.java index a81314ba..610e1cae 100644 --- a/data/src/test/java/io/github/adamw7/tools/data/source/file/ToonSyntaxTest.java +++ b/data/src/test/java/io/github/adamw7/tools/data/source/file/ToonSyntaxTest.java @@ -107,6 +107,19 @@ public void unquoteResolvesEscapeSequences() { assertEquals("line\nbreak", ToonSyntax.unquote("\"line\\nbreak\"")); } + @Test + public void unquoteKeepsEscapedBackslashLiteralBeforeControlLetter() { + // The raw value is a \ \ n b : an escaped backslash followed by a literal + // 'n', so it must decode to a backslash and an 'n', not to a newline. + assertEquals("a\\nb", ToonSyntax.unquote("\"a\\\\nb\"")); + assertEquals("a\\tb", ToonSyntax.unquote("\"a\\\\tb\"")); + } + + @Test + public void unquoteLeavesUnknownEscapeUntouched() { + assertEquals("a\\xb", ToonSyntax.unquote("\"a\\xb\"")); + } + @Test public void keyValuePatternMatchesDottedKeys() { Matcher matcher = ToonSyntax.KEY_VALUE_PATTERN.matcher("user.name: Alice"); diff --git a/data/src/test/java/io/github/adamw7/tools/data/structure/DoubleHashingTest.java b/data/src/test/java/io/github/adamw7/tools/data/structure/DoubleHashingTest.java index 8f018243..5d6c4d8f 100644 --- a/data/src/test/java/io/github/adamw7/tools/data/structure/DoubleHashingTest.java +++ b/data/src/test/java/io/github/adamw7/tools/data/structure/DoubleHashingTest.java @@ -4,6 +4,9 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.HashSet; +import java.util.Set; + import org.junit.jupiter.api.Test; import org.junit.jupiter.api.function.Executable; @@ -79,4 +82,24 @@ private void assertProbesAreValidIndices(int hashCode, int prime, int length) { "index " + index + " out of range for hashCode " + hashCode); } } + + @Test + public void probeVisitsEverySlotForMinValueHashCode() { + // Integer.MIN_VALUE is the one hashCode whose Math.abs stays negative. A prior + // step derived from Math.abs(hashCode) went non-positive for it, folding the + // sequence back onto a subset of the slots (12 of 13 here). On a prime-sized + // table the sequence must be a full permutation of every slot. + int length = 13; + int prime = 11; + assertProbesAllDistinct(Integer.MIN_VALUE, prime, length); + } + + private void assertProbesAllDistinct(int hashCode, int prime, int length) { + Set visited = new HashSet<>(); + for (int iteration = 0; iteration < length; iteration++) { + visited.add(DoubleHashing.probe(hashCode, prime, length, iteration)); + } + assertEquals(length, visited.size(), + "probe should visit every slot exactly once for hashCode " + hashCode); + } }