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     *      http://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    
018    package org.apache.commons.codec.language.bm;
019    
020    import java.io.InputStream;
021    import java.util.ArrayList;
022    import java.util.Arrays;
023    import java.util.Collections;
024    import java.util.Comparator;
025    import java.util.EnumMap;
026    import java.util.HashMap;
027    import java.util.HashSet;
028    import java.util.List;
029    import java.util.Map;
030    import java.util.Scanner;
031    import java.util.Set;
032    import java.util.regex.Matcher;
033    import java.util.regex.Pattern;
034    
035    /**
036     * A phoneme rule.
037     * <p>
038     * Rules have a pattern, left context, right context, output phoneme, set of languages for which they apply
039     * and a logical flag indicating if all languages must be in play. A rule matches if:
040     * <ul>
041     * <li>the pattern matches at the current position</li>
042     * <li>the string up until the beginning of the pattern matches the left context</li>
043     * <li>the string from the end of the pattern matches the right context</li>
044     * <li>logical is ALL and all languages are in scope; or</li>
045     * <li>logical is any other value and at least one language is in scope</li>
046     * </ul>
047     * <p>
048     * Rules are typically generated by parsing rules resources. In normal use, there will be no need for the user
049     * to explicitly construct their own.
050     * <p>
051     * Rules are immutable and thread-safe.
052     * <p>
053     * <b>Rules resources</b>
054     * <p>
055     * Rules are typically loaded from resource files. These are UTF-8 encoded text files. They are systematically
056     * named following the pattern:
057     * <blockquote>org/apache/commons/codec/language/bm/${NameType#getName}_${RuleType#getName}_${language}.txt</blockquote>
058     * <p>
059     * The format of these resources is the following:
060     * <ul>
061     * <li><b>Rules:</b> whitespace separated, double-quoted strings. There should be 4 columns to each row, and these
062     * will be interpreted as:
063     * <ol>
064     * <li>pattern</li>
065     * <li>left context</li>
066     * <li>right context</li>
067     * <li>phoneme</li>
068     * </ol>
069     * </li>
070     * <li><b>End-of-line comments:</b> Any occurrence of '//' will cause all text following on that line to be discarded
071     * as a comment.</li>
072     * <li><b>Multi-line comments:</b> Any line starting with '/*' will start multi-line commenting mode. This will skip
073     * all content until a line ending in '*' and '/' is found.</li>
074     * <li><b>Blank lines:</b> All blank lines will be skipped.</li>
075     * </ul>
076     *
077     * @since 1.6
078     * @version $Id: Rule.PhonemeList.html 889935 2013-12-11 05:05:13Z ggregory $
079     */
080    public class Rule {
081    
082        public static final class Phoneme implements PhonemeExpr {
083            public static final Comparator<Phoneme> COMPARATOR = new Comparator<Phoneme>() {
084                @Override
085                public int compare(Phoneme o1, Phoneme o2) {
086                    for (int i = 0; i < o1.phonemeText.length(); i++) {
087                        if (i >= o2.phonemeText.length()) {
088                            return +1;
089                        }
090                        int c = o1.phonemeText.charAt(i) - o2.phonemeText.charAt(i);
091                        if (c != 0) {
092                            return c;
093                        }
094                    }
095    
096                    if (o1.phonemeText.length() < o2.phonemeText.length()) {
097                        return -1;
098                    }
099    
100                    return 0;
101                }
102            };
103    
104            private final CharSequence phonemeText;
105            private final Languages.LanguageSet languages;
106    
107            public Phoneme(CharSequence phonemeText, Languages.LanguageSet languages) {
108                this.phonemeText = phonemeText;
109                this.languages = languages;
110            }
111    
112            public Phoneme append(CharSequence str) {
113                return new Phoneme(this.phonemeText.toString() + str.toString(), this.languages);
114            }
115    
116            public Languages.LanguageSet getLanguages() {
117                return this.languages;
118            }
119    
120            @Override
121            public Iterable<Phoneme> getPhonemes() {
122                return Collections.singleton(this);
123            }
124    
125            public CharSequence getPhonemeText() {
126                return this.phonemeText;
127            }
128    
129            public Phoneme join(Phoneme right) {
130                return new Phoneme(this.phonemeText.toString() + right.phonemeText.toString(),
131                                   this.languages.restrictTo(right.languages));
132            }
133        }
134    
135        public interface PhonemeExpr {
136            Iterable<Phoneme> getPhonemes();
137        }
138    
139        public static final class PhonemeList implements PhonemeExpr {
140            private final List<Phoneme> phonemes;
141    
142            public PhonemeList(List<Phoneme> phonemes) {
143                this.phonemes = phonemes;
144            }
145    
146            @Override
147            public List<Phoneme> getPhonemes() {
148                return this.phonemes;
149            }
150        }
151    
152        /**
153         * A minimal wrapper around the functionality of Pattern that we use, to allow for alternate implementations.
154         */
155        public static interface RPattern {
156            boolean isMatch(CharSequence input);
157        }
158    
159        public static final RPattern ALL_STRINGS_RMATCHER = new RPattern() {
160            @Override
161            public boolean isMatch(CharSequence input) {
162                return true;
163            }
164        };
165    
166        public static final String ALL = "ALL";
167    
168        private static final String DOUBLE_QUOTE = "\"";
169    
170        private static final String HASH_INCLUDE = "#include";
171    
172        private static final Map<NameType, Map<RuleType, Map<String, List<Rule>>>> RULES =
173                new EnumMap<NameType, Map<RuleType, Map<String, List<Rule>>>>(NameType.class);
174    
175        static {
176            for (NameType s : NameType.values()) {
177                Map<RuleType, Map<String, List<Rule>>> rts = new EnumMap<RuleType, Map<String, List<Rule>>>(RuleType.class);
178    
179                for (RuleType rt : RuleType.values()) {
180                    Map<String, List<Rule>> rs = new HashMap<String, List<Rule>>();
181    
182                    Languages ls = Languages.getInstance(s);
183                    for (String l : ls.getLanguages()) {
184                        try {
185                            rs.put(l, parseRules(createScanner(s, rt, l), createResourceName(s, rt, l)));
186                        } catch (IllegalStateException e) {
187                            throw new IllegalStateException("Problem processing " + createResourceName(s, rt, l), e);
188                        }
189                    }
190                    if (!rt.equals(RuleType.RULES)) {
191                        rs.put("common", parseRules(createScanner(s, rt, "common"), createResourceName(s, rt, "common")));
192                    }
193    
194                    rts.put(rt, Collections.unmodifiableMap(rs));
195                }
196    
197                RULES.put(s, Collections.unmodifiableMap(rts));
198            }
199        }
200    
201        private static boolean contains(CharSequence chars, char input) {
202            for (int i = 0; i < chars.length(); i++) {
203                if (chars.charAt(i) == input) {
204                    return true;
205                }
206            }
207            return false;
208        }
209    
210        private static String createResourceName(NameType nameType, RuleType rt, String lang) {
211            return String.format("org/apache/commons/codec/language/bm/%s_%s_%s.txt",
212                                 nameType.getName(), rt.getName(), lang);
213        }
214    
215        private static Scanner createScanner(NameType nameType, RuleType rt, String lang) {
216            String resName = createResourceName(nameType, rt, lang);
217            InputStream rulesIS = Languages.class.getClassLoader().getResourceAsStream(resName);
218    
219            if (rulesIS == null) {
220                throw new IllegalArgumentException("Unable to load resource: " + resName);
221            }
222    
223            return new Scanner(rulesIS, ResourceConstants.ENCODING);
224        }
225    
226        private static Scanner createScanner(String lang) {
227            String resName = String.format("org/apache/commons/codec/language/bm/%s.txt", lang);
228            InputStream rulesIS = Languages.class.getClassLoader().getResourceAsStream(resName);
229    
230            if (rulesIS == null) {
231                throw new IllegalArgumentException("Unable to load resource: " + resName);
232            }
233    
234            return new Scanner(rulesIS, ResourceConstants.ENCODING);
235        }
236    
237        private static boolean endsWith(CharSequence input, CharSequence suffix) {
238            if (suffix.length() > input.length()) {
239                return false;
240            }
241            for (int i = input.length() - 1, j = suffix.length() - 1; j >= 0; i--, j--) {
242                if (input.charAt(i) != suffix.charAt(j)) {
243                    return false;
244                }
245            }
246            return true;
247        }
248    
249        /**
250         * Gets rules for a combination of name type, rule type and languages.
251         *
252         * @param nameType
253         *            the NameType to consider
254         * @param rt
255         *            the RuleType to consider
256         * @param langs
257         *            the set of languages to consider
258         * @return a list of Rules that apply
259         */
260        public static List<Rule> getInstance(NameType nameType, RuleType rt, Languages.LanguageSet langs) {
261            return langs.isSingleton() ? getInstance(nameType, rt, langs.getAny()) :
262                                         getInstance(nameType, rt, Languages.ANY);
263        }
264    
265        /**
266         * Gets rules for a combination of name type, rule type and a single language.
267         *
268         * @param nameType
269         *            the NameType to consider
270         * @param rt
271         *            the RuleType to consider
272         * @param lang
273         *            the language to consider
274         * @return a list rules for a combination of name type, rule type and a single language.
275         */
276        public static List<Rule> getInstance(NameType nameType, RuleType rt, String lang) {
277            List<Rule> rules = RULES.get(nameType).get(rt).get(lang);
278    
279            if (rules == null) {
280                throw new IllegalArgumentException(String.format("No rules found for %s, %s, %s.",
281                                                   nameType.getName(), rt.getName(), lang));
282            }
283    
284            return rules;
285        }
286    
287        private static Phoneme parsePhoneme(String ph) {
288            int open = ph.indexOf("[");
289            if (open >= 0) {
290                if (!ph.endsWith("]")) {
291                    throw new IllegalArgumentException("Phoneme expression contains a '[' but does not end in ']'");
292                }
293                String before = ph.substring(0, open);
294                String in = ph.substring(open + 1, ph.length() - 1);
295                Set<String> langs = new HashSet<String>(Arrays.asList(in.split("[+]")));
296    
297                return new Phoneme(before, Languages.LanguageSet.from(langs));
298            } else {
299                return new Phoneme(ph, Languages.ANY_LANGUAGE);
300            }
301        }
302    
303        private static PhonemeExpr parsePhonemeExpr(String ph) {
304            if (ph.startsWith("(")) { // we have a bracketed list of options
305                if (!ph.endsWith(")")) {
306                    throw new IllegalArgumentException("Phoneme starts with '(' so must end with ')'");
307                }
308    
309                List<Phoneme> phs = new ArrayList<Phoneme>();
310                String body = ph.substring(1, ph.length() - 1);
311                for (String part : body.split("[|]")) {
312                    phs.add(parsePhoneme(part));
313                }
314                if (body.startsWith("|") || body.endsWith("|")) {
315                    phs.add(new Phoneme("", Languages.ANY_LANGUAGE));
316                }
317    
318                return new PhonemeList(phs);
319            } else {
320                return parsePhoneme(ph);
321            }
322        }
323    
324        private static List<Rule> parseRules(final Scanner scanner, final String location) {
325            List<Rule> lines = new ArrayList<Rule>();
326            int currentLine = 0;
327    
328            boolean inMultilineComment = false;
329            while (scanner.hasNextLine()) {
330                currentLine++;
331                String rawLine = scanner.nextLine();
332                String line = rawLine;
333    
334                if (inMultilineComment) {
335                    if (line.endsWith(ResourceConstants.EXT_CMT_END)) {
336                        inMultilineComment = false;
337                    }
338                } else {
339                    if (line.startsWith(ResourceConstants.EXT_CMT_START)) {
340                        inMultilineComment = true;
341                    } else {
342                        // discard comments
343                        int cmtI = line.indexOf(ResourceConstants.CMT);
344                        if (cmtI >= 0) {
345                            line = line.substring(0, cmtI);
346                        }
347    
348                        // trim leading-trailing whitespace
349                        line = line.trim();
350    
351                        if (line.length() == 0) {
352                            continue; // empty lines can be safely skipped
353                        }
354    
355                        if (line.startsWith(HASH_INCLUDE)) {
356                            // include statement
357                            String incl = line.substring(HASH_INCLUDE.length()).trim();
358                            if (incl.contains(" ")) {
359                                throw new IllegalArgumentException("Malformed import statement '" + rawLine + "' in " +
360                                                                   location);
361                            } else {
362                                lines.addAll(parseRules(createScanner(incl), location + "->" + incl));
363                            }
364                        } else {
365                            // rule
366                            String[] parts = line.split("\\s+");
367                            if (parts.length != 4) {
368                                throw new IllegalArgumentException("Malformed rule statement split into " + parts.length +
369                                                                   " parts: " + rawLine + " in " + location);
370                            } else {
371                                try {
372                                    String pat = stripQuotes(parts[0]);
373                                    String lCon = stripQuotes(parts[1]);
374                                    String rCon = stripQuotes(parts[2]);
375                                    PhonemeExpr ph = parsePhonemeExpr(stripQuotes(parts[3]));
376                                    final int cLine = currentLine;
377                                    Rule r = new Rule(pat, lCon, rCon, ph) {
378                                        private final int myLine = cLine;
379                                        private final String loc = location;
380    
381                                        @Override
382                                        public String toString() {
383                                            final StringBuilder sb = new StringBuilder();
384                                            sb.append("Rule");
385                                            sb.append("{line=").append(myLine);
386                                            sb.append(", loc='").append(loc).append('\'');
387                                            sb.append('}');
388                                            return sb.toString();
389                                        }
390                                    };
391                                    lines.add(r);
392                                } catch (IllegalArgumentException e) {
393                                    throw new IllegalStateException("Problem parsing line '" + currentLine + "' in " +
394                                                                    location, e);
395                                }
396                            }
397                        }
398                    }
399                }
400            }
401    
402            return lines;
403        }
404    
405        /**
406         * Attempts to compile the regex into direct string ops, falling back to Pattern and Matcher in the worst case.
407         *
408         * @param regex
409         *            the regular expression to compile
410         * @return an RPattern that will match this regex
411         */
412        private static RPattern pattern(final String regex) {
413            boolean startsWith = regex.startsWith("^");
414            boolean endsWith = regex.endsWith("$");
415            final String content = regex.substring(startsWith ? 1 : 0, endsWith ? regex.length() - 1 : regex.length());
416            boolean boxes = content.contains("[");
417    
418            if (!boxes) {
419                if (startsWith && endsWith) {
420                    // exact match
421                    if (content.length() == 0) {
422                        // empty
423                        return new RPattern() {
424                            @Override
425                            public boolean isMatch(CharSequence input) {
426                                return input.length() == 0;
427                            }
428                        };
429                    } else {
430                        return new RPattern() {
431                            @Override
432                            public boolean isMatch(CharSequence input) {
433                                return input.equals(content);
434                            }
435                        };
436                    }
437                } else if ((startsWith || endsWith) && content.length() == 0) {
438                    // matches every string
439                    return ALL_STRINGS_RMATCHER;
440                } else if (startsWith) {
441                    // matches from start
442                    return new RPattern() {
443                        @Override
444                        public boolean isMatch(CharSequence input) {
445                            return startsWith(input, content);
446                        }
447                    };
448                } else if (endsWith) {
449                    // matches from start
450                    return new RPattern() {
451                        @Override
452                        public boolean isMatch(CharSequence input) {
453                            return endsWith(input, content);
454                        }
455                    };
456                }
457            } else {
458                boolean startsWithBox = content.startsWith("[");
459                boolean endsWithBox = content.endsWith("]");
460    
461                if (startsWithBox && endsWithBox) {
462                    String boxContent = content.substring(1, content.length() - 1);
463                    if (!boxContent.contains("[")) {
464                        // box containing alternatives
465                        boolean negate = boxContent.startsWith("^");
466                        if (negate) {
467                            boxContent = boxContent.substring(1);
468                        }
469                        final String bContent = boxContent;
470                        final boolean shouldMatch = !negate;
471    
472                        if (startsWith && endsWith) {
473                            // exact match
474                            return new RPattern() {
475                                @Override
476                                public boolean isMatch(CharSequence input) {
477                                    return input.length() == 1 && contains(bContent, input.charAt(0)) == shouldMatch;
478                                }
479                            };
480                        } else if (startsWith) {
481                            // first char
482                            return new RPattern() {
483                                @Override
484                                public boolean isMatch(CharSequence input) {
485                                    return input.length() > 0 && contains(bContent, input.charAt(0)) == shouldMatch;
486                                }
487                            };
488                        } else if (endsWith) {
489                            // last char
490                            return new RPattern() {
491                                @Override
492                                public boolean isMatch(CharSequence input) {
493                                    return input.length() > 0 &&
494                                           contains(bContent, input.charAt(input.length() - 1)) == shouldMatch;
495                                }
496                            };
497                        }
498                    }
499                }
500            }
501    
502            return new RPattern() {
503                Pattern pattern = Pattern.compile(regex);
504    
505                @Override
506                public boolean isMatch(CharSequence input) {
507                    Matcher matcher = pattern.matcher(input);
508                    return matcher.find();
509                }
510            };
511        }
512    
513        private static boolean startsWith(CharSequence input, CharSequence prefix) {
514            if (prefix.length() > input.length()) {
515                return false;
516            }
517            for (int i = 0; i < prefix.length(); i++) {
518                if (input.charAt(i) != prefix.charAt(i)) {
519                    return false;
520                }
521            }
522            return true;
523        }
524    
525        private static String stripQuotes(String str) {
526            if (str.startsWith(DOUBLE_QUOTE)) {
527                str = str.substring(1);
528            }
529    
530            if (str.endsWith(DOUBLE_QUOTE)) {
531                str = str.substring(0, str.length() - 1);
532            }
533    
534            return str;
535        }
536    
537        private final RPattern lContext;
538    
539        private final String pattern;
540    
541        private final PhonemeExpr phoneme;
542    
543        private final RPattern rContext;
544    
545        /**
546         * Creates a new rule.
547         *
548         * @param pattern
549         *            the pattern
550         * @param lContext
551         *            the left context
552         * @param rContext
553         *            the right context
554         * @param phoneme
555         *            the resulting phoneme
556         */
557        public Rule(String pattern, String lContext, String rContext, PhonemeExpr phoneme) {
558            this.pattern = pattern;
559            this.lContext = pattern(lContext + "$");
560            this.rContext = pattern("^" + rContext);
561            this.phoneme = phoneme;
562        }
563    
564        /**
565         * Gets the left context. This is a regular expression that must match to the left of the pattern.
566         *
567         * @return the left context Pattern
568         */
569        public RPattern getLContext() {
570            return this.lContext;
571        }
572    
573        /**
574         * Gets the pattern. This is a string-literal that must exactly match.
575         *
576         * @return the pattern
577         */
578        public String getPattern() {
579            return this.pattern;
580        }
581    
582        /**
583         * Gets the phoneme. If the rule matches, this is the phoneme associated with the pattern match.
584         *
585         * @return the phoneme
586         */
587        public PhonemeExpr getPhoneme() {
588            return this.phoneme;
589        }
590    
591        /**
592         * Gets the right context. This is a regular expression that must match to the right of the pattern.
593         *
594         * @return the right context Pattern
595         */
596        public RPattern getRContext() {
597            return this.rContext;
598        }
599    
600        /**
601         * Decides if the pattern and context match the input starting at a position. It is a match if the
602         * <code>lContext</code> matches <code>input</code> up to <code>i</code>, <code>pattern</code> matches at i and
603         * <code>rContext</code> matches from the end of the match of <code>pattern</code> to the end of <code>input</code>.
604         *
605         * @param input
606         *            the input String
607         * @param i
608         *            the int position within the input
609         * @return true if the pattern and left/right context match, false otherwise
610         */
611        public boolean patternAndContextMatches(CharSequence input, int i) {
612            if (i < 0) {
613                throw new IndexOutOfBoundsException("Can not match pattern at negative indexes");
614            }
615    
616            int patternLength = this.pattern.length();
617            int ipl = i + patternLength;
618    
619            if (ipl > input.length()) {
620                // not enough room for the pattern to match
621                return false;
622            }
623    
624            // evaluate the pattern, left context and right context
625            // fail early if any of the evaluations is not successful
626            if (!input.subSequence(i, ipl).equals(this.pattern)) {
627                return false;
628            } else if (!this.rContext.isMatch(input.subSequence(ipl, input.length()))) {
629                return false;
630            }
631            return this.lContext.isMatch(input.subSequence(0, i));
632        }
633    }