001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      https://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017
018package org.apache.commons.codec.language.bm;
019
020import java.util.ArrayList;
021import java.util.Arrays;
022import java.util.Collections;
023import java.util.Comparator;
024import java.util.EnumMap;
025import java.util.HashMap;
026import java.util.HashSet;
027import java.util.List;
028import java.util.Map;
029import java.util.Scanner;
030import java.util.Set;
031import java.util.regex.Matcher;
032import java.util.regex.Pattern;
033
034import org.apache.commons.codec.Resources;
035import org.apache.commons.codec.language.bm.Languages.LanguageSet;
036
037/**
038 * A phoneme rule.
039 * <p>
040 * Rules have a pattern, left context, right context, output phoneme, set of languages for which they apply
041 * and a logical flag indicating if all languages must be in play. A rule matches if:
042 * </p>
043 * <ul>
044 * <li>the pattern matches at the current position</li>
045 * <li>the string up until the beginning of the pattern matches the left context</li>
046 * <li>the string from the end of the pattern matches the right context</li>
047 * <li>logical is ALL and all languages are in scope; or</li>
048 * <li>logical is any other value and at least one language is in scope</li>
049 * </ul>
050 * <p>
051 * Rules are typically generated by parsing rules resources. In normal use, there will be no need for the user
052 * to explicitly construct their own.
053 * </p>
054 * <p>
055 * Rules are immutable and thread-safe.
056 * </p>
057 * <h2>Rules resources</h2>
058 * <p>
059 * Rules are typically loaded from resource files. These are UTF-8 encoded text files. They are systematically
060 * named following the pattern:
061 * </p>
062 * <blockquote>/org/apache/commons/codec/language/bm/${NameType#getName}_${RuleType#getName}_${language}.txt</blockquote>
063 * <p>
064 * The format of these resources is the following:
065 * </p>
066 * <ul>
067 * <li><strong>Rules:</strong> whitespace separated, double-quoted strings. There should be 4 columns to each row, and these
068 * will be interpreted as:
069 * <ol>
070 * <li>pattern</li>
071 * <li>left context</li>
072 * <li>right context</li>
073 * <li>phoneme</li>
074 * </ol>
075 * </li>
076 * <li><strong>End-of-line comments:</strong> Any occurrence of '//' will cause all text following on that line to be discarded
077 * as a comment.</li>
078 * <li><strong>Multi-line comments:</strong> Any line starting with '/*' will start multi-line commenting mode. This will skip
079 * all content until a line ending in '*' and '/' is found.</li>
080 * <li><strong>Blank lines:</strong> All blank lines will be skipped.</li>
081 * </ul>
082 *
083 * @since 1.6
084 */
085public class Rule {
086
087    /**
088     * A phoneme.
089     */
090    public static final class Phoneme implements PhonemeExpr {
091
092        /**
093         * The Phoneme Comparator.
094         */
095        public static final Comparator<Phoneme> COMPARATOR = (o1, o2) -> {
096            final int o1Length = o1.phonemeText.length();
097            final int o2Length = o2.phonemeText.length();
098            for (int i = 0; i < o1Length; i++) {
099                if (i >= o2Length) {
100                    return +1;
101                }
102                final int c = o1.phonemeText.charAt(i) - o2.phonemeText.charAt(i);
103                if (c != 0) {
104                    return c;
105                }
106            }
107
108            if (o1Length < o2Length) {
109                return -1;
110            }
111
112            return 0;
113        };
114
115        private final StringBuilder phonemeText;
116
117        private final Languages.LanguageSet languages;
118
119        /**
120         * Constructs a new instance.
121         *
122         * @param phonemeText The phoneme text.
123         * @param languages A language set.
124         */
125        public Phoneme(final CharSequence phonemeText, final Languages.LanguageSet languages) {
126            this.phonemeText = new StringBuilder(phonemeText);
127            this.languages = languages;
128        }
129
130        /**
131         * Constructs a new instance.
132         *
133         * @param phonemeLeft The left phoneme text.
134         * @param phonemeRight The right phoneme text.
135         */
136        public Phoneme(final Phoneme phonemeLeft, final Phoneme phonemeRight) {
137            this(phonemeLeft.phonemeText, phonemeLeft.languages);
138            this.phonemeText.append(phonemeRight.phonemeText);
139        }
140
141        /**
142         * Constructs a new instance.
143         *
144         * @param phonemeLeft The left phoneme text.
145         * @param phonemeRight The right phoneme text.
146         * @param languages A language set.
147         */
148        public Phoneme(final Phoneme phonemeLeft, final Phoneme phonemeRight, final Languages.LanguageSet languages) {
149            this(phonemeLeft.phonemeText, languages);
150            this.phonemeText.append(phonemeRight.phonemeText);
151        }
152
153        /**
154         * Appends the sequence to the phone text.
155         *
156         * @param sequence The sequence to append.
157         * @return this instance.
158         */
159        public Phoneme append(final CharSequence sequence) {
160            this.phonemeText.append(sequence);
161            return this;
162        }
163
164        /**
165         * Gets the language set.
166         *
167         * @return the language set.
168         */
169        public Languages.LanguageSet getLanguages() {
170            return this.languages;
171        }
172
173        @Override
174        public Iterable<Phoneme> getPhonemes() {
175            return Collections.singleton(this);
176        }
177
178        /**
179         * Gets the phoneme text sequence.
180         *
181         * @return the phoneme text sequence.
182         */
183        public CharSequence getPhonemeText() {
184            return this.phonemeText;
185        }
186
187        /**
188         * Deprecated since 1.9.
189         *
190         * @param right the Phoneme to join
191         * @return a new Phoneme
192         * @deprecated since 1.9
193         */
194        @Deprecated
195        public Phoneme join(final Phoneme right) {
196            return new Phoneme(this.phonemeText.toString() + right.phonemeText.toString(),
197                               this.languages.restrictTo(right.languages));
198        }
199
200        /**
201         * Returns a new Phoneme with the same text but a union of its
202         * current language set and the given one.
203         *
204         * @param lang the language set to merge
205         * @return a new Phoneme
206         */
207        public Phoneme mergeWithLanguage(final LanguageSet lang) {
208          return new Phoneme(this.phonemeText.toString(), this.languages.merge(lang));
209        }
210
211        @Override
212        public int size() {
213            return 1;
214        }
215
216        @Override
217        public String toString() {
218          return phonemeText.toString() + "[" + languages + "]";
219        }
220    }
221
222    /**
223     * A phoneme expression.
224     */
225    public interface PhonemeExpr {
226
227        /**
228         * Gets an iteration of phonemes.
229         *
230         * @return an iteration of phonemes.
231         */
232        Iterable<Phoneme> getPhonemes();
233
234        /**
235         * Gets the expression size in phonemes.
236         *
237         * @return the expression size in phonemes.
238         * @since 1.17.0
239         */
240        default int size() {
241            // All implementations are int-bound.
242            return (int) Math.min(getPhonemes().spliterator().getExactSizeIfKnown(), Integer.MAX_VALUE);
243        }
244    }
245
246    /**
247     * A list of phonemes.
248     */
249    public static final class PhonemeList implements PhonemeExpr {
250
251        private final List<Phoneme> phonemeList;
252
253        /**
254         * Constructs a new instance.
255         *
256         * @param phonemes the phoneme list.
257         */
258        public PhonemeList(final List<Phoneme> phonemes) {
259            this.phonemeList = phonemes;
260        }
261
262        @Override
263        public List<Phoneme> getPhonemes() {
264            return phonemeList;
265        }
266
267        @Override
268        public int size() {
269            return phonemeList.size();
270        }
271    }
272
273    /**
274     * A minimal wrapper around the functionality of Pattern that we use, to allow for alternate implementations.
275     */
276    public interface RPattern {
277
278        /**
279         * Tests whether the given input matches this instance.
280         *
281         * @param input the input to test.
282         * @return whether the given input matches this instance.
283         */
284        boolean isMatch(CharSequence input);
285    }
286
287    /**
288     * Always matches.
289     */
290    public static final RPattern ALL_STRINGS_RMATCHER = input -> true;
291
292    /**
293     * Unused.
294     */
295    public static final String ALL = "ALL";
296
297    private static final String DOUBLE_QUOTE = "\"";
298
299    private static final String HASH_INCLUDE = "#include";
300
301    private static final int HASH_INCLUDE_LENGTH = HASH_INCLUDE.length();
302
303    private static final Map<NameType, Map<RuleType, Map<String, Map<String, List<Rule>>>>> RULES =
304            new EnumMap<>(NameType.class);
305
306    static {
307        for (final NameType s : NameType.values()) {
308            final Map<RuleType, Map<String, Map<String, List<Rule>>>> rts =
309                    new EnumMap<>(RuleType.class);
310
311            for (final RuleType rt : RuleType.values()) {
312                final Map<String, Map<String, List<Rule>>> rs = new HashMap<>();
313
314                final Languages ls = Languages.getInstance(s);
315                ls.getLanguages().forEach(l -> {
316                    try (Scanner scanner = createScanner(s, rt, l)) {
317                        rs.put(l, parseRules(scanner, createResourceName(s, rt, l)));
318                    } catch (final IllegalStateException e) {
319                        throw new IllegalStateException("Problem processing " + createResourceName(s, rt, l), e);
320                    }
321                });
322                if (!rt.equals(RuleType.RULES)) {
323                    try (Scanner scanner = createScanner(s, rt, "common")) {
324                        rs.put("common", parseRules(scanner, createResourceName(s, rt, "common")));
325                    }
326                }
327
328                rts.put(rt, Collections.unmodifiableMap(rs));
329            }
330
331            RULES.put(s, Collections.unmodifiableMap(rts));
332        }
333    }
334
335    private static boolean contains(final CharSequence chars, final char input) {
336        return chars.chars().anyMatch(c -> c == input);
337    }
338
339    private static String createResourceName(final NameType nameType, final RuleType rt, final String lang) {
340        return String.format("/org/apache/commons/codec/language/bm/%s_%s_%s.txt",
341                             nameType.getName(), rt.getName(), lang);
342    }
343
344    @SuppressWarnings("resource") // Closing the Scanner closes the resource
345    private static Scanner createScanner(final NameType nameType, final RuleType rt, final String lang) {
346        final String resName = createResourceName(nameType, rt, lang);
347        return new Scanner(Resources.getInputStream(resName), ResourceConstants.ENCODING);
348    }
349
350    @SuppressWarnings("resource") // Closing the Scanner closes the resource
351    private static Scanner createScanner(final String lang) {
352        final String resName = String.format("/org/apache/commons/codec/language/bm/%s.txt", lang);
353        return new Scanner(Resources.getInputStream(resName), ResourceConstants.ENCODING);
354    }
355
356    private static boolean endsWith(final CharSequence input, final CharSequence suffix) {
357        final int suffixLength = suffix.length();
358        final int inputLength = input.length();
359
360        if (suffixLength > inputLength) {
361            return false;
362        }
363        for (int i = inputLength - 1, j = suffixLength - 1; j >= 0; i--, j--) {
364            if (input.charAt(i) != suffix.charAt(j)) {
365                return false;
366            }
367        }
368        return true;
369    }
370
371    /**
372     * Gets rules for a combination of name type, rule type and languages.
373     *
374     * @param nameType
375     *            the NameType to consider
376     * @param rt
377     *            the RuleType to consider
378     * @param langs
379     *            the set of languages to consider
380     * @return a list of Rules that apply
381     */
382    public static List<Rule> getInstance(final NameType nameType, final RuleType rt,
383                                         final Languages.LanguageSet langs) {
384        final Map<String, List<Rule>> ruleMap = getInstanceMap(nameType, rt, langs);
385        final List<Rule> allRules = new ArrayList<>();
386        ruleMap.values().forEach(rules -> allRules.addAll(rules));
387        return allRules;
388    }
389
390    /**
391     * Gets rules for a combination of name type, rule type and a single language.
392     *
393     * @param nameType
394     *            the NameType to consider
395     * @param rt
396     *            the RuleType to consider
397     * @param lang
398     *            the language to consider
399     * @return a list of Rules that apply
400     */
401    public static List<Rule> getInstance(final NameType nameType, final RuleType rt, final String lang) {
402        return getInstance(nameType, rt, LanguageSet.from(new HashSet<>(Arrays.asList(lang))));
403    }
404
405    /**
406     * Gets rules for a combination of name type, rule type and languages.
407     *
408     * @param nameType
409     *            the NameType to consider
410     * @param rt
411     *            the RuleType to consider
412     * @param langs
413     *            the set of languages to consider
414     * @return a map containing all Rules that apply, grouped by the first character of the rule pattern
415     * @since 1.9
416     */
417    public static Map<String, List<Rule>> getInstanceMap(final NameType nameType, final RuleType rt,
418                                                         final Languages.LanguageSet langs) {
419        return langs.isSingleton() ? getInstanceMap(nameType, rt, langs.getAny()) :
420                                     getInstanceMap(nameType, rt, Languages.ANY);
421    }
422
423    /**
424     * Gets rules for a combination of name type, rule type and a single language.
425     *
426     * @param nameType
427     *            the NameType to consider
428     * @param rt
429     *            the RuleType to consider
430     * @param lang
431     *            the language to consider
432     * @return a map containing all Rules that apply, grouped by the first character of the rule pattern
433     * @since 1.9
434     */
435    public static Map<String, List<Rule>> getInstanceMap(final NameType nameType, final RuleType rt,
436                                                         final String lang) {
437        final Map<String, List<Rule>> rules = RULES.get(nameType).get(rt).get(lang);
438
439        if (rules == null) {
440            throw new IllegalArgumentException(String.format("No rules found for %s, %s, %s.",
441                                               nameType.getName(), rt.getName(), lang));
442        }
443
444        return rules;
445    }
446
447    private static Phoneme parsePhoneme(final String ph) {
448        final int open = ph.indexOf("[");
449        if (open >= 0) {
450            if (!ph.endsWith("]")) {
451                throw new IllegalArgumentException("Phoneme expression contains a '[' but does not end in ']'");
452            }
453            final String before = ph.substring(0, open);
454            final String in = ph.substring(open + 1, ph.length() - 1);
455            final Set<String> langs = new HashSet<>(Arrays.asList(in.split("[+]")));
456
457            return new Phoneme(before, Languages.LanguageSet.from(langs));
458        }
459        return new Phoneme(ph, Languages.ANY_LANGUAGE);
460    }
461
462    private static PhonemeExpr parsePhonemeExpr(final String ph) {
463        if (ph.startsWith("(")) { // we have a bracketed list of options
464            if (!ph.endsWith(")")) {
465                throw new IllegalArgumentException("Phoneme starts with '(' so must end with ')'");
466            }
467
468            final List<Phoneme> phs = new ArrayList<>();
469            final String body = ph.substring(1, ph.length() - 1);
470            for (final String part : body.split("[|]")) {
471                phs.add(parsePhoneme(part));
472            }
473            if (body.startsWith("|") || body.endsWith("|")) {
474                phs.add(new Phoneme("", Languages.ANY_LANGUAGE));
475            }
476
477            return new PhonemeList(phs);
478        }
479        return parsePhoneme(ph);
480    }
481
482    private static Map<String, List<Rule>> parseRules(final Scanner scanner, final String location) {
483        final Map<String, List<Rule>> lines = new HashMap<>();
484        int currentLine = 0;
485
486        boolean inMultilineComment = false;
487        while (scanner.hasNextLine()) {
488            currentLine++;
489            final String rawLine = scanner.nextLine();
490            String line = rawLine;
491
492            if (inMultilineComment) {
493                if (line.endsWith(ResourceConstants.EXT_CMT_END)) {
494                    inMultilineComment = false;
495                }
496            } else if (line.startsWith(ResourceConstants.EXT_CMT_START)) {
497                inMultilineComment = true;
498            } else {
499                // discard comments
500                final int cmtI = line.indexOf(ResourceConstants.CMT);
501                if (cmtI >= 0) {
502                    line = line.substring(0, cmtI);
503                }
504
505                // trim leading-trailing whitespace
506                line = line.trim();
507
508                if (line.isEmpty()) {
509                    continue; // empty lines can be safely skipped
510                }
511
512                if (line.startsWith(HASH_INCLUDE)) {
513                    // include statement
514                    final String incl = line.substring(HASH_INCLUDE_LENGTH).trim();
515                    if (incl.contains(" ")) {
516                        throw new IllegalArgumentException("Malformed import statement '" + rawLine + "' in " +
517                                                           location);
518                    }
519                    try (Scanner hashIncludeScanner = createScanner(incl)) {
520                        lines.putAll(parseRules(hashIncludeScanner, location + "->" + incl));
521                    }
522                } else {
523                    // rule
524                    final String[] parts = line.split("\\s+");
525                    if (parts.length != 4) {
526                        throw new IllegalArgumentException("Malformed rule statement split into " + parts.length +
527                                                           " parts: " + rawLine + " in " + location);
528                    }
529                    try {
530                        final String pat = stripQuotes(parts[0]);
531                        final String lCon = stripQuotes(parts[1]);
532                        final String rCon = stripQuotes(parts[2]);
533                        final PhonemeExpr ph = parsePhonemeExpr(stripQuotes(parts[3]));
534                        final int cLine = currentLine;
535                        final Rule r = new Rule(pat, lCon, rCon, ph) {
536                            private final int myLine = cLine;
537                            private final String loc = location;
538
539                            @Override
540                            public String toString() {
541                                final StringBuilder sb = new StringBuilder();
542                                sb.append("Rule");
543                                sb.append("{line=").append(myLine);
544                                sb.append(", loc='").append(loc).append('\'');
545                                sb.append(", pat='").append(pat).append('\'');
546                                sb.append(", lcon='").append(lCon).append('\'');
547                                sb.append(", rcon='").append(rCon).append('\'');
548                                sb.append('}');
549                                return sb.toString();
550                            }
551                        };
552                        final String patternKey = r.pattern.substring(0, 1);
553                        final List<Rule> rules = lines.computeIfAbsent(patternKey, k -> new ArrayList<>());
554                        rules.add(r);
555                    } catch (final IllegalArgumentException e) {
556                        throw new IllegalStateException("Problem parsing line '" + currentLine + "' in " +
557                                                        location, e);
558                    }
559                }
560            }
561        }
562
563        return lines;
564    }
565
566    /**
567     * Attempts to compile the regex into direct string ops, falling back to Pattern and Matcher in the worst case.
568     *
569     * @param regex
570     *            the regular expression to compile
571     * @return an RPattern that will match this regex
572     */
573    private static RPattern pattern(final String regex) {
574        final boolean startsWith = regex.startsWith("^");
575        final boolean endsWith = regex.endsWith("$");
576        final String content = regex.substring(startsWith ? 1 : 0, endsWith ? regex.length() - 1 : regex.length());
577        final boolean boxes = content.contains("[");
578
579        if (!boxes) {
580            if (startsWith && endsWith) {
581                // exact match
582                if (content.isEmpty()) {
583                    // empty
584                    return input -> input.length() == 0;
585                }
586                return input -> input.equals(content);
587            }
588            if ((startsWith || endsWith) && content.isEmpty()) {
589                // matches every string
590                return ALL_STRINGS_RMATCHER;
591            }
592            if (startsWith) {
593                // matches from start
594                return input -> startsWith(input, content);
595            }
596            if (endsWith) {
597                // matches from start
598                return input -> endsWith(input, content);
599            }
600        } else {
601            final boolean startsWithBox = content.startsWith("[");
602            final boolean endsWithBox = content.endsWith("]");
603
604            if (startsWithBox && endsWithBox) {
605                String boxContent = content.substring(1, content.length() - 1);
606                if (!boxContent.contains("[")) {
607                    // box containing alternatives
608                    final boolean negate = boxContent.startsWith("^");
609                    if (negate) {
610                        boxContent = boxContent.substring(1);
611                    }
612                    final String bContent = boxContent;
613                    final boolean shouldMatch = !negate;
614
615                    if (startsWith && endsWith) {
616                        // exact match
617                        return input -> input.length() == 1 && contains(bContent, input.charAt(0)) == shouldMatch;
618                    }
619                    if (startsWith) {
620                        // first char
621                        return input -> input.length() > 0 && contains(bContent, input.charAt(0)) == shouldMatch;
622                    }
623                    if (endsWith) {
624                        // last char
625                        return input -> input.length() > 0 &&
626                               contains(bContent, input.charAt(input.length() - 1)) == shouldMatch;
627                    }
628                }
629            }
630        }
631
632        return new RPattern() {
633            final Pattern pattern = Pattern.compile(regex);
634
635            @Override
636            public boolean isMatch(final CharSequence input) {
637                final Matcher matcher = pattern.matcher(input);
638                return matcher.find();
639            }
640        };
641    }
642
643    private static boolean startsWith(final CharSequence input, final CharSequence prefix) {
644        if (prefix.length() > input.length()) {
645            return false;
646        }
647        for (int i = 0; i < prefix.length(); i++) {
648            if (input.charAt(i) != prefix.charAt(i)) {
649                return false;
650            }
651        }
652        return true;
653    }
654
655    private static String stripQuotes(String str) {
656        if (str.startsWith(DOUBLE_QUOTE)) {
657            str = str.substring(1);
658        }
659
660        if (str.endsWith(DOUBLE_QUOTE)) {
661            str = str.substring(0, str.length() - 1);
662        }
663
664        return str;
665    }
666
667    private final RPattern lContext;
668
669    private final String pattern;
670
671    private final PhonemeExpr phoneme;
672
673    private final RPattern rContext;
674
675    /**
676     * Creates a new rule.
677     *
678     * @param pattern
679     *            the pattern
680     * @param lContext
681     *            the left context
682     * @param rContext
683     *            the right context
684     * @param phoneme
685     *            the resulting phoneme
686     */
687    public Rule(final String pattern, final String lContext, final String rContext, final PhonemeExpr phoneme) {
688        this.pattern = pattern;
689        this.lContext = pattern(lContext + "$");
690        this.rContext = pattern("^" + rContext);
691        this.phoneme = phoneme;
692    }
693
694    /**
695     * Gets the left context. This is a regular expression that must match to the left of the pattern.
696     *
697     * @return the left context Pattern
698     */
699    public RPattern getLContext() {
700        return this.lContext;
701    }
702
703    /**
704     * Gets the pattern. This is a string-literal that must exactly match.
705     *
706     * @return the pattern
707     */
708    public String getPattern() {
709        return this.pattern;
710    }
711
712    /**
713     * Gets the phoneme. If the rule matches, this is the phoneme associated with the pattern match.
714     *
715     * @return the phoneme
716     */
717    public PhonemeExpr getPhoneme() {
718        return this.phoneme;
719    }
720
721    /**
722     * Gets the right context. This is a regular expression that must match to the right of the pattern.
723     *
724     * @return the right context Pattern
725     */
726    public RPattern getRContext() {
727        return this.rContext;
728    }
729
730    /**
731     * Decides if the pattern and context match the input starting at a position. It is a match if the
732     * {@code lContext} matches {@code input} up to {@code i}, {@code pattern} matches at i and
733     * {@code rContext} matches from the end of the match of {@code pattern} to the end of {@code input}.
734     *
735     * @param input
736     *            the input String
737     * @param i
738     *            the int position within the input
739     * @return true if the pattern and left/right context match, false otherwise
740     */
741    public boolean patternAndContextMatches(final CharSequence input, final int i) {
742        if (i < 0) {
743            throw new IndexOutOfBoundsException("Can not match pattern at negative indexes");
744        }
745
746        final int patternLength = this.pattern.length();
747        final int ipl = i + patternLength;
748
749        if (ipl > input.length()) {
750            // not enough room for the pattern to match
751            return false;
752        }
753
754        // evaluate the pattern, left context and right context
755        // fail early if any of the evaluations is not successful
756        if (!input.subSequence(i, ipl).equals(this.pattern)) {
757            return false;
758        }
759        if (!this.rContext.isMatch(input.subSequence(ipl, input.length()))) {
760            return false;
761        }
762        return this.lContext.isMatch(input.subSequence(0, i));
763    }
764}