From 87ff85fd6de25dbcdb52728be1901c6fd12fbca6 Mon Sep 17 00:00:00 2001
From: Leo Galambos
Date: Sun, 17 May 2026 15:00:45 +0200
Subject: [PATCH] feat: EGOTHOR v4 hot-path additions
---
.project | 27 +-
docs/compatibility-and-guarantees.md | 8 +
docs/programmatic-querying-and-ambiguity.md | 41 +
.../FrequencyTrieLookupBenchmark.java | 129 +++
.../egothor/stemmer/CaseProcessingMode.java | 3 +-
.../egothor/stemmer/DiacriticStripper.java | 8 +-
.../org/egothor/stemmer/FrequencyTrie.java | 344 +++++++-
.../stemmer/FrequencyTrieBuilders.java | 6 +-
.../egothor/stemmer/PatchCommandEncoder.java | 797 +++++++++++++++++-
.../stemmer/StemmerPatchTrieBinaryIO.java | 9 +-
.../stemmer/StemmerPatchTrieLoader.java | 5 +-
.../org/egothor/stemmer/package-info.java | 12 +-
.../egothor/stemmer/trie/CompiledNode.java | 29 +-
.../egothor/stemmer/FrequencyTrieTest.java | 145 ++++
.../stemmer/PatchCommandEncoderTest.java | 362 ++++++++
.../trie/CompiledNodeAndNodeDataTest.java | 87 +-
16 files changed, 1877 insertions(+), 135 deletions(-)
diff --git a/.project b/.project
index 5da9344..a6a5df7 100644
--- a/.project
+++ b/.project
@@ -2,21 +2,22 @@
Radixor
-
+
+
+
+
+ org.eclipse.jdt.core.javabuilder
+
+
+
+
+ org.eclipse.buildship.core.gradleprojectbuilder
+
+
+
+
org.eclipse.jdt.core.javanature
org.eclipse.buildship.core.gradleprojectnature
-
-
- org.eclipse.jdt.core.javabuilder
-
-
-
- org.eclipse.buildship.core.gradleprojectbuilder
-
-
-
-
-
diff --git a/docs/compatibility-and-guarantees.md b/docs/compatibility-and-guarantees.md
index 88e217c..fdf32c6 100644
--- a/docs/compatibility-and-guarantees.md
+++ b/docs/compatibility-and-guarantees.md
@@ -75,6 +75,14 @@ The distinction between preferred-result lookup and multi-result lookup is part
That model is part of how the public API should be understood.
+Visitor lookup methods such as `getAllNormalized(..., EntrySink, maxResults)` are additive hot-path APIs. They expose the same local ordering and count semantics without allocating result containers, but they do not replace `get()`, `getAll()`, or `getEntries()`.
+
+Compiled `FrequencyTrie` instances are immutable and thread-safe for concurrent reads. Visitor sinks are caller-owned and are not retained by the trie. Stored values passed to sinks are the model-owned trie values; for `FrequencyTrie` patch tries, those patch strings are immutable stored strings rather than fresh per-result strings.
+
+### Stable patch application behavior
+
+`PatchCommandEncoder.apply(...)` remains the compatibility API for string-returning patch application. Buffer-oriented `applyTo(...)` overloads are additive APIs for caller-owned output storage. They do not retain output arrays, report insufficient capacity with `APPLY_INSUFFICIENT_CAPACITY`, and preserve the existing malformed-patch compatibility behavior where `apply(...)` preserves the source.
+
### Stable reduction-mode intent
Each public `ReductionMode` constant carries a semantic contract that should remain meaningful across versions.
diff --git a/docs/programmatic-querying-and-ambiguity.md b/docs/programmatic-querying-and-ambiguity.md
index 29147dc..1309ae6 100644
--- a/docs/programmatic-querying-and-ambiguity.md
+++ b/docs/programmatic-querying-and-ambiguity.md
@@ -33,6 +33,28 @@ import org.egothor.stemmer.ValueCount;
final List> entries = trie.getEntries("axes");
```
+### Visitor lookup for hot paths
+
+For allocation-sensitive token loops, use the visitor-style lookup methods. They visit the same ordered local values and counts without allocating a result array, list, or `ValueCount` objects.
+
+```java
+trie.getAll("axes", (patch, count, rank) -> {
+ // rank is zero-based and follows the same ordering as getAll(String).
+ return true; // return false to stop after this callback
+}, 8);
+```
+
+If the caller has already normalized the input exactly as required by `trie.metadata()`, the normalized methods avoid lookup normalization buffers too:
+
+```java
+final char[] token = "axes".toCharArray();
+trie.getAllNormalized(token, 0, token.length, (patch, count, rank) -> {
+ return true;
+}, 8);
+```
+
+`getAllNormalized(...)` bypasses `caseProcessingMode` and `diacriticProcessingMode`; callers are responsible for supplying canonical input. `maxResults == 0` visits nothing, negative values are rejected, and a sink returning `false` stops iteration after the current callback.
+
## Apply patch commands
A patch command is not the final stem. It must be applied to the original input token. `PatchCommandEncoder.apply(source, patchCommand)` performs that transformation directly on the serialized command format. If the source is `null`, the method returns `null`. If the patch is `null`, empty, or malformed in compatibility-relevant ways, the original source word is preserved. Equal source and target words are represented by the canonical no-op patch.
@@ -45,6 +67,25 @@ final String patch = trie.get(word);
final String stem = PatchCommandEncoder.apply(word, patch);
```
+Hot paths can apply a patch into caller-owned character storage:
+
+```java
+final char[] output = new char[32];
+final int produced = PatchCommandEncoder.applyTo(
+ word,
+ patch,
+ trie.traversalDirection(),
+ output,
+ 0,
+ output.length);
+
+if (produced != PatchCommandEncoder.APPLY_INSUFFICIENT_CAPACITY) {
+ final String stem = new String(output, 0, produced);
+}
+```
+
+`applyTo(...)` returns the produced character count on success and `APPLY_INSUFFICIENT_CAPACITY` when the output range is too small. Capacity failure does not write partial output. The source and output ranges of the `char[]` overload must not overlap.
+
For multiple candidates:
```java
diff --git a/src/jmh/java/org/egothor/stemmer/benchmark/FrequencyTrieLookupBenchmark.java b/src/jmh/java/org/egothor/stemmer/benchmark/FrequencyTrieLookupBenchmark.java
index cc19e4f..ee382e7 100644
--- a/src/jmh/java/org/egothor/stemmer/benchmark/FrequencyTrieLookupBenchmark.java
+++ b/src/jmh/java/org/egothor/stemmer/benchmark/FrequencyTrieLookupBenchmark.java
@@ -31,11 +31,13 @@
package org.egothor.stemmer.benchmark;
import java.io.IOException;
+import java.util.List;
import java.util.concurrent.TimeUnit;
import org.egothor.stemmer.FrequencyTrie;
import org.egothor.stemmer.PatchCommandEncoder;
import org.egothor.stemmer.ReductionMode;
import org.egothor.stemmer.ReductionSettings;
+import org.egothor.stemmer.ValueCount;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Level;
@@ -97,12 +99,45 @@ public class FrequencyTrieLookupBenchmark {
*/
private String[] lookupKeys;
+ /**
+ * Lookup keys as normalized caller-owned character storage.
+ */
+ private char[][] lookupKeyCharacters;
+
/**
* Keys that are known to return multiple patch candidates from
* {@code getAll()}.
*/
private String[] ambiguousLookupKeys;
+ /**
+ * Ambiguous lookup keys as normalized caller-owned character storage.
+ */
+ private char[][] ambiguousLookupKeyCharacters;
+
+ /**
+ * Preferred patches aligned with {@link #lookupKeys}.
+ */
+ private String[] preferredPatches;
+
+ /**
+ * Reusable output buffer for patch application benchmarks.
+ */
+ private char[] outputBuffer;
+
+ /**
+ * Mutable field consumed by visitor sinks.
+ */
+ private int visitorAccumulator;
+
+ /**
+ * Sink used by visitor lookup benchmarks without per-invocation allocation.
+ */
+ private final FrequencyTrie.EntrySink visitorSink = (value, count, rank) -> {
+ this.visitorAccumulator += value.length() + count + rank;
+ return true;
+ };
+
/**
* Initializes the benchmark state.
*
@@ -116,6 +151,23 @@ public class FrequencyTrieLookupBenchmark {
this.trie = BenchmarkCorpusSupport.compilePatchTrie(corpus.dictionaryText(), settings, true);
this.lookupKeys = corpus.lookupKeys();
this.ambiguousLookupKeys = corpus.ambiguousLookupKeys();
+ this.lookupKeyCharacters = toCharArrays(this.lookupKeys);
+ this.ambiguousLookupKeyCharacters = toCharArrays(this.ambiguousLookupKeys);
+ this.preferredPatches = new String[this.lookupKeys.length];
+ int maxKeyLength = 0;
+ for (int index = 0; index < this.lookupKeys.length; index++) {
+ this.preferredPatches[index] = this.trie.get(this.lookupKeys[index]);
+ maxKeyLength = Math.max(maxKeyLength, this.lookupKeys[index].length());
+ }
+ this.outputBuffer = new char[maxKeyLength + 32];
+ }
+
+ private static char[][] toCharArrays(final String[] values) {
+ final char[][] characters = new char[values.length][];
+ for (int index = 0; index < values.length; index++) {
+ characters[index] = values[index].toCharArray();
+ }
+ return characters;
}
}
@@ -155,6 +207,61 @@ public class FrequencyTrieLookupBenchmark {
}
}
+ /**
+ * Measures retrieval of all patch candidates through caller-owned normalized
+ * character storage and a visitor sink.
+ *
+ * @param state prepared lookup state
+ * @param blackhole sink preventing dead-code elimination
+ */
+ @Benchmark
+ public void lookupAllPatchesWithNormalizedCharVisitor(final LookupState state, final Blackhole blackhole) {
+ final char[][] keys = state.ambiguousLookupKeyCharacters;
+ for (char[] key : keys) {
+ final int count = state.trie.getAllNormalized(key, 0, key.length, state.visitorSink, Integer.MAX_VALUE);
+ if (count < 2) {
+ throw new IllegalStateException("Expected multiple patches for benchmark key.");
+ }
+ }
+ blackhole.consume(state.visitorAccumulator);
+ }
+
+ /**
+ * Measures counted candidate retrieval through the allocating entry API.
+ *
+ * @param state prepared lookup state
+ * @param blackhole sink preventing dead-code elimination
+ */
+ @Benchmark
+ public void lookupPatchEntries(final LookupState state, final Blackhole blackhole) {
+ final String[] keys = state.ambiguousLookupKeys;
+ for (String key : keys) {
+ final List> entries = state.trie.getEntries(key);
+ if (entries.size() < 2) {
+ throw new IllegalStateException("Expected multiple entries for key " + key + '.');
+ }
+ blackhole.consume(entries);
+ }
+ }
+
+ /**
+ * Measures counted candidate retrieval through the visitor API.
+ *
+ * @param state prepared lookup state
+ * @param blackhole sink preventing dead-code elimination
+ */
+ @Benchmark
+ public void lookupPatchEntriesWithVisitor(final LookupState state, final Blackhole blackhole) {
+ final char[][] keys = state.ambiguousLookupKeyCharacters;
+ for (char[] key : keys) {
+ final int count = state.trie.getAllNormalized(key, 0, key.length, state.visitorSink, Integer.MAX_VALUE);
+ if (count < 2) {
+ throw new IllegalStateException("Expected multiple entries for benchmark key.");
+ }
+ }
+ blackhole.consume(state.visitorAccumulator);
+ }
+
/**
* Measures end-to-end preferred stemming from lookup plus patch application.
*
@@ -170,6 +277,28 @@ public class FrequencyTrieLookupBenchmark {
}
}
+ /**
+ * Measures patch application into caller-owned output storage.
+ *
+ * @param state prepared lookup state
+ * @param blackhole sink preventing dead-code elimination
+ */
+ @Benchmark
+ public void applyPreferredPatchToBuffer(final LookupState state, final Blackhole blackhole) {
+ final String[] keys = state.lookupKeys;
+ final String[] patches = state.preferredPatches;
+ final char[] output = state.outputBuffer;
+ for (int index = 0; index < keys.length; index++) {
+ final int length = PatchCommandEncoder.applyTo(keys[index], patches[index],
+ state.trie.traversalDirection(), output, 0, output.length);
+ if (length == PatchCommandEncoder.APPLY_INSUFFICIENT_CAPACITY) {
+ throw new IllegalStateException("Output buffer too small for key " + keys[index] + '.');
+ }
+ blackhole.consume(length);
+ blackhole.consume(output[0]);
+ }
+ }
+
/**
* Measures end-to-end full candidate stemming from {@code getAll()} plus
* patch application.
diff --git a/src/main/java/org/egothor/stemmer/CaseProcessingMode.java b/src/main/java/org/egothor/stemmer/CaseProcessingMode.java
index 280e962..44be6ea 100644
--- a/src/main/java/org/egothor/stemmer/CaseProcessingMode.java
+++ b/src/main/java/org/egothor/stemmer/CaseProcessingMode.java
@@ -48,8 +48,7 @@ public enum CaseProcessingMode {
AS_IS,
/**
- * Normalizes all dictionary content to lower case using
- * {@link Locale#ROOT}.
+ * Normalizes all dictionary content to lower case using {@link Locale#ROOT}.
*/
LOWERCASE_WITH_LOCALE_ROOT
}
diff --git a/src/main/java/org/egothor/stemmer/DiacriticStripper.java b/src/main/java/org/egothor/stemmer/DiacriticStripper.java
index 35fe0d9..7cf2c1c 100644
--- a/src/main/java/org/egothor/stemmer/DiacriticStripper.java
+++ b/src/main/java/org/egothor/stemmer/DiacriticStripper.java
@@ -93,12 +93,12 @@ final class DiacriticStripper {
}
/**
- * Removes supported diacritic marks and common Latin ligatures from the supplied
- * text.
+ * Removes supported diacritic marks and common Latin ligatures from the
+ * supplied text.
*
*
- * The method returns the original {@link String} instance when no replacement is
- * required, avoiding an unnecessary allocation on the common ASCII path.
+ * The method returns the original {@link String} instance when no replacement
+ * is required, avoiding an unnecessary allocation on the common ASCII path.
*
*
* @param input text to normalize
diff --git a/src/main/java/org/egothor/stemmer/FrequencyTrie.java b/src/main/java/org/egothor/stemmer/FrequencyTrie.java
index c3a67b9..3beac75 100644
--- a/src/main/java/org/egothor/stemmer/FrequencyTrie.java
+++ b/src/main/java/org/egothor/stemmer/FrequencyTrie.java
@@ -119,7 +119,8 @@ public final class FrequencyTrie {
private final boolean removeDiacritics;
/**
- * Shared empty array instance for empty lookup results from {@link #getAll(String)}.
+ * Shared empty array instance for empty lookup results from
+ * {@link #getAll(String)}.
*/
private final V[] emptyValues;
@@ -165,13 +166,18 @@ public final class FrequencyTrie {
*/
private static final int CASE_VERSION = 4;
+ /**
+ * Argument name for lookup keys.
+ */
+ private static final String ARG_KEY = "key";
+
/**
* Default dense child lookup span in code points used when materializing
* compiled nodes without an explicit override.
*
- * Increasing this value increases the chance of direct array indexing for
- * child lookup at runtime at the cost of per-node dense table memory for
- * compact character spans.
+ * Increasing this value increases the chance of direct array indexing for child
+ * lookup at runtime at the cost of per-node dense table memory for compact
+ * character spans.
*
*/
public static final int DEFAULT_MAX_EXPANDED_INDEX = 512;
@@ -191,6 +197,30 @@ public final class FrequencyTrie {
return STREAM_VERSION;
}
+ /**
+ * Receives trie values during visitor-style lookup.
+ *
+ *
+ * Implementations are caller-owned and are not retained by the trie. Returning
+ * {@code false} stops iteration after the current callback.
+ *
+ *
+ * @param value type
+ */
+ @FunctionalInterface
+ public interface EntrySink {
+
+ /**
+ * Accepts one ordered local value.
+ *
+ * @param value stored value
+ * @param count stored local occurrence count
+ * @param rank zero-based rank in deterministic local ordering
+ * @return {@code true} to continue iteration, {@code false} to stop
+ */
+ boolean accept(V value, int count, int rank);
+ }
+
/**
* Creates a new compiled trie instance.
*
@@ -229,7 +259,7 @@ public final class FrequencyTrie {
* @throws NullPointerException if {@code key} is {@code null}
*/
public V get(final String key) {
- Objects.requireNonNull(key, "key");
+ Objects.requireNonNull(key, ARG_KEY);
final CompiledNode node = findNode(normalizeLookupKey(key));
if (node == null) {
return null;
@@ -266,7 +296,7 @@ public final class FrequencyTrie {
*/
@SuppressWarnings("PMD.MethodReturnsInternalArray")
public V[] getAll(final String key) {
- Objects.requireNonNull(key, "key");
+ Objects.requireNonNull(key, ARG_KEY);
final CompiledNode node = findNode(normalizeLookupKey(key));
if (node == null) {
return this.emptyValues;
@@ -301,7 +331,7 @@ public final class FrequencyTrie {
* @throws NullPointerException if {@code key} is {@code null}
*/
public List> getEntries(final String key) {
- Objects.requireNonNull(key, "key");
+ Objects.requireNonNull(key, ARG_KEY);
final CompiledNode node = findNode(normalizeLookupKey(key));
if (node == null) {
return List.of();
@@ -325,6 +355,132 @@ public final class FrequencyTrie {
return Collections.unmodifiableList(entries);
}
+ /**
+ * Visits all values stored at the node addressed by an already-normalized
+ * {@code char[]} key slice.
+ *
+ *
+ * This method bypasses {@link TrieMetadata#caseProcessingMode()} and
+ * {@link TrieMetadata#diacriticProcessingMode()}. The caller must provide input
+ * normalized exactly as required by this trie's metadata. The trie is immutable
+ * and thread-safe for concurrent reads; the supplied sink is caller-owned and
+ * is not retained.
+ *
+ *
+ * @param key normalized key storage
+ * @param offset first character offset
+ * @param length number of characters to read
+ * @param sink value sink
+ * @param maxResults maximum number of results to visit
+ * @return number of visited values
+ * @throws NullPointerException if {@code key} or {@code sink} is
+ * {@code null}
+ * @throws IndexOutOfBoundsException if the key slice is invalid
+ * @throws IllegalArgumentException if {@code maxResults} is negative
+ */
+ public int getAllNormalized(final char[] key, final int offset, final int length, final EntrySink super V> sink,
+ final int maxResults) {
+ Objects.requireNonNull(key, ARG_KEY);
+ Objects.requireNonNull(sink, "sink");
+ Objects.checkFromIndexSize(offset, length, key.length);
+ validateMaxResults(maxResults);
+ if (maxResults == 0) {
+ return 0;
+ }
+ return visitNode(findNode(key, offset, length), sink, maxResults);
+ }
+
+ /**
+ * Visits all values stored at the node addressed by an already-normalized
+ * character sequence.
+ *
+ * @param key normalized key
+ * @param sink value sink
+ * @param maxResults maximum number of results to visit
+ * @return number of visited values
+ * @throws NullPointerException if {@code key} or {@code sink} is
+ * {@code null}
+ * @throws IllegalArgumentException if {@code maxResults} is negative
+ * @see #getAllNormalized(char[], int, int, EntrySink, int)
+ */
+ public int getAllNormalized(final CharSequence key, final EntrySink super V> sink, final int maxResults) {
+ Objects.requireNonNull(key, ARG_KEY);
+ Objects.requireNonNull(sink, "sink");
+ validateMaxResults(maxResults);
+ if (maxResults == 0) {
+ return 0;
+ }
+ return visitNode(findNode(key), sink, maxResults);
+ }
+
+ /**
+ * Visits the first value stored at the node addressed by an already-normalized
+ * {@code char[]} key slice.
+ *
+ * @param key normalized key storage
+ * @param offset first character offset
+ * @param length number of characters to read
+ * @param sink value sink
+ * @return {@code true} when a value was visited, otherwise {@code false}
+ * @see #getAllNormalized(char[], int, int, EntrySink, int)
+ */
+ public boolean getFirstNormalized(final char[] key, final int offset, final int length,
+ final EntrySink super V> sink) {
+ return getAllNormalized(key, offset, length, sink, 1) == 1;
+ }
+
+ /**
+ * Visits the first value stored at the node addressed by an already-normalized
+ * character sequence.
+ *
+ * @param key normalized key
+ * @param sink value sink
+ * @return {@code true} when a value was visited, otherwise {@code false}
+ * @see #getAllNormalized(CharSequence, EntrySink, int)
+ */
+ public boolean getFirstNormalized(final CharSequence key, final EntrySink super V> sink) {
+ return getAllNormalized(key, sink, 1) == 1;
+ }
+
+ /**
+ * Visits all values stored at the node addressed by the supplied key, applying
+ * metadata-driven lookup normalization when required.
+ *
+ *
+ * This method preserves the same lookup normalization semantics as
+ * {@link #getAll(String)}. It may allocate when metadata requires lowercase or
+ * diacritic normalization.
+ *
+ *
+ * @param key key to resolve
+ * @param sink value sink
+ * @param maxResults maximum number of results to visit
+ * @return number of visited values
+ */
+ public int getAll(final CharSequence key, final EntrySink super V> sink, final int maxResults) {
+ Objects.requireNonNull(key, ARG_KEY);
+ Objects.requireNonNull(sink, "sink");
+ validateMaxResults(maxResults);
+ if (maxResults == 0) {
+ return 0;
+ }
+ final CharSequence normalized = normalizeLookupKey(key);
+ return visitNode(findNode(normalized), sink, maxResults);
+ }
+
+ /**
+ * Visits the first value stored at the node addressed by the supplied key,
+ * applying metadata-driven lookup normalization when required.
+ *
+ * @param key key to resolve
+ * @param sink value sink
+ * @return {@code true} when a value was visited, otherwise {@code false}
+ * @see #getAll(CharSequence, EntrySink, int)
+ */
+ public boolean getFirst(final CharSequence key, final EntrySink super V> sink) {
+ return getAll(key, sink, 1) == 1;
+ }
+
/**
* Returns the logical key traversal direction used by this trie.
*
@@ -431,16 +587,17 @@ public final class FrequencyTrie {
* dense child-index span configuration.
*
* This setting is applied only while materializing the in-memory compiled
- * representation during load. It is not serialized in {@link TrieMetadata},
- * so each load can independently choose its own runtime lookup trade-off.
+ * representation during load. It is not serialized in {@link TrieMetadata}, so
+ * each load can independently choose its own runtime lookup trade-off.
*
*
- * @param inputStream source input stream
- * @param arrayFactory array factory used to create typed arrays
- * @param valueCodec codec used to read values
- * @param maxExpandedIndex dense lookup span override; zero disables dense lookup,
- * negative values use {@link #DEFAULT_MAX_EXPANDED_INDEX}
- * @param value type
+ * @param inputStream source input stream
+ * @param arrayFactory array factory used to create typed arrays
+ * @param valueCodec codec used to read values
+ * @param maxExpandedIndex dense lookup span override; zero disables dense
+ * lookup, negative values use
+ * {@link #DEFAULT_MAX_EXPANDED_INDEX}
+ * @param value type
* @return deserialized compiled trie
* @throws NullPointerException if any argument is {@code null}
* @throws IOException if reading fails or the binary format is invalid
@@ -573,7 +730,8 @@ public final class FrequencyTrie {
final TrieMetadata sourceMetadata = readMetadata(dataInput, version);
final int effectiveMaxExpandedIndex = maxExpandedIndex >= 0 ? maxExpandedIndex : DEFAULT_MAX_EXPANDED_INDEX;
- final CompiledNode[] nodes = readNodes(dataInput, arrayFactory, valueCodec, nodeCount, effectiveMaxExpandedIndex);
+ final CompiledNode[] nodes = readNodes(dataInput, arrayFactory, valueCodec, nodeCount,
+ effectiveMaxExpandedIndex);
final CompiledNode rootNode = nodes[rootNodeId];
if (LOGGER.isLoggable(Level.FINE)) {
@@ -584,12 +742,12 @@ public final class FrequencyTrie {
}
private static DataInputStream wrapInputStream(final InputStream inputStream) {
- return inputStream instanceof DataInputStream
- ? (DataInputStream) inputStream
+ return inputStream instanceof DataInputStream ? (DataInputStream) inputStream
: new DataInputStream(inputStream);
}
- private static TrieMetadata readMetadata(final DataInputStream dataInput, final int version) throws IOException {
+ private static TrieMetadata readMetadata(final DataInputStream dataInput, final int version)
+ throws IOException {
if (version == STREAM_VERSION) {
return readTextMetadata(dataInput);
}
@@ -600,12 +758,12 @@ public final class FrequencyTrie {
}
final ReductionSettings reductionSettings = readReductionSettings(dataInput);
- final DiacriticProcessingMode diacriticProcessingMode = readEnumByOrdinal(dataInput, DiacriticProcessingMode.values(),
- "diacritic processing mode");
- final CaseProcessingMode caseProcessingMode = version >= CASE_VERSION
- ? readCaseProcessingMode(dataInput)
+ final DiacriticProcessingMode diacriticProcessingMode = readEnumByOrdinal(dataInput,
+ DiacriticProcessingMode.values(), "diacritic processing mode");
+ final CaseProcessingMode caseProcessingMode = version >= CASE_VERSION ? readCaseProcessingMode(dataInput)
: CaseProcessingMode.LOWERCASE_WITH_LOCALE_ROOT;
- return new TrieMetadata(version, traversalDirection, reductionSettings, diacriticProcessingMode, caseProcessingMode);
+ return new TrieMetadata(version, traversalDirection, reductionSettings, diacriticProcessingMode,
+ caseProcessingMode);
}
private static TrieMetadata readTextMetadata(final DataInputStream dataInput) throws IOException {
@@ -644,8 +802,9 @@ public final class FrequencyTrie {
return values[ordinal];
}
- private static CompiledNode[] readNodes(final DataInputStream dataInput, final IntFunction arrayFactory,
- final ValueStreamCodec valueCodec, final int nodeCount, final int maxExpandedIndex) throws IOException {
+ private static CompiledNode[] readNodes(final DataInputStream dataInput,
+ final IntFunction arrayFactory, final ValueStreamCodec valueCodec, final int nodeCount,
+ final int maxExpandedIndex) throws IOException {
final char[][] edgeLabelsByNode = new char[nodeCount][];
final int[][] childNodeIdsByNode = new int[nodeCount][];
@SuppressWarnings("unchecked")
@@ -700,14 +859,16 @@ public final class FrequencyTrie {
private static CompiledNode resolveNode(final int nodeIndex, final char[][] edgeLabelsByNode,
final int[][] childNodeIdsByNode, final V[][] orderedValuesByNode, final int[][] orderedCountsByNode,
- final CompiledNode[] nodes, final boolean[] inProgress, final int maxExpandedIndex) throws IOException {
+ final CompiledNode[] nodes, final boolean[] inProgress, final int maxExpandedIndex)
+ throws IOException {
final CompiledNode cachedNode = nodes[nodeIndex];
if (cachedNode != null) {
return cachedNode;
}
if (inProgress[nodeIndex]) {
- throw new IOException("Invalid serialized node graph: cyclic reference detected at node " + nodeIndex + '.');
+ throw new IOException(
+ "Invalid serialized node graph: cyclic reference detected at node " + nodeIndex + '.');
}
inProgress[nodeIndex] = true;
try {
@@ -720,16 +881,15 @@ public final class FrequencyTrie {
for (int edgeIndex = 0; edgeIndex < edgeCount; edgeIndex++) {
final int childNodeId = childNodeIds[edgeIndex];
if (childNodeId < 0 || childNodeId >= edgeLabelsByNode.length) {
- throw new IOException(
- "Invalid child node id at node " + nodeIndex + ", edge index " + edgeIndex + ": "
- + childNodeId);
+ throw new IOException("Invalid child node id at node " + nodeIndex + ", edge index " + edgeIndex
+ + ": " + childNodeId);
}
children[edgeIndex] = resolveNode(childNodeId, edgeLabelsByNode, childNodeIdsByNode,
orderedValuesByNode, orderedCountsByNode, nodes, inProgress, maxExpandedIndex);
}
- final CompiledNode node = new CompiledNode<>(edgeLabels, children, orderedValuesByNode[nodeIndex], maxExpandedIndex,
- orderedCountsByNode[nodeIndex]);
+ final CompiledNode node = new CompiledNode<>(edgeLabels, children, orderedValuesByNode[nodeIndex],
+ maxExpandedIndex, orderedCountsByNode[nodeIndex]);
nodes[nodeIndex] = node;
return node;
} finally {
@@ -740,8 +900,9 @@ public final class FrequencyTrie {
private static void validateSerializedEdges(final int nodeIndex, final char... edgeLabels) throws IOException {
for (int edgeIndex = 1; edgeIndex < edgeLabels.length; edgeIndex++) {
if (edgeLabels[edgeIndex - 1] >= edgeLabels[edgeIndex]) {
- throw new IOException("Edge labels must be strictly ascending at node " + nodeIndex + ", edge index "
- + edgeIndex + ": '" + edgeLabels[edgeIndex - 1] + "' then '" + edgeLabels[edgeIndex] + "'.");
+ throw new IOException(
+ "Edge labels must be strictly ascending at node " + nodeIndex + ", edge index " + edgeIndex
+ + ": '" + edgeLabels[edgeIndex - 1] + "' then '" + edgeLabels[edgeIndex] + "'.");
}
}
}
@@ -754,6 +915,16 @@ public final class FrequencyTrie {
* @return compiled node, or {@code null} if the path does not exist
*/
private CompiledNode findNode(final String key) {
+ return findNode((CharSequence) key);
+ }
+
+ /**
+ * Locates the compiled node for the supplied key.
+ *
+ * @param key already-normalized key to resolve
+ * @return compiled node, or {@code null} if the path does not exist
+ */
+ private CompiledNode findNode(final CharSequence key) {
CompiledNode current = this.root;
if (this.lookupTraversalDirection == WordTraversalDirection.BACKWARD) {
for (int traversalOffset = key.length() - 1; traversalOffset >= 0; traversalOffset--) {
@@ -774,6 +945,77 @@ public final class FrequencyTrie {
return current;
}
+ /**
+ * Locates the compiled node for the supplied key slice.
+ *
+ * @param key already-normalized key storage
+ * @param offset first character offset
+ * @param length number of characters to read
+ * @return compiled node, or {@code null} if the path does not exist
+ */
+ private CompiledNode findNode(final char[] key, final int offset, final int length) {
+ CompiledNode current = this.root;
+ if (this.lookupTraversalDirection == WordTraversalDirection.BACKWARD) {
+ for (int traversalOffset = offset + length - 1; traversalOffset >= offset; traversalOffset--) {
+ current = current.findChild(key[traversalOffset]);
+ if (current == null) {
+ return null;
+ }
+ }
+ return current;
+ }
+
+ final int endExclusive = offset + length;
+ for (int traversalOffset = offset; traversalOffset < endExclusive; traversalOffset++) {
+ current = current.findChild(key[traversalOffset]);
+ if (current == null) {
+ return null;
+ }
+ }
+ return current;
+ }
+
+ /**
+ * Visits node-local values without allocating result containers.
+ *
+ * @param node resolved node, or {@code null}
+ * @param sink value sink
+ * @param maxResults maximum values to visit
+ * @return number of visited values
+ */
+ private int visitNode(final CompiledNode node, final EntrySink super V> sink, final int maxResults) {
+ if (node == null) {
+ return 0;
+ }
+
+ final V[] orderedValues = node.orderedValues();
+ final int valueCount = Math.min(orderedValues.length, maxResults);
+ if (valueCount == 0) {
+ return 0;
+ }
+
+ final int[] orderedCounts = node.orderedCounts();
+ int visited = 0;
+ for (int rank = 0; rank < valueCount; rank++) {
+ visited++;
+ if (!sink.accept(orderedValues[rank], orderedCounts[rank], rank)) {
+ break;
+ }
+ }
+ return visited;
+ }
+
+ /**
+ * Validates visitor maximum result count.
+ *
+ * @param maxResults maximum result count
+ */
+ private static void validateMaxResults(final int maxResults) {
+ if (maxResults < 0) {
+ throw new IllegalArgumentException("maxResults must be non-negative.");
+ }
+ }
+
/**
* Applies lookup-time case normalization according to persisted metadata.
*
@@ -781,11 +1023,21 @@ public final class FrequencyTrie {
* @return normalized key for trie traversal
*/
private String normalizeLookupKey(final String key) {
+ return normalizeLookupKey((CharSequence) key).toString();
+ }
+
+ /**
+ * Applies lookup-time normalization according to persisted metadata.
+ *
+ * @param key lookup key
+ * @return normalized key for trie traversal
+ */
+ private CharSequence normalizeLookupKey(final CharSequence key) {
if (!this.lowercasesLookupKeys && !this.removeDiacritics) {
return key;
}
- String normalized = key;
+ String normalized = key.toString();
if (this.lowercasesLookupKeys) {
normalized = normalized.toLowerCase(Locale.ROOT);
}
@@ -846,9 +1098,9 @@ public final class FrequencyTrie {
/**
* Dense edge lookup span threshold.
*
- * This value controls a speed/memory trade-off during freezing:
- * dense child lookup tables are allocated only for nodes whose child
- * labels fit in this span.
+ * This value controls a speed/memory trade-off during freezing: dense child
+ * lookup tables are allocated only for nodes whose child labels fit in this
+ * span.
*
*/
private final int maxExpandedIndex;
@@ -925,8 +1177,8 @@ public final class FrequencyTrie {
/**
* Creates a new builder with the provided settings, explicit traversal
- * direction, explicit case processing mode, explicit diacritic processing
- * mode, and an explicit dense child lookup threshold.
+ * direction, explicit case processing mode, explicit diacritic processing mode,
+ * and an explicit dense child lookup threshold.
*
* @param arrayFactory array factory
* @param reductionSettings reduction configuration
@@ -934,10 +1186,10 @@ public final class FrequencyTrie {
* @param caseProcessingMode dictionary case processing mode
* @param diacriticProcessingMode dictionary diacritic processing mode
* @param maxExpandedIndex dense lookup span override; zero disables
- * dense lookup. Larger values increase direct
- * indexing opportunities while potentially
- * increasing materialization memory in nodes
- * whose edge label span is within the limit.
+ * dense lookup. Larger values increase direct
+ * indexing opportunities while potentially
+ * increasing materialization memory in nodes
+ * whose edge label span is within the limit.
* @throws NullPointerException if any argument is {@code null}
*/
public Builder(final IntFunction arrayFactory, final ReductionSettings reductionSettings,
@@ -1052,7 +1304,7 @@ public final class FrequencyTrie {
* @throws IllegalArgumentException if {@code count} is less than {@code 1}
*/
public Builder put(final String key, final V value, final int count) {
- Objects.requireNonNull(key, "key");
+ Objects.requireNonNull(key, ARG_KEY);
Objects.requireNonNull(value, "value");
if (count < 1) { // NOPMD
diff --git a/src/main/java/org/egothor/stemmer/FrequencyTrieBuilders.java b/src/main/java/org/egothor/stemmer/FrequencyTrieBuilders.java
index da61b5c..ef4b98e 100644
--- a/src/main/java/org/egothor/stemmer/FrequencyTrieBuilders.java
+++ b/src/main/java/org/egothor/stemmer/FrequencyTrieBuilders.java
@@ -119,11 +119,11 @@ public final class FrequencyTrieBuilders {
* Copies one compiled node and all reachable descendants into the target
* builder.
*
- * @param node current compiled node
- * @param keyBuilder current key builder
+ * @param node current compiled node
+ * @param keyBuilder current key builder
* @param builder target mutable builder
* @param traversalDirection logical key traversal direction used by the source
- * @param value type
+ * @param value type
*/
private static void copyNode(final CompiledNode node, final StringBuilder keyBuilder,
final FrequencyTrie.Builder builder, final WordTraversalDirection traversalDirection) {
diff --git a/src/main/java/org/egothor/stemmer/PatchCommandEncoder.java b/src/main/java/org/egothor/stemmer/PatchCommandEncoder.java
index a5c1eb0..4004efb 100644
--- a/src/main/java/org/egothor/stemmer/PatchCommandEncoder.java
+++ b/src/main/java/org/egothor/stemmer/PatchCommandEncoder.java
@@ -67,7 +67,7 @@ import java.util.concurrent.locks.ReentrantLock;
* instance can still be used safely when needed.
*
*/
-@SuppressWarnings("PMD.CyclomaticComplexity")
+@SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", "PMD.CyclomaticComplexity", "PMD.ForLoopVariableCount" })
public final class PatchCommandEncoder {
/**
@@ -121,6 +121,13 @@ public final class PatchCommandEncoder {
*/
/* default */ static final String NOOP_PATCH = String.valueOf(new char[] { NOOP_OPCODE, NOOP_ARGUMENT });
+ /**
+ * Return value used by
+ * {@link #applyTo(CharSequence, String, WordTraversalDirection, char[], int, int)}
+ * when the caller-owned output range is too small for the transformed text.
+ */
+ public static final int APPLY_INSUFFICIENT_CAPACITY = -1;
+
/**
* Prefix used in unsupported NOOP patch argument exceptions.
*/
@@ -346,6 +353,78 @@ public final class PatchCommandEncoder {
return applyStrategyFor(traversalDirection).apply(source, patchCommand);
}
+ /**
+ * Applies a compact patch command into a caller-owned output buffer.
+ *
+ *
+ * The output array is not retained. Capacity failure is reported by
+ * {@link #APPLY_INSUFFICIENT_CAPACITY} and leaves the output range unchanged.
+ * Malformed compatibility cases preserve the source exactly as
+ * {@link #apply(String, String, WordTraversalDirection)} does.
+ *
+ *
+ * @param source original source text
+ * @param patchCommand compact patch command
+ * @param traversalDirection traversal direction used by the patch command
+ * @param output caller-owned output storage
+ * @param outputOffset first writable output offset
+ * @param outputLength writable output capacity
+ * @return produced character count, or {@link #APPLY_INSUFFICIENT_CAPACITY}
+ * when {@code outputLength} is too small
+ */
+ public static int applyTo(final CharSequence source, final String patchCommand,
+ final WordTraversalDirection traversalDirection, final char[] output, final int outputOffset,
+ final int outputLength) {
+ Objects.requireNonNull(source, "source");
+ Objects.requireNonNull(traversalDirection, "traversalDirection");
+ Objects.requireNonNull(output, "output");
+ Objects.checkFromIndexSize(outputOffset, outputLength, output.length);
+
+ final int sourceLength = source.length();
+ final int producedLength = computeAppliedLength(sourceLength, patchCommand, traversalDirection);
+ if (producedLength > outputLength) {
+ return APPLY_INSUFFICIENT_CAPACITY;
+ }
+ applyToOutput(source, 0, sourceLength, patchCommand, traversalDirection, output, outputOffset, producedLength);
+ return producedLength;
+ }
+
+ /**
+ * Applies a compact patch command from a caller-owned source slice into a
+ * caller-owned output buffer.
+ *
+ * @param source source storage
+ * @param sourceOffset first source character offset
+ * @param sourceLength number of source characters
+ * @param patchCommand compact patch command
+ * @param traversalDirection traversal direction used by the patch command
+ * @param output caller-owned output storage
+ * @param outputOffset first writable output offset
+ * @param outputLength writable output capacity
+ * @return produced character count, or {@link #APPLY_INSUFFICIENT_CAPACITY}
+ * when {@code outputLength} is too small
+ * @throws IllegalArgumentException when source and output ranges overlap in the
+ * same array
+ */
+ public static int applyTo(final char[] source, final int sourceOffset, final int sourceLength,
+ final String patchCommand, final WordTraversalDirection traversalDirection, final char[] output,
+ final int outputOffset, final int outputLength) {
+ Objects.requireNonNull(source, "source");
+ Objects.requireNonNull(traversalDirection, "traversalDirection");
+ Objects.requireNonNull(output, "output");
+ Objects.checkFromIndexSize(sourceOffset, sourceLength, source.length);
+ Objects.checkFromIndexSize(outputOffset, outputLength, output.length);
+ validateNonOverlappingRanges(source, sourceOffset, sourceLength, output, outputOffset, outputLength);
+
+ final int producedLength = computeAppliedLength(sourceLength, patchCommand, traversalDirection);
+ if (producedLength > outputLength) {
+ return APPLY_INSUFFICIENT_CAPACITY;
+ }
+ applyToOutput(source, sourceOffset, sourceLength, patchCommand, traversalDirection, output, outputOffset,
+ producedLength);
+ return producedLength;
+ }
+
/**
* Encodes a patch command using the historical backward Egothor semantics.
*
@@ -409,7 +488,6 @@ public final class PatchCommandEncoder {
* @param patchCommand compact patch command
* @return transformed word, or {@code null} when {@code source} is {@code null}
*/
- @SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.AvoidLiteralsInIfCondition" })
private static String applyBackward(final String source, final String patchCommand) {
if (source == null) {
return null;
@@ -435,7 +513,7 @@ public final class PatchCommandEncoder {
int position = result.length() - 1;
try {
- for (int patchIndex = 0, patchLength = patchCommand.length(); patchIndex < patchLength; patchIndex += 2) { // NOPMD
+ for (int patchIndex = 0, patchLength = patchCommand.length(); patchIndex < patchLength; patchIndex += 2) {
final char opcode = patchCommand.charAt(patchIndex);
final char argument = patchCommand.charAt(patchIndex + 1);
@@ -493,7 +571,6 @@ public final class PatchCommandEncoder {
* @param patchCommand compact patch command
* @return transformed word, or {@code null} when {@code source} is {@code null}
*/
- @SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.AvoidLiteralsInIfCondition" })
private static String applyForward(final String source, final String patchCommand) {
if (source == null) {
return null;
@@ -519,7 +596,7 @@ public final class PatchCommandEncoder {
int position = 0;
try {
- for (int patchIndex = 0, patchLength = patchCommand.length(); patchIndex < patchLength; patchIndex += 2) { // NOPMD
+ for (int patchIndex = 0, patchLength = patchCommand.length(); patchIndex < patchLength; patchIndex += 2) {
final char opcode = patchCommand.charAt(patchIndex);
final char argument = patchCommand.charAt(patchIndex + 1);
@@ -681,7 +758,7 @@ public final class PatchCommandEncoder {
*/
private static String applyBackwardToEmptySource(final StringBuilder result, final String patchCommand) {
try {
- for (int patchIndex = 0, patchLength = patchCommand.length(); patchIndex < patchLength; patchIndex += 2) { // NOPMD
+ for (int patchIndex = 0, patchLength = patchCommand.length(); patchIndex < patchLength; patchIndex += 2) {
final char opcode = patchCommand.charAt(patchIndex);
final char argument = patchCommand.charAt(patchIndex + 1);
@@ -722,7 +799,7 @@ public final class PatchCommandEncoder {
*/
private static String applyForwardToEmptySource(final StringBuilder result, final String patchCommand) {
try {
- for (int patchIndex = 0, patchLength = patchCommand.length(); patchIndex < patchLength; patchIndex += 2) { // NOPMD
+ for (int patchIndex = 0, patchLength = patchCommand.length(); patchIndex < patchLength; patchIndex += 2) {
final char opcode = patchCommand.charAt(patchIndex);
final char argument = patchCommand.charAt(patchIndex + 1);
@@ -753,6 +830,711 @@ public final class PatchCommandEncoder {
return result.toString();
}
+ /**
+ * Computes the transformed length or the preserved source length for malformed
+ * compatibility cases.
+ *
+ * @param sourceLength source length
+ * @param patchCommand patch command
+ * @param traversalDirection traversal direction
+ * @return produced length
+ */
+ private static int computeAppliedLength(final int sourceLength, final String patchCommand,
+ final WordTraversalDirection traversalDirection) {
+ if (patchCommand == null || patchCommand.isEmpty() || NOOP_PATCH.equals(patchCommand)
+ || (patchCommand.length() & 1) != 0) {
+ return sourceLength;
+ }
+ if (traversalDirection == WordTraversalDirection.BACKWARD) {
+ return computeBackwardAppliedLength(sourceLength, patchCommand);
+ }
+ return computeForwardAppliedLength(sourceLength, patchCommand);
+ }
+
+ /**
+ * Computes the backward traversal output length.
+ *
+ * @param sourceLength source length
+ * @param patchCommand patch command
+ * @return produced length
+ */
+ private static int computeBackwardAppliedLength(final int sourceLength, final String patchCommand) {
+ if (patchCommand.length() == 2) {
+ return computeSingleBackwardAppliedLength(sourceLength, patchCommand.charAt(0), patchCommand.charAt(1));
+ }
+ if (sourceLength == 0) {
+ return computeBackwardEmptyAppliedLength(patchCommand);
+ }
+
+ int currentLength = sourceLength;
+ int position = sourceLength - 1;
+ for (int patchIndex = 0, patchLength = patchCommand.length(); patchIndex < patchLength; patchIndex += 2) {
+ final char opcode = patchCommand.charAt(patchIndex);
+ final char argument = patchCommand.charAt(patchIndex + 1);
+
+ switch (opcode) {
+ case SKIP_OPCODE:
+ final int skipCount = decodeEncodedCount(argument);
+ if (skipCount < 1) {
+ return sourceLength;
+ }
+ position = position - skipCount + 1;
+ break;
+
+ case REPLACE_OPCODE:
+ if (position < 0 || position >= currentLength) {
+ return sourceLength;
+ }
+ break;
+
+ case DELETE_OPCODE:
+ final int deleteCount = decodeEncodedCount(argument);
+ if (deleteCount < 1) {
+ return sourceLength;
+ }
+ final int deleteEndExclusive = position + 1;
+ position -= deleteCount - 1;
+ if (position < 0 || deleteEndExclusive > currentLength || position > deleteEndExclusive) {
+ return sourceLength;
+ }
+ currentLength -= deleteEndExclusive - position;
+ break;
+
+ case INSERT_OPCODE:
+ if (position < -1 || position >= currentLength) {
+ return sourceLength;
+ }
+ currentLength++;
+ position++;
+ break;
+
+ case NOOP_OPCODE:
+ if (argument != NOOP_ARGUMENT) {
+ throw new IllegalArgumentException(MSG_NOOP + argument);
+ }
+ return sourceLength;
+
+ default:
+ throw new IllegalArgumentException(MSG_OPCODE + opcode);
+ }
+
+ position--;
+ }
+ return currentLength;
+ }
+
+ /**
+ * Computes the forward traversal output length.
+ *
+ * @param sourceLength source length
+ * @param patchCommand patch command
+ * @return produced length
+ */
+ private static int computeForwardAppliedLength(final int sourceLength, final String patchCommand) {
+ if (patchCommand.length() == 2) {
+ return computeSingleForwardAppliedLength(sourceLength, patchCommand.charAt(0), patchCommand.charAt(1));
+ }
+ if (sourceLength == 0) {
+ return computeForwardEmptyAppliedLength(patchCommand);
+ }
+
+ int currentLength = sourceLength;
+ int position = 0;
+ for (int patchIndex = 0, patchLength = patchCommand.length(); patchIndex < patchLength; patchIndex += 2) {
+ final char opcode = patchCommand.charAt(patchIndex);
+ final char argument = patchCommand.charAt(patchIndex + 1);
+
+ switch (opcode) {
+ case SKIP_OPCODE:
+ final int skipCount = decodeEncodedCount(argument);
+ if (skipCount < 1) {
+ return sourceLength;
+ }
+ position = position + skipCount - 1;
+ break;
+
+ case REPLACE_OPCODE:
+ if (position < 0 || position >= currentLength) {
+ return sourceLength;
+ }
+ break;
+
+ case DELETE_OPCODE:
+ final int deleteCount = decodeEncodedCount(argument);
+ if (deleteCount < 1 || position < 0 || position + deleteCount > currentLength) {
+ return sourceLength;
+ }
+ currentLength -= deleteCount;
+ position--;
+ break;
+
+ case INSERT_OPCODE:
+ if (position < 0 || position > currentLength) {
+ return sourceLength;
+ }
+ currentLength++;
+ break;
+
+ case NOOP_OPCODE:
+ if (argument != NOOP_ARGUMENT) {
+ throw new IllegalArgumentException(MSG_NOOP + argument);
+ }
+ return sourceLength;
+
+ default:
+ throw new IllegalArgumentException(MSG_OPCODE + opcode);
+ }
+
+ position++;
+ }
+ return currentLength;
+ }
+
+ /**
+ * Computes a single backward instruction output length.
+ *
+ * @param sourceLength source length
+ * @param opcode opcode
+ * @param argument argument
+ * @return produced length
+ */
+ private static int computeSingleBackwardAppliedLength(final int sourceLength, final char opcode,
+ final char argument) {
+ final int encodedValue;
+ switch (opcode) {
+ case DELETE_OPCODE:
+ encodedValue = decodeEncodedCount(argument);
+ return encodedValue < 1 || encodedValue > sourceLength ? sourceLength : sourceLength - encodedValue;
+ case INSERT_OPCODE:
+ return sourceLength + 1;
+ case REPLACE_OPCODE:
+ case SKIP_OPCODE:
+ return sourceLength;
+ case NOOP_OPCODE:
+ if (argument != NOOP_ARGUMENT) {
+ throw new IllegalArgumentException(MSG_NOOP + argument);
+ }
+ return sourceLength;
+ default:
+ throw new IllegalArgumentException(MSG_OPCODE + opcode);
+ }
+ }
+
+ /**
+ * Computes a single forward instruction output length.
+ *
+ * @param sourceLength source length
+ * @param opcode opcode
+ * @param argument argument
+ * @return produced length
+ */
+ private static int computeSingleForwardAppliedLength(final int sourceLength, final char opcode,
+ final char argument) {
+ return computeSingleBackwardAppliedLength(sourceLength, opcode, argument);
+ }
+
+ /**
+ * Computes output length for an empty source in backward traversal.
+ *
+ * @param patchCommand patch command
+ * @return produced length
+ */
+ private static int computeBackwardEmptyAppliedLength(final String patchCommand) {
+ int currentLength = 0;
+ for (int patchIndex = 0, patchLength = patchCommand.length(); patchIndex < patchLength; patchIndex += 2) {
+ final char opcode = patchCommand.charAt(patchIndex);
+ final char argument = patchCommand.charAt(patchIndex + 1);
+ switch (opcode) {
+ case INSERT_OPCODE:
+ currentLength++;
+ break;
+ case SKIP_OPCODE:
+ case REPLACE_OPCODE:
+ case DELETE_OPCODE:
+ return 0;
+ case NOOP_OPCODE:
+ if (argument != NOOP_ARGUMENT) {
+ throw new IllegalArgumentException(MSG_NOOP + argument);
+ }
+ return 0;
+ default:
+ throw new IllegalArgumentException(MSG_OPCODE + opcode);
+ }
+ }
+ return currentLength;
+ }
+
+ /**
+ * Computes output length for an empty source in forward traversal.
+ *
+ * @param patchCommand patch command
+ * @return produced length
+ */
+ private static int computeForwardEmptyAppliedLength(final String patchCommand) {
+ return computeBackwardEmptyAppliedLength(patchCommand);
+ }
+
+ /**
+ * Applies an already-sized patch into caller output.
+ *
+ * @param source source text
+ * @param sourceOffset source offset
+ * @param sourceLength source length
+ * @param patchCommand patch command
+ * @param traversalDirection traversal direction
+ * @param output output storage
+ * @param outputOffset output offset
+ * @param producedLength already-validated produced length
+ */
+ private static void applyToOutput(final CharSequence source, final int sourceOffset, final int sourceLength,
+ final String patchCommand, final WordTraversalDirection traversalDirection, final char[] output,
+ final int outputOffset, final int producedLength) {
+ if (isPreservedSource(sourceLength, producedLength, patchCommand, traversalDirection)) {
+ copySource(source, sourceOffset, sourceLength, output, outputOffset);
+ return;
+ }
+
+ if (sourceLength > 0) {
+ copySource(source, sourceOffset, sourceLength, output, outputOffset);
+ }
+
+ if (traversalDirection == WordTraversalDirection.BACKWARD) {
+ applyBackwardToOutput(sourceLength, patchCommand, output, outputOffset);
+ } else {
+ applyForwardToOutput(sourceLength, patchCommand, output, outputOffset);
+ }
+ }
+
+ /**
+ * Applies an already-sized patch into caller output.
+ *
+ * @param source source storage
+ * @param sourceOffset source offset
+ * @param sourceLength source length
+ * @param patchCommand patch command
+ * @param traversalDirection traversal direction
+ * @param output output storage
+ * @param outputOffset output offset
+ * @param producedLength already-validated produced length
+ */
+ private static void applyToOutput(final char[] source, final int sourceOffset, final int sourceLength,
+ final String patchCommand, final WordTraversalDirection traversalDirection, final char[] output,
+ final int outputOffset, final int producedLength) {
+ if (isPreservedSource(sourceLength, producedLength, patchCommand, traversalDirection)) {
+ System.arraycopy(source, sourceOffset, output, outputOffset, sourceLength);
+ return;
+ }
+
+ if (sourceLength > 0) {
+ System.arraycopy(source, sourceOffset, output, outputOffset, sourceLength);
+ }
+
+ if (traversalDirection == WordTraversalDirection.BACKWARD) {
+ applyBackwardToOutput(sourceLength, patchCommand, output, outputOffset);
+ } else {
+ applyForwardToOutput(sourceLength, patchCommand, output, outputOffset);
+ }
+ }
+
+ /**
+ * Determines whether the output is exactly the original source.
+ *
+ * @param sourceLength source length
+ * @param producedLength produced length
+ * @param patchCommand patch command
+ * @param traversalDirection traversal direction
+ * @return {@code true} if copying the source is sufficient
+ */
+ private static boolean isPreservedSource(final int sourceLength, final int producedLength,
+ final String patchCommand, final WordTraversalDirection traversalDirection) {
+ return producedLength == sourceLength
+ && isKnownPreserveOnlyPatch(sourceLength, patchCommand, traversalDirection);
+ }
+
+ /**
+ * Returns whether equal length also means no mutation is needed.
+ *
+ * @param sourceLength source length
+ * @param patchCommand patch command
+ * @param traversalDirection traversal direction
+ * @return {@code true} when the command preserves source content
+ */
+ private static boolean isKnownPreserveOnlyPatch(final int sourceLength, final String patchCommand,
+ final WordTraversalDirection traversalDirection) {
+ if (patchCommand == null || patchCommand.isEmpty() || NOOP_PATCH.equals(patchCommand)
+ || (patchCommand.length() & 1) != 0) {
+ return true;
+ }
+ if (patchCommand.length() == 2) {
+ return isSingleInstructionPreserveOnly(sourceLength, patchCommand.charAt(0), patchCommand.charAt(1));
+ }
+ if (sourceLength == 0) {
+ return hasEmptySourcePreserveOnlyPatch(patchCommand);
+ }
+ return traversalDirection == WordTraversalDirection.BACKWARD
+ ? hasBackwardPreserveOnlyPatch(sourceLength, patchCommand)
+ : hasForwardPreserveOnlyPatch(sourceLength, patchCommand);
+ }
+
+ /**
+ * Tests whether a single instruction preserves the source content.
+ *
+ * @param sourceLength source length
+ * @param opcode opcode
+ * @param argument argument
+ * @return {@code true} when no mutation should be applied
+ */
+ private static boolean isSingleInstructionPreserveOnly(final int sourceLength, final char opcode,
+ final char argument) {
+ switch (opcode) {
+ case DELETE_OPCODE:
+ final int encodedValue = decodeEncodedCount(argument);
+ return encodedValue < 1 || encodedValue > sourceLength;
+ case INSERT_OPCODE:
+ return false;
+ case REPLACE_OPCODE:
+ return sourceLength == 0;
+ case SKIP_OPCODE:
+ return true;
+ case NOOP_OPCODE:
+ if (argument != NOOP_ARGUMENT) {
+ throw new IllegalArgumentException(MSG_NOOP + argument);
+ }
+ return true;
+ default:
+ throw new IllegalArgumentException(MSG_OPCODE + opcode);
+ }
+ }
+
+ /**
+ * Tests whether an empty-source patch preserves the source.
+ *
+ * @param patchCommand patch command
+ * @return {@code true} when no mutation should be applied
+ */
+ private static boolean hasEmptySourcePreserveOnlyPatch(final String patchCommand) {
+ for (int patchIndex = 0, patchLength = patchCommand.length(); patchIndex < patchLength; patchIndex += 2) {
+ final char opcode = patchCommand.charAt(patchIndex);
+ final char argument = patchCommand.charAt(patchIndex + 1);
+ switch (opcode) {
+ case INSERT_OPCODE:
+ break;
+ case SKIP_OPCODE:
+ case REPLACE_OPCODE:
+ case DELETE_OPCODE:
+ return true;
+ case NOOP_OPCODE:
+ if (argument != NOOP_ARGUMENT) {
+ throw new IllegalArgumentException(MSG_NOOP + argument);
+ }
+ return true;
+ default:
+ throw new IllegalArgumentException(MSG_OPCODE + opcode);
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Tests whether a backward patch preserves the source because it is malformed
+ * or a NOOP.
+ *
+ * @param sourceLength source length
+ * @param patchCommand patch command
+ * @return {@code true} when no mutation should be applied
+ */
+ private static boolean hasBackwardPreserveOnlyPatch(final int sourceLength, final String patchCommand) {
+ int currentLength = sourceLength;
+ int position = sourceLength - 1;
+ for (int patchIndex = 0, patchLength = patchCommand.length(); patchIndex < patchLength; patchIndex += 2) {
+ final char opcode = patchCommand.charAt(patchIndex);
+ final char argument = patchCommand.charAt(patchIndex + 1);
+ switch (opcode) {
+ case SKIP_OPCODE:
+ final int skipCount = decodeEncodedCount(argument);
+ if (skipCount < 1) {
+ return true;
+ }
+ position = position - skipCount + 1;
+ break;
+ case REPLACE_OPCODE:
+ if (position < 0 || position >= currentLength) {
+ return true;
+ }
+ break;
+ case DELETE_OPCODE:
+ final int deleteCount = decodeEncodedCount(argument);
+ if (deleteCount < 1) {
+ return true;
+ }
+ final int deleteEndExclusive = position + 1;
+ position -= deleteCount - 1;
+ if (position < 0 || deleteEndExclusive > currentLength || position > deleteEndExclusive) {
+ return true;
+ }
+ currentLength -= deleteEndExclusive - position;
+ break;
+ case INSERT_OPCODE:
+ if (position < -1 || position >= currentLength) {
+ return true;
+ }
+ currentLength++;
+ position++;
+ break;
+ case NOOP_OPCODE:
+ if (argument != NOOP_ARGUMENT) {
+ throw new IllegalArgumentException(MSG_NOOP + argument);
+ }
+ return true;
+ default:
+ throw new IllegalArgumentException(MSG_OPCODE + opcode);
+ }
+ position--;
+ }
+ return false;
+ }
+
+ /**
+ * Tests whether a forward patch preserves the source because it is malformed or
+ * a NOOP.
+ *
+ * @param sourceLength source length
+ * @param patchCommand patch command
+ * @return {@code true} when no mutation should be applied
+ */
+ private static boolean hasForwardPreserveOnlyPatch(final int sourceLength, final String patchCommand) {
+ int currentLength = sourceLength;
+ int position = 0;
+ for (int patchIndex = 0, patchLength = patchCommand.length(); patchIndex < patchLength; patchIndex += 2) {
+ final char opcode = patchCommand.charAt(patchIndex);
+ final char argument = patchCommand.charAt(patchIndex + 1);
+ switch (opcode) {
+ case SKIP_OPCODE:
+ final int skipCount = decodeEncodedCount(argument);
+ if (skipCount < 1) {
+ return true;
+ }
+ position = position + skipCount - 1;
+ break;
+ case REPLACE_OPCODE:
+ if (position < 0 || position >= currentLength) {
+ return true;
+ }
+ break;
+ case DELETE_OPCODE:
+ final int deleteCount = decodeEncodedCount(argument);
+ if (deleteCount < 1 || position < 0 || position + deleteCount > currentLength) {
+ return true;
+ }
+ currentLength -= deleteCount;
+ position--;
+ break;
+ case INSERT_OPCODE:
+ if (position < 0 || position > currentLength) {
+ return true;
+ }
+ currentLength++;
+ break;
+ case NOOP_OPCODE:
+ if (argument != NOOP_ARGUMENT) {
+ throw new IllegalArgumentException(MSG_NOOP + argument);
+ }
+ return true;
+ default:
+ throw new IllegalArgumentException(MSG_OPCODE + opcode);
+ }
+ position++;
+ }
+ return false;
+ }
+
+ /**
+ * Copies source characters from a sequence.
+ *
+ * @param source source text
+ * @param sourceOffset source offset
+ * @param sourceLength source length
+ * @param output output storage
+ * @param outputOffset output offset
+ */
+ private static void copySource(final CharSequence source, final int sourceOffset, final int sourceLength,
+ final char[] output, final int outputOffset) {
+ for (int index = 0; index < sourceLength; index++) {
+ output[outputOffset + index] = source.charAt(sourceOffset + index);
+ }
+ }
+
+ /**
+ * Applies a backward patch after validation.
+ *
+ * @param sourceLength source length
+ * @param patchCommand patch command
+ * @param output output storage initialized with source
+ * @param outputOffset output offset
+ */
+ private static void applyBackwardToOutput(final int sourceLength, final String patchCommand, final char[] output,
+ final int outputOffset) {
+ if (sourceLength == 0) {
+ applyBackwardEmptyToOutput(patchCommand, output, outputOffset);
+ return;
+ }
+
+ int currentLength = sourceLength;
+ int position = sourceLength - 1;
+ for (int patchIndex = 0, patchLength = patchCommand.length(); patchIndex < patchLength; patchIndex += 2) {
+ final char opcode = patchCommand.charAt(patchIndex);
+ final char argument = patchCommand.charAt(patchIndex + 1);
+
+ switch (opcode) {
+ case SKIP_OPCODE:
+ position = position - decodeEncodedCount(argument) + 1;
+ break;
+
+ case REPLACE_OPCODE:
+ output[outputOffset + position] = argument;
+ break;
+
+ case DELETE_OPCODE:
+ final int deleteEndExclusive = position + 1;
+ position -= decodeEncodedCount(argument) - 1;
+ System.arraycopy(output, outputOffset + deleteEndExclusive, output, outputOffset + position,
+ currentLength - deleteEndExclusive);
+ currentLength -= deleteEndExclusive - position;
+ break;
+
+ case INSERT_OPCODE:
+ final int insertIndex = position + 1;
+ System.arraycopy(output, outputOffset + insertIndex, output, outputOffset + insertIndex + 1,
+ currentLength - insertIndex);
+ output[outputOffset + insertIndex] = argument;
+ currentLength++;
+ position++;
+ break;
+
+ case NOOP_OPCODE:
+ return;
+
+ default:
+ throw new AssertionError("Patch command was not validated.");
+ }
+
+ position--;
+ }
+ }
+
+ /**
+ * Applies a forward patch after validation.
+ *
+ * @param sourceLength source length
+ * @param patchCommand patch command
+ * @param output output storage initialized with source
+ * @param outputOffset output offset
+ */
+ private static void applyForwardToOutput(final int sourceLength, final String patchCommand, final char[] output,
+ final int outputOffset) {
+ if (sourceLength == 0) {
+ applyForwardEmptyToOutput(patchCommand, output, outputOffset);
+ return;
+ }
+
+ int currentLength = sourceLength;
+ int position = 0;
+ for (int patchIndex = 0, patchLength = patchCommand.length(); patchIndex < patchLength; patchIndex += 2) {
+ final char opcode = patchCommand.charAt(patchIndex);
+ final char argument = patchCommand.charAt(patchIndex + 1);
+
+ switch (opcode) {
+ case SKIP_OPCODE:
+ position = position + decodeEncodedCount(argument) - 1;
+ break;
+
+ case REPLACE_OPCODE:
+ output[outputOffset + position] = argument;
+ break;
+
+ case DELETE_OPCODE:
+ final int deleteCount = decodeEncodedCount(argument);
+ System.arraycopy(output, outputOffset + position + deleteCount, output, outputOffset + position,
+ currentLength - position - deleteCount);
+ currentLength -= deleteCount;
+ position--;
+ break;
+
+ case INSERT_OPCODE:
+ System.arraycopy(output, outputOffset + position, output, outputOffset + position + 1,
+ currentLength - position);
+ output[outputOffset + position] = argument;
+ currentLength++;
+ break;
+
+ case NOOP_OPCODE:
+ return;
+
+ default:
+ throw new AssertionError("Patch command was not validated.");
+ }
+
+ position++;
+ }
+ }
+
+ /**
+ * Applies an empty-source backward patch after validation.
+ *
+ * @param patchCommand patch command
+ * @param output output storage
+ * @param outputOffset output offset
+ */
+ private static void applyBackwardEmptyToOutput(final String patchCommand, final char[] output,
+ final int outputOffset) {
+ int currentLength = 0;
+ for (int patchIndex = 0, patchLength = patchCommand.length(); patchIndex < patchLength; patchIndex += 2) {
+ final char argument = patchCommand.charAt(patchIndex + 1);
+ System.arraycopy(output, outputOffset, output, outputOffset + 1, currentLength);
+ output[outputOffset] = argument;
+ currentLength++;
+ }
+ }
+
+ /**
+ * Applies an empty-source forward patch after validation.
+ *
+ * @param patchCommand patch command
+ * @param output output storage
+ * @param outputOffset output offset
+ */
+ private static void applyForwardEmptyToOutput(final String patchCommand, final char[] output,
+ final int outputOffset) {
+ int currentLength = 0;
+ for (int patchIndex = 0, patchLength = patchCommand.length(); patchIndex < patchLength; patchIndex += 2) {
+ output[outputOffset + currentLength] = patchCommand.charAt(patchIndex + 1);
+ currentLength++;
+ }
+ }
+
+ /**
+ * Validates that source and output slices do not overlap when backed by the
+ * same array.
+ *
+ * @param source source storage
+ * @param sourceOffset source offset
+ * @param sourceLength source length
+ * @param output output storage
+ * @param outputOffset output offset
+ * @param outputLength output length
+ */
+ private static void validateNonOverlappingRanges(final char[] source, final int sourceOffset,
+ final int sourceLength, final char[] output, final int outputOffset, final int outputLength) {
+ if (!source.equals(output) || sourceLength == 0 || outputLength == 0) {
+ return;
+ }
+ final int sourceEnd = sourceOffset + sourceLength;
+ final int outputEnd = outputOffset + outputLength;
+ if (sourceOffset < outputEnd && outputOffset < sourceEnd) {
+ throw new IllegalArgumentException("source and output ranges must not overlap.");
+ }
+ }
+
/**
* Returns the direction-specialized apply strategy.
*
@@ -769,7 +1551,6 @@ public final class PatchCommandEncoder {
* @param argument serialized count argument
* @return decoded positive count, or {@code -1} when the argument is malformed
*/
- @SuppressWarnings("PMD.AvoidLiteralsInIfCondition")
private static int decodeEncodedCount(final char argument) {
if (argument < 'a') {
return -1;
diff --git a/src/main/java/org/egothor/stemmer/StemmerPatchTrieBinaryIO.java b/src/main/java/org/egothor/stemmer/StemmerPatchTrieBinaryIO.java
index 8f8965a..f91ecd8 100644
--- a/src/main/java/org/egothor/stemmer/StemmerPatchTrieBinaryIO.java
+++ b/src/main/java/org/egothor/stemmer/StemmerPatchTrieBinaryIO.java
@@ -95,8 +95,8 @@ public final class StemmerPatchTrieBinaryIO {
}
/**
- * Reads a GZip-compressed binary patch-command trie from a filesystem path
- * with an optional dense child lookup span override.
+ * Reads a GZip-compressed binary patch-command trie from a filesystem path with
+ * an optional dense child lookup span override.
*
* This is a runtime-only tuning parameter. The dense-span setting is not
* persisted in the file and does not change the compiled metadata.
@@ -183,14 +183,15 @@ public final class StemmerPatchTrieBinaryIO {
* persisted in the file and does not change the compiled metadata.
*
*
- * @param inputStream source stream
+ * @param inputStream source stream
* @param maxExpandedIndex dense lookup span override; negative values use
* {@link FrequencyTrie#DEFAULT_MAX_EXPANDED_INDEX}
* @return deserialized trie
* @throws NullPointerException if {@code inputStream} is {@code null}
* @throws IOException if reading or decompression fails
*/
- public static FrequencyTrie read(final InputStream inputStream, final int maxExpandedIndex) throws IOException {
+ public static FrequencyTrie read(final InputStream inputStream, final int maxExpandedIndex)
+ throws IOException {
Objects.requireNonNull(inputStream, "inputStream");
try (GZIPInputStream gzipInputStream = new GZIPInputStream(new BufferedInputStream(inputStream));
diff --git a/src/main/java/org/egothor/stemmer/StemmerPatchTrieLoader.java b/src/main/java/org/egothor/stemmer/StemmerPatchTrieLoader.java
index 9e60b68..5161a05 100644
--- a/src/main/java/org/egothor/stemmer/StemmerPatchTrieLoader.java
+++ b/src/main/java/org/egothor/stemmer/StemmerPatchTrieLoader.java
@@ -461,7 +461,7 @@ public final class StemmerPatchTrieLoader {
public static FrequencyTrie load(final Path path, final boolean storeOriginal,
final ReductionSettings reductionSettings, final WordTraversalDirection traversalDirection,
final CaseProcessingMode caseProcessingMode, final DiacriticProcessingMode diacriticProcessingMode)
- throws IOException {
+ throws IOException {
Objects.requireNonNull(path, PARAMETER_PATH);
final TrieMetadata metadata = metadataForCompilation(traversalDirection, reductionSettings, caseProcessingMode,
diacriticProcessingMode);
@@ -816,7 +816,8 @@ public final class StemmerPatchTrieLoader {
* @throws IOException if the file cannot be opened, decompressed, or
* read
*/
- public static FrequencyTrie loadBinary(final String fileName, final int maxExpandedIndex) throws IOException {
+ public static FrequencyTrie loadBinary(final String fileName, final int maxExpandedIndex)
+ throws IOException {
Objects.requireNonNull(fileName, FILENAME_REQUIRED);
return StemmerPatchTrieBinaryIO.read(fileName, maxExpandedIndex);
}
diff --git a/src/main/java/org/egothor/stemmer/package-info.java b/src/main/java/org/egothor/stemmer/package-info.java
index 315cc99..0c7ce0c 100644
--- a/src/main/java/org/egothor/stemmer/package-info.java
+++ b/src/main/java/org/egothor/stemmer/package-info.java
@@ -58,17 +58,17 @@
* {@link org.egothor.stemmer.StemmerPatchTrieLoader}, which reads the
* traditional line-oriented tab-separated values resource format in which each
* non-empty logical line starts with a canonical stem followed by known surface
- * variants in subsequent tab-separated columns.
- * Parsing is delegated to {@link org.egothor.stemmer.StemmerDictionaryParser},
- * which applies configurable case processing through
+ * variants in subsequent tab-separated columns. Parsing is delegated to
+ * {@link org.egothor.stemmer.StemmerDictionaryParser}, which applies
+ * configurable case processing through
* {@link org.egothor.stemmer.CaseProcessingMode} (default:
* {@link org.egothor.stemmer.CaseProcessingMode#LOWERCASE_WITH_LOCALE_ROOT}),
* supports whole-line as well as trailing remarks introduced by {@code #} or
* {@code //}, and currently ignores dictionary items containing Unicode
* whitespace characters while reporting them through warning-level diagnostics.
- * During loading, each variant is converted into a patch command
- * targeting the canonical stem, and the stem itself may optionally be stored
- * under the canonical no-operation patch.
+ * During loading, each variant is converted into a patch command targeting the
+ * canonical stem, and the stem itself may optionally be stored under the
+ * canonical no-operation patch.
*
*
*
diff --git a/src/main/java/org/egothor/stemmer/trie/CompiledNode.java b/src/main/java/org/egothor/stemmer/trie/CompiledNode.java
index c48d795..4aad015 100644
--- a/src/main/java/org/egothor/stemmer/trie/CompiledNode.java
+++ b/src/main/java/org/egothor/stemmer/trie/CompiledNode.java
@@ -48,8 +48,8 @@ import java.util.Objects;
public final class CompiledNode {
/**
- * Default dense child lookup span in characters used when an explicit override is
- * not provided.
+ * Default dense child lookup span in characters used when an explicit override
+ * is not provided.
*/
public static final int DEFAULT_MAX_EXPANDED_INDEX = 512;
@@ -71,8 +71,8 @@ public final class CompiledNode {
/**
* Dense child lookup table used when labels fit into a compact char interval.
*
- * The table enables direct O(1) indexing for child lookup and is allocated
- * only when the character span of this node's edges is within the configured
+ * The table enables direct O(1) indexing for child lookup and is allocated only
+ * when the character span of this node's edges is within the configured
* threshold.
*
*/
@@ -111,8 +111,8 @@ public final class CompiledNode {
*
* @param maxExpandedIndex upper bound for the dense lookup interval size; zero
* disables dense lookup. Larger values improve
- * direct-index likelihood while increasing dense
- * table memory in compact-label nodes.
+ * direct-index likelihood while increasing dense table
+ * memory in compact-label nodes.
* @throws NullPointerException if any array argument is {@code null}
* @throws IllegalArgumentException if the edge-related arrays or value-related
* arrays do not have matching lengths or the
@@ -288,7 +288,8 @@ public final class CompiledNode {
}
/**
- * Returns a small memory-related metric describing this node's dense table size.
+ * Returns a small memory-related metric describing this node's dense table
+ * size.
*
* @return number of dense table slots, or {@code 0} when dense lookup is not
* enabled
@@ -328,8 +329,9 @@ public final class CompiledNode {
return false;
}
return Arrays.equals(this.edgeLabels, other.edgeLabels) && Arrays.equals(this.children, other.children)
- && Arrays.equals(this.orderedValues, other.orderedValues) && Arrays.equals(this.orderedCounts, other.orderedCounts)
- && this.denseEdgeMin == other.denseEdgeMin && Arrays.equals(this.denseChildren, other.denseChildren);
+ && Arrays.equals(this.orderedValues, other.orderedValues)
+ && Arrays.equals(this.orderedCounts, other.orderedCounts) && this.denseEdgeMin == other.denseEdgeMin
+ && Arrays.equals(this.denseChildren, other.denseChildren);
}
/**
@@ -339,9 +341,8 @@ public final class CompiledNode {
*/
@Override
public String toString() {
- return "CompiledNode{"
- + "edgeCount=" + this.edgeLabels.length + ", orderedValueCount=" + this.orderedValues.length
- + ", denseTableLength=" + denseTableLength() + '}';
+ return "CompiledNode{" + "edgeCount=" + this.edgeLabels.length + ", orderedValueCount="
+ + this.orderedValues.length + ", denseTableLength=" + denseTableLength() + '}';
}
/**
@@ -350,8 +351,8 @@ public final class CompiledNode {
* Lookup order is:
*
* - dense array index (if the label interval is compact enough),
- * - small-child linear scan when the fallback node has {@value #LINEAR_CHILD_COUNT_THRESHOLD}
- * or fewer edges,
+ * - small-child linear scan when the fallback node has
+ * {@value #LINEAR_CHILD_COUNT_THRESHOLD} or fewer edges,
* - binary search over sorted labels.
*
*
diff --git a/src/test/java/org/egothor/stemmer/FrequencyTrieTest.java b/src/test/java/org/egothor/stemmer/FrequencyTrieTest.java
index 7afbe15..91059c5 100644
--- a/src/test/java/org/egothor/stemmer/FrequencyTrieTest.java
+++ b/src/test/java/org/egothor/stemmer/FrequencyTrieTest.java
@@ -45,6 +45,7 @@ import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
+import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
@@ -63,6 +64,7 @@ import org.junit.jupiter.api.Test;
@Tag("unit")
@Tag("trie")
@Tag("frequency-trie")
+@Tag("lookup")
@DisplayName("FrequencyTrie")
class FrequencyTrieTest {
@@ -398,6 +400,149 @@ class FrequencyTrieTest {
() -> assertThrows(UnsupportedOperationException.class, () -> entries.add(new ValueCount("z", 1))));
}
+ /**
+ * Verifies that visitor lookup returns the same deterministic order and counts
+ * as the allocating APIs.
+ */
+ @Test
+ @DisplayName("Visitor lookup matches getAll order and getEntries counts")
+ void visitorLookupMatchesGetAllOrderAndGetEntriesCounts() {
+ final FrequencyTrie.Builder builder = rankedBuilder();
+ builder.put("house", "noun", 3);
+ builder.put("house", "verb", 2);
+ builder.put("house", "adjective", 1);
+ final FrequencyTrie trie = builder.build();
+ final List values = new ArrayList<>();
+ final List counts = new ArrayList<>();
+ final List ranks = new ArrayList<>();
+
+ final int visited = trie.getAllNormalized("house", (value, count, rank) -> {
+ values.add(value);
+ counts.add(count);
+ ranks.add(rank);
+ return true;
+ }, 10);
+
+ assertAll(() -> assertEquals(3, visited),
+ () -> assertEquals(List.of("noun", "verb", "adjective"), values),
+ () -> assertEquals(List.of(3, 2, 1), counts),
+ () -> assertEquals(List.of(0, 1, 2), ranks));
+ }
+
+ /**
+ * Verifies visitor maximum result and early-stop behavior.
+ */
+ @Test
+ @DisplayName("Visitor lookup honors maxResults and sink early stop")
+ void visitorLookupHonorsMaxResultsAndSinkEarlyStop() {
+ final FrequencyTrie.Builder builder = rankedBuilder();
+ builder.put("house", "noun", 3);
+ builder.put("house", "verb", 2);
+ builder.put("house", "adjective", 1);
+ final FrequencyTrie trie = builder.build();
+ final List limited = new ArrayList<>();
+ final List stopped = new ArrayList<>();
+
+ final int limitedCount = trie.getAllNormalized("house", (value, count, rank) -> {
+ limited.add(value);
+ return true;
+ }, 2);
+ final int stoppedCount = trie.getAllNormalized("house", (value, count, rank) -> {
+ stopped.add(value);
+ return false;
+ }, 10);
+
+ assertAll(() -> assertEquals(2, limitedCount),
+ () -> assertEquals(List.of("noun", "verb"), limited),
+ () -> assertEquals(1, stoppedCount),
+ () -> assertEquals(List.of("noun"), stopped));
+ }
+
+ /**
+ * Verifies visitor zero, negative, missing, and first-result behavior.
+ */
+ @Test
+ @DisplayName("Visitor lookup handles zero, negative, missing, and first-result cases")
+ void visitorLookupHandlesBoundaryCases() {
+ final FrequencyTrie.Builder builder = rankedBuilder();
+ builder.put("house", "noun");
+ final FrequencyTrie trie = builder.build();
+ final int[] calls = new int[1];
+
+ assertAll(() -> assertEquals(0, trie.getAllNormalized("house", (value, count, rank) -> {
+ calls[0]++;
+ return true;
+ }, 0)),
+ () -> assertEquals(0, calls[0]),
+ () -> assertThrows(IllegalArgumentException.class,
+ () -> trie.getAllNormalized("house", (value, count, rank) -> true, -1)),
+ () -> assertEquals(0, trie.getAllNormalized("missing", (value, count, rank) -> true, 10)),
+ () -> assertFalse(trie.getFirstNormalized("missing", (value, count, rank) -> true)),
+ () -> assertTrue(trie.getFirstNormalized("house", (value, count, rank) -> {
+ assertEquals("noun", value);
+ assertEquals(1, count);
+ assertEquals(0, rank);
+ return true;
+ })));
+ }
+
+ /**
+ * Verifies visitor API argument validation.
+ */
+ @Test
+ @DisplayName("Visitor lookup rejects null and invalid range arguments")
+ void visitorLookupRejectsNullAndInvalidRangeArguments() {
+ final FrequencyTrie trie = rankedBuilder().build();
+ final char[] key = "house".toCharArray();
+ final FrequencyTrie.EntrySink sink = (value, count, rank) -> true;
+
+ assertAll(() -> assertThrows(NullPointerException.class,
+ () -> trie.getAllNormalized((char[]) null, 0, 0, sink, 1)),
+ () -> assertThrows(NullPointerException.class,
+ () -> trie.getAllNormalized(key, 0, key.length, null, 1)),
+ () -> assertThrows(IndexOutOfBoundsException.class,
+ () -> trie.getAllNormalized(key, -1, key.length, sink, 1)),
+ () -> assertThrows(IndexOutOfBoundsException.class,
+ () -> trie.getAllNormalized(key, 1, key.length, sink, 1)),
+ () -> assertThrows(NullPointerException.class,
+ () -> trie.getAllNormalized((CharSequence) null, sink, 1)),
+ () -> assertThrows(NullPointerException.class,
+ () -> trie.getAllNormalized("house", null, 1)),
+ () -> assertThrows(NullPointerException.class,
+ () -> trie.getAll((CharSequence) null, sink, 1)),
+ () -> assertThrows(NullPointerException.class,
+ () -> trie.getAll("house", null, 1)),
+ () -> assertThrows(IllegalArgumentException.class,
+ () -> trie.getAll("house", sink, -1)));
+ }
+
+ /**
+ * Verifies normalized char-array slices and metadata-aware CharSequence visitor
+ * lookup.
+ */
+ @Test
+ @DisplayName("Visitor lookup supports normalized char slices and metadata-aware CharSequence keys")
+ void visitorLookupSupportsCharSlicesAndMetadataAwareKeys() {
+ final FrequencyTrie.Builder builder = new FrequencyTrie.Builder<>(String[]::new,
+ ReductionSettings.withDefaults(ReductionMode.MERGE_SUBTREES_WITH_EQUIVALENT_RANKED_GET_ALL_RESULTS),
+ WordTraversalDirection.BACKWARD, CaseProcessingMode.LOWERCASE_WITH_LOCALE_ROOT);
+ builder.put("house", "noun");
+ final FrequencyTrie trie = builder.build();
+ final char[] padded = "__house__".toCharArray();
+
+ assertAll(() -> assertEquals(1,
+ trie.getAllNormalized(padded, 2, 5, (value, count, rank) -> {
+ assertEquals("noun", value);
+ return true;
+ }, 10)),
+ () -> assertFalse(trie.getFirstNormalized("HOUSE", (value, count, rank) -> true),
+ "Normalized lookup must bypass metadata lowercasing."),
+ () -> assertTrue(trie.getFirst("HOUSE", (value, count, rank) -> {
+ assertEquals("noun", value);
+ return true;
+ })));
+ }
+
/**
* Verifies that equal frequencies prefer the shorter string representation.
*/
diff --git a/src/test/java/org/egothor/stemmer/PatchCommandEncoderTest.java b/src/test/java/org/egothor/stemmer/PatchCommandEncoderTest.java
index b608ef7..88cace9 100644
--- a/src/test/java/org/egothor/stemmer/PatchCommandEncoderTest.java
+++ b/src/test/java/org/egothor/stemmer/PatchCommandEncoderTest.java
@@ -31,6 +31,7 @@
package org.egothor.stemmer;
import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
@@ -68,6 +69,8 @@ import org.junit.jupiter.params.provider.MethodSource;
@Tag("unit")
@Tag("stemmer")
@Tag("patch")
+@Tag("encoding")
+@Tag("apply")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class PatchCommandEncoderTest {
@@ -147,6 +150,63 @@ class PatchCommandEncoderTest {
Arguments.of(10, "teacher", PatchCommandEncoder.NOOP_PATCH, "teacher"));
}
+ /**
+ * Provides explicit forward-direction single-instruction patch application cases.
+ *
+ * @return test arguments
+ */
+ private static Stream provideForwardSingleInstructionApplyCases() {
+ return Stream.of(
+ // 1
+ Arguments.of(1, "abcd", "Db", "cd"),
+ // 2
+ Arguments.of(2, "abc", "Ia", "aabc"),
+ // 3
+ Arguments.of(3, "abc", "Ra", "abc"),
+ // 4
+ Arguments.of(4, "abc", "-a", "abc"),
+ // 5
+ Arguments.of(5, "abc", PatchCommandEncoder.NOOP_PATCH, "abc"));
+ }
+
+ /**
+ * Provides forward-direction applyTo cases that exercise preserve-only and
+ * non-preserve branches.
+ *
+ * @return test arguments
+ */
+ private static Stream provideForwardApplyToCases() {
+ return Stream.of(
+ // 1
+ Arguments.of(1, "book", "-aRa", "baok"),
+ // 2
+ Arguments.of(2, "abc", "-dRa", "abc"),
+ // 3
+ Arguments.of(3, "abc", "DdRa", "abc"),
+ // 4
+ Arguments.of(4, "abc", "-dIa", "abc"),
+ // 5
+ Arguments.of(5, "abc", "Na-a", "abc"));
+ }
+
+ /**
+ * Provides empty-source forward applyTo cases that cover empty-source
+ * instruction handling.
+ *
+ * @return test arguments
+ */
+ private static Stream provideForwardEmptySourceApplyCases() {
+ return Stream.of(
+ // 1
+ Arguments.of(1, "IaIb", "ab"),
+ // 2
+ Arguments.of(2, "-aRa", ""),
+ // 3
+ Arguments.of(3, "IaRa", ""),
+ // 4
+ Arguments.of(4, "Na-a", ""));
+ }
+
/**
* Provides malformed or index-invalid patch inputs that must preserve the
* original source according to the implementation contract.
@@ -236,12 +296,31 @@ class PatchCommandEncoderTest {
return new StringBuilder(text).reverse().toString();
}
+ /**
+ * Applies a patch into a right-sized output buffer and returns the produced
+ * string.
+ *
+ * @param source source text
+ * @param patch patch command
+ * @param traversalDirection traversal direction
+ * @return transformed text
+ */
+ private static String applyToString(final String source, final String patch,
+ final WordTraversalDirection traversalDirection) {
+ final char[] output = new char[Math.max(source.length() + 16, 16)];
+ final int produced = PatchCommandEncoder.applyTo(source, patch, traversalDirection, output, 0, output.length);
+ return new String(output, 0, produced);
+ }
+
/**
* Tests constructor validation and basic instantiation behavior.
*/
@Nested
@DisplayName("construction")
@Tag("construction")
+ @Tag("unit")
+ @Tag("stemmer")
+ @Tag("patch")
class ConstructionTests {
/**
@@ -327,6 +406,9 @@ class PatchCommandEncoderTest {
@Nested
@DisplayName("encode(String, String)")
@Tag("encoding")
+ @Tag("unit")
+ @Tag("stemmer")
+ @Tag("patch")
class EncodeTests {
/**
@@ -461,6 +543,9 @@ class PatchCommandEncoderTest {
@Nested
@DisplayName("apply(String, String)")
@Tag("apply")
+ @Tag("unit")
+ @Tag("stemmer")
+ @Tag("patch")
class ApplyTests {
/**
@@ -535,6 +620,101 @@ class PatchCommandEncoderTest {
assertEquals("city", PatchCommandEncoder.apply("cities", patch, WordTraversalDirection.FORWARD));
}
+ /**
+ * Verifies explicit single-instruction forward patch application
+ * semantics.
+ *
+ * @param caseId numeric case identifier
+ * @param source source word
+ * @param patch encoded patch command
+ * @param expected expected transformed word
+ */
+ @ParameterizedTest(name = "[{index}] case {0}: forward single instruction apply({1}, {2}) -> {3}")
+ @MethodSource("org.egothor.stemmer.PatchCommandEncoderTest#provideForwardSingleInstructionApplyCases")
+ @DisplayName("applies forward single instruction patches correctly")
+ void shouldApplyForwardSingleInstructionsExplicitly(int caseId, String source, String patch, String expected) {
+ assertEquals(expected, PatchCommandEncoder.apply(source, patch, WordTraversalDirection.FORWARD),
+ () -> "Case " + caseId + " failed for source='" + source + "', patch='" + patch + "'.");
+ }
+
+ /**
+ * Verifies forward single-instruction malformed commands fail fast.
+ */
+ @Test
+ @DisplayName("throws for unsupported forward opcode and NOOP argument")
+ void shouldThrowForUnsupportedForwardOpcodeAndNoopArgument() {
+ assertAll(() -> assertEquals("Unsupported patch opcode: X",
+ assertThrows(IllegalArgumentException.class,
+ () -> PatchCommandEncoder.apply("abc", "Xa", WordTraversalDirection.FORWARD))
+ .getMessage()),
+ () -> assertEquals("Unsupported NOOP patch argument: `",
+ assertThrows(IllegalArgumentException.class,
+ () -> PatchCommandEncoder.apply("abc", "N`", WordTraversalDirection.FORWARD))
+ .getMessage()));
+ }
+
+ /**
+ * Verifies explicit forward-applyTo cases that exercise preserve-only and
+ * non-preserve branches.
+ *
+ * @param caseId numeric case identifier
+ * @param source source word
+ * @param patch encoded patch command
+ * @param expected expected transformed word
+ */
+ @ParameterizedTest(name = "[{index}] case {0}: applyToForward({1}, {2}) -> {3}")
+ @MethodSource("org.egothor.stemmer.PatchCommandEncoderTest#provideForwardApplyToCases")
+ @DisplayName("applyTo handles forward preserve-only and mutation branches")
+ void shouldApplyToForwardPreserveAndMutationBranches(int caseId, String source, String patch, String expected) {
+ final char[] output = new char[Math.max(source.length() + 16, 16)];
+
+ final int produced = PatchCommandEncoder.applyTo(source, patch, WordTraversalDirection.FORWARD, output, 0,
+ output.length);
+
+ assertAll(
+ () -> assertEquals(expected.length(), produced,
+ () -> "Case " + caseId + " produced wrong length."),
+ () -> assertEquals(expected, new String(output, 0, produced),
+ () -> "Case " + caseId + " failed for patch='" + patch + "'."));
+ }
+
+ /**
+ * Verifies empty-source forward applyTo behavior for insert-only and malformed
+ * instructions.
+ *
+ * @param caseId numeric case identifier
+ * @param patch encoded patch command
+ * @param expected expected transformed word
+ */
+ @ParameterizedTest(name = "[{index}] case {0}: applyToForward(\"\", {1}) -> \"{2}\"")
+ @MethodSource("org.egothor.stemmer.PatchCommandEncoderTest#provideForwardEmptySourceApplyCases")
+ @DisplayName("applies forward empty-source patches correctly")
+ void shouldApplyToForwardEmptySourceCases(int caseId, String patch, String expected) {
+ final char[] output = new char[Math.max(expected.length() + 16, 16)];
+
+ final int produced = PatchCommandEncoder.applyTo("", patch, WordTraversalDirection.FORWARD, output, 0,
+ output.length);
+
+ assertAll(
+ () -> assertEquals(expected.length(), produced,
+ () -> "Case " + caseId + " produced wrong length."),
+ () -> assertEquals(expected, new String(output, 0, produced),
+ () -> "Case " + caseId + " failed for patch='" + patch + "'."));
+ }
+
+ /**
+ * Verifies malformed empty-source forward patches fail fast and preserve
+ * empty-source semantics.
+ */
+ @Test
+ @DisplayName("throws for unsupported NOOP argument on empty-source forward patch")
+ void shouldThrowForUnsupportedNoopArgumentOnForwardEmptySource() {
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> PatchCommandEncoder.apply("", "N`Ra", WordTraversalDirection.FORWARD));
+
+ assertEquals("Unsupported NOOP patch argument: `", exception.getMessage());
+ }
+
/**
* Verifies explicit patch application cases.
*
@@ -590,6 +770,181 @@ class PatchCommandEncoderTest {
assertEquals(source, PatchCommandEncoder.apply(source, malformedPatch), () -> "Case " + caseId
+ " failed for source='" + source + "', malformedPatch='" + malformedPatch + "'.");
}
+
+ /**
+ * Verifies buffer application against string-returning application.
+ *
+ * @param caseId numeric case identifier
+ * @param source source word
+ * @param patch patch command
+ * @param expected expected transformed word
+ */
+ @ParameterizedTest(name = "[{index}] case {0}: applyTo({1}, {2}) -> {3}")
+ @MethodSource("org.egothor.stemmer.PatchCommandEncoderTest#provideApplyCases")
+ @DisplayName("applyTo matches apply for explicit backward patch commands")
+ void shouldApplyToBufferLikeApplyForBackwardCommands(int caseId, String source, String patch, String expected) {
+ final char[] output = "___..............".toCharArray();
+
+ final int produced = PatchCommandEncoder.applyTo(source, patch, WordTraversalDirection.BACKWARD, output, 3,
+ output.length - 3);
+
+ assertAll(() -> assertEquals(expected.length(), produced, () -> "Case " + caseId + " produced wrong length."),
+ () -> assertEquals(expected, new String(output, 3, produced)));
+ }
+
+ /**
+ * Verifies char-array source slices.
+ */
+ @Test
+ @DisplayName("applyTo supports char-array source slices")
+ void shouldApplyToCharArraySourceSlice() {
+ final char[] source = "__teacher__".toCharArray();
+ final char[] output = new char[16];
+
+ final int produced = PatchCommandEncoder.applyTo(source, 2, 7, "Db", WordTraversalDirection.BACKWARD,
+ output, 1, output.length - 1);
+
+ assertAll(() -> assertEquals(5, produced), () -> assertEquals("teach", new String(output, 1, produced)));
+ }
+
+ /**
+ * Verifies null and range validation for buffer application.
+ */
+ @Test
+ @DisplayName("applyTo rejects null and invalid range arguments")
+ void shouldRejectNullAndInvalidRangeArguments() {
+ final char[] source = "teacher".toCharArray();
+ final char[] output = new char[16];
+
+ assertAll(() -> assertThrows(NullPointerException.class,
+ () -> PatchCommandEncoder.applyTo((CharSequence) null, "Db", WordTraversalDirection.BACKWARD,
+ output, 0, output.length)),
+ () -> assertThrows(NullPointerException.class,
+ () -> PatchCommandEncoder.applyTo("teacher", "Db", null, output, 0, output.length)),
+ () -> assertThrows(NullPointerException.class,
+ () -> PatchCommandEncoder.applyTo("teacher", "Db", WordTraversalDirection.BACKWARD, null,
+ 0, output.length)),
+ () -> assertThrows(IndexOutOfBoundsException.class,
+ () -> PatchCommandEncoder.applyTo("teacher", "Db", WordTraversalDirection.BACKWARD,
+ output, -1, output.length)),
+ () -> assertThrows(NullPointerException.class,
+ () -> PatchCommandEncoder.applyTo((char[]) null, 0, 7, "Db",
+ WordTraversalDirection.BACKWARD, output, 0, output.length)),
+ () -> assertThrows(IndexOutOfBoundsException.class,
+ () -> PatchCommandEncoder.applyTo(source, 1, source.length, "Db",
+ WordTraversalDirection.BACKWARD, output, 0, output.length)));
+ }
+
+ /**
+ * Verifies null, empty, and canonical NOOP patch preservation.
+ */
+ @Test
+ @DisplayName("applyTo preserves source for null, empty, and canonical NOOP patches")
+ void shouldApplyToPreserveSourceForEmptyCompatibilityPatches() {
+ final char[] nullPatchOutput = new char[8];
+ final char[] emptyPatchOutput = new char[8];
+ final char[] noopOutput = new char[8];
+
+ final int nullPatchLength = PatchCommandEncoder.applyTo("teacher", null, WordTraversalDirection.BACKWARD,
+ nullPatchOutput, 0, nullPatchOutput.length);
+ final int emptyPatchLength = PatchCommandEncoder.applyTo("teacher", "", WordTraversalDirection.BACKWARD,
+ emptyPatchOutput, 0, emptyPatchOutput.length);
+ final int noopLength = PatchCommandEncoder.applyTo("teacher", PatchCommandEncoder.NOOP_PATCH,
+ WordTraversalDirection.BACKWARD, noopOutput, 0, noopOutput.length);
+
+ assertAll(() -> assertEquals(7, nullPatchLength),
+ () -> assertEquals("teacher", new String(nullPatchOutput, 0, nullPatchLength)),
+ () -> assertEquals(7, emptyPatchLength),
+ () -> assertEquals("teacher", new String(emptyPatchOutput, 0, emptyPatchLength)),
+ () -> assertEquals(7, noopLength),
+ () -> assertEquals("teacher", new String(noopOutput, 0, noopLength)));
+ }
+
+ /**
+ * Verifies insufficient capacity behavior.
+ */
+ @Test
+ @DisplayName("applyTo reports insufficient capacity without writing output")
+ void shouldReportInsufficientCapacityWithoutWritingOutput() {
+ final char[] output = "xxxx".toCharArray();
+
+ final int produced = PatchCommandEncoder.applyTo("abc", "Ic", WordTraversalDirection.BACKWARD, output, 0,
+ output.length - 1);
+
+ assertAll(() -> assertEquals(PatchCommandEncoder.APPLY_INSUFFICIENT_CAPACITY, produced),
+ () -> assertArrayEquals("xxxx".toCharArray(), output));
+ }
+
+ /**
+ * Verifies exception parity with string-returning application.
+ */
+ @Test
+ @DisplayName("applyTo throws for unsupported opcode and NOOP argument")
+ void shouldApplyToThrowForUnsupportedOpcodeAndNoopArgument() {
+ final char[] output = new char[8];
+
+ assertAll(() -> {
+ final IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> PatchCommandEncoder.applyTo("abc", "Xa", WordTraversalDirection.BACKWARD, output, 0,
+ output.length));
+ assertEquals("Unsupported patch opcode: X", exception.getMessage());
+ }, () -> {
+ final IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> PatchCommandEncoder.applyTo("abc", "Nb", WordTraversalDirection.BACKWARD, output, 0,
+ output.length));
+ assertEquals("Unsupported NOOP patch argument: b", exception.getMessage());
+ });
+ }
+
+ /**
+ * Verifies malformed compatibility behavior for buffer application.
+ *
+ * @param caseId numeric case identifier
+ * @param source original source
+ * @param malformedPatch malformed patch
+ */
+ @ParameterizedTest(name = "[{index}] case {0}: malformed applyTo patch {2} preserves {1}")
+ @MethodSource("org.egothor.stemmer.PatchCommandEncoderTest#provideMalformedPatchCases")
+ @DisplayName("applyTo preserves source for malformed or index-invalid patch commands")
+ void shouldApplyToPreserveSourceForMalformedOrIndexInvalidPatchCommands(int caseId, String source,
+ String malformedPatch) {
+ final char[] output = new char[Math.max(source.length(), 1)];
+
+ final int produced = PatchCommandEncoder.applyTo(source, malformedPatch, WordTraversalDirection.BACKWARD,
+ output, 0, output.length);
+
+ assertAll(() -> assertEquals(source.length(), produced, () -> "Case " + caseId + " produced wrong length."),
+ () -> assertEquals(source, new String(output, 0, produced)));
+ }
+
+ /**
+ * Verifies explicit traversal direction for buffer application.
+ */
+ @Test
+ @DisplayName("applyTo follows explicit forward traversal direction")
+ void shouldApplyToWithForwardTraversalDirection() {
+ final PatchCommandEncoder encoder = PatchCommandEncoder.builder()
+ .traversalDirection(WordTraversalDirection.FORWARD)
+ .build();
+ final String patch = encoder.encode("cities", "city");
+
+ assertEquals(PatchCommandEncoder.apply("cities", patch, WordTraversalDirection.FORWARD),
+ applyToString("cities", patch, WordTraversalDirection.FORWARD));
+ }
+
+ /**
+ * Verifies overlapping source/output slices are rejected.
+ */
+ @Test
+ @DisplayName("applyTo rejects overlapping char-array source and output ranges")
+ void shouldRejectOverlappingSourceAndOutputRanges() {
+ final char[] buffer = "teacher....".toCharArray();
+
+ final IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> PatchCommandEncoder.applyTo(buffer, 0, 7, "Db", WordTraversalDirection.BACKWARD, buffer, 2, 5));
+
+ assertEquals("source and output ranges must not overlap.", exception.getMessage());
+ }
}
/**
@@ -598,6 +953,9 @@ class PatchCommandEncoderTest {
@Nested
@DisplayName("stemming-oriented scenarios")
@Tag("regression")
+ @Tag("unit")
+ @Tag("stemmer")
+ @Tag("patch")
class StemmingScenarioTests {
/**
@@ -659,6 +1017,9 @@ class PatchCommandEncoderTest {
@Nested
@DisplayName("reversed-word processing")
@Tag("normalization")
+ @Tag("unit")
+ @Tag("stemmer")
+ @Tag("patch")
class ReversedWordProcessingTests {
/**
@@ -743,6 +1104,7 @@ class PatchCommandEncoderTest {
*/
@ParameterizedTest(name = "[{index}] case {0}: mirrored consistency for {1} -> {2}")
@MethodSource("org.egothor.stemmer.PatchCommandEncoderTest#provideReversedRoundTripPairs")
+ @Tag("normalization")
@DisplayName("preserves correctness under mirrored input orientation")
void shouldPreserveCorrectnessUnderMirroredInputOrientation(int caseId, String source, String target) {
PatchCommandEncoder encoder = PatchCommandEncoder.builder().build();
diff --git a/src/test/java/org/egothor/stemmer/trie/CompiledNodeAndNodeDataTest.java b/src/test/java/org/egothor/stemmer/trie/CompiledNodeAndNodeDataTest.java
index b8a9a93..bac0a5f 100644
--- a/src/test/java/org/egothor/stemmer/trie/CompiledNodeAndNodeDataTest.java
+++ b/src/test/java/org/egothor/stemmer/trie/CompiledNodeAndNodeDataTest.java
@@ -46,9 +46,39 @@ import org.junit.jupiter.api.Test;
*/
@Tag("unit")
@Tag("trie")
+@Tag("lookup")
@DisplayName("CompiledNode and NodeData")
class CompiledNodeAndNodeDataTest {
+ /**
+ * Creates a typed child array for compiled-node tests.
+ *
+ * @param length requested array length
+ * @return typed child array
+ */
+ @SuppressWarnings("unchecked")
+ private static CompiledNode[] children(final int length) {
+ return new CompiledNode[length];
+ }
+
+ /**
+ * Creates an empty child array for leaf compiled-node tests.
+ *
+ * @return empty typed child array
+ */
+ private static CompiledNode[] noChildren() {
+ return children(0);
+ }
+
+ /**
+ * Creates a leaf node used as a child in lookup tests.
+ *
+ * @return leaf node
+ */
+ private static CompiledNode leaf() {
+ return new CompiledNode<>(new char[0], noChildren(), new String[0], new int[0]);
+ }
+
/**
* Verifies that {@link NodeData} rejects mismatched edge-related array lengths.
*/
@@ -99,8 +129,7 @@ class CompiledNodeAndNodeDataTest {
@Test
@DisplayName("CompiledNode rejects mismatched edge and child arrays")
void compiledNodeShouldRejectMismatchedEdgeAndChildArrays() {
- @SuppressWarnings("unchecked")
- final CompiledNode[] children = new CompiledNode[0];
+ final CompiledNode[] children = noChildren();
final IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> new CompiledNode(new char[] { 'a' }, children, new String[0], new int[0]));
@@ -114,8 +143,7 @@ class CompiledNodeAndNodeDataTest {
@Test
@DisplayName("CompiledNode rejects mismatched value arrays")
void compiledNodeShouldRejectMismatchedValueArrays() {
- @SuppressWarnings("unchecked")
- final CompiledNode[] children = new CompiledNode[0];
+ final CompiledNode[] children = noChildren();
final IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> new CompiledNode(new char[0], children, new String[] { "stem" }, new int[0]));
@@ -131,8 +159,7 @@ class CompiledNodeAndNodeDataTest {
@DisplayName("CompiledNode accessors expose documented backing arrays")
void compiledNodeAccessorsShouldExposeDocumentedBackingArrays() {
final char[] edgeLabels = new char[] { 'a' };
- @SuppressWarnings("unchecked")
- final CompiledNode[] children = new CompiledNode[1];
+ final CompiledNode[] children = children(1);
final String[] orderedValues = new String[] { "stem" };
final int[] orderedCounts = new int[] { 5 };
final CompiledNode node = new CompiledNode<>(edgeLabels, children, orderedValues, orderedCounts);
@@ -149,12 +176,11 @@ class CompiledNodeAndNodeDataTest {
@Test
@DisplayName("CompiledNode can resolve child via dense lookup table")
void compiledNodeUsesDenseLookupForCompactIntervals() {
- @SuppressWarnings("unchecked")
- final CompiledNode[] children = new CompiledNode[4];
- children[0] = new CompiledNode<>(new char[0], new CompiledNode[0], new String[0], new int[0]);
- children[1] = new CompiledNode<>(new char[0], new CompiledNode[0], new String[0], new int[0]);
- children[2] = new CompiledNode<>(new char[0], new CompiledNode[0], new String[0], new int[0]);
- children[3] = new CompiledNode<>(new char[0], new CompiledNode[0], new String[0], new int[0]);
+ final CompiledNode[] children = children(4);
+ children[0] = leaf();
+ children[1] = leaf();
+ children[2] = leaf();
+ children[3] = leaf();
final CompiledNode node = new CompiledNode<>(new char[] { 'a', 'b', 'c', 'd' }, children,
new String[] { "1", "2", "3", "4" }, new int[] { 1, 1, 1, 1 });
@@ -172,12 +198,11 @@ class CompiledNodeAndNodeDataTest {
@Test
@DisplayName("CompiledNode resolves child by linear scan for small degree")
void compiledNodeUsesLinearScanForSmallDegree() {
- @SuppressWarnings("unchecked")
- final CompiledNode[] children = new CompiledNode[4];
- final CompiledNode childA = new CompiledNode<>(new char[0], new CompiledNode[0], new String[0], new int[0]);
- final CompiledNode childB = new CompiledNode<>(new char[0], new CompiledNode[0], new String[0], new int[0]);
- final CompiledNode childC = new CompiledNode<>(new char[0], new CompiledNode[0], new String[0], new int[0]);
- final CompiledNode childD = new CompiledNode<>(new char[0], new CompiledNode[0], new String[0], new int[0]);
+ final CompiledNode[] children = children(4);
+ final CompiledNode childA = leaf();
+ final CompiledNode childB = leaf();
+ final CompiledNode childC = leaf();
+ final CompiledNode childD = leaf();
children[0] = childA;
children[1] = childB;
children[2] = childC;
@@ -200,13 +225,12 @@ class CompiledNodeAndNodeDataTest {
@Test
@DisplayName("CompiledNode resolves child by binary search for large degree")
void compiledNodeUsesBinarySearchForLargeDegree() {
- @SuppressWarnings("unchecked")
- final CompiledNode[] children = new CompiledNode[5];
- final CompiledNode childA = new CompiledNode<>(new char[0], new CompiledNode[0], new String[0], new int[0]);
- final CompiledNode childB = new CompiledNode<>(new char[0], new CompiledNode[0], new String[0], new int[0]);
- final CompiledNode childC = new CompiledNode<>(new char[0], new CompiledNode[0], new String[0], new int[0]);
- final CompiledNode childD = new CompiledNode<>(new char[0], new CompiledNode[0], new String[0], new int[0]);
- final CompiledNode childE = new CompiledNode<>(new char[0], new CompiledNode[0], new String[0], new int[0]);
+ final CompiledNode[] children = children(5);
+ final CompiledNode childA = leaf();
+ final CompiledNode childB = leaf();
+ final CompiledNode childC = leaf();
+ final CompiledNode childD = leaf();
+ final CompiledNode childE = leaf();
children[0] = childA;
children[1] = childB;
children[2] = childC;
@@ -230,8 +254,7 @@ class CompiledNodeAndNodeDataTest {
@Test
@DisplayName("CompiledNode reports leaf, value and edge presence state")
void compiledNodeReportsNodeStateHelpers() {
- @SuppressWarnings("unchecked")
- final CompiledNode[] childless = new CompiledNode[0];
+ final CompiledNode[] childless = noChildren();
final CompiledNode leaf = new CompiledNode<>(new char[0], childless, new String[0], new int[0]);
assertTrue(leaf.isLeaf());
@@ -239,11 +262,10 @@ class CompiledNodeAndNodeDataTest {
assertFalse(leaf.hasValues());
assertFalse(leaf.hasEdge('a'));
- @SuppressWarnings("unchecked")
- final CompiledNode[] child = new CompiledNode[1];
+ final CompiledNode[] child = children(1);
final String[] orderedValues = new String[] { "leaf" };
final int[] orderedCounts = new int[] { 1 };
- child[0] = new CompiledNode<>(new char[0], new CompiledNode[0], orderedValues, orderedCounts);
+ child[0] = new CompiledNode<>(new char[0], noChildren(), orderedValues, orderedCounts);
final CompiledNode node = new CompiledNode<>(new char[] { 'a' }, child, orderedValues, orderedCounts);
assertFalse(node.isLeaf());
@@ -260,9 +282,8 @@ class CompiledNodeAndNodeDataTest {
@Test
@DisplayName("CompiledNode equals and hashCode align for identical structure")
void compiledNodeEqualsAndHashCodeAlignForIdenticalStructure() {
- @SuppressWarnings("unchecked")
- final CompiledNode[] child = new CompiledNode[1];
- final CompiledNode leaf = new CompiledNode<>(new char[0], new CompiledNode[0], new String[] { "v" },
+ final CompiledNode[] child = children(1);
+ final CompiledNode leaf = new CompiledNode<>(new char[0], noChildren(), new String[] { "v" },
new int[] { 1 });
child[0] = leaf;