| package org.checkerframework.checker.formatter.util; |
| |
| import java.util.ArrayList; |
| import java.util.HashMap; |
| import java.util.IllegalFormatConversionException; |
| import java.util.IllegalFormatException; |
| import java.util.Map; |
| import java.util.MissingFormatArgumentException; |
| import java.util.regex.Matcher; |
| import java.util.regex.Pattern; |
| import org.checkerframework.checker.formatter.qual.ConversionCategory; |
| import org.checkerframework.checker.formatter.qual.ReturnsFormat; |
| import org.checkerframework.checker.regex.qual.Regex; |
| import org.checkerframework.framework.qual.AnnotatedFor; |
| |
| /** This class provides a collection of utilities to ease working with format strings. */ |
| @AnnotatedFor("nullness") |
| public class FormatUtil { |
| |
| /** |
| * A representation of a format specifier, which is represented by "%..." in the format string. |
| * Indicates how to convert a value into a string. |
| */ |
| private static class Conversion { |
| /** The index in the argument list. */ |
| private final int index; |
| /** The conversion category. */ |
| private final ConversionCategory cath; |
| |
| /** |
| * Construct a new Conversion. |
| * |
| * @param index the index in the argument list |
| * @param c the conversion character |
| */ |
| public Conversion(char c, int index) { |
| this.index = index; |
| this.cath = ConversionCategory.fromConversionChar(c); |
| } |
| |
| /** |
| * Returns the index in the argument list. |
| * |
| * @return the index in the argument list |
| */ |
| int index() { |
| return index; |
| } |
| |
| /** |
| * Returns the conversion category. |
| * |
| * @return the conversion category |
| */ |
| ConversionCategory category() { |
| return cath; |
| } |
| } |
| |
| /** |
| * Returns the first argument if the format string is satisfiable, and if the format's parameters |
| * match the passed {@link ConversionCategory}s. Otherwise throws an exception. |
| * |
| * @param format a format string |
| * @param cc an array of conversion categories |
| * @return the {@code format} argument |
| * @throws IllegalFormatException if the format string is incompatible with the conversion |
| * categories |
| */ |
| // TODO introduce more such functions, see RegexUtil for examples |
| @ReturnsFormat |
| public static String asFormat(String format, ConversionCategory... cc) |
| throws IllegalFormatException { |
| ConversionCategory[] fcc = formatParameterCategories(format); |
| if (fcc.length != cc.length) { |
| throw new ExcessiveOrMissingFormatArgumentException(cc.length, fcc.length); |
| } |
| |
| for (int i = 0; i < cc.length; i++) { |
| if (cc[i] != fcc[i]) { |
| throw new IllegalFormatConversionCategoryException(cc[i], fcc[i]); |
| } |
| } |
| |
| return format; |
| } |
| |
| /** |
| * Throws an exception if the format is not syntactically valid. |
| * |
| * @param format a format string |
| * @throws IllegalFormatException if the format string is invalid |
| */ |
| public static void tryFormatSatisfiability(String format) throws IllegalFormatException { |
| @SuppressWarnings({ |
| "unused", // called for side effect, to see if it throws an exception |
| "nullness:argument", // it's not documented, but String.format permits |
| // a null array, which it treats as matching any format string (null is supplied to each |
| // format specifier). |
| "formatter:format.string", // this is a test of format string validity |
| }) |
| String unused = String.format(format, (Object[]) null); |
| } |
| |
| /** |
| * Returns a {@link ConversionCategory} for every conversion found in the format string. |
| * |
| * <p>Throws an exception if the format is not syntactically valid. |
| */ |
| public static ConversionCategory[] formatParameterCategories(String format) |
| throws IllegalFormatException { |
| tryFormatSatisfiability(format); |
| |
| int last = -1; // index of last argument referenced |
| int lasto = -1; // last ordinary index |
| int maxindex = -1; |
| |
| Conversion[] cs = parse(format); |
| Map<Integer, ConversionCategory> conv = new HashMap<>(cs.length); |
| |
| for (Conversion c : cs) { |
| int index = c.index(); |
| switch (index) { |
| case -1: // relative index |
| break; |
| case 0: // ordinary index |
| lasto++; |
| last = lasto; |
| break; |
| default: // explicit index |
| last = index - 1; |
| break; |
| } |
| maxindex = Math.max(maxindex, last); |
| Integer lastKey = last; |
| conv.put( |
| last, |
| ConversionCategory.intersect( |
| conv.containsKey(lastKey) ? conv.get(lastKey) : ConversionCategory.UNUSED, |
| c.category())); |
| } |
| |
| ConversionCategory[] res = new ConversionCategory[maxindex + 1]; |
| for (int i = 0; i <= maxindex; ++i) { |
| Integer key = i; // autoboxing prevents recognizing that containsKey => get() != null |
| res[i] = conv.containsKey(key) ? conv.get(key) : ConversionCategory.UNUSED; |
| } |
| return res; |
| } |
| |
| /** |
| * A regex that matches a format specifier. Its syntax is specified in the See <a |
| * href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/util/Formatter.html#syntax">{@code |
| * Formatter} documentation</a>. |
| * |
| * <pre> |
| * %[argument_index$][flags][width][.precision][t]conversion |
| * group 1 2 3 4 5 6 |
| * </pre> |
| * |
| * For dates and times, the [t] is required and precision must not be provided. For types other |
| * than dates and times, the [t] must not be provided. |
| */ |
| private static final @Regex(6) String formatSpecifier = |
| "%(\\d+\\$)?([-#+ 0,(\\<]*)?(\\d+)?(\\.\\d+)?([tT])?([a-zA-Z%])"; |
| /** The capturing group for the optional {@code t} character. */ |
| private static final int formatSpecifierT = 5; |
| /** |
| * The capturing group for the last character in a format specifier, which is the conversion |
| * character unless the {@code t} character was given. |
| */ |
| private static final int formatSpecifierConversion = 6; |
| |
| /** |
| * A Pattern that matches a format specifier. |
| * |
| * @see #formatSpecifier |
| */ |
| private static @Regex(6) Pattern fsPattern = Pattern.compile(formatSpecifier); |
| |
| /** |
| * Return the index, in the argument list, of the value that will be formatted by the matched |
| * format specifier. |
| * |
| * @param m a matcher that matches a format specifier |
| * @return the index of the argument to format |
| */ |
| private static int indexFromFormat(Matcher m) { |
| int index; |
| String s = m.group(1); |
| if (s != null) { // explicit index |
| index = Integer.parseInt(s.substring(0, s.length() - 1)); |
| } else { |
| String group2 = m.group(2); // not @Deterministic, so extract into local var |
| if (group2 != null && group2.contains(String.valueOf('<'))) { |
| index = -1; // relative index |
| } else { |
| index = 0; // ordinary index |
| } |
| } |
| return index; |
| } |
| |
| /** |
| * Returns the conversion character from a format specifier.. |
| * |
| * @param m a matcher that matches a format specifier |
| * @return the conversion character from the format specifier |
| */ |
| @SuppressWarnings( |
| "nullness:dereference.of.nullable") // group formatSpecifierConversion always exists |
| private static char conversionCharFromFormat(@Regex(6) Matcher m) { |
| String tGroup = m.group(formatSpecifierT); |
| if (tGroup != null) { |
| return tGroup.charAt(0); // This is the letter "t" or "T". |
| } else { |
| return m.group(formatSpecifierConversion).charAt(0); |
| } |
| } |
| |
| /** |
| * Return the conversion character that is in the given format specifier. |
| * |
| * @param formatSpecifier a <a |
| * href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/util/Formatter.html#syntax">format |
| * specifier</a> |
| * @return the conversion character that is in the given format specifier |
| * @deprecated This method is public only for testing. Use private method {@code |
| * #conversionCharFromFormat(Matcher)}. |
| */ |
| @Deprecated // used only for testing. Use conversionCharFromFormat(Matcher). |
| public static char conversionCharFromFormat(String formatSpecifier) { |
| Matcher m = fsPattern.matcher(formatSpecifier); |
| assert m.find(); |
| return conversionCharFromFormat(m); |
| } |
| |
| /** |
| * Parse the given format string, return information about its format specifiers. |
| * |
| * @param format a format string |
| * @return the list of Conversions from the format specifiers in the format string |
| */ |
| private static Conversion[] parse(String format) { |
| ArrayList<Conversion> cs = new ArrayList<>(); |
| @Regex(7) Matcher m = fsPattern.matcher(format); |
| while (m.find()) { |
| char c = conversionCharFromFormat(m); |
| switch (c) { |
| case '%': |
| case 'n': |
| break; |
| default: |
| cs.add(new Conversion(c, indexFromFormat(m))); |
| } |
| } |
| return cs.toArray(new Conversion[cs.size()]); |
| } |
| |
| public static class ExcessiveOrMissingFormatArgumentException |
| extends MissingFormatArgumentException { |
| private static final long serialVersionUID = 17000126L; |
| |
| private final int expected; |
| private final int found; |
| |
| /** |
| * Constructs an instance of this class with the actual argument length and the expected one. |
| */ |
| public ExcessiveOrMissingFormatArgumentException(int expected, int found) { |
| super("-"); |
| this.expected = expected; |
| this.found = found; |
| } |
| |
| public int getExpected() { |
| return expected; |
| } |
| |
| public int getFound() { |
| return found; |
| } |
| |
| @Override |
| public String getMessage() { |
| return String.format("Expected %d arguments but found %d.", expected, found); |
| } |
| } |
| |
| public static class IllegalFormatConversionCategoryException |
| extends IllegalFormatConversionException { |
| private static final long serialVersionUID = 17000126L; |
| |
| private final ConversionCategory expected; |
| private final ConversionCategory found; |
| |
| /** Constructs an instance of this class with the mismatched conversion and the expected one. */ |
| public IllegalFormatConversionCategoryException( |
| ConversionCategory expected, ConversionCategory found) { |
| super( |
| expected.chars == null || expected.chars.length() == 0 ? '-' : expected.chars.charAt(0), |
| found.types == null ? Object.class : found.types[0]); |
| this.expected = expected; |
| this.found = found; |
| } |
| |
| public ConversionCategory getExpected() { |
| return expected; |
| } |
| |
| public ConversionCategory getFound() { |
| return found; |
| } |
| |
| @Override |
| public String getMessage() { |
| return String.format("Expected category %s but found %s.", expected, found); |
| } |
| } |
| } |