mawiesne commented on code in PR #1138: URL: https://github.com/apache/opennlp/pull/1138#discussion_r3593274227
########## opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/FullCaseFoldCharSequenceNormalizer.java: ########## @@ -0,0 +1,171 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package opennlp.tools.util.normalizer; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * A {@link CharSequenceNormalizer} that applies Unicode full case folding for case-insensitive + * matching, as defined by the Default Case Algorithms in + * <a href="https://www.unicode.org/versions/latest/core-spec/chapter-3/">Section 3.13 of the + * Unicode Standard</a>, using the bundled {@code CaseFolding.txt} data of the Unicode Character + * Database. + * + * <p>Unlike {@link CaseFoldCharSequenceNormalizer}, which lower cases with + * {@link java.util.Locale#ROOT}, this applies the full case foldings (the {@code C} common and + * {@code F} full status mappings), including the expanding folds that plain lower casing does not + * perform: the sharp s (U+00DF) to {@code ss}, the Latin ligatures (for example U+FB00 to + * {@code ff}), and the Greek and Armenian multi-character folds. It is therefore an expanding, + * offset-changing transform, so it is offset-aware: {@link #normalizeAligned(CharSequence)} reports + * the {@link Alignment} from the folded text back to the input. A single cursor pass with no regular + * expression.</p> + * + * <p>The {@code S} simple and {@code T} Turkic status mappings are excluded, so the Turkish and + * Azerbaijani dotless-i rule is not applied here. Input is expected in NFC: the fold matches + * precomposed code points, so decomposed sequences pass through unchanged.</p> + */ +public final class FullCaseFoldCharSequenceNormalizer implements OffsetAwareNormalizer { + + private static final long serialVersionUID = 4520210518330612934L; + + private static final String RESOURCE = "CaseFolding.txt"; + + /** + * Maps a source code point to its full case folding (one or more code points), for the C and F + * status rows of {@code CaseFolding.txt}. Loaded once when this class initializes, which + * happens on first use. + */ + private static final Map<Integer, String> FOLDINGS = Map.copyOf(initFoldings()); + + private static final FullCaseFoldCharSequenceNormalizer INSTANCE = + new FullCaseFoldCharSequenceNormalizer(); + + /** Creates the singleton; use {@link #getInstance()}. */ + private FullCaseFoldCharSequenceNormalizer() { + } + + /** {@return the shared, stateless instance} */ + public static FullCaseFoldCharSequenceNormalizer getInstance() { + return INSTANCE; + } + + /** + * {@inheritDoc} + * + * @throws IllegalArgumentException if {@code text} is {@code null}. + */ + @Override + public CharSequence normalize(CharSequence text) { + if (text == null) { + throw new IllegalArgumentException("text must not be null"); + } + return CharClass.substitute(text, FOLDINGS::get); + } + + /** + * {@inheritDoc} + * + * @throws IllegalArgumentException if {@code text} is {@code null}. + */ + @Override + public AlignedText normalizeAligned(CharSequence text) { + if (text == null) { + throw new IllegalArgumentException("text must not be null"); + } + return CharClass.substituteAligned(text, FOLDINGS::get); + } + + /** + * {@return the folding table parsed from the bundled {@code CaseFolding.txt} resource} + * + * @throws IllegalStateException if the resource is missing. + * @throws UncheckedIOException if the resource cannot be read. + */ + private static Map<Integer, String> initFoldings() { + try (InputStream in = FullCaseFoldCharSequenceNormalizer.class.getResourceAsStream(RESOURCE)) { + if (in == null) { + throw new IllegalStateException("Missing case folding data resource: " + RESOURCE); + } + return parse(in); + } catch (IOException e) { + throw new UncheckedIOException("Unable to read case folding data resource " + RESOURCE, e); + } + } + + /** + * Parses the full-folding mappings, the {@code C} (common) and {@code F} (full) status rows of + * {@code CaseFolding.txt}. The {@code S} (simple) and {@code T} (Turkic) status rows are + * deliberately skipped so the fold stays language-neutral and full rather than simple. + * Package-private so the malformed-data handling can be exercised without the bundled resource. + * + * @param in the stream to parse, in {@code CaseFolding.txt} format. Must not be {@code null}. + * @return the mapping from source code point to its full case folding. + * @throws IOException if the stream cannot be read. + * @throws IllegalArgumentException if the data is malformed. + */ + static Map<Integer, String> parse(InputStream in) throws IOException { + final Map<Integer, String> map = new HashMap<>(); + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + String line; + int lineNumber = 0; + while ((line = reader.readLine()) != null) { + lineNumber++; + final int hash = line.indexOf('#'); + final String content = (hash < 0 ? line : line.substring(0, hash)).strip(); + if (content.isEmpty()) { + continue; + } + final String[] fields = content.split(";"); Review Comment: Please declare `";"` as a constant in this class. ########## opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/FullCaseFoldCharSequenceNormalizer.java: ########## @@ -0,0 +1,171 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package opennlp.tools.util.normalizer; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * A {@link CharSequenceNormalizer} that applies Unicode full case folding for case-insensitive + * matching, as defined by the Default Case Algorithms in + * <a href="https://www.unicode.org/versions/latest/core-spec/chapter-3/">Section 3.13 of the + * Unicode Standard</a>, using the bundled {@code CaseFolding.txt} data of the Unicode Character + * Database. + * + * <p>Unlike {@link CaseFoldCharSequenceNormalizer}, which lower cases with + * {@link java.util.Locale#ROOT}, this applies the full case foldings (the {@code C} common and + * {@code F} full status mappings), including the expanding folds that plain lower casing does not + * perform: the sharp s (U+00DF) to {@code ss}, the Latin ligatures (for example U+FB00 to + * {@code ff}), and the Greek and Armenian multi-character folds. It is therefore an expanding, + * offset-changing transform, so it is offset-aware: {@link #normalizeAligned(CharSequence)} reports + * the {@link Alignment} from the folded text back to the input. A single cursor pass with no regular + * expression.</p> + * + * <p>The {@code S} simple and {@code T} Turkic status mappings are excluded, so the Turkish and + * Azerbaijani dotless-i rule is not applied here. Input is expected in NFC: the fold matches + * precomposed code points, so decomposed sequences pass through unchanged.</p> + */ +public final class FullCaseFoldCharSequenceNormalizer implements OffsetAwareNormalizer { + + private static final long serialVersionUID = 4520210518330612934L; + + private static final String RESOURCE = "CaseFolding.txt"; + + /** + * Maps a source code point to its full case folding (one or more code points), for the C and F + * status rows of {@code CaseFolding.txt}. Loaded once when this class initializes, which + * happens on first use. + */ + private static final Map<Integer, String> FOLDINGS = Map.copyOf(initFoldings()); + + private static final FullCaseFoldCharSequenceNormalizer INSTANCE = + new FullCaseFoldCharSequenceNormalizer(); + + /** Creates the singleton; use {@link #getInstance()}. */ + private FullCaseFoldCharSequenceNormalizer() { + } + + /** {@return the shared, stateless instance} */ + public static FullCaseFoldCharSequenceNormalizer getInstance() { + return INSTANCE; + } + + /** + * {@inheritDoc} + * + * @throws IllegalArgumentException if {@code text} is {@code null}. + */ + @Override + public CharSequence normalize(CharSequence text) { + if (text == null) { + throw new IllegalArgumentException("text must not be null"); + } + return CharClass.substitute(text, FOLDINGS::get); + } + + /** + * {@inheritDoc} + * + * @throws IllegalArgumentException if {@code text} is {@code null}. + */ + @Override + public AlignedText normalizeAligned(CharSequence text) { + if (text == null) { + throw new IllegalArgumentException("text must not be null"); + } + return CharClass.substituteAligned(text, FOLDINGS::get); + } + + /** + * {@return the folding table parsed from the bundled {@code CaseFolding.txt} resource} + * + * @throws IllegalStateException if the resource is missing. + * @throws UncheckedIOException if the resource cannot be read. + */ + private static Map<Integer, String> initFoldings() { + try (InputStream in = FullCaseFoldCharSequenceNormalizer.class.getResourceAsStream(RESOURCE)) { + if (in == null) { + throw new IllegalStateException("Missing case folding data resource: " + RESOURCE); + } + return parse(in); + } catch (IOException e) { + throw new UncheckedIOException("Unable to read case folding data resource " + RESOURCE, e); + } + } + + /** + * Parses the full-folding mappings, the {@code C} (common) and {@code F} (full) status rows of + * {@code CaseFolding.txt}. The {@code S} (simple) and {@code T} (Turkic) status rows are + * deliberately skipped so the fold stays language-neutral and full rather than simple. + * Package-private so the malformed-data handling can be exercised without the bundled resource. + * + * @param in the stream to parse, in {@code CaseFolding.txt} format. Must not be {@code null}. + * @return the mapping from source code point to its full case folding. + * @throws IOException if the stream cannot be read. + * @throws IllegalArgumentException if the data is malformed. + */ + static Map<Integer, String> parse(InputStream in) throws IOException { + final Map<Integer, String> map = new HashMap<>(); + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + String line; + int lineNumber = 0; + while ((line = reader.readLine()) != null) { + lineNumber++; + final int hash = line.indexOf('#'); + final String content = (hash < 0 ? line : line.substring(0, hash)).strip(); + if (content.isEmpty()) { + continue; + } + final String[] fields = content.split(";"); + if (fields.length < 3) { + throw new IllegalArgumentException("Malformed case folding data in " + RESOURCE + + " at line " + lineNumber + ": " + content); + } + final String status = fields[1].strip(); + if ("S".equals(status) || "T".equals(status)) { + // Simple and Turkic mappings are recognized but deliberately not part of the full fold. + continue; + } + if (!"C".equals(status) && !"F".equals(status)) { Review Comment: Please declare "C" and "F" as a constants in this class with a proper and meaningful constant name. ########## opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TextNormalizer.java: ########## @@ -155,18 +171,14 @@ public CharSequenceNormalizer build() { /** * {@return an offset-aware composition of the rungs added so far} * - * <p>Every rung must be an {@link OffsetAwareNormalizer}. Each per-code-point fold is one; - * the folds that delegate to {@link java.text.Normalizer} or to JDK case mapping (NFC, NFKC, - * accent folding, confusable folding, and case folding) cannot report their per-character edits - * and so are rejected here. The returned normalizer's + * <p>Every rung must be an {@link OffsetAwareNormalizer}. NFC, NFKC, accent folding, confusable Review Comment: Please clarify and sharpen the Javadoc: Can we please avoid the term "rung" here? What is a better alternative in the context of this Javadoc snippet and/or class? ########## opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TextNormalizer.java: ########## @@ -70,6 +70,7 @@ public static final class Builder { private final List<CharSequenceNormalizer> steps = new ArrayList<>(); + /** Creates an empty builder; use {@link TextNormalizer#builder()}. */ Review Comment: This is badly written Javadoc. Please clarify and sharpen the text. ########## opennlp-docs/src/docbkx/normalizer.xml: ########## @@ -245,9 +245,11 @@ Span hit = aligned.toOriginalSpan(5, 14); // "the-match" in the normalized tex for it with a plain <code>instanceof</code>, the same pattern the name finder uses for <code>OffsetMappingNameFinder</code>. Every per-code-point fold implements it: whitespace, the line-break-preserving whitespace rung, dashes, invisible-control stripping, quotes, digits, - ellipsis, bullets, and the German umlaut transliteration. The folds that route through + ellipsis, bullets, the German umlaut transliteration, and Unicode full case folding + (<code>fullCaseFold()</code>, whose expansions come from a bundled table with known lengths, so Review Comment: Please check and fix the indentation in line 249 and 250 to align properly with the surrounding text. ########## opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/FullCaseFoldCharSequenceNormalizer.java: ########## @@ -0,0 +1,171 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package opennlp.tools.util.normalizer; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * A {@link CharSequenceNormalizer} that applies Unicode full case folding for case-insensitive + * matching, as defined by the Default Case Algorithms in + * <a href="https://www.unicode.org/versions/latest/core-spec/chapter-3/">Section 3.13 of the + * Unicode Standard</a>, using the bundled {@code CaseFolding.txt} data of the Unicode Character + * Database. + * + * <p>Unlike {@link CaseFoldCharSequenceNormalizer}, which lower cases with + * {@link java.util.Locale#ROOT}, this applies the full case foldings (the {@code C} common and + * {@code F} full status mappings), including the expanding folds that plain lower casing does not + * perform: the sharp s (U+00DF) to {@code ss}, the Latin ligatures (for example U+FB00 to + * {@code ff}), and the Greek and Armenian multi-character folds. It is therefore an expanding, + * offset-changing transform, so it is offset-aware: {@link #normalizeAligned(CharSequence)} reports + * the {@link Alignment} from the folded text back to the input. A single cursor pass with no regular + * expression.</p> + * + * <p>The {@code S} simple and {@code T} Turkic status mappings are excluded, so the Turkish and + * Azerbaijani dotless-i rule is not applied here. Input is expected in NFC: the fold matches + * precomposed code points, so decomposed sequences pass through unchanged.</p> + */ +public final class FullCaseFoldCharSequenceNormalizer implements OffsetAwareNormalizer { + + private static final long serialVersionUID = 4520210518330612934L; + + private static final String RESOURCE = "CaseFolding.txt"; + + /** + * Maps a source code point to its full case folding (one or more code points), for the C and F + * status rows of {@code CaseFolding.txt}. Loaded once when this class initializes, which + * happens on first use. + */ + private static final Map<Integer, String> FOLDINGS = Map.copyOf(initFoldings()); + + private static final FullCaseFoldCharSequenceNormalizer INSTANCE = + new FullCaseFoldCharSequenceNormalizer(); + + /** Creates the singleton; use {@link #getInstance()}. */ + private FullCaseFoldCharSequenceNormalizer() { + } + + /** {@return the shared, stateless instance} */ + public static FullCaseFoldCharSequenceNormalizer getInstance() { + return INSTANCE; + } + + /** + * {@inheritDoc} + * + * @throws IllegalArgumentException if {@code text} is {@code null}. + */ + @Override + public CharSequence normalize(CharSequence text) { + if (text == null) { + throw new IllegalArgumentException("text must not be null"); + } + return CharClass.substitute(text, FOLDINGS::get); + } + + /** + * {@inheritDoc} + * + * @throws IllegalArgumentException if {@code text} is {@code null}. + */ + @Override + public AlignedText normalizeAligned(CharSequence text) { + if (text == null) { + throw new IllegalArgumentException("text must not be null"); + } + return CharClass.substituteAligned(text, FOLDINGS::get); + } + + /** + * {@return the folding table parsed from the bundled {@code CaseFolding.txt} resource} + * + * @throws IllegalStateException if the resource is missing. + * @throws UncheckedIOException if the resource cannot be read. + */ + private static Map<Integer, String> initFoldings() { + try (InputStream in = FullCaseFoldCharSequenceNormalizer.class.getResourceAsStream(RESOURCE)) { + if (in == null) { + throw new IllegalStateException("Missing case folding data resource: " + RESOURCE); + } + return parse(in); + } catch (IOException e) { + throw new UncheckedIOException("Unable to read case folding data resource " + RESOURCE, e); + } + } + + /** + * Parses the full-folding mappings, the {@code C} (common) and {@code F} (full) status rows of + * {@code CaseFolding.txt}. The {@code S} (simple) and {@code T} (Turkic) status rows are + * deliberately skipped so the fold stays language-neutral and full rather than simple. + * Package-private so the malformed-data handling can be exercised without the bundled resource. + * + * @param in the stream to parse, in {@code CaseFolding.txt} format. Must not be {@code null}. + * @return the mapping from source code point to its full case folding. + * @throws IOException if the stream cannot be read. + * @throws IllegalArgumentException if the data is malformed. + */ + static Map<Integer, String> parse(InputStream in) throws IOException { + final Map<Integer, String> map = new HashMap<>(); + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + String line; + int lineNumber = 0; + while ((line = reader.readLine()) != null) { + lineNumber++; + final int hash = line.indexOf('#'); + final String content = (hash < 0 ? line : line.substring(0, hash)).strip(); + if (content.isEmpty()) { + continue; + } + final String[] fields = content.split(";"); + if (fields.length < 3) { + throw new IllegalArgumentException("Malformed case folding data in " + RESOURCE + + " at line " + lineNumber + ": " + content); + } + final String status = fields[1].strip(); + if ("S".equals(status) || "T".equals(status)) { + // Simple and Turkic mappings are recognized but deliberately not part of the full fold. + continue; + } + if (!"C".equals(status) && !"F".equals(status)) { + // An unrecognized status is not a known-and-skipped case (S/T above); treat it as + // corruption rather than silently dropping data. + throw new IllegalArgumentException("Malformed case folding data in " + RESOURCE + + " at line " + lineNumber + ": unrecognized status '" + status + "' in: " + content); + } + try { + final int source = Integer.parseInt(fields[0].strip(), 16); + final StringBuilder target = new StringBuilder(); + for (final String hex : fields[2].strip().split(" ")) { Review Comment: Is it a good idea to have a hard-coded whitespace character here? Shouldn't his better be "\s+" regex which covers all forms of whitespaces? Please check in this context here. In both cases: Declare a constant for the split symbol / regex expr. so that repeated creation of objects is avoided. ########## opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TermAnalyzer.java: ########## @@ -217,6 +216,7 @@ public static final class Builder { private Lemmatizer lemmatizer; private WordTokenizer tokenizer = new WordTokenizer(); + /** Creates an empty builder; use {@link TermAnalyzer#builder()}. */ Review Comment: This is badly written Javadoc. Please clarify and sharpen the text. ########## opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TermAnalyzer.java: ########## @@ -188,9 +189,7 @@ String apply(Dimension dimension, String input, String posTag) { } final String[] lemmas = lemmatizer.lemmatize(new String[] {input}, new String[] {posTag}); if (lemmas == null || lemmas.length == 0 || lemmas[0] == null) { - // A contract-violating Lemmatizer must fail loud here: a null cached under LEMMA would - // read as "absent" in Term.at's lazy cache and recompute through normalized() forever, - // surfacing as a StackOverflowError far from the cause. + // A contract-violating Lemmatizer must fail loud here rather than caching a null lemma. Review Comment: Please carefully reconsider this shortening. It seems odd to a human reviewer and likely unrelated to this PR! ########## opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/TermAnalyzerTest.java: ########## @@ -152,10 +164,7 @@ public List<List<String>> lemmatize(List<String> tokens, List<String> tags) { @Test void testLemmatizerReturningNullFailsLoudlyInsteadOfOverflowing() { - // A contract-violating Lemmatizer that returns a null lemma must surface as a clear - // IllegalStateException. Before this guard the null was cached under LEMMA, read as "absent" - // by Term.at's lazy cache, and recomputed through normalized() forever, surfacing as a - // StackOverflowError far from the cause. + // Pins that a Lemmatizer returning a null lemma fails with a clear IllegalStateException. Review Comment: Why was this longer comment reduced here? Is it a behavior change or what? Could we better keep the previous form if it is still correct and helpful? ########## opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TermAnalyzer.java: ########## @@ -188,9 +189,7 @@ String apply(Dimension dimension, String input, String posTag) { } final String[] lemmas = lemmatizer.lemmatize(new String[] {input}, new String[] {posTag}); if (lemmas == null || lemmas.length == 0 || lemmas[0] == null) { - // A contract-violating Lemmatizer must fail loud here: a null cached under LEMMA would - // read as "absent" in Term.at's lazy cache and recompute through normalized() forever, - // surfacing as a StackOverflowError far from the cause. + // A contract-violating Lemmatizer must fail loud here rather than caching a null lemma. Review Comment: Why was this longer comment reduced here? Is it a behavior change or what? Could we better keep the previous form if it is still correct and helpful? ########## opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/FullCaseFoldCharSequenceNormalizer.java: ########## @@ -0,0 +1,171 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package opennlp.tools.util.normalizer; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * A {@link CharSequenceNormalizer} that applies Unicode full case folding for case-insensitive + * matching, as defined by the Default Case Algorithms in + * <a href="https://www.unicode.org/versions/latest/core-spec/chapter-3/">Section 3.13 of the + * Unicode Standard</a>, using the bundled {@code CaseFolding.txt} data of the Unicode Character + * Database. + * + * <p>Unlike {@link CaseFoldCharSequenceNormalizer}, which lower cases with + * {@link java.util.Locale#ROOT}, this applies the full case foldings (the {@code C} common and + * {@code F} full status mappings), including the expanding folds that plain lower casing does not + * perform: the sharp s (U+00DF) to {@code ss}, the Latin ligatures (for example U+FB00 to + * {@code ff}), and the Greek and Armenian multi-character folds. It is therefore an expanding, + * offset-changing transform, so it is offset-aware: {@link #normalizeAligned(CharSequence)} reports + * the {@link Alignment} from the folded text back to the input. A single cursor pass with no regular + * expression.</p> + * + * <p>The {@code S} simple and {@code T} Turkic status mappings are excluded, so the Turkish and + * Azerbaijani dotless-i rule is not applied here. Input is expected in NFC: the fold matches + * precomposed code points, so decomposed sequences pass through unchanged.</p> + */ +public final class FullCaseFoldCharSequenceNormalizer implements OffsetAwareNormalizer { + + private static final long serialVersionUID = 4520210518330612934L; + + private static final String RESOURCE = "CaseFolding.txt"; + + /** + * Maps a source code point to its full case folding (one or more code points), for the C and F + * status rows of {@code CaseFolding.txt}. Loaded once when this class initializes, which + * happens on first use. + */ + private static final Map<Integer, String> FOLDINGS = Map.copyOf(initFoldings()); + + private static final FullCaseFoldCharSequenceNormalizer INSTANCE = + new FullCaseFoldCharSequenceNormalizer(); + + /** Creates the singleton; use {@link #getInstance()}. */ + private FullCaseFoldCharSequenceNormalizer() { + } + + /** {@return the shared, stateless instance} */ + public static FullCaseFoldCharSequenceNormalizer getInstance() { + return INSTANCE; + } + + /** + * {@inheritDoc} + * + * @throws IllegalArgumentException if {@code text} is {@code null}. + */ + @Override + public CharSequence normalize(CharSequence text) { + if (text == null) { + throw new IllegalArgumentException("text must not be null"); + } + return CharClass.substitute(text, FOLDINGS::get); + } + + /** + * {@inheritDoc} + * + * @throws IllegalArgumentException if {@code text} is {@code null}. + */ + @Override + public AlignedText normalizeAligned(CharSequence text) { + if (text == null) { + throw new IllegalArgumentException("text must not be null"); + } + return CharClass.substituteAligned(text, FOLDINGS::get); + } + + /** + * {@return the folding table parsed from the bundled {@code CaseFolding.txt} resource} + * + * @throws IllegalStateException if the resource is missing. + * @throws UncheckedIOException if the resource cannot be read. + */ + private static Map<Integer, String> initFoldings() { + try (InputStream in = FullCaseFoldCharSequenceNormalizer.class.getResourceAsStream(RESOURCE)) { + if (in == null) { + throw new IllegalStateException("Missing case folding data resource: " + RESOURCE); + } + return parse(in); + } catch (IOException e) { + throw new UncheckedIOException("Unable to read case folding data resource " + RESOURCE, e); + } + } + + /** + * Parses the full-folding mappings, the {@code C} (common) and {@code F} (full) status rows of + * {@code CaseFolding.txt}. The {@code S} (simple) and {@code T} (Turkic) status rows are + * deliberately skipped so the fold stays language-neutral and full rather than simple. + * Package-private so the malformed-data handling can be exercised without the bundled resource. + * + * @param in the stream to parse, in {@code CaseFolding.txt} format. Must not be {@code null}. + * @return the mapping from source code point to its full case folding. + * @throws IOException if the stream cannot be read. + * @throws IllegalArgumentException if the data is malformed. + */ + static Map<Integer, String> parse(InputStream in) throws IOException { + final Map<Integer, String> map = new HashMap<>(); + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + String line; + int lineNumber = 0; + while ((line = reader.readLine()) != null) { + lineNumber++; + final int hash = line.indexOf('#'); + final String content = (hash < 0 ? line : line.substring(0, hash)).strip(); + if (content.isEmpty()) { + continue; + } + final String[] fields = content.split(";"); + if (fields.length < 3) { + throw new IllegalArgumentException("Malformed case folding data in " + RESOURCE + + " at line " + lineNumber + ": " + content); + } + final String status = fields[1].strip(); + if ("S".equals(status) || "T".equals(status)) { Review Comment: Please declare "S" and "T" as a constants in this class with a proper and meaningful constant name. ########## opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/FullCaseFoldCharSequenceNormalizer.java: ########## @@ -0,0 +1,171 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package opennlp.tools.util.normalizer; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * A {@link CharSequenceNormalizer} that applies Unicode full case folding for case-insensitive + * matching, as defined by the Default Case Algorithms in + * <a href="https://www.unicode.org/versions/latest/core-spec/chapter-3/">Section 3.13 of the + * Unicode Standard</a>, using the bundled {@code CaseFolding.txt} data of the Unicode Character + * Database. + * + * <p>Unlike {@link CaseFoldCharSequenceNormalizer}, which lower cases with + * {@link java.util.Locale#ROOT}, this applies the full case foldings (the {@code C} common and + * {@code F} full status mappings), including the expanding folds that plain lower casing does not + * perform: the sharp s (U+00DF) to {@code ss}, the Latin ligatures (for example U+FB00 to + * {@code ff}), and the Greek and Armenian multi-character folds. It is therefore an expanding, + * offset-changing transform, so it is offset-aware: {@link #normalizeAligned(CharSequence)} reports + * the {@link Alignment} from the folded text back to the input. A single cursor pass with no regular + * expression.</p> + * + * <p>The {@code S} simple and {@code T} Turkic status mappings are excluded, so the Turkish and + * Azerbaijani dotless-i rule is not applied here. Input is expected in NFC: the fold matches + * precomposed code points, so decomposed sequences pass through unchanged.</p> + */ +public final class FullCaseFoldCharSequenceNormalizer implements OffsetAwareNormalizer { + + private static final long serialVersionUID = 4520210518330612934L; + + private static final String RESOURCE = "CaseFolding.txt"; + + /** + * Maps a source code point to its full case folding (one or more code points), for the C and F + * status rows of {@code CaseFolding.txt}. Loaded once when this class initializes, which + * happens on first use. + */ + private static final Map<Integer, String> FOLDINGS = Map.copyOf(initFoldings()); + + private static final FullCaseFoldCharSequenceNormalizer INSTANCE = + new FullCaseFoldCharSequenceNormalizer(); + + /** Creates the singleton; use {@link #getInstance()}. */ + private FullCaseFoldCharSequenceNormalizer() { + } + + /** {@return the shared, stateless instance} */ + public static FullCaseFoldCharSequenceNormalizer getInstance() { + return INSTANCE; + } + + /** + * {@inheritDoc} + * + * @throws IllegalArgumentException if {@code text} is {@code null}. + */ + @Override + public CharSequence normalize(CharSequence text) { + if (text == null) { + throw new IllegalArgumentException("text must not be null"); + } + return CharClass.substitute(text, FOLDINGS::get); + } + + /** + * {@inheritDoc} + * + * @throws IllegalArgumentException if {@code text} is {@code null}. + */ + @Override + public AlignedText normalizeAligned(CharSequence text) { + if (text == null) { + throw new IllegalArgumentException("text must not be null"); + } + return CharClass.substituteAligned(text, FOLDINGS::get); + } + + /** + * {@return the folding table parsed from the bundled {@code CaseFolding.txt} resource} + * + * @throws IllegalStateException if the resource is missing. + * @throws UncheckedIOException if the resource cannot be read. + */ + private static Map<Integer, String> initFoldings() { + try (InputStream in = FullCaseFoldCharSequenceNormalizer.class.getResourceAsStream(RESOURCE)) { + if (in == null) { + throw new IllegalStateException("Missing case folding data resource: " + RESOURCE); + } + return parse(in); + } catch (IOException e) { + throw new UncheckedIOException("Unable to read case folding data resource " + RESOURCE, e); + } + } + + /** + * Parses the full-folding mappings, the {@code C} (common) and {@code F} (full) status rows of + * {@code CaseFolding.txt}. The {@code S} (simple) and {@code T} (Turkic) status rows are + * deliberately skipped so the fold stays language-neutral and full rather than simple. + * Package-private so the malformed-data handling can be exercised without the bundled resource. + * + * @param in the stream to parse, in {@code CaseFolding.txt} format. Must not be {@code null}. + * @return the mapping from source code point to its full case folding. + * @throws IOException if the stream cannot be read. + * @throws IllegalArgumentException if the data is malformed. + */ + static Map<Integer, String> parse(InputStream in) throws IOException { + final Map<Integer, String> map = new HashMap<>(); + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + String line; + int lineNumber = 0; + while ((line = reader.readLine()) != null) { + lineNumber++; + final int hash = line.indexOf('#'); + final String content = (hash < 0 ? line : line.substring(0, hash)).strip(); + if (content.isEmpty()) { + continue; + } + final String[] fields = content.split(";"); Review Comment: Please check other open PRs for similar occasions of repeated String object creation in loops. This is not preferable for performance / object allocation reasons! -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
