package com.google.inject.internal; import static java.util.Comparator.comparing; import com.google.common.base.Joiner; import com.google.common.base.Splitter; import com.google.common.base.Strings; import com.google.common.collect.HashMultimap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; import com.google.common.collect.Maps; import com.google.common.collect.Ordering; import java.util.ArrayList; import java.util.Collection; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeMap; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; /** * Munges an error message to remove/shorten package names and adds a legend at the end. */ final class PackageNameCompressor { static final String LEGEND_HEADER = "\n\n======================\nFull classname legend:\n======================\n"; static final String LEGEND_FOOTER = "========================\nEnd of classname legend:\n========================\n"; private static final ImmutableSet PACKAGES_SKIPPED_IN_LEGEND = ImmutableSet.of( "java.lang.", "java.util."); private static final Splitter PACKAGE_SPLITTER = Splitter.on('.'); private static final Joiner PACKAGE_JOINER = Joiner.on('.'); // TODO(erichang): Consider validating this regex by also passing in all of the known types from // keys, module names, component names, etc and checking against that list. This may have some // extra complications with taking apart types like List to get the inner class names. private static final Pattern CLASSNAME_PATTERN = // Match lowercase package names with trailing dots. Start with a non-word character so we // don't match substrings in like Bar.Foo and match the com.foo.Foo. Require at least 2 // package names to avoid matching non package names like a sentence ending with a period and // starting with an upper case letter without space, for example: // foo.Must in message "Invalid value for foo.Must not be empty." should not be compressed. // Start a group to not include the non-word character. Pattern.compile( "[\\W](([a-z_0-9]++[.]){2,}+" // Then match a name starting with an uppercase letter. This is the outer class name. + "[A-Z][\\w$]*)"); // Pattern used to filter out quoted strings that should not have their package name compressed. // Picked '"' here because Guice uses it when including a string literal in an error message. This // will allow user to include class names in the error message and disable the compressor by // putting the name in a pair of '"'. // The pattern without the escapes: ([^"]+)((")?[^"\r\n]*")? // First group captures non quoted strings // Second group captures either a single quote or a string with a pair of quotes within a line // Class names in second group will not be compressed. private static final Pattern QUOTED_PATTERN = Pattern.compile("([^\\\"]+)((\\\")?[^\\\"\\r\\n]*\\\")?"); /** * Compresses an error message by stripping the packages out of class names and adding them * to a legend at the bottom of the error. */ static String compressPackagesInMessage(String input) { Matcher matcher = CLASSNAME_PATTERN.matcher(input); Set names = new HashSet<>(); // Find all classnames in the error. Note that if our regex isn't complete, it just means the // classname is left in the full form, which is a fine fallback. while (matcher.find()) { String name = matcher.group(1); names.add(name); } // Now dedupe any conflicts. Use a TreeMap since we're going to need the legend sorted anyway. // This map is from short name to full name. Map replacementMap = shortenNames(names); // If we have nothing to replace, just return the original. if (replacementMap.isEmpty()) { return input; } StringBuilder output = new StringBuilder(); Set replacedShortNames = replaceFullNames(input, replacementMap, output); if (replacedShortNames.isEmpty()) { return input; } String classNameLegend = buildClassNameLegend(Maps.filterKeys(replacementMap, replacedShortNames::contains)); return output.append(classNameLegend).toString(); } /** * Replaces full class names in {@code input} and append the replaced content to {@code output} * and then returns a set of short names that were used as replacement. * *

String literals that are quoted in the {@code input} will be added to the {@code output} * unchanged. So any full class name that only appear in the string literal will not be included * in the returned short names set. */ private static ImmutableSet replaceFullNames( String input, Map replacementMap, StringBuilder output) { ImmutableSet.Builder replacedShortNames = ImmutableSet.builder(); // Sort short names in reverse alphabetical order. This is necessary so that a short name that // has a prefix that is another short name will be replaced first, otherwise the longer name // will not be collected as one of the replacedShortNames. List shortNames = replacementMap.keySet().stream() .sorted(Ordering.natural().reverse()) .collect(Collectors.toList()); Matcher matcher = QUOTED_PATTERN.matcher(input); while (matcher.find()) { String replaced = matcher.group(1); for (String shortName : shortNames) { String fullName = replacementMap.get(shortName); int beforeLen = replaced.length(); replaced = replaced.replace(fullName, shortName); // If the replacement happened then put the short name in replacedShortNames. // Only values in replacedShortNames are included in the full class name legend. if (replaced.length() < beforeLen) { replacedShortNames.add(shortName); } } output.append(replaced); String quoted = matcher.group(2); if (quoted != null) { output.append(quoted); } } return replacedShortNames.build(); } private static String buildClassNameLegend(Map replacementMap) { StringBuilder legendBuilder = new StringBuilder(); // Find the longest key for building the legend int longestKey = replacementMap.keySet().stream().max(comparing(String::length)).get().length(); for (Map.Entry entry : replacementMap.entrySet()) { String shortName = entry.getKey(); String fullName = entry.getValue(); // Skip certain prefixes. We need to check the shortName for a . though in case // there was some type of conflict like java.util.concurrent.Future and // java.util.foo.Future that got shortened to concurrent.Future and foo.Future. // In those cases we do not want to skip the legend. We only skip if the class // is directly in that package. String prefix = fullName.substring(0, fullName.length() - shortName.length()); if (PACKAGES_SKIPPED_IN_LEGEND.contains(prefix) && !shortName.contains(".")) { continue; } // Add to the legend legendBuilder .append(shortName) .append(": ") // Add enough spaces to adjust the columns .append(Strings.repeat(" ", longestKey - shortName.length())) // Surround the full class name with quotes to avoid them getting compressed again if // the error is wrapped inside another Guice error. .append('"') .append(fullName) .append('"') .append("\n"); } return legendBuilder.length() == 0 ? "" : Messages.bold(LEGEND_HEADER) + Messages.faint(legendBuilder.toString()) + Messages.bold(LEGEND_FOOTER); } /** * Returns a map from short name to full name after resolving conflicts. This resolves conflicts * by adding on segments of the package name until they are unique. For example, com.foo.Baz and * com.bar.Baz will conflict on Baz and then resolve with foo.Baz and bar.Baz as replacements. */ private static Map shortenNames(Collection names) { HashMultimap> shortNameToPartsMap = HashMultimap.create(); for (String name : names) { List parts = new ArrayList<>(PACKAGE_SPLITTER.splitToList(name)); // Start with the just the class name as the simple name String className = parts.remove(parts.size() - 1); shortNameToPartsMap.put(className, parts); } // Iterate through looking for conflicts adding the next part of the package until there are no // more conflicts while (true) { // Save the keys with conflicts to avoid concurrent modification issues List conflictingShortNames = new ArrayList<>(); for (Map.Entry>> entry : shortNameToPartsMap.asMap().entrySet()) { if (entry.getValue().size() > 1) { conflictingShortNames.add(entry.getKey()); } } if (conflictingShortNames.isEmpty()) { break; } // For all conflicts, add in the next part of the package for (String conflictingShortName : conflictingShortNames) { Set> partsCollection = shortNameToPartsMap.removeAll(conflictingShortName); for (List parts : partsCollection) { String newShortName = parts.remove(parts.size() - 1) + "." + conflictingShortName; // If we've removed the last part of the package, then just skip it entirely because // now we're not shortening it at all. if (!parts.isEmpty()) { shortNameToPartsMap.put(newShortName, parts); } } } } // Turn the multimap into a regular map now that conflicts have been resolved. Use a TreeMap // since we're going to need the legend sorted anyway. This map is from short name to full name. Map replacementMap = new TreeMap<>(); for (Map.Entry>> entry : shortNameToPartsMap.asMap().entrySet()) { replacementMap.put( entry.getKey(), PACKAGE_JOINER.join(Iterables.getOnlyElement(entry.getValue())) + "." + entry.getKey()); } return replacementMap; } private PackageNameCompressor() {} }