1 /* 2 * Copyright (C) 2020 The Dagger Authors. 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package dagger.internal.codegen.validation; 18 19 import static java.util.Comparator.comparing; 20 21 import com.google.common.base.Joiner; 22 import com.google.common.base.Splitter; 23 import com.google.common.base.Strings; 24 import com.google.common.collect.HashMultimap; 25 import com.google.common.collect.ImmutableSet; 26 import com.google.common.collect.Iterables; 27 import java.util.ArrayList; 28 import java.util.Collection; 29 import java.util.HashSet; 30 import java.util.List; 31 import java.util.Map; 32 import java.util.Set; 33 import java.util.TreeMap; 34 import java.util.regex.Matcher; 35 import java.util.regex.Pattern; 36 37 /** 38 * Munges an error message to remove/shorten package names and adds a legend at the end. 39 */ 40 final class PackageNameCompressor { 41 42 static final String LEGEND_HEADER = 43 "\n\n======================\nFull classname legend:\n======================\n"; 44 static final String LEGEND_FOOTER = 45 "========================\nEnd of classname legend:\n========================\n"; 46 47 private static final ImmutableSet<String> PACKAGES_SKIPPED_IN_LEGEND = ImmutableSet.of( 48 "java.lang.", 49 "java.util."); 50 51 private static final Splitter PACKAGE_SPLITTER = Splitter.on('.'); 52 53 private static final Joiner PACKAGE_JOINER = Joiner.on('.'); 54 55 // TODO(erichang): Consider validating this regex by also passing in all of the known types from 56 // keys, module names, component names, etc and checking against that list. This may have some 57 // extra complications with taking apart types like List<Foo> to get the inner class names. 58 private static final Pattern CLASSNAME_PATTERN = 59 // Match lowercase package names with trailing dots. Start with a non-word character so we 60 // don't match substrings in like Bar.Foo and match the ar.Foo. Start a group to not include 61 // the non-word character. 62 Pattern.compile("[\\W](([a-z_0-9]++[.])++" 63 // Then match a name starting with an uppercase letter. This is the outer class name. 64 + "[A-Z][\\w$]++)"); 65 66 /** 67 * Compresses an error message by stripping the packages out of class names and adding them 68 * to a legend at the bottom of the error. 69 */ compressPackagesInMessage(String input)70 static String compressPackagesInMessage(String input) { 71 Matcher matcher = CLASSNAME_PATTERN.matcher(input); 72 73 Set<String> names = new HashSet<>(); 74 // Find all classnames in the error. Note that if our regex isn't complete, it just means the 75 // classname is left in the full form, which is a fine fallback. 76 while (matcher.find()) { 77 String name = matcher.group(1); 78 names.add(name); 79 } 80 // Now dedupe any conflicts. Use a TreeMap since we're going to need the legend sorted anyway. 81 // This map is from short name to full name. 82 Map<String, String> replacementMap = shortenNames(names); 83 84 // If we have nothing to replace, just return the original. 85 if (replacementMap.isEmpty()) { 86 return input; 87 } 88 89 // Find the longest key for building the legend 90 int longestKey = replacementMap.keySet().stream().max(comparing(String::length)).get().length(); 91 92 String replacedString = input; 93 StringBuilder legendBuilder = new StringBuilder(); 94 for (Map.Entry<String, String> entry : replacementMap.entrySet()) { 95 String shortName = entry.getKey(); 96 String fullName = entry.getValue(); 97 // Do the replacements in the message 98 replacedString = replacedString.replace(fullName, shortName); 99 100 // Skip certain prefixes. We need to check the shortName for a . though in case 101 // there was some type of conflict like java.util.concurrent.Future and 102 // java.util.foo.Future that got shortened to concurrent.Future and foo.Future. 103 // In those cases we do not want to skip the legend. We only skip if the class 104 // is directly in that package. 105 String prefix = fullName.substring(0, fullName.length() - shortName.length()); 106 if (PACKAGES_SKIPPED_IN_LEGEND.contains(prefix) && !shortName.contains(".")) { 107 continue; 108 } 109 110 // Add to the legend 111 legendBuilder 112 .append(shortName) 113 .append(": ") 114 // Add enough spaces to adjust the columns 115 .append(Strings.repeat(" ", longestKey - shortName.length())) 116 .append(fullName) 117 .append("\n"); 118 } 119 120 return legendBuilder.length() == 0 ? replacedString 121 : replacedString + LEGEND_HEADER + legendBuilder + LEGEND_FOOTER; 122 } 123 124 /** 125 * Returns a map from short name to full name after resolving conflicts. This resolves conflicts 126 * by adding on segments of the package name until they are unique. For example, com.foo.Baz and 127 * com.bar.Baz will conflict on Baz and then resolve with foo.Baz and bar.Baz as replacements. 128 */ shortenNames(Collection<String> names)129 private static Map<String, String> shortenNames(Collection<String> names) { 130 HashMultimap<String, List<String>> shortNameToPartsMap = HashMultimap.create(); 131 for (String name : names) { 132 List<String> parts = new ArrayList<>(PACKAGE_SPLITTER.splitToList(name)); 133 // Start with the just the class name as the simple name 134 String className = parts.remove(parts.size() - 1); 135 shortNameToPartsMap.put(className, parts); 136 } 137 138 // Iterate through looking for conflicts adding the next part of the package until there are no 139 // more conflicts 140 while (true) { 141 // Save the keys with conflicts to avoid concurrent modification issues 142 List<String> conflictingShortNames = new ArrayList<>(); 143 for (Map.Entry<String, Collection<List<String>>> entry 144 : shortNameToPartsMap.asMap().entrySet()) { 145 if (entry.getValue().size() > 1) { 146 conflictingShortNames.add(entry.getKey()); 147 } 148 } 149 150 if (conflictingShortNames.isEmpty()) { 151 break; 152 } 153 154 // For all conflicts, add in the next part of the package 155 for (String conflictingShortName : conflictingShortNames) { 156 Set<List<String>> partsCollection = shortNameToPartsMap.removeAll(conflictingShortName); 157 for (List<String> parts : partsCollection) { 158 String newShortName = parts.remove(parts.size() - 1) + "." + conflictingShortName; 159 // If we've removed the last part of the package, then just skip it entirely because 160 // now we're not shortening it at all. 161 if (!parts.isEmpty()) { 162 shortNameToPartsMap.put(newShortName, parts); 163 } 164 } 165 } 166 } 167 168 // Turn the multimap into a regular map now that conflicts have been resolved. Use a TreeMap 169 // since we're going to need the legend sorted anyway. This map is from short name to full name. 170 Map<String, String> replacementMap = new TreeMap<>(); 171 for (Map.Entry<String, Collection<List<String>>> entry 172 : shortNameToPartsMap.asMap().entrySet()) { 173 replacementMap.put( 174 entry.getKey(), 175 PACKAGE_JOINER.join(Iterables.getOnlyElement(entry.getValue())) + "." + entry.getKey()); 176 } 177 return replacementMap; 178 } 179 PackageNameCompressor()180 private PackageNameCompressor() {} 181 } 182