1 /* 2 * Copyright 2014 Google LLC 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 package com.google.auto.value.processor; 17 18 import com.google.common.base.Throwables; 19 import com.google.common.collect.ImmutableList; 20 import com.google.common.collect.ImmutableMap; 21 import com.google.escapevelocity.Template; 22 import java.io.File; 23 import java.io.FileInputStream; 24 import java.io.FilterInputStream; 25 import java.io.IOException; 26 import java.io.InputStream; 27 import java.io.InputStreamReader; 28 import java.io.Reader; 29 import java.io.UnsupportedEncodingException; 30 import java.lang.reflect.Field; 31 import java.lang.reflect.Modifier; 32 import java.net.URI; 33 import java.net.URISyntaxException; 34 import java.net.URL; 35 import java.nio.charset.StandardCharsets; 36 import java.util.Map; 37 import java.util.TreeMap; 38 import java.util.jar.JarEntry; 39 import java.util.jar.JarFile; 40 41 /** 42 * A template and a set of variables to be substituted into that template. A concrete subclass of 43 * this class defines a set of fields that are template variables, and an implementation of the 44 * {@link #parsedTemplate()} method which is the template to substitute them into. Once the values 45 * of the fields have been assigned, the {@link #toText()} method returns the result of substituting 46 * them into the template. 47 * 48 * <p>The subclass may be a direct subclass of this class or a more distant descendant. Every field 49 * in the starting class and its ancestors up to this class will be included. Fields cannot be 50 * static unless they are also final. They cannot be private, though they can be package-private if 51 * the class is in the same package as this class. They cannot be primitive or null, so that there 52 * is a clear indication when a field has not been set. 53 * 54 * @author Éamonn McManus 55 */ 56 abstract class TemplateVars { parsedTemplate()57 abstract Template parsedTemplate(); 58 59 private final ImmutableList<Field> fields; 60 TemplateVars()61 TemplateVars() { 62 this.fields = getFields(getClass()); 63 } 64 getFields(Class<?> c)65 private static ImmutableList<Field> getFields(Class<?> c) { 66 ImmutableList.Builder<Field> fieldsBuilder = ImmutableList.builder(); 67 while (c != TemplateVars.class) { 68 addFields(fieldsBuilder, c.getDeclaredFields()); 69 c = c.getSuperclass(); 70 } 71 return fieldsBuilder.build(); 72 } 73 addFields( ImmutableList.Builder<Field> fieldsBuilder, Field[] declaredFields)74 private static void addFields( 75 ImmutableList.Builder<Field> fieldsBuilder, Field[] declaredFields) { 76 for (Field field : declaredFields) { 77 if (field.isSynthetic() || isStaticFinal(field)) { 78 continue; 79 } 80 if (Modifier.isPrivate(field.getModifiers())) { 81 throw new IllegalArgumentException("Field cannot be private: " + field); 82 } 83 if (Modifier.isStatic(field.getModifiers())) { 84 throw new IllegalArgumentException("Field cannot be static unless also final: " + field); 85 } 86 if (field.getType().isPrimitive()) { 87 throw new IllegalArgumentException("Field cannot be primitive: " + field); 88 } 89 fieldsBuilder.add(field); 90 } 91 } 92 93 /** 94 * Returns the result of substituting the variables defined by the fields of this class (a 95 * concrete subclass of TemplateVars) into the template returned by {@link #parsedTemplate()}. 96 */ toText()97 String toText() { 98 Map<String, Object> vars = toVars(); 99 return parsedTemplate().evaluate(vars); 100 } 101 toVars()102 private ImmutableMap<String, Object> toVars() { 103 Map<String, Object> vars = new TreeMap<>(); 104 for (Field field : fields) { 105 Object value = fieldValue(field, this); 106 if (value == null) { 107 throw new IllegalArgumentException("Field cannot be null (was it set?): " + field); 108 } 109 Object old = vars.put(field.getName(), value); 110 if (old != null) { 111 throw new IllegalArgumentException("Two fields called " + field.getName() + "?!"); 112 } 113 } 114 return ImmutableMap.copyOf(vars); 115 } 116 117 @Override toString()118 public String toString() { 119 return getClass().getSimpleName() + toVars(); 120 } 121 parsedTemplateForResource(String resourceName)122 static Template parsedTemplateForResource(String resourceName) { 123 try { 124 return Template.parseFrom(resourceName, TemplateVars::readerFromResource); 125 } catch (UnsupportedEncodingException e) { 126 throw new AssertionError(e); 127 } catch (IOException | NullPointerException | IllegalStateException e) { 128 // https://github.com/google/auto/pull/439 says that we can get NullPointerException. 129 // https://github.com/google/auto/issues/715 says that we can get IllegalStateException 130 return retryParseAfterException(resourceName, e); 131 } 132 } 133 retryParseAfterException(String resourceName, Exception exception)134 private static Template retryParseAfterException(String resourceName, Exception exception) { 135 try { 136 return Template.parseFrom(resourceName, TemplateVars::readerFromUrl); 137 } catch (IOException t) { 138 // Chain the original exception so we can see both problems. 139 Throwables.getRootCause(exception).initCause(t); 140 throw new AssertionError(exception); 141 } 142 } 143 readerFromResource(String resourceName)144 private static Reader readerFromResource(String resourceName) { 145 InputStream in = TemplateVars.class.getResourceAsStream(resourceName); 146 if (in == null) { 147 throw new IllegalArgumentException("Could not find resource: " + resourceName); 148 } 149 return new InputStreamReader(in, StandardCharsets.UTF_8); 150 } 151 152 // This is an ugly workaround for https://bugs.openjdk.java.net/browse/JDK-6947916, as 153 // reported in https://github.com/google/auto/issues/365. 154 // The issue is that sometimes the InputStream returned by JarURLCollection.getInputStream() 155 // can be closed prematurely, which leads to an IOException saying "Stream closed". 156 // We catch all IOExceptions, and fall back on logic that opens the jar file directly and 157 // loads the resource from it. Since that doesn't use JarURLConnection, it shouldn't be 158 // susceptible to the same bug. We only use this as fallback logic rather than doing it always, 159 // because jars are memory-mapped by URLClassLoader, so loading a resource in the usual way 160 // through the getResourceAsStream should be a lot more efficient than reopening the jar. readerFromUrl(String resourceName)161 private static Reader readerFromUrl(String resourceName) throws IOException { 162 URL resourceUrl = TemplateVars.class.getResource(resourceName); 163 if (resourceUrl == null) { 164 // This is unlikely, since getResourceAsStream has already succeeded for the same resource. 165 throw new IllegalArgumentException("Could not find resource: " + resourceName); 166 } 167 InputStream in; 168 try { 169 if (resourceUrl.getProtocol().equalsIgnoreCase("file")) { 170 in = inputStreamFromFile(resourceUrl); 171 } else if (resourceUrl.getProtocol().equalsIgnoreCase("jar")) { 172 in = inputStreamFromJar(resourceUrl); 173 } else { 174 throw new AssertionError("Template fallback logic fails for: " + resourceUrl); 175 } 176 } catch (URISyntaxException e) { 177 throw new IOException(e); 178 } 179 return new InputStreamReader(in, StandardCharsets.UTF_8); 180 } 181 inputStreamFromJar(URL resourceUrl)182 private static InputStream inputStreamFromJar(URL resourceUrl) 183 throws URISyntaxException, IOException { 184 // Jar URLs look like this: jar:file:/path/to/file.jar!/entry/within/jar 185 // So take apart the URL to open the jar /path/to/file.jar and read the entry 186 // entry/within/jar from it. 187 String resourceUrlString = resourceUrl.toString().substring("jar:".length()); 188 int bang = resourceUrlString.lastIndexOf('!'); 189 String entryName = resourceUrlString.substring(bang + 1); 190 if (entryName.startsWith("/")) { 191 entryName = entryName.substring(1); 192 } 193 URI jarUri = new URI(resourceUrlString.substring(0, bang)); 194 JarFile jar = new JarFile(new File(jarUri)); 195 JarEntry entry = jar.getJarEntry(entryName); 196 InputStream in = jar.getInputStream(entry); 197 // We have to be careful not to close the JarFile before the stream has been read, because 198 // that would also close the stream. So we defer closing the JarFile until the stream is closed. 199 return new FilterInputStream(in) { 200 @Override 201 public void close() throws IOException { 202 super.close(); 203 jar.close(); 204 } 205 }; 206 } 207 208 // We don't really expect this case to arise, since the bug we're working around concerns jars 209 // not individual files. However, when running the test for this workaround from Maven, we do 210 // have files. That does mean the test is basically useless there, but Google's internal build 211 // system does run it using a jar, so we do have coverage. 212 private static InputStream inputStreamFromFile(URL resourceUrl) 213 throws IOException, URISyntaxException { 214 File resourceFile = new File(resourceUrl.toURI()); 215 return new FileInputStream(resourceFile); 216 } 217 218 private static Object fieldValue(Field field, Object container) { 219 try { 220 return field.get(container); 221 } catch (IllegalAccessException e) { 222 throw new RuntimeException(e); 223 } 224 } 225 226 private static boolean isStaticFinal(Field field) { 227 int modifiers = field.getModifiers(); 228 return Modifier.isStatic(modifiers) && Modifier.isFinal(modifiers); 229 } 230 } 231