blob: c456124c5c850fca9d590e8be7d059a2071bf0fd [file] [log] [blame]
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);
}
}
}