package org.ovirt.engine.ui.uicompat; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.lang.annotation.Annotation; import java.lang.reflect.Method; import java.net.URISyntaxException; import java.net.URL; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Properties; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import com.google.gwt.i18n.client.Messages; import com.google.gwt.i18n.client.Messages.Optional; import com.google.gwt.i18n.client.Messages.Select; /** * Validates GWT {@link com.google.gwt.i18n.client.Messages Messages} sub-interfaces to detect errors early, * i.e. before GWT compilation phase. Validation logic in this class generally follows GWT compiler's i18n * validation rules. * * <p>Two main kinds of errors are to be detected. First, detect if a key does not have a defined message * associated with it from either the default English or the locale specific files on a per locale basis. * This is done with in method {@link #checkForMissingDefault(Method, Properties, Properties, String, List)}. * Second, make sure that the message to be used by a locale specific message has to correct number of * substitution arguments. This is done in method {@link #checkPlaceHolders(Method, Properties, String, List)}. * * @see com.google.gwt.i18n.rebind.MessagesMethodCreator */ public class GwtMessagesValidator { private static final String PLACE_HOLDER_STRING = "\\{(\\d+)(?:,\\s*list,\\s*\\w+)?\\}"; private static final Pattern placeHolderPattern = Pattern.compile(PLACE_HOLDER_STRING); public static List<String> validateClass(Class<? extends Messages> classUnderTest) throws URISyntaxException, IOException { List<String> errors = new ArrayList<>(); if (classUnderTest.isInterface()) { File messagesDir = new File(classUnderTest.getResource(".") .toURI().toASCIIString().replaceAll("file:", "")); List<Method> messagesMethods = Arrays.asList(classUnderTest.getMethods()); Properties defaultProperties = loadDefaultProperties(classUnderTest); for (Method method : messagesMethods) { checkPlaceHolders(method, defaultProperties, classUnderTest.getSimpleName() + ".properties", errors); } File[] localePropertiesFiles = getMessagesLocalePropertiesFiles(messagesDir, classUnderTest.getSimpleName()); if (localePropertiesFiles != null) { for (File localeFile : localePropertiesFiles) { Properties localeProperties = loadProperties(localeFile); String localeFileName = localeFile.getName(); for (Method method : messagesMethods) { checkForMissingDefault(method, defaultProperties, localeProperties, localeFileName, errors); checkPlaceHolders(method, localeProperties, localeFileName, errors); } } } if (defaultProperties.size() == 0 && localePropertiesFiles == null) { errors.add("Class under test does not have a default or any locale specific properties files: " + classUnderTest.getName()); } } else { errors.add("Class under test is not an interface: " + classUnderTest.getName()); } return errors; } /** * Discover the full Messages hierarchy of a given interface, load and return the default English * text for each definition that can be found in properties files. * * @param leafClass Class to look up from * @return Set of default messages from <i>leafClass</i> up to, but not including, the root Messages * interface */ @SuppressWarnings("unchecked") private static Properties loadDefaultProperties(Class<? extends Messages> leafClass) throws URISyntaxException, IOException { Properties hierarchyProps = new Properties(); ArrayList<Class<? extends Messages>> hierarchy = new ArrayList<>(); // discover from the leafClass up to the root ArrayList<Class<? extends Messages>> round = new ArrayList<>(); round.add(leafClass); while (!round.isEmpty()) { ArrayList<Class<? extends Messages>> round2 = new ArrayList<>(); for (Class<? extends Messages> c : round) { hierarchy.add(c); for (Class<?> up : c.getInterfaces()) { if (Messages.class.isAssignableFrom(up) && Messages.class != up) { round2.add((Class<? extends Messages>)up); } } } round = round2; } Collections.reverse(hierarchy); // load the properties into the hierarchy from the root down for (Class<?> theClass : hierarchy) { String classPropertyFileName = theClass.getName().replace(".", "/") + ".properties"; URL theResource = theClass.getResource(classPropertyFileName); if (theResource == null) { theResource = theClass.getResource(theClass.getSimpleName() + ".properties"); } Properties classProps = new Properties(); try (InputStream input = theResource.openStream()) { classProps.load(input); } hierarchyProps.putAll(classProps); } return hierarchyProps; } private static void checkPlaceHolders(Method method, Properties localeProperties, String localeFileName, List<String> errors) { int count = 0; String methodName = method.getName(); if (localeProperties.getProperty(methodName) != null) { Set<Integer> foundIndex = new HashSet<>(); Set<Integer> requiredIndexes = determineRequiredIndexes(method.getParameterAnnotations()); int minRequired = requiredIndexes.size(); int methodParamCount = method.getParameterTypes().length; // Check to make sure the number of parameters is inside the range defined. Matcher matcher = placeHolderPattern.matcher(localeProperties.getProperty(methodName)); while (matcher.find()) { int placeHolderIndex = -1; try { placeHolderIndex = Integer.parseInt(matcher.group(1)); if (!foundIndex.contains(placeHolderIndex)) { count++; foundIndex.add(placeHolderIndex); requiredIndexes.remove(placeHolderIndex); } if (placeHolderIndex < 0 || placeHolderIndex >= methodParamCount) { errors.add(methodName + " contains out of bound index " + placeHolderIndex + " in " + localeFileName); } } catch (NumberFormatException nfe) { errors.add(methodName + " contains invalid key " + matcher.group(0) + " in " + localeFileName); } } if (count < minRequired || count > methodParamCount) { errors.add(methodName + " does not match the number of parameters in " + localeFileName); } if (!requiredIndexes.isEmpty()) { errors.add(methodName + " is missing required indexes in " + localeFileName); } } } private static Set<Integer> determineRequiredIndexes(Annotation[][] methodParamAnnotations) { Set<Integer> result = new HashSet<>(); for (int i = 0; i < methodParamAnnotations.length; i++) { boolean isOptional = false; boolean isSelect = false; Annotation[] annotations = methodParamAnnotations[i]; if (annotations.length > 0) { for (Annotation annotation : annotations) { if (annotation.annotationType().equals(Optional.class)) { isOptional = true; break; } else if (annotation.annotationType().equals(Select.class)) { isSelect = true; } } } if (!isOptional && !isSelect) { result.add(i); } } return result; } /** * For the given message definition method, make sure a value exists in either the default English * properties file(s) or in the locale specific properties file. The goal is to fail the unit test * if a key does not have a corresponding message. */ private static void checkForMissingDefault(Method method, Properties defaultProperties, Properties localeProperties, String localeFileName, List<String> errors) { String key = method.getName(); if (!defaultProperties.containsKey(key)) { if (!localeProperties.containsKey(key)) { errors.add("Key: " + method.getName() + " not found in properties file: " + localeFileName + " and no default is defined"); } } } private static Properties loadProperties(File localeFile) throws IOException { Properties properties = new Properties(); try (FileInputStream fis = new FileInputStream(localeFile)) { properties.load(fis); } return properties; } /** * Locate any existing locale specific properties files. * * @return An {@code Array} of {@code File} objects. * @throws URISyntaxException * If path doesn't exist */ private static File[] getMessagesLocalePropertiesFiles(final File currentDir, final String fileNamePrefix) throws URISyntaxException { return currentDir.listFiles((dir, name) -> name.matches("^" + fileNamePrefix + "_[a-zA-Z]{2}.*\\.properties$")); } /** * Format errors to be human readable. * * @param errors * The {@code List} of error {@code String}s * @return A {@code String} containing the human readable errors. */ public static String format(List<String> errors) { StringBuilder builder = new StringBuilder(); for (String error : errors) { builder.append(error); builder.append("\n"); } return builder.toString(); } }