]> gitweb.fperrin.net Git - DictionaryPC.git/blob - src/com/hughes/android/dictionary/parser/WikiTokenizer.java
Optimize plaintext dispatch path.
[DictionaryPC.git] / src / com / hughes / android / dictionary / parser / WikiTokenizer.java
1 // Copyright 2011 Google Inc. All Rights Reserved.
2 //
3 // Licensed under the Apache License, Version 2.0 (the "License");
4 // you may not use this file except in compliance with the License.
5 // You may obtain a copy of the License at
6 //
7 //     http://www.apache.org/licenses/LICENSE-2.0
8 //
9 // Unless required by applicable law or agreed to in writing, software
10 // distributed under the License is distributed on an "AS IS" BASIS,
11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 // See the License for the specific language governing permissions and
13 // limitations under the License.
14
15 package com.hughes.android.dictionary.parser;
16
17 import java.util.*;
18 import java.util.regex.Matcher;
19 import java.util.regex.Pattern;
20
21 public final class WikiTokenizer {
22
23     public interface Callback {
24         void onPlainText(final String text);
25         void onMarkup(WikiTokenizer wikiTokenizer);
26         void onWikiLink(WikiTokenizer wikiTokenizer);
27         void onNewline(WikiTokenizer wikiTokenizer);
28         void onFunction(final WikiTokenizer tokenizer, String functionName, List<String> functionPositionArgs,
29                         Map<String, String> functionNamedArgs);
30         void onHeading(WikiTokenizer wikiTokenizer);
31         void onListItem(WikiTokenizer wikiTokenizer);
32         void onComment(WikiTokenizer wikiTokenizer);
33         void onHtml(WikiTokenizer wikiTokenizer);
34     }
35
36     public static class DoNothingCallback implements Callback {
37
38         @Override
39         public void onPlainText(String text) {
40         }
41
42         @Override
43         public void onMarkup(WikiTokenizer wikiTokenizer) {
44         }
45
46         @Override
47         public void onWikiLink(WikiTokenizer wikiTokenizer) {
48         }
49
50         @Override
51         public void onNewline(WikiTokenizer wikiTokenizer) {
52         }
53
54         @Override
55         public void onFunction(WikiTokenizer tokenizer, String functionName,
56                                List<String> functionPositionArgs, Map<String, String> functionNamedArgs) {
57         }
58
59         @Override
60         public void onHeading(WikiTokenizer wikiTokenizer) {
61         }
62
63         @Override
64         public void onListItem(WikiTokenizer wikiTokenizer) {
65         }
66
67         @Override
68         public void onComment(WikiTokenizer wikiTokenizer) {
69         }
70
71         @Override
72         public void onHtml(WikiTokenizer wikiTokenizer) {
73         }
74     }
75
76     //private static final Pattern wikiTokenEvent = Pattern.compile("($)", Pattern.MULTILINE);
77     private static final Pattern wikiTokenEvent = Pattern.compile("(" +
78             "\\{\\{|\\}\\}|" +
79             "\\[\\[|\\]\\]|" +
80             "\\||" +  // Need the | because we might have to find unescaped pipes
81             "=|" +  // Need the = because we might have to find unescaped =
82             "<!--|" +
83             "''|" +
84             "<pre>|" +
85             "<math>|" +
86             "<ref>|" +
87             "$)", Pattern.MULTILINE);
88     private static final String listChars = "*#:;";
89
90
91     final String wikiText;
92     final Matcher matcher;
93
94     boolean justReturnedNewline = true;
95     int lastLineStart = 0;
96     int end = 0;
97     int start = -1;
98
99     final List<String> errors = new ArrayList<>();
100     final List<String> tokenStack = new ArrayList<>();
101
102
103     private String headingWikiText;
104     private int headingDepth;
105     private int listPrefixEnd;
106     private boolean isPlainText;
107     private boolean isMarkup;
108     private boolean isComment;
109     private boolean isFunction;
110     private boolean isWikiLink;
111     private boolean isHtml;
112     private int firstUnescapedPipePos;
113
114     private int lastUnescapedPipePos;
115     private int lastUnescapedEqualsPos;
116     private final List<String> positionArgs = new ArrayList<>();
117     private final Map<String,String> namedArgs = new LinkedHashMap<>();
118
119
120     public WikiTokenizer(final String wikiText) {
121         this(wikiText, true);
122     }
123
124     public WikiTokenizer(String wikiText, final boolean isNewline) {
125         wikiText = wikiText.replace('\u2028', '\n');
126         wikiText = wikiText.replace('\u0085', '\n');
127         this.wikiText = wikiText;
128         this.matcher = wikiTokenEvent.matcher(wikiText);
129         justReturnedNewline = isNewline;
130     }
131
132     private void clear() {
133         errors.clear();
134         tokenStack.clear();
135
136         headingWikiText = null;
137         headingDepth = -1;
138         listPrefixEnd = -1;
139         isPlainText = false;
140         isMarkup = false;
141         isComment = false;
142         isFunction = false;
143         isWikiLink = false;
144         isHtml = false;
145
146         firstUnescapedPipePos = -1;
147         lastUnescapedPipePos = -1;
148         lastUnescapedEqualsPos = -1;
149         positionArgs.clear();
150         namedArgs.clear();
151     }
152
153     private static final Matcher POSSIBLE_WIKI_TEXT = Pattern.compile(
154                 "\\{\\{|" +
155                 "\\[\\[|" +
156                 "<!--|" +
157                 "''|" +
158                 "<pre>|" +
159                 "<math>|" +
160                 "<ref>|" +
161                 "[\n]"
162             ).matcher("");
163
164     public static void dispatch(final String wikiText, final boolean isNewline, final Callback callback) {
165         // Statistical background, from EN-DE dictionary generation:
166         // out of 12083000 calls, 9697686 can be skipped via the test
167         // for ', \n and ((c - 0x3b) & 0xff9f) < 2 (which covers among others
168         // <, { and [).
169         // This increased to 10006466 checking for <, { and [ specifically,
170         // and is minimally faster overall.
171         // A even more precise one using regex and checking for {{, [[, <!--, '',
172         // <pre>, <math>, <ref> and \n increased that to 10032846.
173         // Regex thus seems far too costly for a measly increase from 80%/82% to 83% rejection rate
174         // However completely removing it changes output (likely a bug), so leave it in for now
175         // but at least run it only on the 18% not caught by the faster logic.
176         // Original runtime: 1m29.708s
177         // Optimized: 1m19.170s
178         // Regex removed: 1m20.314s (not statistically significant)
179         boolean matched = false;
180         for (int i = 0; i < wikiText.length(); i++) {
181             int c = wikiText.charAt(i);
182             if (c == '\'' || c == '\n' || c == '<' || c == '[' || c == '{') {
183                 matched = true;
184                 break;
185             }
186         }
187         if (!matched || !POSSIBLE_WIKI_TEXT.reset(wikiText).find()) {
188             callback.onPlainText(wikiText);
189         } else {
190             final WikiTokenizer tokenizer = new WikiTokenizer(wikiText, isNewline);
191             while (tokenizer.nextToken() != null) {
192                 if (tokenizer.isPlainText()) {
193                     callback.onPlainText(tokenizer.token());
194                 } else if (tokenizer.isMarkup()) {
195                     callback.onMarkup(tokenizer);
196                 } else if (tokenizer.isWikiLink()) {
197                     callback.onWikiLink(tokenizer);
198                 } else if (tokenizer.isNewline()) {
199                     callback.onNewline(tokenizer);
200                 } else if (tokenizer.isFunction()) {
201                     callback.onFunction(tokenizer, tokenizer.functionName(), tokenizer.functionPositionArgs(), tokenizer.functionNamedArgs());
202                 } else if (tokenizer.isHeading()) {
203                     callback.onHeading(tokenizer);
204                 } else if (tokenizer.isListItem()) {
205                     callback.onListItem(tokenizer);
206                 } else if (tokenizer.isComment()) {
207                     callback.onComment(tokenizer);
208                 } else if (tokenizer.isHtml()) {
209                     callback.onHtml(tokenizer);
210                 } else if (!tokenizer.errors.isEmpty()) {
211                     // Log was already printed....
212                 } else {
213                     throw new IllegalStateException("Unknown wiki state: " + tokenizer.token());
214                 }
215             }
216         }
217     }
218
219     public List<String> errors() {
220         return errors;
221     }
222
223     public boolean isNewline() {
224         return justReturnedNewline;
225     }
226
227     public void returnToLineStart() {
228         end = start = lastLineStart;
229         justReturnedNewline = true;
230     }
231
232     public boolean isHeading() {
233         return headingWikiText != null;
234     }
235
236     public String headingWikiText() {
237         assert isHeading();
238         return headingWikiText;
239     }
240
241     public int headingDepth() {
242         assert isHeading();
243         return headingDepth;
244     }
245
246     public boolean isMarkup() {
247         return isMarkup;
248     }
249
250     public boolean isComment() {
251         return isComment;
252     }
253
254     public boolean isListItem() {
255         return listPrefixEnd != -1;
256     }
257
258     public String listItemPrefix() {
259         assert isListItem();
260         return wikiText.substring(start, listPrefixEnd);
261     }
262
263     public static String getListTag(char c) {
264         if (c == '#') {
265             return "ol";
266         }
267         return "ul";
268     }
269
270     public String listItemWikiText() {
271         assert isListItem();
272         return wikiText.substring(listPrefixEnd, end);
273     }
274
275     public boolean isFunction() {
276         return isFunction;
277     }
278
279     public String functionName() {
280         assert isFunction();
281         // "{{.."
282         if (firstUnescapedPipePos != -1) {
283             return trimNewlines(wikiText.substring(start + 2, firstUnescapedPipePos).trim());
284         }
285         final int safeEnd = Math.max(start + 2, end - 2);
286         return trimNewlines(wikiText.substring(start + 2, safeEnd).trim());
287     }
288
289     public List<String> functionPositionArgs() {
290         return positionArgs;
291     }
292
293     public Map<String, String> functionNamedArgs() {
294         return namedArgs;
295     }
296
297     public boolean isPlainText() {
298         return isPlainText;
299     }
300
301     public boolean isWikiLink() {
302         return isWikiLink;
303     }
304
305     public String wikiLinkText() {
306         assert isWikiLink();
307         // "[[.."
308         if (lastUnescapedPipePos != -1) {
309             return trimNewlines(wikiText.substring(lastUnescapedPipePos + 1, end - 2));
310         }
311         assert start + 2 < wikiText.length() && end >= 2: wikiText;
312         return trimNewlines(wikiText.substring(start + 2, end - 2));
313     }
314
315     public String wikiLinkDest() {
316         assert isWikiLink();
317         // "[[.."
318         if (firstUnescapedPipePos != -1) {
319             return trimNewlines(wikiText.substring(start + 2, firstUnescapedPipePos));
320         }
321         return null;
322     }
323
324     public boolean isHtml() {
325         return isHtml;
326     }
327
328     public boolean remainderStartsWith(final String prefix) {
329         return wikiText.startsWith(prefix, start);
330     }
331
332     public void nextLine() {
333         final int oldStart = start;
334         while(nextToken() != null && !isNewline()) {}
335         if (isNewline()) {
336             --end;
337         }
338         start = oldStart;
339     }
340
341
342     public WikiTokenizer nextToken() {
343         this.clear();
344
345         start = end;
346
347         if (justReturnedNewline) {
348             lastLineStart = start;
349         }
350
351         try {
352
353             final int len = wikiText.length();
354             if (start >= len) {
355                 return null;
356             }
357
358             // Eat a newline if we're looking at one:
359             final boolean atNewline = wikiText.charAt(end) == '\n' || wikiText.charAt(end) == '\u2028' || wikiText.charAt(end) == '\u2029';
360             if (atNewline) {
361                 justReturnedNewline = true;
362                 ++end;
363                 return this;
364             }
365
366             if (justReturnedNewline) {
367                 justReturnedNewline = false;
368
369                 final char firstChar = wikiText.charAt(end);
370                 if (firstChar == '=') {
371                     final int headerStart = end;
372                     // Skip ===...
373                     while (++end < len && wikiText.charAt(end) == '=') {}
374                     final int headerTitleStart = end;
375                     headingDepth = headerTitleStart - headerStart;
376                     // Skip non-=...
377                     if (end < len) {
378                         final int nextNewline = safeIndexOf(wikiText, end, "\n", "\n");
379                         final int closingEquals = escapedFindEnd(end, "=");
380                         if (wikiText.charAt(closingEquals - 1) == '=') {
381                             end = closingEquals - 1;
382                         } else {
383                             end = nextNewline;
384                         }
385                     }
386                     final int headerTitleEnd = end;
387                     headingWikiText = wikiText.substring(headerTitleStart, headerTitleEnd);
388                     // Skip ===...
389                     while (end < len && ++end < len && wikiText.charAt(end) == '=') {}
390                     final int headerEnd = end;
391                     if (headerEnd - headerTitleEnd != headingDepth) {
392                         errors.add("Mismatched header depth: " + token());
393                     }
394                     return this;
395                 }
396                 if (listChars.indexOf(firstChar) != -1) {
397                     while (++end < len && listChars.indexOf(wikiText.charAt(end)) != -1) {}
398                     listPrefixEnd = end;
399                     end = escapedFindEnd(start, "\n");
400                     return this;
401                 }
402             }
403
404             if (wikiText.startsWith("'''", start)) {
405                 isMarkup = true;
406                 end = start + 3;
407                 return this;
408             }
409
410             if (wikiText.startsWith("''", start)) {
411                 isMarkup = true;
412                 end = start + 2;
413                 return this;
414             }
415
416             if (wikiText.startsWith("[[", start)) {
417                 end = escapedFindEnd(start + 2, "]]");
418                 isWikiLink = errors.isEmpty();
419                 return this;
420             }
421
422             if (wikiText.startsWith("{{", start)) {
423                 end = escapedFindEnd(start + 2, "}}");
424                 isFunction = errors.isEmpty();
425                 return this;
426             }
427
428             if (wikiText.startsWith("<pre>", start)) {
429                 end = safeIndexOf(wikiText, start, "</pre>", "\n");
430                 isHtml = true;
431                 return this;
432             }
433
434             if (wikiText.startsWith("<ref>", start)) {
435                 end = safeIndexOf(wikiText, start, "</ref>", "\n");
436                 isHtml = true;
437                 return this;
438             }
439
440             if (wikiText.startsWith("<math>", start)) {
441                 end = safeIndexOf(wikiText, start, "</math>", "\n");
442                 isHtml = true;
443                 return this;
444             }
445
446             if (wikiText.startsWith("<!--", start)) {
447                 isComment = true;
448                 end = safeIndexOf(wikiText, start, "-->", "\n");
449                 return this;
450             }
451
452             if (wikiText.startsWith("}}", start) || wikiText.startsWith("]]", start)) {
453                 errors.add("Close without open!");
454                 end += 2;
455                 return this;
456             }
457
458             if (wikiText.charAt(start) == '|' || wikiText.charAt(start) == '=') {
459                 isPlainText = true;
460                 ++end;
461                 return this;
462             }
463
464
465             if (this.matcher.find(start)) {
466                 end = this.matcher.start(1);
467                 isPlainText = true;
468                 if (end == start) {
469                     // stumbled over a new type of newline?
470                     // Or matcher is out of sync with checks above
471                     errors.add("Empty group: " + this.matcher.group() + " char: " + (int)wikiText.charAt(end));
472                     assert false;
473                     throw new RuntimeException("matcher not in sync with code, or new type of newline, errors :" + errors);
474                 }
475                 return this;
476             }
477
478             end = wikiText.length();
479             return this;
480
481         } finally {
482             if (!errors.isEmpty()) {
483                 System.err.println("Errors: " + errors + ", token=" + token());
484             }
485         }
486
487     }
488
489     public String token() {
490         final String token = wikiText.substring(start, end);
491         assert token.equals("\n") || !token.endsWith("\n") : "token='" + token + "'";
492         return token;
493     }
494
495     static final String[] patterns = { "\n", "{{", "}}", "[[", "]]", "[", "]", "|", "=", "<!--" };
496     private int escapedFindEnd(final int start, final String toFind) {
497         assert tokenStack.isEmpty();
498
499         final boolean insideFunction = toFind.equals("}}");
500
501         int end = start;
502         int firstNewline = -1;
503         int[] nextMatch = new int[patterns.length];
504         Arrays.fill(nextMatch, -2);
505         int singleBrackets = 0;
506         while (end < wikiText.length()) {
507             // Manual replacement for matcher.find(end),
508             // because Java regexp is a ridiculously slow implementation.
509             // Initialize to always match the end.
510             int matchIdx = 0;
511             for (int i = 0; i < nextMatch.length; ++i) {
512                 if (nextMatch[i] <= end) {
513                     nextMatch[i] = wikiText.indexOf(patterns[i], end);
514                     if (nextMatch[i] == -1) nextMatch[i] = i > 0 ? 0x7fffffff : wikiText.length();
515                 }
516                 if (nextMatch[i] < nextMatch[matchIdx]) {
517                     matchIdx = i;
518                 }
519             }
520
521             int matchStart = nextMatch[matchIdx];
522             String matchText = patterns[matchIdx];
523             int matchEnd = matchStart + matchText.length();
524             if (matchIdx == 0) {
525                 matchText = "";
526                 matchEnd = matchStart;
527             }
528
529             assert matchEnd > end || matchText.length() == 0: "Group=" + matchText;
530             if (matchText.length() == 0) {
531                 assert matchStart == wikiText.length() || wikiText.charAt(matchStart) == '\n' : wikiText + ", " + matchStart;
532                 if (firstNewline == -1) {
533                     firstNewline = matchEnd;
534                 }
535                 if (tokenStack.isEmpty() && toFind.equals("\n")) {
536                     return matchStart;
537                 }
538                 ++end;
539             } else if (tokenStack.isEmpty() && matchText.equals(toFind)) {
540                 // The normal return....
541                 if (insideFunction) {
542                     addFunctionArg(insideFunction, matchStart);
543                 }
544                 return matchEnd;
545             } else if (matchText.equals("[")) {
546                 singleBrackets++;
547             } else if (matchText.equals("]")) {
548                 if (singleBrackets > 0) singleBrackets--;
549             } else if (matchText.equals("[[") || matchText.equals("{{")) {
550                 tokenStack.add(matchText);
551             } else if (matchText.equals("]]") || matchText.equals("}}")) {
552                 if (tokenStack.size() > 0) {
553                     final String removed = tokenStack.remove(tokenStack.size() - 1);
554                     if (removed.equals("{{") && !matchText.equals("}}")) {
555                         if (singleBrackets >= 2) { // assume this is really two closing single ]
556                             singleBrackets -= 2;
557                             tokenStack.add(removed);
558                         } else {
559                             errors.add("Unmatched {{ error: " + wikiText.substring(start, matchEnd));
560                             return safeIndexOf(wikiText, start, "\n", "\n");
561                         }
562                     } else if (removed.equals("[[") && !matchText.equals("]]")) {
563                         errors.add("Unmatched [[ error: " + wikiText.substring(start, matchEnd));
564                         return safeIndexOf(wikiText, start, "\n", "\n");
565                     }
566                 } else {
567                     errors.add("Pop too many " + matchText + " error: " + wikiText.substring(start, matchEnd).replace("\n", "\\\\n"));
568                     // If we were looking for a newline
569                     return safeIndexOf(wikiText, start, "\n", "\n");
570                 }
571             } else if (matchText.equals("|")) {
572                 if (tokenStack.isEmpty()) {
573                     addFunctionArg(insideFunction, matchStart);
574                 }
575             } else if (matchText.equals("=")) {
576                 if (tokenStack.isEmpty()) {
577                     lastUnescapedEqualsPos = matchStart;
578                 }
579                 // Do nothing.  These can match spuriously, and if it's not the thing
580                 // we're looking for, keep on going.
581             } else if (matchText.equals("<!--")) {
582                 end = wikiText.indexOf("-->", matchStart);
583                 if (end == -1) {
584                     errors.add("Unmatched <!-- error: " + wikiText.substring(start));
585                     return safeIndexOf(wikiText, start, "\n", "\n");
586                 }
587             } else if (matchText.equals("''") || (matchText.startsWith("<") && matchText.endsWith(">"))) {
588                 // Don't care.
589             } else {
590                 assert false : "Match text='" + matchText + "'";
591                 throw new IllegalStateException();
592             }
593
594             // Inside the while loop.  Just go forward.
595             end = Math.max(end, matchEnd);
596         }
597         if (toFind.equals("\n") && tokenStack.isEmpty()) {
598             // We were looking for the end, we got it.
599             return end;
600         }
601         errors.add("Couldn't find: " + (toFind.equals("\n") ? "newline" : toFind) + ", "+ wikiText.substring(start));
602         if (firstNewline != -1) {
603             return firstNewline;
604         }
605         return end;
606     }
607
608     private void addFunctionArg(final boolean insideFunction, final int matchStart) {
609         if (firstUnescapedPipePos == -1) {
610             firstUnescapedPipePos = lastUnescapedPipePos = matchStart;
611         } else if (insideFunction) {
612             if (lastUnescapedEqualsPos > lastUnescapedPipePos) {
613                 final String key = wikiText.substring(lastUnescapedPipePos + 1, lastUnescapedEqualsPos);
614                 final String value = wikiText.substring(lastUnescapedEqualsPos + 1, matchStart);
615                 namedArgs.put(trimNewlines(key), trimNewlines(value));
616             } else {
617                 final String value = wikiText.substring(lastUnescapedPipePos + 1, matchStart);
618                 positionArgs.add(trimNewlines(value));
619             }
620         }
621         lastUnescapedPipePos = matchStart;
622     }
623
624     static String trimNewlines(String s) {
625         while (s.startsWith("\n")) {
626             s = s.substring(1);
627         }
628         while (s.endsWith("\n")) {
629             s = s.substring(0, s.length() - 1);
630         }
631         return s.replace('\n', ' ');
632     }
633
634     static int safeIndexOf(final String s, final int start, final String target, final String backup) {
635         int close = s.indexOf(target, start);
636         if (close != -1) {
637             // Don't step over a \n.
638             return close + (target.equals("\n") ? 0 : target.length());
639         }
640         close = s.indexOf(backup, start);
641         if (close != -1) {
642             return close + (backup.equals("\n") ? 0 : backup.length());
643         }
644         return s.length();
645     }
646
647     public static String toPlainText(final String wikiText) {
648         final WikiTokenizer wikiTokenizer = new WikiTokenizer(wikiText);
649         final StringBuilder builder = new StringBuilder();
650         while (wikiTokenizer.nextToken() != null) {
651             if (wikiTokenizer.isPlainText()) {
652                 builder.append(wikiTokenizer.token());
653             } else if (wikiTokenizer.isWikiLink()) {
654                 builder.append(wikiTokenizer.wikiLinkText());
655             } else if (wikiTokenizer.isNewline()) {
656                 builder.append("\n");
657             } else if (wikiTokenizer.isFunction()) {
658                 builder.append(wikiTokenizer.token());
659             }
660         }
661         return builder.toString();
662     }
663
664     public static StringBuilder appendFunction(final StringBuilder builder, final String name, List<String> args,
665             final Map<String, String> namedArgs) {
666         builder.append(name);
667         for (final String arg : args) {
668             builder.append("|").append(arg);
669         }
670         for (final Map.Entry<String, String> entry : namedArgs.entrySet()) {
671             builder.append("|").append(entry.getKey()).append("=").append(entry.getValue());
672         }
673         return builder;
674     }
675
676 }