aliases, int lineNumber) throws IOException {
+ if (!aliases.isEmpty() && isCount(text)) {
+ final int alias;
+ try {
+ alias = Integer.parseInt(text);
+ } catch (NumberFormatException e) {
+ throw new IOException("malformed flag alias '" + text + "' at line "
+ + lineNumber, e);
+ }
+ if (alias < 1 || alias > aliases.size()) {
+ throw new IOException("flag alias " + alias + " at line " + lineNumber
+ + " is outside the AF table of " + aliases.size() + " aliases");
+ }
+ return aliases.get(alias - 1);
+ }
+ return parseFlags(text, mode, lineNumber);
+ }
+
/**
* Finds the first {@code /} that is not escaped as {@code \/}, which separates the
* word from its flag run in a word-list entry.
@@ -1053,8 +1451,8 @@ private static boolean isCount(String line) {
* @return The index of the separator, or {@code -1} when the entry has no flags.
*/
private static int unescapedSlash(String line) {
- for (int i = 0; i < line.length(); i++) {
- if (line.charAt(i) == '/' && (i == 0 || line.charAt(i - 1) != '\\')) {
+ for (int i = 1; i < line.length(); i++) {
+ if (line.charAt(i) == '/' && line.charAt(i - 1) != '\\') {
return i;
}
}
@@ -1149,11 +1547,16 @@ private static int[] parseFlags(String text, FlagMode mode, int lineNumber)
final String[] parts = splitOn(text, ',');
final int[] flags = new int[parts.length];
for (int i = 0; i < parts.length; i++) {
+ final String value = trim(parts[i]);
try {
- flags[i] = Integer.parseInt(trim(parts[i]));
+ flags[i] = Integer.parseInt(value);
} catch (NumberFormatException e) {
throw new IOException("malformed numeric flag at line " + lineNumber, e);
}
+ if (flags[i] < 1 || flags[i] > MAX_NUMERIC_FLAG) {
+ throw new IOException("numeric flag outside 1.." + MAX_NUMERIC_FLAG + " at line "
+ + lineNumber + ": " + value);
+ }
}
return flags;
}
diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/hunspell/HunspellStemmer.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/hunspell/HunspellStemmer.java
index 656064da37..c5699fa149 100644
--- a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/hunspell/HunspellStemmer.java
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/hunspell/HunspellStemmer.java
@@ -31,7 +31,8 @@
/**
* A dictionary-backed {@link Stemmer} over a {@link HunspellDictionary}: a surface form
* is reduced to the dictionary words it can be derived from by removing one suffix, one
- * prefix, or a cross-product combination of both.
+ * prefix, a cross-product combination of both, or an additional suffix licensed by a
+ * continuation class.
*
* {@link #stem(CharSequence)} returns the first analysis, preferring the word's own
* dictionary entry; {@link #stemAll(CharSequence)} returns every distinct analysis. A
@@ -137,16 +138,22 @@ private List variants(String surface) {
* Adds every analysis of one case variant to the result set: the word's own
* dictionary entry, single suffix removal, twofold suffix removal through
* continuation classes, single prefix removal, and cross-product removal of one
- * prefix together with one suffix. Insertion order into the set fixes the
+ * prefix together with one suffix and an optional continuation suffix. Insertion
+ * order into the set fixes the
* preference order reported by {@link #stemAll(CharSequence)}.
*
* @param word The case variant to analyze.
* @param analyses The mutable, insertion-ordered set collecting the stems found.
*/
private void analyze(String word, Set analyses) {
- final List own = dictionary.lookup(word);
- if (own != null && dictionary.validStandalone(own)) {
- analyses.add(word);
+ final List entries = dictionary.lookup(word);
+ if (entries != null) {
+ if (dictionary.anyForbidden(entries)) {
+ return;
+ }
+ if (dictionary.validStandalone(entries)) {
+ analyses.add(word);
+ }
}
for (final Affix suffix : dictionary.suffixesEndingWith(
word.codePointBefore(word.length()))) {
@@ -181,22 +188,30 @@ private void analyze(String word, Set analyses) {
* @param analyses The mutable, insertion-ordered set collecting the part stems.
*/
private void decompose(String word, String surface, Set analyses) {
- final List own = dictionary.lookup(word);
- if (own != null && dictionary.anyForbidden(own)) {
+ final List entries = dictionary.lookup(word);
+ if (entries != null && dictionary.anyForbidden(entries)) {
return;
}
- if (word.length() < 2 * dictionary.compoundMin()) {
+ final int codePointCount = word.codePointCount(0, word.length());
+ if (codePointCount < 2 * dictionary.compoundMin()) {
return;
}
+ final int[] codePointOffsets = new int[codePointCount + 1];
+ int offset = 0;
+ for (int i = 0; i < codePointCount; i++) {
+ codePointOffsets[i] = offset;
+ offset += Character.charCount(word.codePointAt(offset));
+ }
+ codePointOffsets[codePointCount] = word.length();
// lowercasing may change the length in exceptional mappings, in which case the
// offsets no longer align and the variant itself is the only usable case source
final String caseSource = surface.length() == word.length() ? surface : word;
- search(word, caseSource, 0, new ArrayList<>(), new ArrayList<>(), analyses,
- new int[] {PART_CHECK_BUDGET});
+ search(word, caseSource, codePointOffsets, 0, new ArrayList<>(),
+ new ArrayList<>(), analyses, new int[] {PART_CHECK_BUDGET});
}
/**
- * Extends a partial decomposition with the part starting at {@code from}, trying
+ * Extends a partial decomposition with the part starting at {@code fromPoint}, trying
* every admissible length and recursing on the remainder. The boundary into this
* part honors the {@code CHECKCOMPOUNDCASE} and {@code CHECKCOMPOUNDTRIPLE}
* declarations, a part repeating its left neighbor honors
@@ -206,28 +221,34 @@ private void decompose(String word, String surface, Set analyses) {
* @param word The case variant under decomposition.
* @param caseSource The character-case source for junction checks, the surface
* form when its offsets align with the variant.
- * @param from The index the next part starts at.
+ * @param codePointOffsets UTF-16 offsets for each code point boundary.
+ * @param fromPoint The code point index where the next part starts.
* @param surfaces The surface strings of the parts taken so far.
* @param stems The licensed stems of the parts taken so far, one list per part.
* @param analyses The mutable, insertion-ordered set collecting the part stems.
* @param budget The remaining part-licensing attempts, counted down in place.
*/
- private void search(String word, String caseSource, int from, List surfaces,
- List> stems, Set analyses, int[] budget) {
+ private void search(String word, String caseSource, int[] codePointOffsets,
+ int fromPoint, List surfaces, List> stems,
+ Set analyses, int[] budget) {
+ final int from = codePointOffsets[fromPoint];
if (from > 0 && violatesBoundaryChecks(word, caseSource, from)) {
return;
}
final int min = dictionary.compoundMin();
final int max = dictionary.compoundWordMax();
final boolean first = from == 0;
+ final int remaining = codePointOffsets.length - 1 - fromPoint;
// every split leaving room for a further part; a first-position part must also
// leave the closing part, so the whole word is never one part
- if (max == 0 || surfaces.size() + 2 <= max) {
- for (int end = from + min; end <= word.length() - min; end++) {
+ if (remaining >= 2 * min && (max == 0 || surfaces.size() + 2 <= max)) {
+ final int lastEndPoint = codePointOffsets.length - 1 - min;
+ for (int endPoint = fromPoint + min; endPoint <= lastEndPoint; endPoint++) {
if (budget[0] <= 0) {
return;
}
budget[0]--;
+ final int end = codePointOffsets[endPoint];
final String part = word.substring(from, end);
if (duplicatesNeighbor(part, surfaces)) {
continue;
@@ -239,13 +260,14 @@ private void search(String word, String caseSource, int from, List surfa
}
surfaces.add(part);
stems.add(partStems);
- search(word, caseSource, end, surfaces, stems, analyses, budget);
+ search(word, caseSource, codePointOffsets, endPoint, surfaces, stems,
+ analyses, budget);
surfaces.remove(surfaces.size() - 1);
stems.remove(stems.size() - 1);
}
}
// the closing part takes the whole remainder; a compound has at least two parts
- if (first || word.length() - from < min
+ if (first || remaining < min
|| (max > 0 && surfaces.size() + 1 > max) || budget[0] <= 0) {
return;
}
@@ -289,17 +311,20 @@ private boolean duplicatesNeighbor(String part, List surfaces) {
* @return {@code true} if a declaration forbids this junction.
*/
private boolean violatesBoundaryChecks(String word, String caseSource, int from) {
- final char before = word.charAt(from - 1);
- final char after = word.charAt(from);
+ final int before = word.codePointBefore(from);
+ final int after = word.codePointAt(from);
if (dictionary.checkCompoundCase()
- && (Character.isUpperCase(caseSource.charAt(from - 1))
- || Character.isUpperCase(caseSource.charAt(from)))) {
+ && (Character.isUpperCase(caseSource.codePointBefore(from))
+ || Character.isUpperCase(caseSource.codePointAt(from)))) {
return true;
}
- if (dictionary.checkCompoundTriple() && before == after
- && ((from >= 2 && word.charAt(from - 2) == after)
- || (from + 1 < word.length() && word.charAt(from + 1) == after))) {
- return true;
+ if (dictionary.checkCompoundTriple() && before == after) {
+ final int beforeStart = from - Character.charCount(before);
+ final int afterEnd = from + Character.charCount(after);
+ if ((beforeStart > 0 && word.codePointBefore(beforeStart) == after)
+ || (afterEnd < word.length() && word.codePointAt(afterEnd) == after)) {
+ return true;
+ }
}
return false;
}
@@ -347,8 +372,8 @@ private List partStems(String part, CompoundPosition position,
*/
private void collectPartStems(String part, CompoundPosition position,
boolean first, boolean last, Set stems) {
- final List own = dictionary.lookup(part);
- if (own != null && dictionary.mayStand(own, position)) {
+ final List entries = dictionary.lookup(part);
+ if (entries != null && dictionary.mayStand(entries, position)) {
stems.add(part);
}
for (final Affix suffix : dictionary.suffixesEndingWith(
@@ -408,20 +433,18 @@ private void collectAffixedPartStem(String part, Affix affix, boolean suffix,
* @return The candidate stem, or {@code null} when the rule does not apply.
*/
private String removeAffixInCompound(String part, Affix affix, boolean suffix) {
- if (affix.affix().isEmpty() && affix.strip().isEmpty()) {
- return affix.condition().matches(part) ? part : null;
- }
- return suffix ? removeSuffix(part, affix) : removePrefix(part, affix);
+ return suffix
+ ? removeSuffixAllowingIdentity(part, affix)
+ : removePrefixAllowingIdentity(part, affix);
}
/**
* Undoes one suffix rule and, through continuation classes, one further suffix on
- * the intermediate stem, adding every dictionary-confirmed analysis. A rule that
- * applies only inside compounds or only as half of a circumfix is not undone at all,
- * the latter because no prefix accompanies it on this path; a rule marked as needing
- * a further affix yields no single-removal analysis, because the surface form it
- * makes alone is a virtual stem; its twofold analyses stand, the inner affix being
- * exactly the further one required.
+ * the intermediate stem, adding dictionary-confirmed analyses. A rule that applies
+ * only inside compounds or requires the matching circumfix member is not undone
+ * because no prefix accompanies this path. A rule requiring a further affix produces
+ * no single-removal analysis. An identity rule also produces no single-removal
+ * analysis, but it can complete a two-suffix analysis through continuation classes.
*
* @param word The case variant under analysis.
* @param suffix The suffix rule to undo.
@@ -431,11 +454,12 @@ private void undoSuffix(String word, Affix suffix, Set analyses) {
if (dictionary.compoundOnly(suffix) || dictionary.circumfixOnly(suffix)) {
return;
}
- final String stem = removeSuffix(word, suffix);
+ final boolean identity = isIdentityRule(suffix);
+ final String stem = removeSuffixAllowingIdentity(word, suffix);
if (stem == null) {
return;
}
- if (!dictionary.needsFurtherAffix(suffix)) {
+ if (!identity && !dictionary.needsFurtherAffix(suffix)) {
final List flagSets = dictionary.lookup(stem);
if (flagSets != null && dictionary.supports(flagSets, suffix.flag())) {
analyses.add(stem);
@@ -451,8 +475,9 @@ private void undoSuffix(String word, Affix suffix, Set analyses) {
}
/**
- * Undoes the second suffix of a twofold removal when the inner rule's continuation
- * classes allow it after the outer one.
+ * Undoes the inner suffix of a twofold removal when the rule's continuation
+ * classes allow the outer one. The continuation-linked combination satisfies a
+ * {@code NEEDAFFIX} marker on either rule.
*
* @param stem The intermediate stem after the outer removal.
* @param outer The already-undone outer suffix rule.
@@ -465,7 +490,7 @@ private void undoInnerSuffix(String stem, Affix outer, Affix inner,
|| dictionary.circumfixOnly(inner)) {
return;
}
- final String doubleStem = removeSuffix(stem, inner);
+ final String doubleStem = removeSuffixAllowingIdentity(stem, inner);
if (doubleStem == null) {
return;
}
@@ -477,11 +502,11 @@ private void undoInnerSuffix(String stem, Affix outer, Affix inner,
/**
* Undoes one prefix rule and, for cross-product rules, one further suffix on the
- * intermediate stem, adding every dictionary-confirmed analysis. A rule that
+ * intermediate stem, adding dictionary-confirmed analyses. A rule that
* applies only inside compounds is not undone at all. A rule marked as needing a
- * further affix or as half of a circumfix yields no single-removal analysis; its
- * cross-product analyses stand, the suffix being exactly the further affix or the
- * other circumfix half required.
+ * further affix or the matching circumfix member produces no single-removal analysis.
+ * An identity rule also produces no single-removal analysis. A valid cross-product
+ * suffix can combine with either kind of rule.
*
* @param word The case variant under analysis.
* @param prefix The prefix rule to undo.
@@ -491,11 +516,13 @@ private void undoPrefix(String word, Affix prefix, Set analyses) {
if (dictionary.compoundOnly(prefix)) {
return;
}
- final String stem = removePrefix(word, prefix);
+ final boolean identity = isIdentityRule(prefix);
+ final String stem = removePrefixAllowingIdentity(word, prefix);
if (stem == null) {
return;
}
- if (!dictionary.needsFurtherAffix(prefix) && !dictionary.circumfixOnly(prefix)) {
+ if (!identity && !dictionary.needsFurtherAffix(prefix)
+ && !dictionary.circumfixOnly(prefix)) {
final List flagSets = dictionary.lookup(stem);
if (flagSets != null && dictionary.supports(flagSets, prefix.flag())) {
analyses.add(stem);
@@ -534,12 +561,50 @@ private void undoCrossProductSuffix(String stem, Affix prefix, Affix suffix,
if (doubleStem == null) {
return;
}
- // a needs-further-affix marker on either rule is satisfied by the other rule,
- // so no such check applies here; both flags must sit in one homonym's flag set
+ // One member can satisfy the other member's needs-further-affix marker. Both rule
+ // flags must occur in one homonym's flag set.
final List both = dictionary.lookup(doubleStem);
- if (both != null && dictionary.supports(both, prefix.flag(), suffix.flag())) {
+ if (both != null && dictionary.supportsCrossProduct(both, prefix, suffix)
+ && !(dictionary.needsFurtherAffix(prefix)
+ && dictionary.needsFurtherAffix(suffix))) {
analyses.add(doubleStem);
}
+ for (final Affix inner : dictionary.suffixesEndingWith(
+ doubleStem.codePointBefore(doubleStem.length()))) {
+ undoCrossProductInnerSuffix(doubleStem, prefix, suffix, inner, analyses);
+ }
+ for (final Affix inner : dictionary.suffixesWithoutMaterial()) {
+ undoCrossProductInnerSuffix(doubleStem, prefix, suffix, inner, analyses);
+ }
+ }
+
+ /**
+ * Undoes an inner suffix after a prefix and an outer suffix have been removed.
+ * The inner suffix must license the outer suffix through the continuation flags.
+ * The suffix combination satisfies {@code NEEDAFFIX} markers in the derivation.
+ *
+ * @param stem The intermediate stem after the prefix and outer suffix removal.
+ * @param prefix The already-undone prefix rule.
+ * @param outer The already-undone outer suffix rule.
+ * @param inner The candidate inner suffix rule.
+ * @param analyses The mutable, insertion-ordered set collecting the stems found.
+ */
+ private void undoCrossProductInnerSuffix(String stem, Affix prefix, Affix outer,
+ Affix inner, Set analyses) {
+ if (!inner.crossProduct() || !inner.allowsContinuation(outer.flag())
+ || dictionary.compoundOnly(inner)
+ || dictionary.circumfixOnly(outer)
+ || dictionary.circumfixOnly(prefix) != dictionary.circumfixOnly(inner)) {
+ return;
+ }
+ final String root = removeSuffixAllowingIdentity(stem, inner);
+ if (root == null) {
+ return;
+ }
+ final List flagSets = dictionary.lookup(root);
+ if (flagSets != null && dictionary.supportsCrossProduct(flagSets, prefix, inner)) {
+ analyses.add(root);
+ }
}
/**
@@ -568,6 +633,46 @@ private String removeSuffix(String word, Affix suffix) {
return suffix.condition().matches(stem) ? stem : null;
}
+ /**
+ * Undoes a suffix in a continuation sequence, including a rule that changes no
+ * material. An identity rule still has to satisfy the condition.
+ *
+ * @param word The surface form at this point in the sequence.
+ * @param suffix The rule to undo.
+ * @return The candidate stem, or {@code null} when the rule does not apply.
+ */
+ private String removeSuffixAllowingIdentity(String word, Affix suffix) {
+ if (isIdentityRule(suffix)) {
+ return suffix.condition().matches(word) ? word : null;
+ }
+ return removeSuffix(word, suffix);
+ }
+
+ /**
+ * Undoes a prefix in a continuation sequence, including a rule that changes no
+ * material. An identity rule still has to satisfy the condition.
+ *
+ * @param word The surface form at this point in the sequence.
+ * @param prefix The rule to undo.
+ * @return The candidate stem, or {@code null} when the rule does not apply.
+ */
+ private String removePrefixAllowingIdentity(String word, Affix prefix) {
+ if (isIdentityRule(prefix)) {
+ return prefix.condition().matches(word) ? word : null;
+ }
+ return removePrefix(word, prefix);
+ }
+
+ /**
+ * Checks whether an affix rule adds and strips no material.
+ *
+ * @param affix The rule to inspect.
+ * @return {@code true} if applying the rule does not change the spelling.
+ */
+ private boolean isIdentityRule(Affix affix) {
+ return affix.affix().isEmpty() && affix.strip().isEmpty();
+ }
+
/**
* Undoes one prefix rule: cuts the affix material off the start of the word,
* restores the strip string the rule removed on application, and checks the rule's
diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/hunspell/HunspellStemmerTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/hunspell/HunspellStemmerTest.java
index 8657b7e8c0..312d785483 100644
--- a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/hunspell/HunspellStemmerTest.java
+++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/hunspell/HunspellStemmerTest.java
@@ -40,6 +40,8 @@
*/
public class HunspellStemmerTest {
+ private static final byte TRUNCATED_UTF8_LEAD_BYTE = (byte) 0xC3;
+
private static final String AFFIX = String.join("\n",
"# project-authored test fixture",
"SET UTF-8",
@@ -367,6 +369,10 @@ void testMalformedFlagDeclarationMessages() {
e = Assertions.assertThrows(IOException.class, () -> load("FLAG short\n", "0\n"));
Assertions.assertEquals("unsupported FLAG mode 'short' at line 1", e.getMessage());
+
+ e = Assertions.assertThrows(IOException.class,
+ () -> load("FLAG num\nFLAG UTF-8\n", "0\n"));
+ Assertions.assertEquals("multiple FLAG directives at line 2", e.getMessage());
}
/**
@@ -389,10 +395,22 @@ void testMalformedAffixBlockMessages() {
() -> load("SFX S Y 2\nSFX S 0 s .", "0\n"));
Assertions.assertEquals("affix block truncated at line 3", e.getMessage());
+ e = Assertions.assertThrows(IOException.class,
+ () -> load("SFX S X 1\nSFX S 0 s .\n", "0\n"));
+ Assertions.assertEquals("invalid cross-product marker at line 1", e.getMessage());
+
e = Assertions.assertThrows(IOException.class,
() -> load("SFX S Y 1\nPFX S 0 s .\n", "0\n"));
Assertions.assertEquals("malformed affix rule at line 2", e.getMessage());
+ e = Assertions.assertThrows(IOException.class,
+ () -> load("SFX S Y 1\nSFX T 0 s .\n", "0\n"));
+ Assertions.assertEquals("affix rule flag does not match header at line 2", e.getMessage());
+
+ e = Assertions.assertThrows(IOException.class,
+ () -> load("SFX S Y -1\n", "0\n"));
+ Assertions.assertEquals("negative affix rule count at line 1", e.getMessage());
+
e = Assertions.assertThrows(IOException.class,
() -> load("SFX S Y 1\nSFX S 0 s [ab\n", "0\n"));
Assertions.assertEquals("unterminated character class at line 2", e.getMessage());
@@ -1231,25 +1249,38 @@ void testForbiddenEntryBlocksItsDecomposition() throws IOException {
}
/**
- * Verifies that result-altering unsupported affix directives fail at load time.
- * Ignoring {@code ICONV}, {@code OCONV}, {@code COMPLEXPREFIXES},
- * {@code COMPOUNDRULE}, {@code IGNORE}, or {@code KEEPCASE} would change stems
- * with no signal.
+ * Verifies that directives outside the affix-stemming subset do not prevent use of
+ * the rules this implementation supports.
+ *
+ * @param line The affix file line.
*/
@ParameterizedTest
- @CsvSource({
- "ICONV, ICONV 1",
- "OCONV, OCONV 1",
- "COMPLEXPREFIXES, COMPLEXPREFIXES",
- "COMPOUNDRULE, COMPOUNDRULE 1",
- "IGNORE, IGNORE x",
- "KEEPCASE, KEEPCASE k"
+ @ValueSource(strings = {
+ "ICONV 1",
+ "OCONV 1",
+ "COMPLEXPREFIXES",
+ "COMPOUNDRULE 1",
+ "COMPOUNDMORESUFFIXES",
+ "COMPOUNDROOT R",
+ "CHECKCOMPOUNDREP",
+ "SIMPLIFIEDTRIPLE",
+ "CHECKCOMPOUNDPATTERN 1",
+ "FORCEUCASE U",
+ "COMPOUNDSYLLABLE 6 aeiou",
+ "SYLLABLENUM ABC",
+ "LANG tr",
+ "CHECKSHARPS",
+ "BREAK 1",
+ "FORBIDWARN",
+ "IGNORE x",
+ "KEEPCASE k"
})
- void testResultAlteringUnsupportedDirectiveFailsLoud(String name, String line) {
- final IOException e = Assertions.assertThrows(IOException.class,
- () -> load(line + "\n", "0\n"));
- Assertions.assertEquals("unsupported affix directive '" + name + "' at line 1",
- e.getMessage());
+ void testUnsupportedDirectiveDoesNotBlockSupportedRules(String line)
+ throws IOException {
+ final HunspellStemmer stemmer = new HunspellStemmer(load(
+ line + "\nSFX A Y 1\nSFX A 0 s .\n", "1\ndog/A\n"));
+
+ Assertions.assertEquals("dog", stemmer.stem("dogs").toString());
}
/**
@@ -1397,4 +1428,489 @@ void testCosmeticUnsupportedDirectiveIsSkipped() throws IOException {
final HunspellDictionary dictionary = load("REP 1\nREP alot a lot\n", "1\nlock\n");
Assertions.assertNotNull(dictionary.lookup("lock"));
}
+
+ /**
+ * Verifies that an {@code AF} table applies to affix rules above the table.
+ *
+ * @throws IOException Thrown if the fixture fails to load.
+ */
+ @Test
+ void testAliasTableAppliesToAffixesThatPrecedeIt() throws IOException {
+ final HunspellStemmer stemmer = new HunspellStemmer(load(
+ String.join("\n",
+ "FLAG num",
+ "SFX 1 Y 1",
+ "SFX 1 0 er/1 .",
+ "SFX 2 Y 1",
+ "SFX 2 0 s .",
+ "AF 2",
+ "AF 2",
+ "AF 1",
+ ""),
+ "1\nkind/2\n"));
+
+ Assertions.assertEquals(List.of("kind"), stemmer.stemAll("kinders"));
+ }
+
+ /**
+ * Verifies that {@code COMPOUNDMIN} counts Unicode code points.
+ *
+ * @throws IOException Thrown if the fixture fails to load.
+ */
+ @Test
+ void testCompoundMinCountsSupplementaryCharactersOnce() throws IOException {
+ final String first = "\uD840\uDC00";
+ final String rightPart = "\uD840\uDC01";
+ final String words = "2\n" + first + "/Z\n" + rightPart + "/Z\n";
+
+ final HunspellStemmer minimumTwo = new HunspellStemmer(load(
+ "COMPOUNDFLAG Z\nCOMPOUNDMIN 2\n", words));
+ Assertions.assertEquals(List.of(first + rightPart), minimumTwo.stemAll(first + rightPart));
+
+ final HunspellStemmer minimumOne = new HunspellStemmer(load(
+ "COMPOUNDFLAG Z\nCOMPOUNDMIN 1\n", words));
+ Assertions.assertEquals(List.of(first, rightPart), minimumOne.stemAll(first + rightPart));
+ }
+
+ /**
+ * Verifies cross-product analysis with stacked suffixes.
+ *
+ * @throws IOException Thrown if the fixture fails to load.
+ */
+ @Test
+ void testCrossProductSupportsTwofoldSuffixes() throws IOException {
+ final HunspellStemmer stemmer = new HunspellStemmer(load(
+ String.join("\n",
+ "PFX U Y 1",
+ "PFX U 0 un .",
+ "SFX A Y 1",
+ "SFX A 0 s/B .",
+ "SFX B Y 1",
+ "SFX B 0 bar .",
+ ""),
+ "1\nfoo/AU\n"));
+
+ Assertions.assertEquals("foo", stemmer.stem("unfoosbar").toString());
+ }
+
+ /** Verifies that an unrecognized directive does not block supported affix rules. */
+ @Test
+ void testUnknownAffixDirectiveIsSkipped() throws IOException {
+ final HunspellStemmer stemmer = new HunspellStemmer(load(
+ "UNRECOGNIZED value\nSFX A Y 1\nSFX A 0 s .\n", "1\ndog/A\n"));
+
+ Assertions.assertEquals("dog", stemmer.stem("dogs").toString());
+ }
+
+ /** Verifies validation of the {@code AF} count line. */
+ @ParameterizedTest
+ @ValueSource(strings = {"count-mismatch", "malformed", "negative"})
+ void testAliasTableCountIsValidated(String fixture) {
+ final String affix;
+ final String message;
+ switch (fixture) {
+ case "count-mismatch" -> {
+ affix = "AF 2\nAF A\n";
+ message = "AF header specifies 2 aliases but found 1";
+ }
+ case "malformed" -> {
+ affix = "AF count\n";
+ message = "malformed AF at line 1";
+ }
+ case "negative" -> {
+ affix = "AF -1\n";
+ message = "negative AF count at line 1";
+ }
+ default -> throw new AssertionError(fixture);
+ }
+ final IOException exception = Assertions.assertThrows(IOException.class,
+ () -> load(affix, "0\n"));
+
+ Assertions.assertEquals(message, exception.getMessage());
+ }
+
+ /**
+ * Verifies the numeric flag range.
+ *
+ * @param flag The invalid numeric flag.
+ */
+ @ParameterizedTest
+ @ValueSource(strings = {"-1", "0", "65001"})
+ void testNumericFlagOutsideRangeIsRejected(String flag) {
+ final IOException e = Assertions.assertThrows(IOException.class,
+ () -> load("FLAG num\n", "1\nword/" + flag + "\n"));
+
+ Assertions.assertEquals("numeric flag outside 1..65000 at line 2: " + flag,
+ e.getMessage());
+ }
+
+ /**
+ * Verifies that an identity suffix can license an outer suffix.
+ *
+ * @throws IOException Thrown if the fixture fails to load.
+ */
+ @Test
+ void testZeroMaterialInnerSuffixLicensesOuterSuffix() throws IOException {
+ final HunspellStemmer stemmer = new HunspellStemmer(load(
+ String.join("\n",
+ "SFX A Y 1",
+ "SFX A 0 0/B .",
+ "SFX B Y 1",
+ "SFX B 0 baz .",
+ ""),
+ "1\nbar/A\n"));
+
+ Assertions.assertEquals(List.of("bar"), stemmer.stemAll("barbaz"));
+ }
+
+ /**
+ * Verifies that both {@code NEEDAFFIX} markers cannot satisfy one another.
+ *
+ * @throws IOException Thrown if the fixture fails to load.
+ */
+ @Test
+ void testCrossProductNeedAffixMarkersDoNotSatisfyEachOther() throws IOException {
+ final HunspellStemmer stemmer = new HunspellStemmer(load(
+ String.join("\n",
+ "NEEDAFFIX X",
+ "PFX P Y 1",
+ "PFX P 0 pseudo/X .",
+ "SFX A Y 1",
+ "SFX A 0 pseudo/X .",
+ ""),
+ "1\nfoo/AP\n"));
+
+ Assertions.assertEquals(List.of("pseudofoopseudo"),
+ stemmer.stemAll("pseudofoopseudo"));
+ }
+
+ /**
+ * Verifies that a slash at the start of a dictionary entry is word text.
+ *
+ * @throws IOException Thrown if the fixture fails to load.
+ */
+ @Test
+ void testLeadingSlashIsPartOfWord() throws IOException {
+ final HunspellStemmer slashWord = new HunspellStemmer(load(
+ "SFX X Y 1\nSFX X 0 s .\n",
+ "2\n/foo\n/foo/X\n"));
+
+ Assertions.assertEquals(List.of("/foo"), slashWord.stemAll("/foos"));
+ }
+
+ /**
+ * Verifies that an identity continuation completes a virtual suffix.
+ *
+ * @throws IOException Thrown if the fixture fails to load.
+ */
+ @Test
+ void testZeroMaterialContinuationCompletesVirtualSuffix() throws IOException {
+ final HunspellStemmer stemmer = new HunspellStemmer(load(
+ String.join("\n",
+ "PSEUDOROOT X",
+ "SFX A Y 1",
+ "SFX A 0 0 .",
+ "SFX C Y 1",
+ "SFX C 0 baz/XA .",
+ ""),
+ "1\nbar/C\n"));
+
+ Assertions.assertEquals(List.of("bar"), stemmer.stemAll("barbaz"));
+ }
+
+ /**
+ * Verifies cross-product licensing from either member's continuation flags.
+ *
+ * @param licensingRule The member that identifies the partner.
+ * @throws IOException Thrown if a fixture fails to load.
+ */
+ @ParameterizedTest
+ @ValueSource(strings = {"prefix", "suffix"})
+ void testContinuationFlagLicensesCrossProductPartner(String licensingRule)
+ throws IOException {
+ final boolean prefixLicenses = "prefix".equals(licensingRule);
+ final String affix = prefixLicenses
+ ? String.join("\n",
+ "PFX P Y 1",
+ "PFX P 0 un/S .",
+ "SFX S Y 1",
+ "SFX S 0 s .",
+ "")
+ : String.join("\n",
+ "PFX P Y 1",
+ "PFX P 0 un .",
+ "SFX R Y 1",
+ "SFX R 0 able/P .",
+ "");
+ final String words = prefixLicenses ? "1\nlock/P\n" : "1\ndrink/R\n";
+ final String surface = prefixLicenses ? "unlocks" : "undrinkable";
+ final String expected = prefixLicenses ? "lock" : "drink";
+
+ final HunspellStemmer stemmer = new HunspellStemmer(load(affix, words));
+
+ Assertions.assertEquals(expected, stemmer.stem(surface).toString());
+ }
+
+ /**
+ * Verifies that {@code FLAG} applies to affix rules above the declaration.
+ *
+ * @throws IOException Thrown if the fixture fails to load.
+ */
+ @Test
+ void testFlagModeAppliesToRulesThatPrecedeTheDeclaration() throws IOException {
+ final HunspellStemmer lateFlagMode = new HunspellStemmer(load(String.join("\n",
+ "SFX 1 Y 1",
+ "SFX 1 0 s .",
+ "FLAG num",
+ ""), String.join("\n", "1", "dog/1", "")));
+
+ Assertions.assertEquals("dog", lateFlagMode.stem("dogs").toString());
+ }
+
+ /**
+ * Verifies that {@code COMPOUNDFORBIDFLAG} rejects nonfinal dictionary entries.
+ *
+ * @throws IOException Thrown if the fixture fails to load.
+ */
+ @Test
+ void testCompoundForbidFlagBarsDictionaryEntryBeforeEnd() throws IOException {
+ final HunspellStemmer stemmer = new HunspellStemmer(load(
+ "COMPOUNDFLAG Z\nCOMPOUNDFORBIDFLAG F\nCOMPOUNDMIN 3\n",
+ "2\ndog/ZF\nhouse/Z\n"));
+
+ Assertions.assertEquals(List.of("doghouse"), stemmer.stemAll("doghouse"));
+ Assertions.assertEquals(List.of("house", "dog"), stemmer.stemAll("housedog"));
+ }
+
+ /**
+ * Verifies that a forbidden surface form is not analyzed through an affix rule.
+ *
+ * @throws IOException Thrown if the fixture fails to load.
+ */
+ @Test
+ void testForbiddenSurfaceOverridesAffixAnalysis() throws IOException {
+ final HunspellStemmer stemmer = new HunspellStemmer(load(
+ "FORBIDDENWORD X\nSFX A Y 1\nSFX A 0 s .\n",
+ "2\nfoo/A\nfoos/X\n"));
+
+ Assertions.assertEquals(List.of("foos"), stemmer.stemAll("foos"));
+ }
+
+ /**
+ * Verifies that a forbidden homonym blocks affix analysis even when another entry
+ * for the same surface is valid as a standalone entry.
+ *
+ * @throws IOException Thrown if the fixture fails to load.
+ */
+ @Test
+ void testForbiddenHomonymOverridesStandaloneEntry() throws IOException {
+ final HunspellStemmer stemmer = new HunspellStemmer(load(
+ "FORBIDDENWORD X\nSFX A Y 1\nSFX A 0 s .\n",
+ "3\nfoo/A\nfoos\nfoos/X\n"));
+
+ Assertions.assertEquals(List.of("foos"), stemmer.stemAll("foos"));
+ }
+
+ /**
+ * Verifies that malformed UTF-8 is rejected in semantic affix content and in the
+ * dictionary file.
+ *
+ * @param file The malformed input file.
+ */
+ @ParameterizedTest
+ @ValueSource(strings = {"affix", "dictionary"})
+ void testMalformedFileEncodingIsRejected(String file) {
+ final byte[] affix = "SET UTF-8\n".getBytes(StandardCharsets.UTF_8);
+ final byte[] words = Arrays.copyOf("1\n".getBytes(StandardCharsets.UTF_8), 3);
+ words[2] = TRUNCATED_UTF8_LEAD_BYTE;
+ final byte[] affixPrefix = "SET UTF-8\nSFX A Y 1\nSFX A 0 "
+ .getBytes(StandardCharsets.UTF_8);
+ final byte[] malformedAffix = Arrays.copyOf(affixPrefix, affixPrefix.length + 1);
+ malformedAffix[malformedAffix.length - 1] = TRUNCATED_UTF8_LEAD_BYTE;
+ final byte[] selectedAffix = "affix".equals(file) ? malformedAffix : affix;
+ final byte[] selectedWords = "dictionary".equals(file)
+ ? words : "0\n".getBytes(StandardCharsets.UTF_8);
+
+ final IOException exception = Assertions.assertThrows(IOException.class,
+ () -> HunspellDictionary.load(new ByteArrayInputStream(selectedAffix),
+ new ByteArrayInputStream(selectedWords)));
+
+ Assertions.assertEquals(file + " stream is not valid UTF-8", exception.getMessage());
+ }
+
+ /**
+ * Verifies that invalid bytes in a comment do not prevent loading an otherwise valid
+ * UTF-8 affix file.
+ *
+ * @throws IOException Thrown if the fixture fails to load.
+ */
+ @Test
+ void testMalformedCommentEncodingIsIgnored() throws IOException {
+ final byte[] prefix = "SET UTF-8\n# ".getBytes(StandardCharsets.UTF_8);
+ final byte[] affix = Arrays.copyOf(prefix, prefix.length + 1);
+ affix[affix.length - 1] = TRUNCATED_UTF8_LEAD_BYTE;
+
+ final HunspellDictionary dictionary = HunspellDictionary.load(
+ new ByteArrayInputStream(affix),
+ new ByteArrayInputStream("1\ndog\n".getBytes(StandardCharsets.UTF_8)));
+
+ Assertions.assertNotNull(dictionary.lookup("dog"));
+ }
+
+ /**
+ * Verifies raw one-byte flags in a file where word text uses UTF-8.
+ *
+ * @param representation Whether the dictionary entry uses an alias or a direct flag.
+ * @throws IOException Thrown if the fixture fails to load.
+ */
+ @ParameterizedTest
+ @ValueSource(strings = {"alias", "direct"})
+ void testDefaultFlagModePreservesRawBytesInUtf8File(String representation)
+ throws IOException {
+ final boolean alias = "alias".equals(representation);
+ final String aliasTable = alias ? "AF 1\nAF \u00D7\n" : "";
+ final byte[] affix = ("SET UTF-8\n" + aliasTable
+ + "SFX \u00D7 Y 1\nSFX \u00D7 0 s .\n")
+ .getBytes(StandardCharsets.ISO_8859_1);
+ final byte[] words = (alias ? "1\ndog/1\n" : "1\ndog/\u00D7\n")
+ .getBytes(alias ? StandardCharsets.UTF_8 : StandardCharsets.ISO_8859_1);
+ final HunspellStemmer stemmer = new HunspellStemmer(HunspellDictionary.load(
+ new ByteArrayInputStream(affix), new ByteArrayInputStream(words)));
+
+ Assertions.assertEquals("dog", stemmer.stem("dogs").toString());
+ }
+
+ /**
+ * Verifies {@code AF} references in affix continuation fields.
+ *
+ * @throws IOException Thrown if the fixture fails to load.
+ */
+ @Test
+ void testAffixContinuationFlagsResolveThroughTheAliasTable() throws IOException {
+ final HunspellStemmer stemmer = new HunspellStemmer(load(
+ String.join("\n",
+ "AF 2",
+ "AF AB",
+ "AF A",
+ "SFX A Y 1",
+ "SFX A 0 x .",
+ "SFX B Y 1",
+ "SFX B 0 y/2 .",
+ ""),
+ "1\nfoo/1\n"));
+
+ Assertions.assertEquals(List.of("foo"), stemmer.stemAll("fooyx"));
+ }
+
+ /**
+ * Verifies that a stacked suffix satisfies {@code NEEDAFFIX} in a cross-product.
+ *
+ * @throws IOException Thrown if the fixture fails to load.
+ */
+ @Test
+ void testStackedSuffixSatisfiesNeedAffixWithinCrossProduct() throws IOException {
+ final HunspellStemmer stemmer = new HunspellStemmer(load(
+ String.join("\n",
+ "NEEDAFFIX X",
+ "PFX P Y 1",
+ "PFX P 0 pseudo/X .",
+ "SFX A Y 1",
+ "SFX A 0 pseudo/XB .",
+ "SFX B Y 1",
+ "SFX B 0 bar/X .",
+ ""),
+ "1\nfoo/AP\n"));
+
+ Assertions.assertEquals(List.of("foo"),
+ stemmer.stemAll("pseudofoopseudobar"));
+ }
+
+ /**
+ * Verifies cross-product analysis with an identity inner suffix.
+ *
+ * @throws IOException Thrown if the fixture fails to load.
+ */
+ @Test
+ void testZeroMaterialInnerSuffixSupportsCrossProduct() throws IOException {
+ final HunspellStemmer stemmer = new HunspellStemmer(load(
+ String.join("\n",
+ "PFX P Y 1",
+ "PFX P 0 un .",
+ "SFX A Y 1",
+ "SFX A 0 0/B .",
+ "SFX B Y 1",
+ "SFX B 0 baz .",
+ ""),
+ "1\nbar/AP\n"));
+
+ Assertions.assertEquals(List.of("bar"), stemmer.stemAll("unbarbaz"));
+ }
+
+ /**
+ * Verifies that compound limits cannot be negative.
+ *
+ * @param directive The compound limit directive.
+ */
+ @ParameterizedTest
+ @ValueSource(strings = {"COMPOUNDMIN", "COMPOUNDWORDMAX"})
+ void testNegativeCompoundLimitIsRejected(String directive) {
+ final IOException e = Assertions.assertThrows(IOException.class,
+ () -> load(directive + " -1\n", "0\n"));
+
+ Assertions.assertEquals("negative " + directive + " at line 1", e.getMessage());
+ }
+
+ /** Verifies that {@code COMPOUNDMIN} cannot overflow the doubled length check. */
+ @Test
+ void testCompoundMinAboveSafeRangeIsRejected() {
+ final IOException exception = Assertions.assertThrows(IOException.class,
+ () -> load("COMPOUNDMIN 1073741824\n", "0\n"));
+
+ Assertions.assertEquals("COMPOUNDMIN exceeds 1073741823 at line 1",
+ exception.getMessage());
+ }
+
+ /**
+ * Verifies cross-product analysis with an identity prefix.
+ *
+ * @throws IOException Thrown if the fixture fails to load.
+ */
+ @Test
+ void testZeroMaterialPrefixParticipatesInCrossProduct() throws IOException {
+ final HunspellStemmer stemmer = new HunspellStemmer(load(
+ String.join("\n",
+ "PFX P Y 1",
+ "PFX P 0 0/S .",
+ "SFX S Y 1",
+ "SFX S 0 s .",
+ ""),
+ "1\nroot/P\n"));
+
+ Assertions.assertEquals(List.of("root"), stemmer.stemAll("roots"));
+ }
+
+ /**
+ * Verifies supplementary characters in compound boundary checks.
+ *
+ * @param check The boundary check to exercise.
+ * @throws IOException Thrown if a fixture fails to load.
+ */
+ @ParameterizedTest
+ @ValueSource(strings = {"case", "triple"})
+ void testCompoundBoundaryChecksUseCodePoints(String check) throws IOException {
+ final boolean triple = "triple".equals(check);
+ final String codePoint = triple ? "\uD840\uDC00" : "\uD801\uDC00";
+ final String words = triple
+ ? "2\na" + codePoint + codePoint + "/Z\n" + codePoint + "b/Z\n"
+ : "2\na/Z\n" + codePoint + "b/Z\n";
+ final String declaration = triple ? "CHECKCOMPOUNDTRIPLE" : "CHECKCOMPOUNDCASE";
+ final String surface = triple
+ ? "a" + codePoint + codePoint + codePoint + "b"
+ : "a" + codePoint + "b";
+ final HunspellStemmer stemmer = new HunspellStemmer(load(
+ "COMPOUNDFLAG Z\nCOMPOUNDMIN 1\n" + declaration + "\n", words));
+
+ Assertions.assertEquals(List.of(surface), stemmer.stemAll(surface));
+ }
+
}
diff --git a/opennlp-docs/src/docbkx/stemmer.xml b/opennlp-docs/src/docbkx/stemmer.xml
index ddb5056411..d2367684b5 100644
--- a/opennlp-docs/src/docbkx/stemmer.xml
+++ b/opennlp-docs/src/docbkx/stemmer.xml
@@ -99,10 +99,22 @@ stemmer.stem("table"); // "table" (unknown vocabulary is unchanged)]]>
-Dopennlp.download.remote=true, and fetches through the
digest-verified ResourceInstaller path. A file that already exists
in the target is not replaced. Remove old files before refreshing a dictionary.
- Directives that would change stems when ignored
- (ICONV, OCONV, COMPLEXPREFIXES,
- COMPOUNDRULE, IGNORE, KEEPCASE)
- fail at load time; cosmetic tables such as REP are skipped.
+ Directives outside the supported affix-stemming subset are skipped, so
+ published dictionaries can still use their supported rules. Conversion,
+ suggestion, and advanced compound behavior from skipped directives is not
+ applied to the returned stems.
+ This includes ICONV, OCONV,
+ COMPLEXPREFIXES, COMPOUNDRULE,
+ IGNORE, and KEEPCASE. Results can differ from
+ Hunspell for words that need these rules.
+ FLAG and AF declarations apply to the complete
+ affix file, including rules listed before those declarations. Parsing
+ rejects malformed text in parsed rules, invalid counts, numeric flags
+ outside the range 1 through 65000, and COMPOUNDMIN values
+ that cannot be doubled safely. Comments and unused metadata may retain a
+ legacy encoding. Default and long flag modes preserve raw
+ one-byte flag values in UTF-8 files. Compound length and boundary checks
+ count Unicode code points.
A rule that strips a whole stem applies only when the affix file
declares FULLSTRIP, as in Hunspell itself.
Each affix or dictionary stream is rejected when it exceeds