package org.checkerframework.checker.i18nformatter; import java.text.ChoiceFormat; import java.text.DecimalFormat; import java.text.DecimalFormatSymbols; import java.text.MessageFormat; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.HashMap; import java.util.IllegalFormatException; import java.util.List; import java.util.Locale; import java.util.Map; import org.checkerframework.checker.i18nformatter.qual.I18nChecksFormat; import org.checkerframework.checker.i18nformatter.qual.I18nConversionCategory; import org.checkerframework.checker.i18nformatter.qual.I18nValidFormat; /** * This class provides a collection of utilities to ease working with i18n format strings. * * @checker_framework.manual #i18n-formatter-checker Internationalization Format String Checker * @author Siwakorn Srisakaokul */ public class I18nFormatUtil { /** Throws an exception if the format is not syntactically valid. */ public static void tryFormatSatisfiability(String format) throws IllegalFormatException { MessageFormat.format(format, (Object[]) null); } /** * Returns a {@link I18nConversionCategory} for every conversion found in the format string. * * <p>Throws an exception if the format is not syntactically valid. */ public static I18nConversionCategory[] formatParameterCategories(String format) throws IllegalFormatException { tryFormatSatisfiability(format); I18nConversion[] cs = MessageFormatParser.parse(format); int max_index = -1; Map<Integer, I18nConversionCategory> conv = new HashMap<Integer, I18nConversionCategory>(); for (I18nConversion c : cs) { int index = c.index; conv.put( index, I18nConversionCategory.intersect( c.category, conv.containsKey(index) ? conv.get(index) : I18nConversionCategory.UNUSED)); max_index = Math.max(max_index, index); } I18nConversionCategory[] res = new I18nConversionCategory[max_index + 1]; for (int i = 0; i <= max_index; i++) { res[i] = conv.containsKey(i) ? conv.get(i) : I18nConversionCategory.UNUSED; } return res; } /** * Returns true if the format string is satisfiable, and if the format's parameters match the * passed {@link I18nConversionCategory}s. Otherwise an error is thrown. */ // TODO introduce more such functions, see RegexUtil for examples @I18nChecksFormat public static boolean hasFormat(String format, I18nConversionCategory... cc) { I18nConversionCategory[] fcc = formatParameterCategories(format); if (fcc.length != cc.length) { return false; } for (int i = 0; i < cc.length; i++) { if (!I18nConversionCategory.isSubsetOf(cc[i], fcc[i])) { return false; } } return true; } @I18nValidFormat public static boolean isFormat(String format) { try { formatParameterCategories(format); } catch (Exception e) { return false; } return true; } private static class I18nConversion { public int index; public I18nConversionCategory category; public I18nConversion(int index, I18nConversionCategory category) { this.index = index; this.category = category; } @Override public String toString() { return category.toString() + "(index: " + index + ")"; } } private static class MessageFormatParser { public static int maxOffset; /** The locale to use for formatting numbers and dates. */ private static Locale locale; /** An array of formatters, which are used to format the arguments. */ private static List<I18nConversionCategory> categories; /** * The argument numbers corresponding to each formatter. (The formatters are stored in the * order they occur in the pattern, not in the order in which the arguments are specified.) */ private static List<Integer> argumentIndices; /** The number of subformats */ private static int numFormat; // Indices for segments private static final int SEG_RAW = 0; private static final int SEG_INDEX = 1; private static final int SEG_TYPE = 2; private static final int SEG_MODIFIER = 3; // modifier or subformat // Indices for type keywords private static final int TYPE_NULL = 0; private static final int TYPE_NUMBER = 1; private static final int TYPE_DATE = 2; private static final int TYPE_TIME = 3; private static final int TYPE_CHOICE = 4; private static final String[] TYPE_KEYWORDS = {"", "number", "date", "time", "choice"}; // Indices for number modifiers private static final int MODIFIER_DEFAULT = 0; // common in number and // date-time private static final int MODIFIER_CURRENCY = 1; private static final int MODIFIER_PERCENT = 2; private static final int MODIFIER_INTEGER = 3; private static final String[] NUMBER_MODIFIER_KEYWORDS = { "", "currency", "percent", "integer" }; private static final String[] DATE_TIME_MODIFIER_KEYWORDS = { "", "short", "medium", "long", "full" }; public static I18nConversion[] parse(String pattern) { MessageFormatParser.categories = new ArrayList<I18nConversionCategory>(); MessageFormatParser.argumentIndices = new ArrayList<Integer>(); MessageFormatParser.locale = Locale.getDefault(Locale.Category.FORMAT); applyPattern(pattern); I18nConversion[] ret = new I18nConversion[MessageFormatParser.numFormat]; for (int i = 0; i < MessageFormatParser.numFormat; i++) { ret[i] = new I18nConversion(argumentIndices.get(i), categories.get(i)); } return ret; } private static void applyPattern(String pattern) { StringBuilder[] segments = new StringBuilder[4]; // Allocate only segments[SEG_RAW] here. The rest are // allocated on demand. segments[SEG_RAW] = new StringBuilder(); int part = SEG_RAW; MessageFormatParser.numFormat = 0; boolean inQuote = false; int braceStack = 0; maxOffset = -1; for (int i = 0; i < pattern.length(); ++i) { char ch = pattern.charAt(i); if (part == SEG_RAW) { if (ch == '\'') { if (i + 1 < pattern.length() && pattern.charAt(i + 1) == '\'') { segments[part].append(ch); // handle doubles ++i; } else { inQuote = !inQuote; } } else if (ch == '{' && !inQuote) { part = SEG_INDEX; if (segments[SEG_INDEX] == null) { segments[SEG_INDEX] = new StringBuilder(); } } else { segments[part].append(ch); } } else { if (inQuote) { // just copy quotes in parts segments[part].append(ch); if (ch == '\'') { inQuote = false; } } else { switch (ch) { case ',': if (part < SEG_MODIFIER) { if (segments[++part] == null) { segments[part] = new StringBuilder(); } } else { segments[part].append(ch); } break; case '{': ++braceStack; segments[part].append(ch); break; case '}': if (braceStack == 0) { part = SEG_RAW; makeFormat(i, numFormat, segments); numFormat++; // throw away other segments segments[SEG_INDEX] = null; segments[SEG_TYPE] = null; segments[SEG_MODIFIER] = null; } else { --braceStack; segments[part].append(ch); } break; case ' ': // Skip any leading space chars for SEG_TYPE. if (part != SEG_TYPE || segments[SEG_TYPE].length() > 0) { segments[part].append(ch); } break; case '\'': inQuote = true; segments[part].append(ch); break; default: segments[part].append(ch); break; } } } } if (braceStack == 0 && part != 0) { maxOffset = -1; throw new IllegalArgumentException("Unmatched braces in the pattern"); } } private static void makeFormat( int position, int offsetNumber, StringBuilder[] textSegments) { String[] segments = new String[textSegments.length]; for (int i = 0; i < textSegments.length; i++) { StringBuilder oneseg = textSegments[i]; segments[i] = (oneseg != null) ? oneseg.toString() : ""; } // get the argument number int argumentNumber; try { argumentNumber = Integer.parseInt(segments[SEG_INDEX]); // always // unlocalized! } catch (NumberFormatException e) { throw new IllegalArgumentException( "can't parse argument number: " + segments[SEG_INDEX], e); } if (argumentNumber < 0) { throw new IllegalArgumentException("negative argument number: " + argumentNumber); } int oldMaxOffset = maxOffset; maxOffset = offsetNumber; argumentIndices.add(argumentNumber); // now get the format I18nConversionCategory category = null; if (segments[SEG_TYPE].length() != 0) { int type = findKeyword(segments[SEG_TYPE], TYPE_KEYWORDS); switch (type) { case TYPE_NULL: category = I18nConversionCategory.GENERAL; break; case TYPE_NUMBER: switch (findKeyword(segments[SEG_MODIFIER], NUMBER_MODIFIER_KEYWORDS)) { case MODIFIER_DEFAULT: case MODIFIER_CURRENCY: case MODIFIER_PERCENT: case MODIFIER_INTEGER: break; default: // DecimalFormat pattern try { new DecimalFormat( segments[SEG_MODIFIER], DecimalFormatSymbols.getInstance(locale)); } catch (IllegalArgumentException e) { maxOffset = oldMaxOffset; // invalid decimal subformat pattern throw e; } break; } category = I18nConversionCategory.NUMBER; break; case TYPE_DATE: case TYPE_TIME: int mod = findKeyword(segments[SEG_MODIFIER], DATE_TIME_MODIFIER_KEYWORDS); if (mod >= 0 && mod < DATE_TIME_MODIFIER_KEYWORDS.length) { // nothing to do } else { // SimpleDateFormat pattern try { new SimpleDateFormat(segments[SEG_MODIFIER], locale); } catch (IllegalArgumentException e) { maxOffset = oldMaxOffset; // invalid date subformat pattern throw e; } } category = I18nConversionCategory.DATE; break; case TYPE_CHOICE: if (segments[SEG_MODIFIER].length() == 0) { throw new IllegalArgumentException( "Choice Pattern requires Subformat Pattern: " + segments[SEG_MODIFIER]); } try { // ChoiceFormat pattern new ChoiceFormat(segments[SEG_MODIFIER]); } catch (Exception e) { maxOffset = oldMaxOffset; // invalid choice subformat pattern throw new IllegalArgumentException( "Choice Pattern incorrect: " + segments[SEG_MODIFIER], e); } category = I18nConversionCategory.NUMBER; break; default: maxOffset = oldMaxOffset; throw new IllegalArgumentException( "unknown format type: " + segments[SEG_TYPE]); } } else { category = I18nConversionCategory.GENERAL; } categories.add(category); } private static final int findKeyword(String s, String[] list) { for (int i = 0; i < list.length; ++i) { if (s.equals(list[i])) { return i; } } // Try trimmed lowercase. String ls = s.trim().toLowerCase(Locale.ROOT); if (ls != s) { for (int i = 0; i < list.length; ++i) { if (ls.equals(list[i])) { return i; } } } return -1; } } }