1/*
2 * Copyright (C) 2010 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.inputmethod.keyboard.internal;
18
19import static com.android.inputmethod.latin.Constants.CODE_OUTPUT_TEXT;
20import static com.android.inputmethod.latin.Constants.CODE_UNSPECIFIED;
21
22import android.text.TextUtils;
23
24import com.android.inputmethod.latin.Constants;
25import com.android.inputmethod.latin.LatinImeLogger;
26import com.android.inputmethod.latin.utils.CollectionUtils;
27import com.android.inputmethod.latin.utils.StringUtils;
28
29import java.util.ArrayList;
30import java.util.Arrays;
31import java.util.Locale;
32
33/**
34 * The string parser of more keys specification.
35 * The specification is comma separated texts each of which represents one "more key".
36 * The specification might have label or string resource reference in it. These references are
37 * expanded before parsing comma.
38 * - Label reference should be a string representation of label (!text/label_name)
39 * - String resource reference should be a string representation of resource (!text/resource_name)
40 * Each "more key" specification is one of the following:
41 * - Label optionally followed by keyOutputText or code (keyLabel|keyOutputText).
42 * - Icon followed by keyOutputText or code (!icon/icon_name|!code/code_name)
43 *   - Icon should be a string representation of icon (!icon/icon_name).
44 *   - Code should be a code point presented by hexadecimal string prefixed with "0x", or a string
45 *     representation of code (!code/code_name).
46 * Special character, comma ',' backslash '\', and bar '|' can be escaped by '\' character.
47 * Note that the '\' is also parsed by XML parser and CSV parser as well.
48 * See {@link KeyboardIconsSet} about icon_name.
49 */
50public final class KeySpecParser {
51    private static final boolean DEBUG = LatinImeLogger.sDBG;
52
53    private static final int MAX_STRING_REFERENCE_INDIRECTION = 10;
54
55    // Constants for parsing.
56    private static final char COMMA = ',';
57    private static final char BACKSLASH = '\\';
58    private static final char VERTICAL_BAR = '|';
59    private static final String PREFIX_TEXT = "!text/";
60    static final String PREFIX_ICON = "!icon/";
61    private static final String PREFIX_CODE = "!code/";
62    private static final String PREFIX_HEX = "0x";
63    private static final String ADDITIONAL_MORE_KEY_MARKER = "%";
64
65    private KeySpecParser() {
66        // Intentional empty constructor for utility class.
67    }
68
69    /**
70     * Split the text containing multiple key specifications separated by commas into an array of
71     * key specifications.
72     * A key specification can contain a character escaped by the backslash character, including a
73     * comma character.
74     * Note that an empty key specification will be eliminated from the result array.
75     *
76     * @param text the text containing multiple key specifications.
77     * @return an array of key specification text. Null if the specified <code>text</code> is empty
78     * or has no key specifications.
79     */
80    public static String[] splitKeySpecs(final String text) {
81        final int size = text.length();
82        if (size == 0) {
83            return null;
84        }
85        // Optimization for one-letter key specification.
86        if (size == 1) {
87            return text.charAt(0) == COMMA ? null : new String[] { text };
88        }
89
90        ArrayList<String> list = null;
91        int start = 0;
92        // The characters in question in this loop are COMMA and BACKSLASH. These characters never
93        // match any high or low surrogate character. So it is OK to iterate through with char
94        // index.
95        for (int pos = 0; pos < size; pos++) {
96            final char c = text.charAt(pos);
97            if (c == COMMA) {
98                // Skip empty entry.
99                if (pos - start > 0) {
100                    if (list == null) {
101                        list = CollectionUtils.newArrayList();
102                    }
103                    list.add(text.substring(start, pos));
104                }
105                // Skip comma
106                start = pos + 1;
107            } else if (c == BACKSLASH) {
108                // Skip escape character and escaped character.
109                pos++;
110            }
111        }
112        final String remain = (size - start > 0) ? text.substring(start) : null;
113        if (list == null) {
114            return remain != null ? new String[] { remain } : null;
115        }
116        if (remain != null) {
117            list.add(remain);
118        }
119        return list.toArray(new String[list.size()]);
120    }
121
122    private static boolean hasIcon(final String moreKeySpec) {
123        return moreKeySpec.startsWith(PREFIX_ICON);
124    }
125
126    private static boolean hasCode(final String moreKeySpec) {
127        final int end = indexOfLabelEnd(moreKeySpec, 0);
128        if (end > 0 && end + 1 < moreKeySpec.length() && moreKeySpec.startsWith(
129                PREFIX_CODE, end + 1)) {
130            return true;
131        }
132        return false;
133    }
134
135    private static String parseEscape(final String text) {
136        if (text.indexOf(BACKSLASH) < 0) {
137            return text;
138        }
139        final int length = text.length();
140        final StringBuilder sb = new StringBuilder();
141        for (int pos = 0; pos < length; pos++) {
142            final char c = text.charAt(pos);
143            if (c == BACKSLASH && pos + 1 < length) {
144                // Skip escape char
145                pos++;
146                sb.append(text.charAt(pos));
147            } else {
148                sb.append(c);
149            }
150        }
151        return sb.toString();
152    }
153
154    private static int indexOfLabelEnd(final String moreKeySpec, final int start) {
155        if (moreKeySpec.indexOf(BACKSLASH, start) < 0) {
156            final int end = moreKeySpec.indexOf(VERTICAL_BAR, start);
157            if (end == 0) {
158                throw new KeySpecParserError(VERTICAL_BAR + " at " + start + ": " + moreKeySpec);
159            }
160            return end;
161        }
162        final int length = moreKeySpec.length();
163        for (int pos = start; pos < length; pos++) {
164            final char c = moreKeySpec.charAt(pos);
165            if (c == BACKSLASH && pos + 1 < length) {
166                // Skip escape char
167                pos++;
168            } else if (c == VERTICAL_BAR) {
169                return pos;
170            }
171        }
172        return -1;
173    }
174
175    public static String getLabel(final String moreKeySpec) {
176        if (hasIcon(moreKeySpec)) {
177            return null;
178        }
179        final int end = indexOfLabelEnd(moreKeySpec, 0);
180        final String label = (end > 0) ? parseEscape(moreKeySpec.substring(0, end))
181                : parseEscape(moreKeySpec);
182        if (TextUtils.isEmpty(label)) {
183            throw new KeySpecParserError("Empty label: " + moreKeySpec);
184        }
185        return label;
186    }
187
188    private static String getOutputTextInternal(final String moreKeySpec) {
189        final int end = indexOfLabelEnd(moreKeySpec, 0);
190        if (end <= 0) {
191            return null;
192        }
193        if (indexOfLabelEnd(moreKeySpec, end + 1) >= 0) {
194            throw new KeySpecParserError("Multiple " + VERTICAL_BAR + ": " + moreKeySpec);
195        }
196        return parseEscape(moreKeySpec.substring(end + /* VERTICAL_BAR */1));
197    }
198
199    static String getOutputText(final String moreKeySpec) {
200        if (hasCode(moreKeySpec)) {
201            return null;
202        }
203        final String outputText = getOutputTextInternal(moreKeySpec);
204        if (outputText != null) {
205            if (StringUtils.codePointCount(outputText) == 1) {
206                // If output text is one code point, it should be treated as a code.
207                // See {@link #getCode(Resources, String)}.
208                return null;
209            }
210            if (!TextUtils.isEmpty(outputText)) {
211                return outputText;
212            }
213            throw new KeySpecParserError("Empty outputText: " + moreKeySpec);
214        }
215        final String label = getLabel(moreKeySpec);
216        if (label == null) {
217            throw new KeySpecParserError("Empty label: " + moreKeySpec);
218        }
219        // Code is automatically generated for one letter label. See {@link getCode()}.
220        return (StringUtils.codePointCount(label) == 1) ? null : label;
221    }
222
223    static int getCode(final String moreKeySpec, final KeyboardCodesSet codesSet) {
224        if (hasCode(moreKeySpec)) {
225            final int end = indexOfLabelEnd(moreKeySpec, 0);
226            if (indexOfLabelEnd(moreKeySpec, end + 1) >= 0) {
227                throw new KeySpecParserError("Multiple " + VERTICAL_BAR + ": " + moreKeySpec);
228            }
229            return parseCode(moreKeySpec.substring(end + 1), codesSet, CODE_UNSPECIFIED);
230        }
231        final String outputText = getOutputTextInternal(moreKeySpec);
232        if (outputText != null) {
233            // If output text is one code point, it should be treated as a code.
234            // See {@link #getOutputText(String)}.
235            if (StringUtils.codePointCount(outputText) == 1) {
236                return outputText.codePointAt(0);
237            }
238            return CODE_OUTPUT_TEXT;
239        }
240        final String label = getLabel(moreKeySpec);
241        // Code is automatically generated for one letter label.
242        if (StringUtils.codePointCount(label) == 1) {
243            return label.codePointAt(0);
244        }
245        return CODE_OUTPUT_TEXT;
246    }
247
248    public static int parseCode(final String text, final KeyboardCodesSet codesSet,
249            final int defCode) {
250        if (text == null) return defCode;
251        if (text.startsWith(PREFIX_CODE)) {
252            return codesSet.getCode(text.substring(PREFIX_CODE.length()));
253        } else if (text.startsWith(PREFIX_HEX)) {
254            return Integer.parseInt(text.substring(PREFIX_HEX.length()), 16);
255        } else {
256            return Integer.parseInt(text);
257        }
258    }
259
260    public static int getIconId(final String moreKeySpec) {
261        if (moreKeySpec != null && hasIcon(moreKeySpec)) {
262            final int end = moreKeySpec.indexOf(VERTICAL_BAR, PREFIX_ICON.length());
263            final String name = (end < 0) ? moreKeySpec.substring(PREFIX_ICON.length())
264                    : moreKeySpec.substring(PREFIX_ICON.length(), end);
265            return KeyboardIconsSet.getIconId(name);
266        }
267        return KeyboardIconsSet.ICON_UNDEFINED;
268    }
269
270    private static <T> ArrayList<T> arrayAsList(final T[] array, final int start, final int end) {
271        if (array == null) {
272            throw new NullPointerException();
273        }
274        if (start < 0 || start > end || end > array.length) {
275            throw new IllegalArgumentException();
276        }
277
278        final ArrayList<T> list = CollectionUtils.newArrayList(end - start);
279        for (int i = start; i < end; i++) {
280            list.add(array[i]);
281        }
282        return list;
283    }
284
285    private static final String[] EMPTY_STRING_ARRAY = new String[0];
286
287    private static String[] filterOutEmptyString(final String[] array) {
288        if (array == null) {
289            return EMPTY_STRING_ARRAY;
290        }
291        ArrayList<String> out = null;
292        for (int i = 0; i < array.length; i++) {
293            final String entry = array[i];
294            if (TextUtils.isEmpty(entry)) {
295                if (out == null) {
296                    out = arrayAsList(array, 0, i);
297                }
298            } else if (out != null) {
299                out.add(entry);
300            }
301        }
302        if (out == null) {
303            return array;
304        }
305        return out.toArray(new String[out.size()]);
306    }
307
308    public static String[] insertAdditionalMoreKeys(final String[] moreKeySpecs,
309            final String[] additionalMoreKeySpecs) {
310        final String[] moreKeys = filterOutEmptyString(moreKeySpecs);
311        final String[] additionalMoreKeys = filterOutEmptyString(additionalMoreKeySpecs);
312        final int moreKeysCount = moreKeys.length;
313        final int additionalCount = additionalMoreKeys.length;
314        ArrayList<String> out = null;
315        int additionalIndex = 0;
316        for (int moreKeyIndex = 0; moreKeyIndex < moreKeysCount; moreKeyIndex++) {
317            final String moreKeySpec = moreKeys[moreKeyIndex];
318            if (moreKeySpec.equals(ADDITIONAL_MORE_KEY_MARKER)) {
319                if (additionalIndex < additionalCount) {
320                    // Replace '%' marker with additional more key specification.
321                    final String additionalMoreKey = additionalMoreKeys[additionalIndex];
322                    if (out != null) {
323                        out.add(additionalMoreKey);
324                    } else {
325                        moreKeys[moreKeyIndex] = additionalMoreKey;
326                    }
327                    additionalIndex++;
328                } else {
329                    // Filter out excessive '%' marker.
330                    if (out == null) {
331                        out = arrayAsList(moreKeys, 0, moreKeyIndex);
332                    }
333                }
334            } else {
335                if (out != null) {
336                    out.add(moreKeySpec);
337                }
338            }
339        }
340        if (additionalCount > 0 && additionalIndex == 0) {
341            // No '%' marker is found in more keys.
342            // Insert all additional more keys to the head of more keys.
343            if (DEBUG && out != null) {
344                throw new RuntimeException("Internal logic error:"
345                        + " moreKeys=" + Arrays.toString(moreKeys)
346                        + " additionalMoreKeys=" + Arrays.toString(additionalMoreKeys));
347            }
348            out = arrayAsList(additionalMoreKeys, additionalIndex, additionalCount);
349            for (int i = 0; i < moreKeysCount; i++) {
350                out.add(moreKeys[i]);
351            }
352        } else if (additionalIndex < additionalCount) {
353            // The number of '%' markers are less than additional more keys.
354            // Append remained additional more keys to the tail of more keys.
355            if (DEBUG && out != null) {
356                throw new RuntimeException("Internal logic error:"
357                        + " moreKeys=" + Arrays.toString(moreKeys)
358                        + " additionalMoreKeys=" + Arrays.toString(additionalMoreKeys));
359            }
360            out = arrayAsList(moreKeys, 0, moreKeysCount);
361            for (int i = additionalIndex; i < additionalCount; i++) {
362                out.add(additionalMoreKeys[additionalIndex]);
363            }
364        }
365        if (out == null && moreKeysCount > 0) {
366            return moreKeys;
367        } else if (out != null && out.size() > 0) {
368            return out.toArray(new String[out.size()]);
369        } else {
370            return null;
371        }
372    }
373
374    @SuppressWarnings("serial")
375    public static final class KeySpecParserError extends RuntimeException {
376        public KeySpecParserError(final String message) {
377            super(message);
378        }
379    }
380
381    public static String resolveTextReference(final String rawText,
382            final KeyboardTextsSet textsSet) {
383        int level = 0;
384        String text = rawText;
385        StringBuilder sb;
386        do {
387            level++;
388            if (level >= MAX_STRING_REFERENCE_INDIRECTION) {
389                throw new RuntimeException("too many @string/resource indirection: " + text);
390            }
391
392            final int prefixLen = PREFIX_TEXT.length();
393            final int size = text.length();
394            if (size < prefixLen) {
395                return text;
396            }
397
398            sb = null;
399            for (int pos = 0; pos < size; pos++) {
400                final char c = text.charAt(pos);
401                if (text.startsWith(PREFIX_TEXT, pos) && textsSet != null) {
402                    if (sb == null) {
403                        sb = new StringBuilder(text.substring(0, pos));
404                    }
405                    final int end = searchTextNameEnd(text, pos + prefixLen);
406                    final String name = text.substring(pos + prefixLen, end);
407                    sb.append(textsSet.getText(name));
408                    pos = end - 1;
409                } else if (c == BACKSLASH) {
410                    if (sb != null) {
411                        // Append both escape character and escaped character.
412                        sb.append(text.substring(pos, Math.min(pos + 2, size)));
413                    }
414                    pos++;
415                } else if (sb != null) {
416                    sb.append(c);
417                }
418            }
419
420            if (sb != null) {
421                text = sb.toString();
422            }
423        } while (sb != null);
424        return text;
425    }
426
427    private static int searchTextNameEnd(final String text, final int start) {
428        final int size = text.length();
429        for (int pos = start; pos < size; pos++) {
430            final char c = text.charAt(pos);
431            // Label name should be consisted of [a-zA-Z_0-9].
432            if ((c >= 'a' && c <= 'z') || c == '_' || (c >= '0' && c <= '9')) {
433                continue;
434            }
435            return pos;
436        }
437        return size;
438    }
439
440    public static int getIntValue(final String[] moreKeys, final String key,
441            final int defaultValue) {
442        if (moreKeys == null) {
443            return defaultValue;
444        }
445        final int keyLen = key.length();
446        boolean foundValue = false;
447        int value = defaultValue;
448        for (int i = 0; i < moreKeys.length; i++) {
449            final String moreKeySpec = moreKeys[i];
450            if (moreKeySpec == null || !moreKeySpec.startsWith(key)) {
451                continue;
452            }
453            moreKeys[i] = null;
454            try {
455                if (!foundValue) {
456                    value = Integer.parseInt(moreKeySpec.substring(keyLen));
457                    foundValue = true;
458                }
459            } catch (NumberFormatException e) {
460                throw new RuntimeException(
461                        "integer should follow after " + key + ": " + moreKeySpec);
462            }
463        }
464        return value;
465    }
466
467    public static boolean getBooleanValue(final String[] moreKeys, final String key) {
468        if (moreKeys == null) {
469            return false;
470        }
471        boolean value = false;
472        for (int i = 0; i < moreKeys.length; i++) {
473            final String moreKeySpec = moreKeys[i];
474            if (moreKeySpec == null || !moreKeySpec.equals(key)) {
475                continue;
476            }
477            moreKeys[i] = null;
478            value = true;
479        }
480        return value;
481    }
482
483    public static int toUpperCaseOfCodeForLocale(final int code, final boolean needsToUpperCase,
484            final Locale locale) {
485        if (!Constants.isLetterCode(code) || !needsToUpperCase) return code;
486        final String text = new String(new int[] { code } , 0, 1);
487        final String casedText = KeySpecParser.toUpperCaseOfStringForLocale(
488                text, needsToUpperCase, locale);
489        return StringUtils.codePointCount(casedText) == 1
490                ? casedText.codePointAt(0) : CODE_UNSPECIFIED;
491    }
492
493    public static String toUpperCaseOfStringForLocale(final String text,
494            final boolean needsToUpperCase, final Locale locale) {
495        if (text == null || !needsToUpperCase) return text;
496        return text.toUpperCase(locale);
497    }
498}
499