You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@lucene.apache.org by to...@apache.org on 2021/09/17 13:38:10 UTC
[lucene] branch main updated: LUCENE-10102: Add
JapaneseCompletionFilter for Input Method-aware auto-completion (#297)
This is an automated email from the ASF dual-hosted git repository.
tomoko pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/lucene.git
The following commit(s) were added to refs/heads/main by this push:
new 4e86df9 LUCENE-10102: Add JapaneseCompletionFilter for Input Method-aware auto-completion (#297)
4e86df9 is described below
commit 4e86df96c0385a7b552434d7e2a9bee69f18bd77
Author: Tomoko Uchida <to...@gmail.com>
AuthorDate: Fri Sep 17 22:37:12 2021 +0900
LUCENE-10102: Add JapaneseCompletionFilter for Input Method-aware auto-completion (#297)
Co-authored-by: Robert Muir <rm...@apache.org>
---
lucene/CHANGES.txt | 2 +
.../analysis/ja/JapaneseCompletionAnalyzer.java | 65 ++++
.../analysis/ja/JapaneseCompletionFilter.java | 267 ++++++++++++++++
.../ja/JapaneseCompletionFilterFactory.java | 66 ++++
.../analysis/ja/completion/CharSequenceUtils.java | 91 ++++++
.../analysis/ja/completion/KatakanaRomanizer.java | 193 ++++++++++++
.../analysis/ja/completion/package-info.java | 19 ++
.../org.apache.lucene.analysis.TokenFilterFactory | 1 +
.../lucene/analysis/ja/completion/romaji_map.txt | 344 +++++++++++++++++++++
.../ja/TestJapaneseCompletionAnalyzer.java | 72 +++++
.../analysis/ja/TestJapaneseCompletionFilter.java | 271 ++++++++++++++++
.../ja/TestJapaneseCompletionFilterFactory.java | 59 ++++
.../ja/completion/TestKatakanaRomanizer.java | 68 ++++
13 files changed, 1518 insertions(+)
diff --git a/lucene/CHANGES.txt b/lucene/CHANGES.txt
index 000b1ac..7be7398 100644
--- a/lucene/CHANGES.txt
+++ b/lucene/CHANGES.txt
@@ -21,6 +21,8 @@ New Features
* LUCENE-10096: Add TamilAnalyzer based on the snowball stemmer. (Robert Muir)
+* LUCENE-10102: Add JapaneseCompletionFilter for Input Method-aware auto-completion (Tomoko Uchida, Robert Muir, Jun Ohtani)
+
System Requirements
* LUCENE-8738: Move to Java 11 as minimum Java version.
diff --git a/lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseCompletionAnalyzer.java b/lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseCompletionAnalyzer.java
new file mode 100644
index 0000000..473e078
--- /dev/null
+++ b/lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseCompletionAnalyzer.java
@@ -0,0 +1,65 @@
+/*
+ * 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 org.apache.lucene.analysis.ja;
+
+import java.io.Reader;
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.analysis.TokenStream;
+import org.apache.lucene.analysis.Tokenizer;
+import org.apache.lucene.analysis.cjk.CJKWidthCharFilter;
+import org.apache.lucene.analysis.core.LowerCaseFilter;
+import org.apache.lucene.analysis.ja.dict.UserDictionary;
+
+/**
+ * Analyzer for Japanese completion suggester.
+ *
+ * @see JapaneseCompletionFilter
+ */
+public class JapaneseCompletionAnalyzer extends Analyzer {
+ private final JapaneseCompletionFilter.Mode mode;
+ private final UserDictionary userDict;
+
+ /** Creates a new {@code JapaneseCompletionAnalyzer} with default configurations */
+ public JapaneseCompletionAnalyzer() {
+ this(null, JapaneseCompletionFilter.Mode.INDEX);
+ }
+
+ /** Creates a new {@code JapaneseCompletionAnalyzer} */
+ public JapaneseCompletionAnalyzer(UserDictionary userDict, JapaneseCompletionFilter.Mode mode) {
+ this.userDict = userDict;
+ this.mode = mode;
+ }
+
+ @Override
+ protected TokenStreamComponents createComponents(String fieldName) {
+ Tokenizer tokenizer =
+ new JapaneseTokenizer(userDict, true, true, JapaneseTokenizer.Mode.NORMAL);
+ TokenStream stream = new JapaneseCompletionFilter(tokenizer, mode);
+ stream = new LowerCaseFilter(stream);
+ return new TokenStreamComponents(tokenizer, stream);
+ }
+
+ @Override
+ protected Reader initReader(String fieldName, Reader reader) {
+ return new CJKWidthCharFilter(reader);
+ }
+
+ @Override
+ protected Reader initReaderForNormalization(String fieldName, Reader reader) {
+ return new CJKWidthCharFilter(reader);
+ }
+}
diff --git a/lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseCompletionFilter.java b/lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseCompletionFilter.java
new file mode 100644
index 0000000..041a7c1
--- /dev/null
+++ b/lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseCompletionFilter.java
@@ -0,0 +1,267 @@
+/*
+ * 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 org.apache.lucene.analysis.ja;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import org.apache.lucene.analysis.TokenFilter;
+import org.apache.lucene.analysis.TokenStream;
+import org.apache.lucene.analysis.ja.completion.CharSequenceUtils;
+import org.apache.lucene.analysis.ja.completion.KatakanaRomanizer;
+import org.apache.lucene.analysis.ja.tokenattributes.ReadingAttribute;
+import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
+import org.apache.lucene.analysis.tokenattributes.OffsetAttribute;
+import org.apache.lucene.analysis.tokenattributes.PositionIncrementAttribute;
+import org.apache.lucene.util.CharsRef;
+import org.apache.lucene.util.CharsRefBuilder;
+
+/**
+ * A {@link org.apache.lucene.analysis.TokenFilter} that adds Japanese romanized tokens to the term
+ * attribute. Also, this keeps original tokens (surface forms). Main usage of this filter is Query
+ * Auto-Completion.
+ *
+ * <p>Supported romanization form: (modified) Hepburn-shiki, Kunrei-shiki (Nihon-shiki) and Wāpuro
+ * shiki.
+ *
+ * <p>This does NOT support some romanji forms which are official but not commonly used with
+ * Japanese <a href="https://en.wikipedia.org/wiki/Input_method">Input Methods</a>. e.g.: circumflex
+ * or macron representing <a href="https://en.wikipedia.org/wiki/Ch%C5%8Donpu">Chōonpu (長音符)</a> are
+ * not supported.
+ *
+ * <p>The romanization behaviour changes according to its {@link Mode}. The default mode is {@link
+ * Mode#INDEX}.
+ *
+ * <p>Note: This filter must be applied AFTER half-width and full-width normalization. Please ensure
+ * that a width normalizer such as {@link org.apache.lucene.analysis.cjk.CJKWidthCharFilter} or
+ * {@link org.apache.lucene.analysis.cjk.CJKWidthFilter} is included in your analysis chain. IF THE
+ * WIDTH NORMALIZATION IS NOT PERFORMED, THIS DOES NOT WORK AS EXPECTED. See also: {@link
+ * JapaneseCompletionAnalyzer}.
+ */
+public final class JapaneseCompletionFilter extends TokenFilter {
+ public static final Mode DEFAULT_MODE = Mode.INDEX;
+
+ private final CharTermAttribute termAttr = addAttribute(CharTermAttribute.class);
+ private final ReadingAttribute readingAttr = addAttribute(ReadingAttribute.class);
+ private final PositionIncrementAttribute posIncAtt =
+ addAttribute(PositionIncrementAttribute.class);
+ private final OffsetAttribute offsetAtt = addAttribute(OffsetAttribute.class);
+
+ private final CompletionTokenGenerator tokenGenerator;
+
+ /** Completion mode */
+ public enum Mode {
+ /** Simple romanization. Expected to be used when indexing. */
+ INDEX,
+ /** Input Method aware romanization. Expected to be used when querying. */
+ QUERY
+ }
+
+ /** Creates a new {@code JapaneseCompletionFilter} with default configurations */
+ public JapaneseCompletionFilter(TokenStream input) {
+ this(input, DEFAULT_MODE);
+ }
+
+ /** Creates a new {@code JapaneseCompletionFilter} */
+ public JapaneseCompletionFilter(TokenStream input, Mode mode) {
+ super(input);
+ this.tokenGenerator = new CompletionTokenGenerator(mode);
+ }
+
+ @Override
+ public void reset() throws IOException {
+ super.reset();
+ tokenGenerator.reset();
+ }
+
+ @Override
+ public boolean incrementToken() throws IOException {
+ mayIncrementToken();
+ if (tokenGenerator.hasNext()) {
+ clearAttributes();
+ CompletionToken token = tokenGenerator.next();
+ termAttr.setEmpty().append(token.term);
+ if (token.isFirst) {
+ posIncAtt.setPositionIncrement(1);
+ } else {
+ posIncAtt.setPositionIncrement(0);
+ }
+ offsetAtt.setOffset(token.startOffset, token.endOffset);
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ private void mayIncrementToken() throws IOException {
+ while (!tokenGenerator.hasNext()) {
+ if (input.incrementToken()) {
+ String surface = termAttr.toString();
+ String reading = readingAttr.getReading();
+ int startOffset = offsetAtt.startOffset();
+ int endOffset = offsetAtt.endOffset();
+ if (reading == null && CharSequenceUtils.isKana(surface)) {
+ // use the surface form as reading when possible.
+ reading = CharSequenceUtils.toKatakana(surface);
+ }
+ tokenGenerator.addToken(surface, reading, startOffset, endOffset);
+ } else {
+ if (tokenGenerator.hasPendingToken()) {
+ // a pending token remains.
+ tokenGenerator.finish();
+ } else {
+ // already consumed all tokens. there's no next token to output.
+ break;
+ }
+ }
+ }
+ }
+
+ private static class CompletionToken {
+ final String term;
+ final boolean isFirst;
+ final int startOffset;
+ final int endOffset;
+
+ CompletionToken(String term, boolean isFirst, int startOffset, int endOffset) {
+ this.term = term;
+ this.isFirst = isFirst;
+ this.startOffset = startOffset;
+ this.endOffset = endOffset;
+ }
+ }
+
+ private static class CompletionTokenGenerator implements Iterator<CompletionToken> {
+
+ private final Mode mode;
+
+ private List<CompletionToken> outputs;
+
+ private CharsRefBuilder pdgSurface;
+ private CharsRefBuilder pdgReading;
+ private int pdgStartOffset;
+ private int pdgEndOffset;
+
+ CompletionTokenGenerator(Mode mode) {
+ this.mode = mode;
+ outputs = new ArrayList<>();
+ }
+
+ public void reset() {
+ clearPendingToken();
+ outputs.clear();
+ }
+
+ @Override
+ public boolean hasNext() {
+ return outputs.size() > 0;
+ }
+
+ @Override
+ public CompletionToken next() {
+ return outputs.remove(0);
+ }
+
+ void addToken(String surface, String reading, int startOffset, int endOffset) {
+ assert surface != null : "surface must not be null.";
+
+ if (hasPendingToken()) {
+ if (mode == Mode.QUERY
+ && pdgReading != null
+ && !CharSequenceUtils.isLowercaseAlphabets(pdgSurface.get())
+ && CharSequenceUtils.isLowercaseAlphabets(surface)) {
+ // words that are in mid-IME composition are split into two tokens by JapaneseTokenizer;
+ // should be recovered when querying.
+ // Note: in this case, the reading attribute is null; use the surface form in place of the
+ // reading.
+ // e.g.: "サッ" + "k" => "サッk", "反" + "sy" => "反sy"
+ pdgSurface.append(surface);
+ pdgReading.append(surface);
+ pdgEndOffset = endOffset;
+ generateOutputs();
+ clearPendingToken();
+ } else if (mode == Mode.QUERY
+ && CharSequenceUtils.isKana(pdgSurface.get())
+ && CharSequenceUtils.isKana(surface)) {
+ // words that are all composed only of Katakana or Hiragana should be concatenated when
+ // querying.
+ // e.g.: "こい" + "ぬ" => "こいぬ"
+ pdgSurface.append(surface);
+ pdgReading.append(reading);
+ pdgEndOffset = endOffset;
+ } else {
+ generateOutputs();
+ resetPendingToken(surface, reading, startOffset, endOffset);
+ }
+ } else {
+ resetPendingToken(surface, reading, startOffset, endOffset);
+ }
+ }
+
+ void finish() {
+ generateOutputs();
+ clearPendingToken();
+ }
+
+ private void generateOutputs() {
+ // preserve original surface form as an output.
+ outputs.add(new CompletionToken(pdgSurface.toString(), true, pdgStartOffset, pdgEndOffset));
+ // skip readings that cannot be translated to romaji.
+ if (pdgReading == null
+ || pdgReading.length() == 0
+ || !CharSequenceUtils.isKatakanaOrHWAlphabets(pdgReading.get())) {
+ return;
+ }
+ // translate the reading to romaji.
+ List<CharsRef> romaji = KatakanaRomanizer.getInstance().romanize(pdgReading.get());
+ for (CharsRef ref : romaji) {
+ // set the same start/end offset as the original surface form for romanized tokens.
+ outputs.add(new CompletionToken(ref.toString(), false, pdgStartOffset, pdgEndOffset));
+ }
+ }
+
+ boolean hasPendingToken() {
+ return pdgSurface != null;
+ }
+
+ void resetPendingToken(
+ CharSequence surface, CharSequence reading, int startOffset, int endOffset) {
+ if (this.pdgSurface == null) {
+ this.pdgSurface = new CharsRefBuilder();
+ } else {
+ this.pdgSurface.clear();
+ }
+ this.pdgSurface.append(surface);
+ if (this.pdgReading == null) {
+ this.pdgReading = new CharsRefBuilder();
+ } else {
+ this.pdgReading.clear();
+ }
+ this.pdgReading.append(reading);
+ this.pdgStartOffset = startOffset;
+ this.pdgEndOffset = endOffset;
+ }
+
+ void clearPendingToken() {
+ this.pdgSurface = null;
+ this.pdgReading = null;
+ this.pdgStartOffset = 0;
+ this.pdgEndOffset = 0;
+ }
+ }
+}
diff --git a/lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseCompletionFilterFactory.java b/lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseCompletionFilterFactory.java
new file mode 100644
index 0000000..608bc5a
--- /dev/null
+++ b/lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/JapaneseCompletionFilterFactory.java
@@ -0,0 +1,66 @@
+/*
+ * 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 org.apache.lucene.analysis.ja;
+
+import java.util.Locale;
+import java.util.Map;
+import org.apache.lucene.analysis.TokenFilterFactory;
+import org.apache.lucene.analysis.TokenStream;
+
+/**
+ * Factory for {@link JapaneseCompletionFilter}.
+ *
+ * <p>Supported attributes:
+ *
+ * <ul>
+ * <li>mode: Completion mode. see {@link JapaneseCompletionFilter.Mode}
+ * </ul>
+ *
+ * @lucene.spi {@value #NAME}
+ */
+public class JapaneseCompletionFilterFactory extends TokenFilterFactory {
+
+ /** SPI name */
+ public static final String NAME = "japaneseCompletion";
+
+ private static final String MODE_PARAM = "mode";
+ private final JapaneseCompletionFilter.Mode mode;
+
+ /** Creates a new {@code JapaneseCompletionFilterFactory} */
+ public JapaneseCompletionFilterFactory(Map<String, String> args) {
+ super(args);
+ mode =
+ JapaneseCompletionFilter.Mode.valueOf(
+ get(
+ args,
+ MODE_PARAM,
+ JapaneseCompletionFilter.DEFAULT_MODE.name().toUpperCase(Locale.ROOT)));
+ if (!args.isEmpty()) {
+ throw new IllegalArgumentException("Unknown parameters: " + args);
+ }
+ }
+
+ /** Default ctor for compatibility with SPI */
+ public JapaneseCompletionFilterFactory() {
+ throw defaultCtorException();
+ }
+
+ @Override
+ public TokenStream create(TokenStream input) {
+ return new JapaneseCompletionFilter(input, mode);
+ }
+}
diff --git a/lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/completion/CharSequenceUtils.java b/lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/completion/CharSequenceUtils.java
new file mode 100644
index 0000000..7130e6a
--- /dev/null
+++ b/lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/completion/CharSequenceUtils.java
@@ -0,0 +1,91 @@
+/*
+ * 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 org.apache.lucene.analysis.ja.completion;
+
+/** Utility functions for {@link org.apache.lucene.analysis.ja.JapaneseCompletionFilter} */
+public class CharSequenceUtils {
+
+ /** Checks if a char sequence is composed only of lowercase alphabets */
+ public static boolean isLowercaseAlphabets(CharSequence s) {
+ for (int i = 0; i < s.length(); i++) {
+ char ch = s.charAt(i);
+ if (!(isHalfWidthLowercaseAlphabet(ch) || isFullWidthLowercaseAlphabet(ch))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /** Checks if a char sequence is composed only of Katakana or hiragana */
+ public static boolean isKana(CharSequence s) {
+ for (int i = 0; i < s.length(); i++) {
+ char ch = s.charAt(i);
+ if (!(isHiragana(ch) || isKatakana(ch))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /** Checks if a char sequence is composed only of Katakana or lowercase alphabets */
+ public static boolean isKatakanaOrHWAlphabets(CharSequence ref) {
+ for (int i = 0; i < ref.length(); i++) {
+ char ch = ref.charAt(i);
+ if (!isKatakana(ch) && !isHalfWidthLowercaseAlphabet(ch)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /** Checks if a char is a Hiragana */
+ private static boolean isHiragana(char ch) {
+ return ch >= 0x3040 && ch <= 0x309f;
+ }
+
+ /** Checks if a char is a Katakana */
+ private static boolean isKatakana(char ch) {
+ return ch >= 0x30a0 && ch <= 0x30ff;
+ }
+
+ /** Checks if a char is a half-width lowercase alphabet */
+ private static boolean isHalfWidthLowercaseAlphabet(char ch) {
+ return ch >= 0x61 && ch <= 0x7a;
+ }
+
+ /** Checks if a char is a full-width lowercase alphabet */
+ public static boolean isFullWidthLowercaseAlphabet(char ch) {
+ return ch >= 0xff41 && ch <= 0xff5a;
+ }
+
+ /** Convert all hiragana in a string into kanataka */
+ public static String toKatakana(CharSequence s) {
+ char[] chars = new char[s.length()];
+ for (int i = 0; i < s.length(); i++) {
+ char ch = s.charAt(i);
+ // if the character is from 'ぁ' to 'ゖ' or 'ゝ' or 'ゞ', can be converted to katakana.
+ if (ch >= 0x3041 && ch <= 0x3096 || ch == 0x309d || ch == 0x309e) {
+ chars[i] = (char) (ch + 0x60);
+ } else {
+ chars[i] = ch;
+ }
+ }
+ return new String(chars);
+ }
+
+ private CharSequenceUtils() {}
+}
diff --git a/lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/completion/KatakanaRomanizer.java b/lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/completion/KatakanaRomanizer.java
new file mode 100644
index 0000000..9f68724
--- /dev/null
+++ b/lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/completion/KatakanaRomanizer.java
@@ -0,0 +1,193 @@
+/*
+ * 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 org.apache.lucene.analysis.ja.completion;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.apache.lucene.util.CharsRef;
+import org.apache.lucene.util.CharsRefBuilder;
+
+/**
+ * Converts a Katakana string to <a
+ * href="https://en.wikipedia.org/wiki/Romanization_of_Japanese">Romaji</a> using the pre-defined
+ * Katakana-Romaji mapping rules. Internally, this repeatedly performs prefix match on the given
+ * char sequence to the pre-built keystroke array until it reaches the end of the sequence, or there
+ * are no matched keystrokes.
+ */
+public class KatakanaRomanizer {
+ private static final String ROMAJI_MAP_FILE = "romaji_map.txt";
+
+ private static KatakanaRomanizer INSTANCE;
+
+ static {
+ // Build romaji-map and keystroke arrays from the pre-defined Katakana-Romaji mapping file.
+ try (InputStreamReader is =
+ new InputStreamReader(
+ KatakanaRomanizer.class.getResourceAsStream(ROMAJI_MAP_FILE),
+ Charset.forName("UTF-8"));
+ BufferedReader ir = new BufferedReader(is)) {
+ Map<CharsRef, List<CharsRef>> romajiMap = new HashMap<>();
+ String line;
+ while ((line = ir.readLine()) != null) {
+ if (line.startsWith("#")) {
+ continue;
+ }
+ String[] cols = line.trim().split(",");
+ if (cols.length < 2) {
+ continue;
+ }
+ CharsRef prefix = new CharsRef(cols[0]);
+ romajiMap.put(prefix, new ArrayList<>());
+ for (int i = 1; i < cols.length; i++) {
+ romajiMap.get(prefix).add(new CharsRef(cols[i]));
+ }
+ }
+
+ Set<CharsRef> keystrokeSet = romajiMap.keySet();
+ int maxKeystrokeLength = keystrokeSet.stream().mapToInt(CharsRef::length).max().getAsInt();
+ CharsRef[][] keystrokes = new CharsRef[maxKeystrokeLength][];
+ for (int len = 0; len < maxKeystrokeLength; len++) {
+ final int l = len;
+ keystrokes[l] =
+ keystrokeSet.stream().filter(k -> k.length - 1 == l).toArray(CharsRef[]::new);
+ }
+ for (CharsRef[] ks : keystrokes) {
+ // keystroke array must be sorted in ascending order for binary search.
+ Arrays.sort(ks);
+ }
+
+ INSTANCE = new KatakanaRomanizer(keystrokes, romajiMap);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private final CharsRef[][] keystrokes;
+ private final Map<CharsRef, List<CharsRef>> romajiMap;
+
+ /** Returns the singleton instance of {@code KatakanaRomenizer} */
+ public static KatakanaRomanizer getInstance() {
+ return INSTANCE;
+ }
+
+ private KatakanaRomanizer(CharsRef[][] keystrokes, Map<CharsRef, List<CharsRef>> romajiMap) {
+ this.keystrokes = keystrokes;
+ this.romajiMap = romajiMap;
+ }
+
+ /**
+ * Translates a sequence of katakana to romaji. An input can produce multiple outputs because a
+ * keystroke can be mapped to multiple romajis.
+ */
+ public List<CharsRef> romanize(CharsRef input) {
+ assert CharSequenceUtils.isKatakanaOrHWAlphabets(input);
+
+ List<CharsRefBuilder> pendingOutputs = new ArrayList<>();
+ int pos = 0;
+ while (pos < input.length) {
+ // Greedily looks up the longest matched keystroke.
+ // e.g.: Consider input="キョウ", then there are two matched keystrokes (romaji mapping rules)
+ // "キ" -> "ki" and "キョ" -> "kyo". Only the longest one "キョ" will be selected.
+ MatchedKeystroke matched = longestKeystrokeMatch(input, pos);
+ if (matched == null) {
+ break;
+ }
+
+ List<CharsRef> candidates =
+ romajiMap.get(keystrokes[matched.keystrokeLen - 1][matched.keystrokeIndex]);
+
+ if (pendingOutputs.size() == 0) {
+ // There is no pending output.
+ // Add the matched keystrokes to pending outputs list.
+ for (CharsRef cref : candidates) {
+ CharsRefBuilder output = new CharsRefBuilder();
+ output.copyChars(cref);
+ pendingOutputs.add(output);
+ }
+ } else if (candidates.size() == 1) {
+ // There are one or more pending output(s) and one matched keystroke.
+ // Append the matched keystroke to all pending outputs.
+ // e.g.: Consider we already have two pending outputs "shi" and "si" and the matched
+ // keystroke "ka";
+ // then results are "shika" and "sika".
+ CharsRef cref = candidates.get(0);
+ for (CharsRefBuilder pdgOutput : pendingOutputs) {
+ pdgOutput.append(cref.chars, 0, cref.length);
+ }
+ } else {
+ // There are one or more pending output(s) and multiple matched keystrokes.
+ // Combine the matched keystrokes to all pending outputs.
+ // e.g.: Consider we already have two pending outputs "shi" and "si" and the matched
+ // keystroke "n" and "nn".
+ // To produce all possible keystroke patterns, result outputs should be "shin", "shinn",
+ // "sin" and "sinn".
+ List<CharsRefBuilder> outputs = new ArrayList<>();
+ for (CharsRef cref : candidates) {
+ for (CharsRefBuilder pdgOutput : pendingOutputs) {
+ CharsRefBuilder buffer = new CharsRefBuilder();
+ buffer.copyChars(pdgOutput.chars(), 0, pdgOutput.length());
+ buffer.append(cref.chars, cref.offset, cref.length);
+ outputs.add(buffer);
+ }
+ }
+ // update the pending outputs
+ pendingOutputs = outputs;
+ }
+
+ // proceed to the next input position
+ pos += matched.keystrokeLen;
+ }
+
+ if (pos < input.length) {
+ // add the remnants (that cannot be mapped to any romaji) as suffix
+ for (CharsRefBuilder output : pendingOutputs) {
+ output.append(input.chars, pos, input.length - pos);
+ }
+ }
+ return pendingOutputs.stream().map(CharsRefBuilder::get).collect(Collectors.toList());
+ }
+
+ private MatchedKeystroke longestKeystrokeMatch(CharsRef input, int inputOffset) {
+ for (int len = Math.min(input.length - inputOffset, keystrokes.length); len > 0; len--) {
+ CharsRef ref = new CharsRef(input.chars, inputOffset, len);
+ int index = Arrays.binarySearch(keystrokes[len - 1], ref);
+ if (index >= 0) {
+ return new MatchedKeystroke(len, index);
+ }
+ }
+ // there's no matched keystroke
+ return null;
+ }
+
+ private static class MatchedKeystroke {
+ final int keystrokeLen;
+ final int keystrokeIndex;
+
+ MatchedKeystroke(int keystrokeLen, int keystrokeIndex) {
+ this.keystrokeLen = keystrokeLen;
+ this.keystrokeIndex = keystrokeIndex;
+ }
+ }
+}
diff --git a/lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/completion/package-info.java b/lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/completion/package-info.java
new file mode 100644
index 0000000..25befe1
--- /dev/null
+++ b/lucene/analysis/kuromoji/src/java/org/apache/lucene/analysis/ja/completion/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * 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.
+ */
+
+/** Utilities for {@link org.apache.lucene.analysis.ja.JapaneseCompletionFilter} */
+package org.apache.lucene.analysis.ja.completion;
diff --git a/lucene/analysis/kuromoji/src/resources/META-INF/services/org.apache.lucene.analysis.TokenFilterFactory b/lucene/analysis/kuromoji/src/resources/META-INF/services/org.apache.lucene.analysis.TokenFilterFactory
index 70684ee..a5cfe7b 100644
--- a/lucene/analysis/kuromoji/src/resources/META-INF/services/org.apache.lucene.analysis.TokenFilterFactory
+++ b/lucene/analysis/kuromoji/src/resources/META-INF/services/org.apache.lucene.analysis.TokenFilterFactory
@@ -14,6 +14,7 @@
# limitations under the License.
org.apache.lucene.analysis.ja.JapaneseBaseFormFilterFactory
+org.apache.lucene.analysis.ja.JapaneseCompletionFilterFactory
org.apache.lucene.analysis.ja.JapaneseKatakanaStemFilterFactory
org.apache.lucene.analysis.ja.JapaneseNumberFilterFactory
org.apache.lucene.analysis.ja.JapanesePartOfSpeechStopFilterFactory
diff --git a/lucene/analysis/kuromoji/src/resources/org/apache/lucene/analysis/ja/completion/romaji_map.txt b/lucene/analysis/kuromoji/src/resources/org/apache/lucene/analysis/ja/completion/romaji_map.txt
new file mode 100644
index 0000000..4a803a4
--- /dev/null
+++ b/lucene/analysis/kuromoji/src/resources/org/apache/lucene/analysis/ja/completion/romaji_map.txt
@@ -0,0 +1,344 @@
+# mapping rules of katakana (an unit of keystroke) to list of acceptable romanizations.
+# longest-match is used to find entries in this list.
+# covers romanization systems: modified Hepburn-shiki, Kunrei-shiki (Nihon-shiki), and Wāpuro shiki.
+# note: this does not strictly comply with the romanization systems listed above,
+# but tries to cover possible keystoroke supported by various Input Methods.
+
+ア,a
+イ,i
+ウ,u
+エ,e
+オ,o
+カ,ka
+キ,ki
+ク,ku
+ケ,ke
+コ,ko
+キャ,kya
+キュ,kyu
+キョ,kyo
+ガ,ga
+ギ,gi
+グ,gu
+ゲ,ge
+ゴ,go
+ギャ,gya
+ギュ,gyu
+ギョ,gyo
+サ,sa
+シ,si,shi
+ス,su
+セ,se
+ソ,so
+シャ,sya,sha
+シュ,syu,shu
+シェ,sye,she
+ショ,syo,sho
+ザ,za
+ジ,zi,ji
+ズ,zu
+ゼ,ze
+ゾ,zo
+ジャ,zya,ja
+ジュ,zyu,ju
+ジェ,zye,je
+ジョ,zyo,jo
+タ,ta
+チ,ti,chi
+ツ,tu,tsu
+テ,te
+ト,to
+チャ,tya,cha,cya
+チュ,tyu,chu,cyu
+チョ,tyo,cho,cyo
+ダ,da
+ヂ,di,zi,ji
+ヅ,du,zu
+デ,de
+ド,do
+ヂャ,dya,zya,ja
+ヂュ,dyu,zyu,ju
+ヂョ,dyo,zyo,jo
+ナ,na
+ニ,ni
+ヌ,nu
+ネ,ne
+ノ,no
+ニャ,nya
+ニュ,nyu
+ニョ,nyo
+ハ,ha
+ヒ,hi
+フ,hu,fu
+ヘ,he
+ホ,ho
+ヒャ,hya
+ヒュ,hyu
+ヒョ,hyo
+バ,ba
+ビ,bi
+ブ,bu
+ベ,be
+ボ,bo
+ビャ,bya
+ビュ,byu
+ビョ,byo
+パ,pa
+ピ,pi
+プ,pu
+ペ,pe
+ポ,po
+ピャ,pya
+ピュ,pyu
+ピョ,pyo
+マ,ma
+ミ,mi
+ム,mu
+メ,me
+モ,mo
+ミャ,mya
+ミュ,myu
+ミョ,myo
+ヤ,ya
+ユ,yu
+ヨ,yo
+ラ,ra
+リ,ri
+ル,ru
+レ,re
+ロ,ro
+リャ,rya
+リュ,ryu
+リョ,ryo
+ワ,wa
+ウィ,wi
+ヰ,wi
+ウェ,we
+ヱ,we
+ヲ,wo,o
+ン,n,nn
+
+クァ,kwa,kuxa
+クィ,kwi,kuxi
+クゥ,kwu,kuxu
+クェ,kwe,kuxe
+クォ,kwo,kuxo
+グァ,gwa,guxa
+グィ,gwi,guxi
+グゥ,gwu,guxu
+グェ,gwe,guxe
+グォ,gwo,guxo
+スァ,swa,suxa
+スィ,swi,suxi
+スゥ,swu,suxu
+スェ,swe,suxe
+スォ,swo,suxo
+トァ,twa,toxa
+トィ,twi,toxi
+トゥ,twu,toxu
+トェ,twe,toxe
+トォ,two,toxo
+ドァ,dwa,doxa
+ドィ,dwi,
+ドゥ,dwu,doxu
+ドェ,dwe,doxe
+ドォ,dwo,doxo
+ファ,hwa,fa,huxa
+フィ,hwi,fi,huxi
+フェ,hwe,fe,huxe
+フォ,hwo,fo,huxo
+ヴァ,va,vuxa
+ヴィ,vi,vuxi
+ヴ,vu
+ヴェ,ve,vuxe
+ヴォ,vo,vuxo
+テァ,tha,texa
+ティ,thi,texi
+テェ,the,texe
+テャ,tha,texya
+テュ,thu,texyu
+テョ,tho,texyo
+フャ,fya,huxya,fuxya
+フュ,fyu,huxyu,fuxyu
+フョ,fyo,huxyo,fuxyo
+ヴャ,vya,vuxya
+ヴュ,vyu,vuxyu
+ヴョ,vyo,vuxyo
+
+ッカ,kka
+ッキ,kki
+ック,kku
+ッケ,kke
+ッコ,kko
+ッキャ,kkya
+ッキュ,kkyu
+ッキョ,kkyo
+ッガ,gga
+ッギ,ggi
+ッグ,ggu
+ッゲ,gge
+ッゴ,ggo
+ッギャ,ggya
+ッギュ,ggyu
+ッギョ,ggyo
+ッサ,ssa
+ッシ,ssi
+ッス,ssu
+ッセ,sse
+ッソ,sso
+ッシャ,ssya,ssha
+ッシュ,ssyu,sshu
+ッショ,ssyo,ssho
+ッザ,zza
+ッジ,zzi,jji
+ッズ,zzu
+ッゼ,zze
+ッゾ,zzo
+ッジャ,zzya,jja
+ッジュ,zzyu,jju
+ッジョ,zzyo,jjo
+ッタ,tta
+ッチ,tti
+ッツ,ttu
+ッテ,tte
+ット,tto
+ッチャ,ttya,ccha,ccya
+ッチュ,ttyu,cchu,ccyu
+ッチョ,ttyo,ccho,ccyo
+ッダ,dda
+ッヂ,ddi,
+ッヅ,ddu
+ッデ,dde
+ッド,ddo
+ッヂャ,ddya
+ッヂュ,ddyu
+ッヂョ,ddyo
+ッハ,hha
+ッヒ,hhi
+ッフ,hhu,ffu
+ッへ,hhe
+ッホ,hho
+ッヒャ,hhya
+ッヒュ,hhyu
+ッヒョ,hhyo
+ッバ,bba
+ッビ,bbi
+ッブ,bbu
+ッベ,bbe
+ッボ,bbo
+ッビャ,bbya
+ッビュ,bbyu
+ッビョ,bbyo
+ッパ,ppa
+ッピ,ppi
+ップ,ppu
+ッペ,ppe
+ッポ,ppo
+ッピャ,ppya
+ッピュ,ppyu
+ッピョ,ppyo
+ッマ,mma
+ッミ,mmi
+ッム,mmu
+ッメ,mme
+ッモ,mmo
+ッミャ,mmya
+ッミュ,mmyu
+ッミョ,mmyo
+ッヤ,yya
+ッイ,yyi
+ッユ,yyu
+ッイェ,yye
+ッヨ,yyo
+ッラ,rra
+ッリ,rri
+ッル,rru
+ッレ,rre
+ッロ,rro
+ッリャ,rrya
+ッリュ,rryu
+ッリョ,rryo
+ッワ,wwa
+ッウィ,wwi
+ッウ,wwu
+ッウェ,wwe
+ッヲ,wwo
+
+ックァ,kkwa,kkuxa
+ックィ,kkwi,kkuxi
+ックゥ,kkwu,kkuxu
+ックェ,kkwe,kkuxe
+ックォ,kkwo,kkuxo
+ッグァ,ggwa,gguxa
+ッグィ,ggwi,gguxi
+ッグゥ,ggwu,gguxu
+ッグェ,ggwe,gguxe
+ッグォ,ggwo,gguxo
+ッスァ,sswa,ssuxa
+ッスィ,sswi,ssuxi
+ッスゥ,sswu,ssuxu
+ッスェ,sswe,ssuxe
+ッスォ,sswo,suxo
+ットァ,ttwa,ttoxa
+ットィ,ttwi,ttoxi
+ットゥ,ttwu,ttoxu
+ットェ,ttwe,ttoxe
+ットォ,ttwo,ttoxo
+ッドァ,ddwa,ddoxa
+ッドィ,ddwi,ddoxi
+ッドゥ,ddwu,ddoxi
+ッドェ,ddwe,ddoxe
+ッドォ,ddwo,ddoxo
+ッファ,hhwa,ffa,hhuxa,ffuxa
+ッフィ,hhwi,ffi,hhuxi,ffuxi
+ッフェ,hhwe,ffe,hhuxe,ffuxe
+ッフォ,hhwo,ffo,hhuxo,ffuxo
+ッヴァ,vva,vvuxa
+ッヴィ,vvi,vvuxi
+ッヴ,vvu
+ッヴェ,vve,vvuxe
+ッヴォ,vvo,vvuxo
+ッテァ,ttha,ttexa
+ッティ,tthi,ttexi
+ッテェ,tthe,ttexe
+ッテャ,ttha,ttexya
+ッテュ,tthu,ttexyu
+ッテョ,ttho,ttexyo
+ッフャ,ffya,hhuxya,ffuxya
+ッフュ,ffyu,hhuxyu,ffuxyu
+ッフョ,ffyo,hhuxyo,ffuxyo
+ッヴャ,vvya,vvuxya
+ッヴュ,vvyu,vvuxyu
+ッヴョ,vvyo,vvuxyo
+
+ァ,xa
+ィ,xi
+ゥ,xu
+ェ,xe
+ォ,xo
+ヵ,xka
+ヶ,xke
+ッ,xtu
+ャ,xya
+ュ,xyu
+ョ,xyo
+
+ッk,kk
+ッg,gg
+ッs,ss
+ッz,zz
+ッt,tt
+ッd,dd
+ッh,hh
+ッb,bb
+ッp,pp
+ッm,mm
+ッy,yy
+ッr,rr
+ッw,ww
+
+# below are characters that should be kept but have no explicit romanization rules.
+# Chōonpu (Katakana-Hiragana Prolonged Sound Mark)
+ー,ー
+# Interpunct (Middle Dot)
+・,・
\ No newline at end of file
diff --git a/lucene/analysis/kuromoji/src/test/org/apache/lucene/analysis/ja/TestJapaneseCompletionAnalyzer.java b/lucene/analysis/kuromoji/src/test/org/apache/lucene/analysis/ja/TestJapaneseCompletionAnalyzer.java
new file mode 100644
index 0000000..bae627a
--- /dev/null
+++ b/lucene/analysis/kuromoji/src/test/org/apache/lucene/analysis/ja/TestJapaneseCompletionAnalyzer.java
@@ -0,0 +1,72 @@
+/*
+ * 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 org.apache.lucene.analysis.ja;
+
+import java.io.IOException;
+import java.util.Random;
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.analysis.BaseTokenStreamTestCase;
+import org.junit.Test;
+
+public class TestJapaneseCompletionAnalyzer extends BaseTokenStreamTestCase {
+
+ @Test
+ public void testCompletionDefault() throws IOException {
+ // mode=INDEX (default)
+ Analyzer analyzer = new JapaneseCompletionAnalyzer();
+ assertAnalyzesTo(
+ analyzer,
+ "東京",
+ new String[] {"東京", "toukyou"},
+ new int[] {0, 0},
+ new int[] {2, 2},
+ new int[] {1, 0});
+ analyzer.close();
+ }
+
+ @Test
+ public void testCompletionQuery() throws IOException {
+ // mode=QUERY
+ Analyzer analyzer = new JapaneseCompletionAnalyzer(null, JapaneseCompletionFilter.Mode.QUERY);
+ assertAnalyzesTo(
+ analyzer,
+ "東京t",
+ new String[] {"東京t", "toukyout"},
+ new int[] {0, 0},
+ new int[] {3, 3},
+ new int[] {1, 0});
+ analyzer.close();
+ }
+
+ /** blast random strings against the analyzer */
+ @Test
+ public void testRandom() throws IOException {
+ Random random = random();
+ final Analyzer a = new JapaneseCompletionAnalyzer();
+ checkRandomData(random, a, atLeast(100));
+ a.close();
+ }
+
+ /** blast some random large strings through the analyzer */
+ @Test
+ public void testRandomHugeStrings() throws Exception {
+ Random random = random();
+ final Analyzer a = new JapaneseCompletionAnalyzer();
+ checkRandomData(random, a, 2 * RANDOM_MULTIPLIER, 8192);
+ a.close();
+ }
+}
diff --git a/lucene/analysis/kuromoji/src/test/org/apache/lucene/analysis/ja/TestJapaneseCompletionFilter.java b/lucene/analysis/kuromoji/src/test/org/apache/lucene/analysis/ja/TestJapaneseCompletionFilter.java
new file mode 100644
index 0000000..302ae3e
--- /dev/null
+++ b/lucene/analysis/kuromoji/src/test/org/apache/lucene/analysis/ja/TestJapaneseCompletionFilter.java
@@ -0,0 +1,271 @@
+/*
+ * 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 org.apache.lucene.analysis.ja;
+
+import java.io.IOException;
+import java.io.Reader;
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.analysis.BaseTokenStreamTestCase;
+import org.apache.lucene.analysis.Tokenizer;
+import org.apache.lucene.analysis.cjk.CJKWidthCharFilter;
+import org.apache.lucene.analysis.core.KeywordTokenizer;
+import org.apache.lucene.util.IOUtils;
+import org.junit.Test;
+
+public class TestJapaneseCompletionFilter extends BaseTokenStreamTestCase {
+ private Analyzer indexAnalyzer;
+ private Analyzer queryAnalyzer;
+
+ @Override
+ public void setUp() throws Exception {
+ super.setUp();
+ indexAnalyzer =
+ new Analyzer() {
+ @Override
+ protected TokenStreamComponents createComponents(String fieldName) {
+ Tokenizer tokenizer = new JapaneseTokenizer(null, true, JapaneseTokenizer.Mode.NORMAL);
+ return new TokenStreamComponents(
+ tokenizer,
+ new JapaneseCompletionFilter(tokenizer, JapaneseCompletionFilter.Mode.INDEX));
+ }
+
+ @Override
+ protected Reader initReader(String fieldName, Reader reader) {
+ return new CJKWidthCharFilter(reader);
+ }
+
+ @Override
+ protected Reader initReaderForNormalization(String fieldName, Reader reader) {
+ return new CJKWidthCharFilter(reader);
+ }
+ };
+ queryAnalyzer =
+ new Analyzer() {
+ @Override
+ protected TokenStreamComponents createComponents(String fieldName) {
+ Tokenizer tokenizer = new JapaneseTokenizer(null, true, JapaneseTokenizer.Mode.NORMAL);
+ return new TokenStreamComponents(
+ tokenizer,
+ new JapaneseCompletionFilter(tokenizer, JapaneseCompletionFilter.Mode.QUERY));
+ }
+
+ @Override
+ protected Reader initReader(String fieldName, Reader reader) {
+ return new CJKWidthCharFilter(reader);
+ }
+
+ @Override
+ protected Reader initReaderForNormalization(String fieldName, Reader reader) {
+ return new CJKWidthCharFilter(reader);
+ }
+ };
+ }
+
+ @Override
+ public void tearDown() throws Exception {
+ IOUtils.close(indexAnalyzer);
+ IOUtils.close(queryAnalyzer);
+ super.tearDown();
+ }
+
+ @Test
+ public void testCompletionIndex() throws IOException {
+ assertAnalyzesTo(
+ indexAnalyzer,
+ "東京",
+ new String[] {"東京", "toukyou"},
+ new int[] {0, 0},
+ new int[] {2, 2},
+ new int[] {1, 0});
+
+ assertAnalyzesTo(
+ indexAnalyzer,
+ "東京都",
+ new String[] {"東京", "toukyou", "都", "to"},
+ new int[] {0, 0, 2, 2},
+ new int[] {2, 2, 3, 3},
+ new int[] {1, 0, 1, 0});
+
+ assertAnalyzesTo(
+ indexAnalyzer,
+ "ドラえもん",
+ new String[] {"ドラえもん", "doraemon", "doraemonn"},
+ new int[] {0, 0, 0},
+ new int[] {5, 5, 5},
+ new int[] {1, 0, 0});
+
+ assertAnalyzesTo(
+ indexAnalyzer,
+ "ソースコード",
+ new String[] {"ソース", "soーsu", "コード", "koーdo"},
+ new int[] {0, 0, 3, 3},
+ new int[] {3, 3, 6, 6},
+ new int[] {1, 0, 1, 0});
+
+ assertAnalyzesTo(
+ indexAnalyzer,
+ "反社会的勢力",
+ new String[] {"反", "han", "hann", "社会", "syakai", "shakai", "的", "teki", "勢力", "seiryoku"},
+ new int[] {0, 0, 0, 1, 1, 1, 3, 3, 4, 4},
+ new int[] {1, 1, 1, 3, 3, 3, 4, 4, 6, 6},
+ new int[] {1, 0, 0, 1, 0, 0, 1, 0, 1, 0});
+
+ assertAnalyzesTo(
+ indexAnalyzer, "々", new String[] {"々"}, new int[] {0}, new int[] {1}, new int[] {1});
+
+ assertAnalyzesTo(
+ indexAnalyzer,
+ "是々",
+ new String[] {"是", "ze", "々"},
+ new int[] {0, 0, 1},
+ new int[] {1, 1, 2},
+ new int[] {1, 0, 1});
+
+ assertAnalyzesTo(
+ indexAnalyzer,
+ "是々の",
+ new String[] {"是", "ze", "々", "の", "no"},
+ new int[] {0, 0, 1, 2, 2},
+ new int[] {1, 1, 2, 3, 3},
+ new int[] {1, 0, 1, 1, 0});
+ }
+
+ @Test
+ public void testCompletionQuery() throws IOException {
+ assertAnalyzesTo(
+ queryAnalyzer,
+ "東京",
+ new String[] {"東京", "toukyou"},
+ new int[] {0, 0},
+ new int[] {2, 2},
+ new int[] {1, 0});
+
+ assertAnalyzesTo(
+ queryAnalyzer,
+ "東京都",
+ new String[] {"東京", "toukyou", "都", "to"},
+ new int[] {0, 0, 2, 2},
+ new int[] {2, 2, 3, 3},
+ new int[] {1, 0, 1, 0});
+
+ assertAnalyzesTo(
+ queryAnalyzer,
+ "ドラえもん",
+ new String[] {"ドラえもん", "doraemon", "doraemonn"},
+ new int[] {0, 0, 0},
+ new int[] {5, 5, 5},
+ new int[] {1, 0, 0});
+
+ assertAnalyzesTo(
+ queryAnalyzer,
+ "ソースコード",
+ new String[] {"ソースコード", "soーsukoーdo"},
+ new int[] {0, 0},
+ new int[] {6, 6},
+ new int[] {1, 0});
+
+ assertAnalyzesTo(
+ queryAnalyzer,
+ "反社会的勢力",
+ new String[] {"反", "han", "hann", "社会", "syakai", "shakai", "的", "teki", "勢力", "seiryoku"},
+ new int[] {0, 0, 0, 1, 1, 1, 3, 3, 4, 4},
+ new int[] {1, 1, 1, 3, 3, 3, 4, 4, 6, 6},
+ new int[] {1, 0, 0, 1, 0, 0, 1, 0, 1, 0});
+
+ assertAnalyzesTo(
+ queryAnalyzer, "々", new String[] {"々"}, new int[] {0}, new int[] {1}, new int[] {1});
+
+ assertAnalyzesTo(
+ queryAnalyzer,
+ "是々",
+ new String[] {"是", "ze", "々"},
+ new int[] {0, 0, 1},
+ new int[] {1, 1, 2},
+ new int[] {1, 0, 1});
+
+ assertAnalyzesTo(
+ indexAnalyzer,
+ "是々の",
+ new String[] {"是", "ze", "々", "の", "no"},
+ new int[] {0, 0, 1, 2, 2},
+ new int[] {1, 1, 2, 3, 3},
+ new int[] {1, 0, 1, 1, 0});
+
+ assertAnalyzesTo(
+ queryAnalyzer,
+ "東京t",
+ new String[] {"東京t", "toukyout"},
+ new int[] {0, 0},
+ new int[] {3, 3},
+ new int[] {1, 0});
+
+ assertAnalyzesTo(
+ queryAnalyzer,
+ "サッk",
+ new String[] {"サッk", "sakk"},
+ new int[] {0, 0},
+ new int[] {3, 3},
+ new int[] {1, 0});
+
+ assertAnalyzesTo(
+ queryAnalyzer,
+ "反sy",
+ new String[] {"反sy", "hansy", "hannsy"},
+ new int[] {0, 0, 0},
+ new int[] {3, 3, 3},
+ new int[] {1, 0, 0});
+
+ assertAnalyzesTo(
+ queryAnalyzer,
+ "さーきゅr",
+ new String[] {"さーきゅr", "saーkyur"},
+ new int[] {0, 0},
+ new int[] {5, 5},
+ new int[] {1, 0});
+
+ assertAnalyzesTo(
+ queryAnalyzer,
+ "是々h",
+ new String[] {"是", "ze", "々h"},
+ new int[] {0, 0, 1},
+ new int[] {1, 1, 3},
+ new int[] {1, 0, 1});
+ }
+
+ public void testEnglish() throws IOException {
+ assertAnalyzesTo(indexAnalyzer, "this atest", new String[] {"this", "atest"});
+ assertAnalyzesTo(queryAnalyzer, "this atest", new String[] {"this", "atest"});
+ }
+
+ public void testRandomStrings() throws IOException {
+ checkRandomData(random(), indexAnalyzer, atLeast(200));
+ checkRandomData(random(), queryAnalyzer, atLeast(200));
+ }
+
+ public void testEmptyTerm() throws IOException {
+ Analyzer a =
+ new Analyzer() {
+ @Override
+ protected TokenStreamComponents createComponents(String fieldName) {
+ Tokenizer tokenizer = new KeywordTokenizer();
+ return new TokenStreamComponents(tokenizer, new JapaneseCompletionFilter(tokenizer));
+ }
+ };
+ checkOneTerm(a, "", "");
+ a.close();
+ }
+}
diff --git a/lucene/analysis/kuromoji/src/test/org/apache/lucene/analysis/ja/TestJapaneseCompletionFilterFactory.java b/lucene/analysis/kuromoji/src/test/org/apache/lucene/analysis/ja/TestJapaneseCompletionFilterFactory.java
new file mode 100644
index 0000000..6295dbf
--- /dev/null
+++ b/lucene/analysis/kuromoji/src/test/org/apache/lucene/analysis/ja/TestJapaneseCompletionFilterFactory.java
@@ -0,0 +1,59 @@
+/*
+ * 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 org.apache.lucene.analysis.ja;
+
+import java.io.IOException;
+import java.io.StringReader;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.lucene.analysis.BaseTokenStreamFactoryTestCase;
+import org.apache.lucene.analysis.TokenStream;
+import org.apache.lucene.analysis.Tokenizer;
+import org.apache.lucene.analysis.cjk.CJKWidthFilterFactory;
+import org.junit.Test;
+
+public class TestJapaneseCompletionFilterFactory extends BaseTokenStreamFactoryTestCase {
+ @Test
+ public void testCompletion() throws IOException {
+ JapaneseTokenizerFactory tokenizerFactory = new JapaneseTokenizerFactory(new HashMap<>());
+ TokenStream tokenStream = tokenizerFactory.create();
+ ((Tokenizer) tokenStream).setReader(new StringReader("東京t"));
+ CJKWidthFilterFactory cjkWidthFactory = new CJKWidthFilterFactory(new HashMap<>());
+ tokenStream = cjkWidthFactory.create(tokenStream);
+ Map<String, String> map = new HashMap<>();
+ map.put("mode", "QUERY");
+ JapaneseCompletionFilterFactory filterFactory = new JapaneseCompletionFilterFactory(map);
+ assertTokenStreamContents(filterFactory.create(tokenStream), new String[] {"東京t", "toukyout"});
+ }
+
+ /** Test that bogus arguments result in exception */
+ @Test
+ public void testBogusArguments() throws Exception {
+ IllegalArgumentException expected =
+ expectThrows(
+ IllegalArgumentException.class,
+ () -> {
+ new JapaneseCompletionFilterFactory(
+ new HashMap<String, String>() {
+ {
+ put("bogusArg", "bogusValue");
+ }
+ });
+ });
+ assertTrue(expected.getMessage().contains("Unknown parameters"));
+ }
+}
diff --git a/lucene/analysis/kuromoji/src/test/org/apache/lucene/analysis/ja/completion/TestKatakanaRomanizer.java b/lucene/analysis/kuromoji/src/test/org/apache/lucene/analysis/ja/completion/TestKatakanaRomanizer.java
new file mode 100644
index 0000000..d1f739e
--- /dev/null
+++ b/lucene/analysis/kuromoji/src/test/org/apache/lucene/analysis/ja/completion/TestKatakanaRomanizer.java
@@ -0,0 +1,68 @@
+/*
+ * 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 org.apache.lucene.analysis.ja.completion;
+
+import java.util.List;
+import org.apache.lucene.util.CharsRef;
+import org.apache.lucene.util.LuceneTestCase;
+import org.junit.Test;
+
+public class TestKatakanaRomanizer extends LuceneTestCase {
+ private final KatakanaRomanizer romanizer = KatakanaRomanizer.getInstance();
+
+ @Test
+ public void testRomanize() {
+ assertCharsRefListEqualsUnordered(
+ List.of(new CharsRef("hasi"), new CharsRef("hashi")),
+ romanizer.romanize(new CharsRef("ハシ")));
+ assertCharsRefListEqualsUnordered(
+ List.of(new CharsRef("yuukyuu")), romanizer.romanize(new CharsRef("ユウキュウ")));
+ assertCharsRefListEqualsUnordered(
+ List.of(new CharsRef("yakyuu")), romanizer.romanize(new CharsRef("ヤキュウ")));
+ assertCharsRefListEqualsUnordered(
+ List.of(new CharsRef("toukyou")), romanizer.romanize(new CharsRef("トウキョウ")));
+ assertCharsRefListEqualsUnordered(
+ List.of(new CharsRef("toーkyoー")), romanizer.romanize(new CharsRef("トーキョー")));
+ assertCharsRefListEqualsUnordered(
+ List.of(new CharsRef("sakka")), romanizer.romanize(new CharsRef("サッカ")));
+ assertCharsRefListEqualsUnordered(
+ List.of(new CharsRef("hyakkaten"), new CharsRef("hyakkatenn")),
+ romanizer.romanize(new CharsRef("ヒャッカテン")));
+ assertCharsRefListEqualsUnordered(
+ List.of(new CharsRef("voruteーru"), new CharsRef("vuxoruteーru")),
+ romanizer.romanize(new CharsRef("ヴォルテール")));
+ }
+
+ @Test
+ public void testRomanizeWithAlphabets() {
+ assertCharsRefListEqualsUnordered(
+ List.of(new CharsRef("toukyout")), romanizer.romanize(new CharsRef("トウキョウt")));
+ assertCharsRefListEqualsUnordered(
+ List.of(new CharsRef("kodakk")), romanizer.romanize(new CharsRef("コダッk")));
+ assertCharsRefListEqualsUnordered(
+ List.of(new CharsRef("syousy"), new CharsRef("shousy")),
+ romanizer.romanize(new CharsRef("ショウsy")));
+ }
+
+ private static void assertCharsRefListEqualsUnordered(
+ List<CharsRef> expected, List<CharsRef> actual) {
+ assertEquals(expected.size(), actual.size());
+ for (CharsRef ref : expected) {
+ assertTrue(ref.toString() + " is not contained in " + actual, actual.contains(ref));
+ }
+ }
+}