diff options
author | Colin Cross <ccross@android.com> | 2017-04-28 21:36:31 +0000 |
---|---|---|
committer | android-build-merger <android-build-merger@google.com> | 2017-04-28 21:36:31 +0000 |
commit | 11c90c2309a059e17841db2c02e51621f14e7813 (patch) | |
tree | 03534af441228307207a8cdd82e4af966e930be5 | |
parent | aba105bab9aca9d0f7354ccf116f8defaf02669a (diff) | |
parent | c18767262aeedcfe0648a08635082e472985cc77 (diff) | |
download | desugar-11c90c2309a059e17841db2c02e51621f14e7813.tar.gz |
Merge remote-tracking branch 'aosp/upstream-master' into master am: 0949aa80fc am: dd6801b47d
am: c18767262a
Change-Id: Ief3f8a403b589a017dfc1e6db335fe777c7cef51
15 files changed, 1594 insertions, 168 deletions
diff --git a/java/com/google/devtools/build/android/desugar/ClassReaderFactory.java b/java/com/google/devtools/build/android/desugar/ClassReaderFactory.java index 2b44d76..bae5251 100644 --- a/java/com/google/devtools/build/android/desugar/ClassReaderFactory.java +++ b/java/com/google/devtools/build/android/desugar/ClassReaderFactory.java @@ -28,7 +28,7 @@ class ClassReaderFactory { } /** - * Returns a reader for the given/internal/Class$Name if the class is defined in the wrapped Jar + * Returns a reader for the given/internal/Class$Name if the class is defined in the wrapped input * and {@code null} otherwise. For simplicity this method turns checked into runtime exceptions * under the assumption that all classes have already been read once when this method is called. */ @@ -50,4 +50,12 @@ class ClassReaderFactory { return null; } + + /** + * Returns {@code true} if the given given/internal/Class$Name is defined in the wrapped input. + */ + public boolean isKnown(String internalClassName) { + String filename = rewriter.unprefix(internalClassName) + ".class"; + return indexedInputs.getInputFileProvider(filename) != null; + } } diff --git a/java/com/google/devtools/build/android/desugar/DefaultMethodClassFixer.java b/java/com/google/devtools/build/android/desugar/DefaultMethodClassFixer.java new file mode 100644 index 0000000..8ad5dc2 --- /dev/null +++ b/java/com/google/devtools/build/android/desugar/DefaultMethodClassFixer.java @@ -0,0 +1,300 @@ +// Copyright 2017 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package com.google.devtools.build.android.desugar; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; + +import com.google.common.collect.ImmutableList; +import java.util.HashSet; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; + +/** + * Fixer of classes that extend interfaces with default methods to declare any missing methods + * explicitly and call the corresponding companion method generated by {@link InterfaceDesugaring}. + */ +public class DefaultMethodClassFixer extends ClassVisitor { + + private final ClassReaderFactory classpath; + private final ClassReaderFactory bootclasspath; + private final HashSet<String> instanceMethods = new HashSet<>(); + private final HashSet<String> seenInterfaces = new HashSet<>(); + + private boolean isInterface; + private ImmutableList<String> interfaces; + private String superName; + + public DefaultMethodClassFixer(ClassVisitor dest, ClassReaderFactory classpath, + ClassReaderFactory bootclasspath) { + super(Opcodes.ASM5, dest); + this.classpath = classpath; + this.bootclasspath = bootclasspath; + } + + @Override + public void visit( + int version, + int access, + String name, + String signature, + String superName, + String[] interfaces) { + checkState(this.interfaces == null); + isInterface = BitFlags.isSet(access, Opcodes.ACC_INTERFACE); + checkArgument(superName != null || "java/lang/Object".equals(name), // ASM promises this + "Type without superclass: %s", name); + this.interfaces = ImmutableList.copyOf(interfaces); + this.superName = superName; + super.visit(version, access, name, signature, superName, interfaces); + } + + @Override + public void visitEnd() { + if (!isInterface && defaultMethodsDefined(interfaces)) { + // Inherited methods take precedence over default methods, so visit all superclasses and + // figure out what methods they declare before stubbing in any missing default methods. + recordInheritedMethods(); + stubMissingDefaultMethods(interfaces); + } + super.visitEnd(); + } + + @Override + public MethodVisitor visitMethod( + int access, String name, String desc, String signature, String[] exceptions) { + // Keep track of instance methods implemented in this class for later. + if (!isInterface) { + recordIfInstanceMethod(access, name, desc); + } + return super.visitMethod(access, name, desc, signature, exceptions); + } + + private void recordInheritedMethods() { + InstanceMethodRecorder recorder = new InstanceMethodRecorder(); + String internalName = superName; + while (internalName != null) { + ClassReader bytecode = bootclasspath.readIfKnown(internalName); + if (bytecode == null) { + bytecode = checkNotNull(classpath.readIfKnown(internalName), + "Superclass not found: %s", internalName); + } + bytecode.accept(recorder, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG); + internalName = bytecode.getSuperName(); + } + } + + private void recordIfInstanceMethod(int access, String name, String desc) { + if (BitFlags.noneSet(access, Opcodes.ACC_STATIC)) { + // Record all declared instance methods, including abstract, bridge, and native methods, as + // they all take precedence over default methods. + instanceMethods.add(name + ":" + desc); + } + } + + /** + * Recursively searches the given interfaces for default methods not implemented by this class + * directly. If this method returns true we need to think about stubbing missing default methods. + */ + private boolean defaultMethodsDefined(ImmutableList<String> interfaces) { + for (String implemented : interfaces) { + ClassReader bytecode = classpath.readIfKnown(implemented); + if (bytecode != null && !bootclasspath.isKnown(implemented)) { + // Class in classpath and bootclasspath is a bad idea but in any event, assume the + // bootclasspath will take precedence like in a classloader. + // We can skip code attributes as we just need to find default methods to stub. + DefaultMethodFinder finder = new DefaultMethodFinder(); + bytecode.accept(finder, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG); + if (finder.foundDefaultMethods()) { + return true; + } + } + // Else interface isn't on the classpath, which indicates incomplete classpaths. For now + // we'll just assume the missing interfaces don't declare default methods but if they do + // we'll end up with concrete classes that don't implement an abstract method, which can + // cause runtime failures. The classpath needs to be fixed in this case. + } + return false; + } + + private void stubMissingDefaultMethods(ImmutableList<String> interfaces) { + for (String implemented : interfaces) { + if (!seenInterfaces.add(implemented)) { + // Skip: a superclass already implements this interface, or we've seen it here + continue; + } + ClassReader bytecode = classpath.readIfKnown(implemented); + if (bytecode != null && !bootclasspath.isKnown(implemented)) { + // Class in classpath and bootclasspath is a bad idea but in any event, assume the + // bootclasspath will take precedence like in a classloader. + // We can skip code attributes as we just need to find default methods to stub. + bytecode.accept(new DefaultMethodStubber(), ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG); + } + } + } + + /** + * Visitor for interfaces that produces delegates in the class visited by the outer + * {@link DefaultMethodClassFixer} for every default method encountered. + */ + public class DefaultMethodStubber extends ClassVisitor { + + @SuppressWarnings("hiding") private ImmutableList<String> interfaces; + private String interfaceName; + + public DefaultMethodStubber() { + super(Opcodes.ASM5); + } + + @Override + public void visit( + int version, + int access, + String name, + String signature, + String superName, + String[] interfaces) { + checkArgument(BitFlags.isSet(access, Opcodes.ACC_INTERFACE)); + checkState(this.interfaces == null); + this.interfaces = ImmutableList.copyOf(interfaces); + interfaceName = name; + } + + @Override + public void visitEnd() { + stubMissingDefaultMethods(this.interfaces); + } + + @Override + public MethodVisitor visitMethod( + int access, String name, String desc, String signature, String[] exceptions) { + if (BitFlags.noneSet(access, Opcodes.ACC_ABSTRACT | Opcodes.ACC_STATIC | Opcodes.ACC_BRIDGE) + && !instanceMethods.contains(name + ":" + desc)) { + // Add this method to the class we're desugaring and stub in a body to call the default + // implementation in the interface's companion class. ijar omits these methods when setting + // ACC_SYNTHETIC modifier, so don't. Don't do this for bridge methods, which we handle + // separately. + // Signatures can be wrong, e.g., when type variables are introduced, instantiated, or + // refined in the class we're processing, so drop them. + MethodVisitor stubMethod = + DefaultMethodClassFixer.this.visitMethod(access, name, desc, (String) null, exceptions); + + int slot = 0; + stubMethod.visitVarInsn(Opcodes.ALOAD, slot++); // load the receiver + Type neededType = Type.getMethodType(desc); + for (Type arg : neededType.getArgumentTypes()) { + stubMethod.visitVarInsn(arg.getOpcode(Opcodes.ILOAD), slot); + slot += arg.getSize(); + } + stubMethod.visitMethodInsn( + Opcodes.INVOKESTATIC, + interfaceName + InterfaceDesugaring.COMPANION_SUFFIX, + name, + InterfaceDesugaring.companionDefaultMethodDescriptor(interfaceName, desc), + /*itf*/ false); + stubMethod.visitInsn(neededType.getReturnType().getOpcode(Opcodes.IRETURN)); + + stubMethod.visitMaxs(0, 0); // rely on class writer to compute these + stubMethod.visitEnd(); + } + return null; // we don't care about the actual code in these methods + } + } + + /** + * Visitor for interfaces that recursively searches interfaces for default method declarations. + */ + public class DefaultMethodFinder extends ClassVisitor { + + @SuppressWarnings("hiding") private ImmutableList<String> interfaces; + private boolean found; + + public DefaultMethodFinder() { + super(Opcodes.ASM5); + } + + @Override + public void visit( + int version, + int access, + String name, + String signature, + String superName, + String[] interfaces) { + checkArgument(BitFlags.isSet(access, Opcodes.ACC_INTERFACE)); + checkState(this.interfaces == null); + this.interfaces = ImmutableList.copyOf(interfaces); + } + + public boolean foundDefaultMethods() { + return found; + } + + @Override + public void visitEnd() { + if (!found) { + found = defaultMethodsDefined(this.interfaces); + } + } + + @Override + public MethodVisitor visitMethod( + int access, String name, String desc, String signature, String[] exceptions) { + if (BitFlags.noneSet(access, Opcodes.ACC_ABSTRACT | Opcodes.ACC_STATIC | Opcodes.ACC_BRIDGE) + && !instanceMethods.contains(name + ":" + desc)) { + // Found a default method we're not ignoring (instanceMethods at this point contains methods + // the top-level visited class implements itself). + found = true; + } + return null; // we don't care about the actual code in these methods + } + } + + private class InstanceMethodRecorder extends ClassVisitor { + + public InstanceMethodRecorder() { + super(Opcodes.ASM5); + } + + @Override + public void visit( + int version, + int access, + String name, + String signature, + String superName, + String[] interfaces) { + checkArgument(BitFlags.noneSet(access, Opcodes.ACC_INTERFACE)); + for (String inheritedInterface : interfaces) { + // No point copying default methods that we'll also copy for a superclass. Note we may + // be processing a class in the bootclasspath, in which case the interfaces must also + // be in the bootclasspath and we can skip those as well. Also note this is best-effort, + // since these interfaces may extend other interfaces that we're not recording here. + seenInterfaces.add(inheritedInterface); + } + super.visit(version, access, name, signature, superName, interfaces); + } + + @Override + public MethodVisitor visitMethod( + int access, String name, String desc, String signature, String[] exceptions) { + recordIfInstanceMethod(access, name, desc); + return null; + } + } +} diff --git a/java/com/google/devtools/build/android/desugar/Desugar.java b/java/com/google/devtools/build/android/desugar/Desugar.java index 797cbc4..c4528ae 100644 --- a/java/com/google/devtools/build/android/desugar/Desugar.java +++ b/java/com/google/devtools/build/android/desugar/Desugar.java @@ -14,6 +14,7 @@ package com.google.devtools.build.android.desugar; import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.base.Preconditions.checkState; import static java.nio.charset.StandardCharsets.ISO_8859_1; @@ -22,6 +23,7 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSet.Builder; +import com.google.common.io.ByteStreams; import com.google.common.io.Closer; import com.google.devtools.build.android.Converters.ExistingPathConverter; import com.google.devtools.build.android.Converters.PathConverter; @@ -31,6 +33,7 @@ import com.google.devtools.common.options.OptionsBase; import com.google.devtools.common.options.OptionsParser; import com.google.devtools.common.options.OptionsParser.OptionUsageRestrictions; import com.google.errorprone.annotations.MustBeClosed; +import java.io.IOError; import java.io.IOException; import java.io.InputStream; import java.nio.file.FileVisitResult; @@ -42,9 +45,12 @@ import java.nio.file.attribute.BasicFileAttributes; import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import javax.annotation.Nullable; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassVisitor; import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.tree.ClassNode; /** * Command-line tool to desugar Java 8 constructs that dx doesn't know what to do with, in @@ -146,6 +152,25 @@ class Desugar { public int minSdkVersion; @Option( + name = "desugar_interface_method_bodies_if_needed", + defaultValue = "true", + category = "misc", + help = + "Rewrites default and static methods in interfaces if --min_sdk_version < 24. This " + + "only works correctly if subclasses of rewritten interfaces as well as uses of " + + "static interface methods are run through this tool as well." + ) + public boolean desugarInterfaceMethodBodiesIfNeeded; + + @Option( + name = "desugar_try_with_resources_if_needed", + defaultValue = "false", + category = "misc", + help = "Rewrites try-with-resources statements if --min_sdk_version < 19." + ) + public boolean desugarTryWithResourcesIfNeeded; + + @Option( name = "copy_bridges_from_classpath", defaultValue = "false", category = "misc", @@ -167,6 +192,11 @@ class Desugar { private final Path dumpDirectory; private final CoreLibraryRewriter rewriter; private final LambdaClassMaker lambdas; + private final GeneratedClassStore store; + /** The counter to record the times of try-with-resources desugaring is invoked. */ + private final AtomicInteger numOfTryWithResourcesInvoked = new AtomicInteger(); + + private final boolean outputJava7; private final boolean allowDefaultMethods; private final boolean allowCallsToObjectsNonNull; /** An instance of Desugar is expected to be used ONLY ONCE */ @@ -177,7 +207,10 @@ class Desugar { this.dumpDirectory = dumpDirectory; this.rewriter = new CoreLibraryRewriter(options.coreLibrary ? "__desugar__/" : ""); this.lambdas = new LambdaClassMaker(dumpDirectory); - this.allowDefaultMethods = options.minSdkVersion >= 24; + this.store = new GeneratedClassStore(); + this.outputJava7 = options.minSdkVersion < 24; + this.allowDefaultMethods = + options.desugarInterfaceMethodBodiesIfNeeded || options.minSdkVersion >= 24; this.allowCallsToObjectsNonNull = options.minSdkVersion >= 19; this.used = false; } @@ -187,27 +220,33 @@ class Desugar { this.used = true; try (Closer closer = Closer.create()) { - IndexedInputs indexedClasspath = - new IndexedInputs(toRegisteredInputFileProvider(closer, options.classpath)); + IndexedInputs indexedBootclasspath = + new IndexedInputs(toRegisteredInputFileProvider(closer, options.bootclasspath)); // Use a classloader that as much as possible uses the provided bootclasspath instead of // the tool's system classloader. Unfortunately we can't do that for java. classes. ClassLoader bootclassloader = options.bootclasspath.isEmpty() ? new ThrowingClassLoader() - : new HeaderClassLoader( - new IndexedInputs(toRegisteredInputFileProvider(closer, options.bootclasspath)), - rewriter, - new ThrowingClassLoader()); + : new HeaderClassLoader(indexedBootclasspath, rewriter, new ThrowingClassLoader()); + IndexedInputs indexedClasspath = + new IndexedInputs(toRegisteredInputFileProvider(closer, options.classpath)); // Process each input separately for (InputOutputPair inputOutputPair : toInputOutputPairs(options)) { - desugarOneInput(inputOutputPair, indexedClasspath, bootclassloader); + desugarOneInput( + inputOutputPair, + indexedClasspath, + bootclassloader, + new ClassReaderFactory(indexedBootclasspath, rewriter)); } } } private void desugarOneInput( - InputOutputPair inputOutputPair, IndexedInputs indexedClasspath, ClassLoader bootclassloader) + InputOutputPair inputOutputPair, + IndexedInputs indexedClasspath, + ClassLoader bootclassloader, + ClassReaderFactory bootclasspathReader) throws Exception { Path inputPath = inputOutputPair.getInput(); Path outputPath = inputOutputPair.getOutput(); @@ -227,24 +266,62 @@ class Desugar { ClassLoader loader = new HeaderClassLoader(indexedClasspathAndInputFiles, rewriter, bootclassloader); - ClassReaderFactory readerFactory = - new ClassReaderFactory( - (options.copyBridgesFromClasspath && !allowDefaultMethods) - ? indexedClasspathAndInputFiles - : indexedInputFiles, - rewriter); + ClassReaderFactory classpathReader = null; + ClassReaderFactory bridgeMethodReader = null; + if (outputJava7) { + classpathReader = new ClassReaderFactory(indexedClasspathAndInputFiles, rewriter); + if (options.copyBridgesFromClasspath) { + bridgeMethodReader = classpathReader; + } else { + bridgeMethodReader = new ClassReaderFactory(indexedInputFiles, rewriter); + } + } ImmutableSet.Builder<String> interfaceLambdaMethodCollector = ImmutableSet.builder(); desugarClassesInInput( - inputFiles, outputFileProvider, loader, readerFactory, interfaceLambdaMethodCollector); + inputFiles, + outputFileProvider, + loader, + classpathReader, + bootclasspathReader, + interfaceLambdaMethodCollector); desugarAndWriteDumpedLambdaClassesToOutput( - outputFileProvider, loader, readerFactory, interfaceLambdaMethodCollector); + outputFileProvider, + loader, + classpathReader, + bootclasspathReader, + interfaceLambdaMethodCollector.build(), + bridgeMethodReader); + + desugarAndWriteGeneratedClasses(outputFileProvider); + copyThrowableExtensionClass(outputFileProvider); } - ImmutableMap<Path, LambdaInfo> leftBehind = lambdas.drain(); - checkState(leftBehind.isEmpty(), "Didn't process %s", leftBehind); + ImmutableMap<Path, LambdaInfo> lambdasLeftBehind = lambdas.drain(); + checkState(lambdasLeftBehind.isEmpty(), "Didn't process %s", lambdasLeftBehind); + ImmutableMap<String, ClassNode> generatedLeftBehind = store.drain(); + checkState(generatedLeftBehind.isEmpty(), "Didn't process %s", generatedLeftBehind.keySet()); + } + + private void copyThrowableExtensionClass(OutputFileProvider outputFileProvider) { + if (!outputJava7 || !options.desugarTryWithResourcesIfNeeded) { + // try-with-resources statements are okay in the output jar. + return; + } + if (this.numOfTryWithResourcesInvoked.get() <= 0) { + // the try-with-resources desugaring pass does nothing, so no need to copy these class files. + return; + } + for (String className : + TryWithResourcesRewriter.THROWABLE_EXT_CLASS_INTERNAL_NAMES_WITH_CLASS_EXT) { + try (InputStream stream = Desugar.class.getClassLoader().getResourceAsStream(className)) { + outputFileProvider.write(className, ByteStreams.toByteArray(stream)); + } catch (IOException e) { + throw new IOError(e); + } + } } /** Desugar the classes that are in the inputs specified in the command line arguments. */ @@ -252,7 +329,8 @@ class Desugar { InputFileProvider inputFiles, OutputFileProvider outputFileProvider, ClassLoader loader, - ClassReaderFactory readerFactory, + @Nullable ClassReaderFactory classpathReader, + ClassReaderFactory bootclasspathReader, Builder<String> interfaceLambdaMethodCollector) throws IOException { for (String filename : inputFiles) { @@ -262,11 +340,14 @@ class Desugar { // any danger of accidentally uncompressed resources ending up in an .apk. if (filename.endsWith(".class")) { ClassReader reader = rewriter.reader(content); - UnprefixingClassWriter writer = - rewriter.writer(ClassWriter.COMPUTE_MAXS /*for bridge methods*/); + UnprefixingClassWriter writer = rewriter.writer(ClassWriter.COMPUTE_MAXS); ClassVisitor visitor = createClassVisitorsForClassesInInputs( - loader, readerFactory, interfaceLambdaMethodCollector, writer); + loader, + classpathReader, + bootclasspathReader, + interfaceLambdaMethodCollector, + writer); reader.accept(visitor, 0); outputFileProvider.write(filename, writer.toByteArray()); @@ -284,10 +365,11 @@ class Desugar { private void desugarAndWriteDumpedLambdaClassesToOutput( OutputFileProvider outputFileProvider, ClassLoader loader, - ClassReaderFactory readerFactory, - Builder<String> interfaceLambdaMethodCollector) + @Nullable ClassReaderFactory classpathReader, + ClassReaderFactory bootclasspathReader, + ImmutableSet<String> interfaceLambdaMethods, + @Nullable ClassReaderFactory bridgeMethodReader) throws IOException { - ImmutableSet<String> interfaceLambdaMethods = interfaceLambdaMethodCollector.build(); checkState( !allowDefaultMethods || interfaceLambdaMethods.isEmpty(), "Desugaring with default methods enabled moved interface lambdas"); @@ -307,7 +389,13 @@ class Desugar { rewriter.writer(ClassWriter.COMPUTE_MAXS /*for invoking bridges*/); ClassVisitor visitor = createClassVisitorsForDumpedLambdaClasses( - loader, readerFactory, interfaceLambdaMethods, lambdaClass.getValue(), writer); + loader, + classpathReader, + bootclasspathReader, + interfaceLambdaMethods, + bridgeMethodReader, + lambdaClass.getValue(), + writer); reader.accept(visitor, 0); String filename = rewriter.unprefix(lambdaClass.getValue().desiredInternalName()) + ".class"; @@ -316,26 +404,58 @@ class Desugar { } } + private void desugarAndWriteGeneratedClasses(OutputFileProvider outputFileProvider) + throws IOException { + // Write out any classes we generated along the way + ImmutableMap<String, ClassNode> generatedClasses = store.drain(); + checkState( + generatedClasses.isEmpty() || (allowDefaultMethods && outputJava7), + "Didn't expect generated classes but got %s", + generatedClasses.keySet()); + for (Map.Entry<String, ClassNode> generated : generatedClasses.entrySet()) { + UnprefixingClassWriter writer = rewriter.writer(ClassWriter.COMPUTE_MAXS); + // checkState above implies that we want Java 7 .class files, so send through that visitor. + // Don't need a ClassReaderFactory b/c static interface methods should've been moved. + ClassVisitor visitor = new Java7Compatibility(writer, (ClassReaderFactory) null); + generated.getValue().accept(visitor); + String filename = rewriter.unprefix(generated.getKey()) + ".class"; + outputFileProvider.write(filename, writer.toByteArray()); + } + } + /** * Create the class visitors for the lambda classes that are generated on the fly. If no new class * visitors are not generated, then the passed-in {@code writer} will be returned. */ private ClassVisitor createClassVisitorsForDumpedLambdaClasses( ClassLoader loader, - ClassReaderFactory readerFactory, + @Nullable ClassReaderFactory classpathReader, + ClassReaderFactory bootclasspathReader, ImmutableSet<String> interfaceLambdaMethods, + @Nullable ClassReaderFactory bridgeMethodReader, LambdaInfo lambdaClass, UnprefixingClassWriter writer) { - ClassVisitor visitor = writer; + ClassVisitor visitor = checkNotNull(writer); - if (!allowDefaultMethods) { + if (outputJava7) { // null ClassReaderFactory b/c we don't expect to need it for lambda classes visitor = new Java7Compatibility(visitor, (ClassReaderFactory) null); + if (options.desugarTryWithResourcesIfNeeded) { + visitor = new TryWithResourcesRewriter(visitor, loader, numOfTryWithResourcesInvoked); + } + if (options.desugarInterfaceMethodBodiesIfNeeded) { + visitor = new DefaultMethodClassFixer(visitor, classpathReader, bootclasspathReader); + visitor = new InterfaceDesugaring(visitor, bootclasspathReader, store); + } } - visitor = new LambdaClassFixer( - visitor, lambdaClass, readerFactory, interfaceLambdaMethods, allowDefaultMethods); + visitor, + lambdaClass, + bridgeMethodReader, + interfaceLambdaMethods, + allowDefaultMethods, + outputJava7); // Send lambda classes through desugaring to make sure there's no invokedynamic // instructions in generated lambda classes (checkState below will fail) visitor = new LambdaDesugaring(visitor, loader, lambdas, null, allowDefaultMethods); @@ -357,17 +477,23 @@ class Desugar { */ private ClassVisitor createClassVisitorsForClassesInInputs( ClassLoader loader, - ClassReaderFactory readerFactory, + @Nullable ClassReaderFactory classpathReader, + ClassReaderFactory bootclasspathReader, Builder<String> interfaceLambdaMethodCollector, UnprefixingClassWriter writer) { - checkArgument(writer != null, "The class writer cannot be null"); - ClassVisitor visitor = writer; + ClassVisitor visitor = checkNotNull(writer); if (!options.onlyDesugarJavac9ForLint) { - if (!allowDefaultMethods) { - visitor = new Java7Compatibility(visitor, readerFactory); + if (outputJava7) { + visitor = new Java7Compatibility(visitor, classpathReader); + if (options.desugarTryWithResourcesIfNeeded) { + visitor = new TryWithResourcesRewriter(visitor, loader, numOfTryWithResourcesInvoked); + } + if (options.desugarInterfaceMethodBodiesIfNeeded) { + visitor = new DefaultMethodClassFixer(visitor, classpathReader, bootclasspathReader); + visitor = new InterfaceDesugaring(visitor, bootclasspathReader, store); + } } - visitor = new LambdaDesugaring( visitor, loader, lambdas, interfaceLambdaMethodCollector, allowDefaultMethods); @@ -382,7 +508,6 @@ class Desugar { return visitor; } - public static void main(String[] args) throws Exception { // It is important that this method is called first. See its javadoc. Path dumpDirectory = createAndRegisterLambdaDumpDirectory(); @@ -440,7 +565,8 @@ class Desugar { final ImmutableList.Builder<InputOutputPair> ioPairListbuilder = ImmutableList.builder(); for (Iterator<Path> inputIt = options.inputJars.iterator(), outputIt = options.outputJars.iterator(); - inputIt.hasNext();) { + inputIt.hasNext(); + ) { ioPairListbuilder.add(InputOutputPair.create(inputIt.next(), outputIt.next())); } return ioPairListbuilder.build(); @@ -498,8 +624,7 @@ class Desugar { /** Transform a Path to an {@link OutputFileProvider} */ @MustBeClosed - private static OutputFileProvider toOutputFileProvider(Path path) - throws IOException { + private static OutputFileProvider toOutputFileProvider(Path path) throws IOException { if (Files.isDirectory(path)) { return new DirectoryOutputFileProvider(path); } else { @@ -509,8 +634,7 @@ class Desugar { /** Transform a Path to an InputFileProvider that needs to be closed by the caller. */ @MustBeClosed - private static InputFileProvider toInputFileProvider(Path path) - throws IOException { + private static InputFileProvider toInputFileProvider(Path path) throws IOException { if (Files.isDirectory(path)) { return new DirectoryInputFileProvider(path); } else { @@ -532,9 +656,7 @@ class Desugar { return builder.build(); } - /** - * Pair input and output. - */ + /** Pair input and output. */ @AutoValue abstract static class InputOutputPair { diff --git a/java/com/google/devtools/build/android/desugar/GeneratedClassStore.java b/java/com/google/devtools/build/android/desugar/GeneratedClassStore.java new file mode 100644 index 0000000..bf376a4 --- /dev/null +++ b/java/com/google/devtools/build/android/desugar/GeneratedClassStore.java @@ -0,0 +1,49 @@ +// Copyright 2017 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package com.google.devtools.build.android.desugar; + +import static com.google.common.base.Preconditions.checkState; + +import com.google.common.collect.ImmutableMap; +import java.util.LinkedHashMap; +import java.util.Map; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.tree.ClassNode; + +/** + * Simple wrapper around a map that holds generated classes so they can be processed later. + */ +class GeneratedClassStore { + + /** Map from internal names to generated classes with deterministic iteration order. */ + private final Map<String, ClassNode> classes = new LinkedHashMap<>(); + + /** + * Adds a class for the given internal name. It's the caller's responsibility to {@link + * ClassVisitor#visit} the returned object to initialize the desired class, and to avoid + * confusion, this method throws if the class had already been present. + */ + public ClassVisitor add(String internalClassName) { + ClassNode result = new ClassNode(); + checkState( + classes.put(internalClassName, result) == null, "Already present: %s", internalClassName); + return result; + } + + public ImmutableMap<String, ClassNode> drain() { + ImmutableMap<String, ClassNode> result = ImmutableMap.copyOf(classes); + classes.clear(); + return result; + } +} diff --git a/java/com/google/devtools/build/android/desugar/InterfaceDesugaring.java b/java/com/google/devtools/build/android/desugar/InterfaceDesugaring.java new file mode 100644 index 0000000..974c90b --- /dev/null +++ b/java/com/google/devtools/build/android/desugar/InterfaceDesugaring.java @@ -0,0 +1,313 @@ +// Copyright 2017 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package com.google.devtools.build.android.desugar; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkState; + +import javax.annotation.Nullable; +import org.objectweb.asm.AnnotationVisitor; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; +import org.objectweb.asm.TypePath; + +/** + * Visitor that moves methods with bodies from interfaces into a companion class and rewrites + * call sites accordingly (which is only needed for static interface methods). Default methods + * are kept as abstract methods with all their annotations. + * + * <p>Any necessary companion classes will be added to the given {@link GeneratedClassStore}. It's + * the caller's responsibility to write those out. + * + * <p>Relies on {@link DefaultMethodClassFixer} to stub in method bodies for moved default methods. + * Assumes that lambdas are already desugared. Ignores bridge methods, which are handled specially. + */ +class InterfaceDesugaring extends ClassVisitor { + + static final String COMPANION_SUFFIX = "$$CC"; + + private final ClassReaderFactory bootclasspath; + private final GeneratedClassStore store; + + private String internalName; + private int bytecodeVersion; + private int accessFlags; + @Nullable private ClassVisitor companion; + + public InterfaceDesugaring(ClassVisitor dest, ClassReaderFactory bootclasspath, + GeneratedClassStore store) { + super(Opcodes.ASM5, dest); + this.bootclasspath = bootclasspath; + this.store = store; + } + + @Override + public void visit( + int version, + int access, + String name, + String signature, + String superName, + String[] interfaces) { + companion = null; + internalName = name; + bytecodeVersion = version; + accessFlags = access; + super.visit(version, access, name, signature, superName, interfaces); + } + + @Override + public void visitEnd() { + if (companion != null) { + companion.visitEnd(); + } + super.visitEnd(); + } + + @Override + public MethodVisitor visitMethod( + int access, String name, String desc, String signature, String[] exceptions) { + MethodVisitor result; + if (BitFlags.isSet(accessFlags, Opcodes.ACC_INTERFACE) + && BitFlags.noneSet(access, Opcodes.ACC_ABSTRACT | Opcodes.ACC_BRIDGE) + && !"<clinit>".equals(name)) { + checkArgument(BitFlags.noneSet(access, Opcodes.ACC_NATIVE), "Forbidden per JLS ch 9.4"); + + boolean isLambdaBody = + name.startsWith("lambda$") && BitFlags.isSet(access, Opcodes.ACC_SYNTHETIC); + if (isLambdaBody) { + access &= ~Opcodes.ACC_PUBLIC; // undo visibility change from LambdaDesugaring + // Rename lambda method to reflect the new owner. Not doing so confuses LambdaDesugaring + // if it's run over this class again. + name += COMPANION_SUFFIX; + } + if (BitFlags.isSet(access, Opcodes.ACC_STATIC)) { + // Completely move static interface methods, which requires rewriting call sites + result = + companion() + .visitMethod(access & ~Opcodes.ACC_PRIVATE, name, desc, signature, exceptions); + } else { + MethodVisitor abstractDest; + if (isLambdaBody) { + // Completely move lambda bodies, which requires rewriting call sites + access &= ~Opcodes.ACC_PRIVATE; + abstractDest = null; + } else { + // Make default methods abstract but move their implementation into a static method with + // corresponding signature. Doesn't require callsite rewriting but implementing classes + // may need to implement default methods explicitly. + checkArgument(BitFlags.noneSet(access, Opcodes.ACC_PRIVATE), + "Unexpected private interface method %s.%s : %s", name, internalName, desc); + abstractDest = super.visitMethod( + access | Opcodes.ACC_ABSTRACT, name, desc, signature, exceptions); + } + + // TODO(b/37110951): adjust signature with explicit receiver type, which may be generic + MethodVisitor codeDest = + companion() + .visitMethod( + access | Opcodes.ACC_STATIC, + name, + companionDefaultMethodDescriptor(internalName, desc), + (String) null, // drop signature, since given one doesn't include the new param + exceptions); + + result = abstractDest != null ? new MultiplexAnnotations(codeDest, abstractDest) : codeDest; + } + } else { + result = super.visitMethod(access, name, desc, signature, exceptions); + } + return result != null ? new InterfaceInvocationRewriter(result) : null; + } + + /** + * Returns the descriptor of a static method for an instance method with the given receiver and + * description, simply by pre-pending the given descriptor's parameter list with the given + * receiver type. + */ + static String companionDefaultMethodDescriptor(String interfaceName, String desc) { + Type type = Type.getMethodType(desc); + Type[] companionArgs = new Type[type.getArgumentTypes().length + 1]; + companionArgs[0] = Type.getObjectType(interfaceName); + System.arraycopy(type.getArgumentTypes(), 0, companionArgs, 1, type.getArgumentTypes().length); + return Type.getMethodDescriptor(type.getReturnType(), companionArgs); + } + + private ClassVisitor companion() { + if (companion == null) { + checkState(BitFlags.isSet(accessFlags, Opcodes.ACC_INTERFACE)); + String companionName = internalName + COMPANION_SUFFIX; + + companion = store.add(companionName); + companion.visit( + bytecodeVersion, + // Companion class must be public so moved methods can be called from anywhere + (accessFlags | Opcodes.ACC_SYNTHETIC | Opcodes.ACC_PUBLIC) & ~Opcodes.ACC_INTERFACE, + companionName, + (String) null, // signature + "java/lang/Object", + new String[0]); + } + return companion; + } + + /** + * Rewriter for calls to static interface methods and super calls to default methods, unless + * they're part of the bootclasspath, as well as all lambda body methods. Keeps calls to + * interface methods declared in the bootclasspath as-is (but note that these would presumably + * fail on devices without those methods). + */ + private class InterfaceInvocationRewriter extends MethodVisitor { + + public InterfaceInvocationRewriter(MethodVisitor dest) { + super(Opcodes.ASM5, dest); + } + + @Override + public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) { + // Assume that any static interface methods on the classpath are moved + if (itf) { + if (name.startsWith("lambda$")) { + // Redirect lambda invocations to completely remove all lambda methods from interfaces. + checkArgument(!owner.endsWith(COMPANION_SUFFIX), + "%s shouldn't consider %s an interface", internalName, owner); + checkArgument(!bootclasspath.isKnown(owner)); // must be in current input + if (opcode == Opcodes.INVOKEINTERFACE) { + opcode = Opcodes.INVOKESTATIC; + desc = companionDefaultMethodDescriptor(owner, desc); + } else { + checkArgument(opcode == Opcodes.INVOKESTATIC, + "Unexpected opcode %s to invoke %s.%s", opcode, owner, name); + } + // Reflect that InterfaceDesugaring moves and renames the lambda body method + owner += COMPANION_SUFFIX; + name += COMPANION_SUFFIX; + checkState(name.equals(LambdaDesugaring.uniqueInPackage(owner, name)), + "Unexpected lambda body method name %s for %s", name, owner); + itf = false; + } else if ((opcode == Opcodes.INVOKESTATIC || opcode == Opcodes.INVOKESPECIAL) + && !bootclasspath.isKnown(owner)) { + checkArgument(!owner.endsWith(COMPANION_SUFFIX), + "%s shouldn't consider %s an interface", internalName, owner); + if (opcode == Opcodes.INVOKESPECIAL) { + // Turn Interface.super.m() into Interface$$CC.m(receiver) + opcode = Opcodes.INVOKESTATIC; + desc = companionDefaultMethodDescriptor(owner, desc); + } + owner += COMPANION_SUFFIX; + itf = false; + } + } + super.visitMethodInsn(opcode, owner, name, desc, itf); + } + } + + /** + * Method visitor that behaves like a passthrough but additionally duplicates all annotations + * into a second given {@link MethodVisitor}. + */ + private static class MultiplexAnnotations extends MethodVisitor { + + private final MethodVisitor annotationOnlyDest; + + public MultiplexAnnotations(@Nullable MethodVisitor dest, MethodVisitor annotationOnlyDest) { + super(Opcodes.ASM5, dest); + this.annotationOnlyDest = annotationOnlyDest; + } + + @Override + public void visitParameter(String name, int access) { + super.visitParameter(name, access); + annotationOnlyDest.visitParameter(name, access); + } + + @Override + public AnnotationVisitor visitTypeAnnotation( + int typeRef, TypePath typePath, String desc, boolean visible) { + AnnotationVisitor dest = super.visitTypeAnnotation(typeRef, typePath, desc, visible); + AnnotationVisitor annoDest = + annotationOnlyDest.visitTypeAnnotation(typeRef, typePath, desc, visible); + return new MultiplexAnnotationVisitor(dest, annoDest); + } + + @Override + public AnnotationVisitor visitParameterAnnotation(int parameter, String desc, boolean visible) { + AnnotationVisitor dest = super.visitParameterAnnotation(parameter, desc, visible); + AnnotationVisitor annoDest = + annotationOnlyDest.visitParameterAnnotation(parameter, desc, visible); + return new MultiplexAnnotationVisitor(dest, annoDest); + } + } + + /** + * Annotation visitor that recursively passes the visited annotations to any number of given + * {@link AnnotationVisitor}s. + */ + private static class MultiplexAnnotationVisitor extends AnnotationVisitor { + + private final AnnotationVisitor[] moreDestinations; + + public MultiplexAnnotationVisitor(@Nullable AnnotationVisitor dest, + AnnotationVisitor... moreDestinations) { + super(Opcodes.ASM5, dest); + this.moreDestinations = moreDestinations; + } + + @Override + public void visit(String name, Object value) { + super.visit(name, value); + for (AnnotationVisitor dest : moreDestinations) { + dest.visit(name, value); + } + } + + @Override + public void visitEnum(String name, String desc, String value) { + super.visitEnum(name, desc, value); + for (AnnotationVisitor dest : moreDestinations) { + dest.visitEnum(name, desc, value); + } + } + + @Override + public AnnotationVisitor visitAnnotation(String name, String desc) { + AnnotationVisitor[] subVisitors = new AnnotationVisitor[moreDestinations.length]; + AnnotationVisitor dest = super.visitAnnotation(name, desc); + for (int i = 0; i < subVisitors.length; ++i) { + subVisitors[i] = moreDestinations[i].visitAnnotation(name, desc); + } + return new MultiplexAnnotationVisitor(dest, subVisitors); + } + + @Override + public AnnotationVisitor visitArray(String name) { + AnnotationVisitor[] subVisitors = new AnnotationVisitor[moreDestinations.length]; + AnnotationVisitor dest = super.visitArray(name); + for (int i = 0; i < subVisitors.length; ++i) { + subVisitors[i] = moreDestinations[i].visitArray(name); + } + return new MultiplexAnnotationVisitor(dest, subVisitors); + } + + @Override + public void visitEnd() { + super.visitEnd(); + for (AnnotationVisitor dest : moreDestinations) { + dest.visitEnd(); + } + } + } +} diff --git a/java/com/google/devtools/build/android/desugar/Java7Compatibility.java b/java/com/google/devtools/build/android/desugar/Java7Compatibility.java index cc3fe14..55f82b3 100644 --- a/java/com/google/devtools/build/android/desugar/Java7Compatibility.java +++ b/java/com/google/devtools/build/android/desugar/Java7Compatibility.java @@ -27,7 +27,7 @@ import org.objectweb.asm.Opcodes; import org.objectweb.asm.TypePath; /** - * Visitor that ensures bytecode version <= 51 (Java 7) and that throws if it default or static + * Visitor that ensures bytecode version <= 51 (Java 7) and that throws if it sees default or static * interface methods (i.e., non-abstract interface methods), which don't exist in Java 7. */ public class Java7Compatibility extends ClassVisitor { diff --git a/java/com/google/devtools/build/android/desugar/LambdaClassFixer.java b/java/com/google/devtools/build/android/desugar/LambdaClassFixer.java index dea6339..5e50fc8 100644 --- a/java/com/google/devtools/build/android/desugar/LambdaClassFixer.java +++ b/java/com/google/devtools/build/android/desugar/LambdaClassFixer.java @@ -49,6 +49,7 @@ class LambdaClassFixer extends ClassVisitor { private final ClassReaderFactory factory; private final ImmutableSet<String> interfaceLambdaMethods; private final boolean allowDefaultMethods; + private final boolean copyBridgeMethods; private final HashSet<String> implementedMethods = new HashSet<>(); private final LinkedHashSet<String> methodsToMoveIn = new LinkedHashSet<>(); @@ -61,14 +62,17 @@ class LambdaClassFixer extends ClassVisitor { private String desc; private String signature; - public LambdaClassFixer(ClassVisitor dest, LambdaInfo lambdaInfo, ClassReaderFactory factory, - ImmutableSet<String> interfaceLambdaMethods, boolean allowDefaultMethods) { + ImmutableSet<String> interfaceLambdaMethods, boolean allowDefaultMethods, + boolean copyBridgeMethods) { super(Opcodes.ASM5, dest); + checkArgument(!allowDefaultMethods || interfaceLambdaMethods.isEmpty()); + checkArgument(allowDefaultMethods || copyBridgeMethods); this.lambdaInfo = lambdaInfo; this.factory = factory; this.interfaceLambdaMethods = interfaceLambdaMethods; this.allowDefaultMethods = allowDefaultMethods; + this.copyBridgeMethods = copyBridgeMethods; } @Override @@ -181,7 +185,7 @@ class LambdaClassFixer extends ClassVisitor { } copyRewrittenLambdaMethods(); - if (!allowDefaultMethods) { + if (copyBridgeMethods) { copyBridgeMethods(interfaces); } super.visitEnd(); @@ -200,6 +204,7 @@ class LambdaClassFixer extends ClassVisitor { CopyOneMethod copier = new CopyOneMethod(methodName); // TODO(kmb): Set source file attribute for lambda classes so lambda debug info makes sense bytecode.accept(copier, ClassReader.SKIP_DEBUG); + checkState(copier.copied(), "Didn't find %s", rewritten); } } @@ -232,15 +237,16 @@ class LambdaClassFixer extends ClassVisitor { owner = getInternalName(); itf = false; // owner was interface but is now a class methodsToMoveIn.add(method); - } else { - if (originalInternalName.equals(owner)) { - // Reflect renaming of lambda classes - owner = getInternalName(); - } - if (name.startsWith("lambda$")) { - // Reflect renaming of lambda$ instance methods to avoid accidental overrides - name = LambdaDesugaring.uniqueInPackage(owner, name); - } + } else if (originalInternalName.equals(owner)) { + // Reflect renaming of lambda classes + owner = getInternalName(); + } + + if (name.startsWith("lambda$")) { + // Reflect renaming of lambda$ instance methods in LambdaDesugaring. Do this even if we'll + // move the method into the lambda class we're processing so the renaming done in + // LambdaDesugaring doesn't kick in if the class were desugared a second time. + name = LambdaDesugaring.uniqueInPackage(owner, name); } super.visitMethodInsn(opcode, owner, name, desc, itf); } @@ -326,8 +332,9 @@ class LambdaClassFixer extends ClassVisitor { // Only copy bridge methods--hand-written default methods are not supported--and only if // we haven't seen the method already. if (implementedMethods.add(name + ":" + desc)) { - return new AvoidJacocoInit( - LambdaClassFixer.super.visitMethod(access, name, desc, signature, exceptions)); + MethodVisitor result = + LambdaClassFixer.super.visitMethod(access, name, desc, signature, exceptions); + return allowDefaultMethods ? result : new AvoidJacocoInit(result); } } return null; @@ -347,9 +354,14 @@ class LambdaClassFixer extends ClassVisitor { public CopyOneMethod(String methodName) { // No delegate visitor; instead we'll add methods to the outer class's delegate where needed super(Opcodes.ASM5); + checkState(!allowDefaultMethods, "Couldn't copy interface lambda bodies"); this.methodName = methodName; } + public boolean copied() { + return copied > 0; + } + @Override public void visit( int version, @@ -367,6 +379,8 @@ class LambdaClassFixer extends ClassVisitor { if (name.equals(methodName)) { checkState(copied == 0, "Found unexpected second method %s with descriptor %s", name, desc); ++copied; + // Rename for consistency with what we do in LambdaClassMethodRewriter + name = LambdaDesugaring.uniqueInPackage(getInternalName(), name); return new AvoidJacocoInit( LambdaClassFixer.super.visitMethod(access, name, desc, signature, exceptions)); } diff --git a/java/com/google/devtools/build/android/desugar/TryWithResourcesRewriter.java b/java/com/google/devtools/build/android/desugar/TryWithResourcesRewriter.java new file mode 100644 index 0000000..2429d2f --- /dev/null +++ b/java/com/google/devtools/build/android/desugar/TryWithResourcesRewriter.java @@ -0,0 +1,146 @@ +// Copyright 2017 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package com.google.devtools.build.android.desugar; + +import static org.objectweb.asm.Opcodes.ASM5; +import static org.objectweb.asm.Opcodes.INVOKESTATIC; +import static org.objectweb.asm.Opcodes.INVOKEVIRTUAL; + +import com.google.common.base.Function; +import com.google.common.collect.FluentIterable; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableMultimap; +import com.google.common.collect.ImmutableSet; +import java.util.concurrent.atomic.AtomicInteger; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.MethodVisitor; + +/** + * Desugar try-with-resources. This class visitor intercepts calls to the following methods, and + * redirect them to ThrowableExtension. + * <li>{@code Throwable.addSuppressed(Throwable)} + * <li>{@code Throwable.getSuppressed()} + * <li>{@code Throwable.printStackTrace()} + * <li>{@code Throwable.printStackTrace(PrintStream)} + * <li>{@code Throwable.printStackTrace(PringWriter)} + */ +public class TryWithResourcesRewriter extends ClassVisitor { + + private static final String RUNTIME_PACKAGE_INTERNAL_NAME = + "com/google/devtools/build/android/desugar/runtime"; + + static final String THROWABLE_EXTENSION_INTERNAL_NAME = + RUNTIME_PACKAGE_INTERNAL_NAME + '/' + "ThrowableExtension"; + + /** The extension classes for java.lang.Throwable. */ + static final ImmutableSet<String> THROWABLE_EXT_CLASS_INTERNAL_NAMES = + ImmutableSet.of( + THROWABLE_EXTENSION_INTERNAL_NAME, + THROWABLE_EXTENSION_INTERNAL_NAME + "$AbstractDesugaringStrategy", + THROWABLE_EXTENSION_INTERNAL_NAME + "$MimicDesugaringStrategy", + THROWABLE_EXTENSION_INTERNAL_NAME + "$NullDesugaringStrategy", + THROWABLE_EXTENSION_INTERNAL_NAME + "$ReuseDesugaringStrategy"); + + /** The extension classes for java.lang.Throwable. All the names end with ".class" */ + static final ImmutableSet<String> THROWABLE_EXT_CLASS_INTERNAL_NAMES_WITH_CLASS_EXT = + FluentIterable.from(THROWABLE_EXT_CLASS_INTERNAL_NAMES) + .transform( + new Function<String, String>() { + @Override + public String apply(String s) { + return s + ".class"; + } + }) + .toSet(); + + static final ImmutableMultimap<String, String> TARGET_METHODS = + ImmutableMultimap.<String, String>builder() + .put("addSuppressed", "(Ljava/lang/Throwable;)V") + .put("getSuppressed", "()[Ljava/lang/Throwable;") + .put("printStackTrace", "()V") + .put("printStackTrace", "(Ljava/io/PrintStream;)V") + .put("printStackTrace", "(Ljava/io/PrintWriter;)V") + .build(); + + static final ImmutableMap<String, String> METHOD_DESC_MAP = + ImmutableMap.<String, String>builder() + .put("(Ljava/lang/Throwable;)V", "(Ljava/lang/Throwable;Ljava/lang/Throwable;)V") + .put("()[Ljava/lang/Throwable;", "(Ljava/lang/Throwable;)[Ljava/lang/Throwable;") + .put("()V", "(Ljava/lang/Throwable;)V") + .put("(Ljava/io/PrintStream;)V", "(Ljava/lang/Throwable;Ljava/io/PrintStream;)V") + .put("(Ljava/io/PrintWriter;)V", "(Ljava/lang/Throwable;Ljava/io/PrintWriter;)V") + .build(); + + private final ClassLoader classLoader; + + private final AtomicInteger numOfTryWithResourcesInvoked; + + public TryWithResourcesRewriter( + ClassVisitor classVisitor, + ClassLoader classLoader, + AtomicInteger numOfTryWithResourcesInvoked) { + super(ASM5, classVisitor); + this.classLoader = classLoader; + this.numOfTryWithResourcesInvoked = numOfTryWithResourcesInvoked; + } + + @Override + public MethodVisitor visitMethod( + int access, String name, String desc, String signature, String[] exceptions) { + MethodVisitor visitor = super.cv.visitMethod(access, name, desc, signature, exceptions); + return visitor == null || THROWABLE_EXT_CLASS_INTERNAL_NAMES.contains(name) + ? visitor + : new TryWithResourceVisitor(visitor, classLoader); + } + + private class TryWithResourceVisitor extends MethodVisitor { + + private final ClassLoader classLoader; + + public TryWithResourceVisitor(MethodVisitor methodVisitor, ClassLoader classLoader) { + super(ASM5, methodVisitor); + this.classLoader = classLoader; + } + + @Override + public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) { + if (!isMethodCallTargeted(opcode, owner, name, desc)) { + super.visitMethodInsn(opcode, owner, name, desc, itf); + return; + } + numOfTryWithResourcesInvoked.incrementAndGet(); + super.visitMethodInsn( + INVOKESTATIC, THROWABLE_EXTENSION_INTERNAL_NAME, name, METHOD_DESC_MAP.get(desc), false); + } + + private boolean isMethodCallTargeted(int opcode, String owner, String name, String desc) { + if (opcode != INVOKEVIRTUAL) { + return false; + } + if (!TARGET_METHODS.containsEntry(name, desc)) { + return false; + } + if (owner.equals("java/lang/Throwable")) { + return true; // early return, for performance. + } + try { + Class<?> throwableClass = classLoader.loadClass("java.lang.Throwable"); + Class<?> klass = classLoader.loadClass(owner.replace('/', '.')); + return throwableClass.isAssignableFrom(klass); + } catch (ClassNotFoundException e) { + throw new AssertionError(e); + } + } + } +} diff --git a/java/com/google/devtools/build/android/desugar/runtime/ThrowableExtension.java b/java/com/google/devtools/build/android/desugar/runtime/ThrowableExtension.java new file mode 100644 index 0000000..3581fe8 --- /dev/null +++ b/java/com/google/devtools/build/android/desugar/runtime/ThrowableExtension.java @@ -0,0 +1,276 @@ +// Copyright 2017 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package com.google.devtools.build.android.desugar.runtime; + +import java.io.PrintStream; +import java.io.PrintWriter; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.WeakHashMap; + +/** + * This is an extension class for java.lang.Throwable. It emulates the methods + * addSuppressed(Throwable) and getSuppressed(), so the language feature try-with-resources can be + * used on Android devices whose API level is below 19. + * + * <p>Note that the Desugar should avoid desugaring this class. + */ +public class ThrowableExtension { + + static final AbstractDesugaringStrategy STRATEGY; + /** + * This property allows users to change the desugared behavior of try-with-resources at runtime. + * If its value is {@code true}, then {@link MimicDesugaringStrategy} will NOT be used, and {@link + * NullDesugaringStrategy} is used instead. + * + * <p>Note: this property is ONLY used when the API level on the device is below 19. + */ + public static final String SYSTEM_PROPERTY_TWR_DISABLE_MIMIC = + "com.google.devtools.build.android.desugar.runtime.twr_disable_mimic"; + + static { + AbstractDesugaringStrategy strategy; + try { + Integer apiLevel = readApiLevelFromBuildVersion(); + if (apiLevel != null && apiLevel.intValue() >= 19) { + strategy = new ReuseDesugaringStrategy(); + } else if (useMimicStrategy()) { + strategy = new MimicDesugaringStrategy(); + } else { + strategy = new NullDesugaringStrategy(); + } + } catch (Throwable e) { + // This catchall block is intentionally created to avoid anything unexpected, so that + // the desugared app will continue running in case of exceptions. + System.err.println( + "An error has occured when initializing the try-with-resources desuguring strategy. " + + "The default strategy " + + NullDesugaringStrategy.class.getName() + + "will be used. The error is: "); + e.printStackTrace(System.err); + strategy = new NullDesugaringStrategy(); + } + STRATEGY = strategy; + } + + public static AbstractDesugaringStrategy getStrategy() { + return STRATEGY; + } + + public static void addSuppressed(Throwable receiver, Throwable suppressed) { + STRATEGY.addSuppressed(receiver, suppressed); + } + + public static Throwable[] getSuppressed(Throwable receiver) { + return STRATEGY.getSuppressed(receiver); + } + + public static void printStackTrace(Throwable receiver) { + STRATEGY.printStackTrace(receiver); + } + + public static void printStackTrace(Throwable receiver, PrintWriter writer) { + STRATEGY.printStackTrace(receiver, writer); + } + + public static void printStackTrace(Throwable receiver, PrintStream stream) { + STRATEGY.printStackTrace(receiver, stream); + } + + private static boolean useMimicStrategy() { + return !Boolean.getBoolean(SYSTEM_PROPERTY_TWR_DISABLE_MIMIC); + } + + private static final String ANDROID_OS_BUILD_VERSION = "android.os.Build$VERSION"; + + /** + * Get the API level from {@link android.os.Build.VERSION} via reflection. The reason to use + * relection is to avoid dependency on {@link android.os.Build.VERSION}. The advantage of doing + * this is that even when you desugar a jar twice, and Desugars sees this class, there is no need + * to put {@link android.os.Build.VERSION} on the classpath. + * + * <p>Another reason of doing this is that it does not introduce any additional dependency into + * the input jars. + * + * @return The API level of the current device. If it is {@code null}, then it means there was an + * exception. + */ + private static Integer readApiLevelFromBuildVersion() { + try { + Class<?> buildVersionClass = Class.forName(ANDROID_OS_BUILD_VERSION); + Field field = buildVersionClass.getField("SDK_INT"); + return (Integer) field.get(null); + } catch (Exception e) { + System.err.println( + "Failed to retrieve value from " + + ANDROID_OS_BUILD_VERSION + + ".SDK_INT due to the following exception."); + e.printStackTrace(System.err); + return null; + } + } + + /** + * The strategy to desugar try-with-resources statements. A strategy handles the behavior of an + * exception in terms of suppressed exceptions and stack trace printing. + */ + abstract static class AbstractDesugaringStrategy { + + protected static final Throwable[] EMPTY_THROWABLE_ARRAY = new Throwable[0]; + + public abstract void addSuppressed(Throwable receiver, Throwable suppressed); + + public abstract Throwable[] getSuppressed(Throwable receiver); + + public abstract void printStackTrace(Throwable receiver); + + public abstract void printStackTrace(Throwable receiver, PrintStream stream); + + public abstract void printStackTrace(Throwable receiver, PrintWriter writer); + } + + /** This strategy just delegates all the method calls to java.lang.Throwable. */ + static class ReuseDesugaringStrategy extends AbstractDesugaringStrategy { + + @Override + public void addSuppressed(Throwable receiver, Throwable suppressed) { + receiver.addSuppressed(suppressed); + } + + @Override + public Throwable[] getSuppressed(Throwable receiver) { + return receiver.getSuppressed(); + } + + @Override + public void printStackTrace(Throwable receiver) { + receiver.printStackTrace(); + } + + @Override + public void printStackTrace(Throwable receiver, PrintStream stream) { + receiver.printStackTrace(stream); + } + + @Override + public void printStackTrace(Throwable receiver, PrintWriter writer) { + receiver.printStackTrace(writer); + } + } + + /** This strategy mimics the behavior of suppressed exceptions with a map. */ + static class MimicDesugaringStrategy extends AbstractDesugaringStrategy { + + public static final String SUPPRESSED_PREFIX = "Suppressed: "; + private final WeakHashMap<Throwable, List<Throwable>> map = new WeakHashMap<>(); + + /** + * Suppress an exception. If the exception to be suppressed is {@receiver} or {@null}, an + * exception will be thrown. + * + * @param receiver + * @param suppressed + */ + @Override + public void addSuppressed(Throwable receiver, Throwable suppressed) { + if (suppressed == receiver) { + throw new IllegalArgumentException("Self suppression is not allowed.", suppressed); + } + if (suppressed == null) { + throw new NullPointerException("The suppressed exception cannot be null."); + } + synchronized (this) { + List<Throwable> list = map.get(receiver); + if (list == null) { + list = new ArrayList<>(1); + map.put(receiver, list); + } + list.add(suppressed); + } + } + + @Override + public synchronized Throwable[] getSuppressed(Throwable receiver) { + List<Throwable> list = map.get(receiver); + if (list == null || list.isEmpty()) { + return EMPTY_THROWABLE_ARRAY; + } + return list.toArray(new Throwable[0]); + } + + /** + * Print the stack trace for the parameter {@code receiver}. Note that it is deliberate to NOT + * reuse the implementation {@code MimicDesugaringStrategy.printStackTrace(Throwable, + * PrintStream)}, because we are not sure whether the developer prints the stack trace to a + * different stream other than System.err. Therefore, it is a caveat that the stack traces of + * {@code receiver} and its suppressed exceptions are printed in two different streams. + */ + @Override + public synchronized void printStackTrace(Throwable receiver) { + receiver.printStackTrace(); + for (Throwable suppressed : getSuppressed(receiver)) { + System.err.print(SUPPRESSED_PREFIX); + suppressed.printStackTrace(); + } + } + + @Override + public synchronized void printStackTrace(Throwable receiver, PrintStream stream) { + receiver.printStackTrace(stream); + for (Throwable suppressed : getSuppressed(receiver)) { + stream.print(SUPPRESSED_PREFIX); + suppressed.printStackTrace(stream); + } + } + + @Override + public synchronized void printStackTrace(Throwable receiver, PrintWriter writer) { + receiver.printStackTrace(writer); + for (Throwable suppressed : getSuppressed(receiver)) { + writer.print(SUPPRESSED_PREFIX); + suppressed.printStackTrace(writer); + } + } + } + + /** This strategy ignores all suppressed exceptions, which is how retrolambda does. */ + static class NullDesugaringStrategy extends AbstractDesugaringStrategy { + + @Override + public void addSuppressed(Throwable receiver, Throwable suppressed) { + // Do nothing. The suppressed exception is discarded. + } + + @Override + public Throwable[] getSuppressed(Throwable receiver) { + return EMPTY_THROWABLE_ARRAY; + } + + @Override + public void printStackTrace(Throwable receiver) { + receiver.printStackTrace(); + } + + @Override + public void printStackTrace(Throwable receiver, PrintStream stream) { + receiver.printStackTrace(stream); + } + + @Override + public void printStackTrace(Throwable receiver, PrintWriter writer) { + receiver.printStackTrace(writer); + } + } +} diff --git a/java/com/google/devtools/common/options/IsolatedOptionsData.java b/java/com/google/devtools/common/options/IsolatedOptionsData.java index 0dc787c..81820c4 100644 --- a/java/com/google/devtools/common/options/IsolatedOptionsData.java +++ b/java/com/google/devtools/common/options/IsolatedOptionsData.java @@ -17,6 +17,7 @@ package com.google.devtools.common.options; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Ordering; +import com.google.devtools.common.options.OptionsParser.ConstructionException; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.Method; @@ -93,6 +94,16 @@ public class IsolatedOptionsData extends OpaqueOptionsData { */ private final ImmutableMap<Field, Boolean> allowMultiple; + /** + * Mapping from each options class to whether or not it has the {@link UsesOnlyCoreTypes} + * annotation (unordered). + */ + private final ImmutableMap<Class<? extends OptionsBase>, Boolean> usesOnlyCoreTypes; + + /** These categories used to indicate OptionUsageRestrictions, but no longer. */ + private static final ImmutableList<String> DEPRECATED_CATEGORIES = ImmutableList.of( + "undocumented", "hidden", "internal"); + private IsolatedOptionsData( Map<Class<? extends OptionsBase>, Constructor<?>> optionsClasses, @@ -101,7 +112,8 @@ public class IsolatedOptionsData extends OpaqueOptionsData { Map<Class<? extends OptionsBase>, ImmutableList<Field>> allOptionsFields, Map<Field, Object> optionDefaults, Map<Field, Converter<?>> converters, - Map<Field, Boolean> allowMultiple) { + Map<Field, Boolean> allowMultiple, + Map<Class<? extends OptionsBase>, Boolean> usesOnlyCoreTypes) { this.optionsClasses = ImmutableMap.copyOf(optionsClasses); this.nameToField = ImmutableMap.copyOf(nameToField); this.abbrevToField = ImmutableMap.copyOf(abbrevToField); @@ -110,6 +122,7 @@ public class IsolatedOptionsData extends OpaqueOptionsData { this.optionDefaults = Collections.unmodifiableMap(optionDefaults); this.converters = ImmutableMap.copyOf(converters); this.allowMultiple = ImmutableMap.copyOf(allowMultiple); + this.usesOnlyCoreTypes = ImmutableMap.copyOf(usesOnlyCoreTypes); } protected IsolatedOptionsData(IsolatedOptionsData other) { @@ -120,7 +133,8 @@ public class IsolatedOptionsData extends OpaqueOptionsData { other.allOptionsFields, other.optionDefaults, other.converters, - other.allowMultiple); + other.allowMultiple, + other.usesOnlyCoreTypes); } /** @@ -173,6 +187,10 @@ public class IsolatedOptionsData extends OpaqueOptionsData { return allowMultiple.get(field); } + public boolean getUsesOnlyCoreTypes(Class<? extends OptionsBase> optionsClass) { + return usesOnlyCoreTypes.get(optionsClass); + } + /** * For an option that does not use {@link Option#allowMultiple}, returns its type. For an option * that does use it, asserts that the type is a {@code List<T>} and returns its element type @@ -183,11 +201,11 @@ public class IsolatedOptionsData extends OpaqueOptionsData { if (annotation.allowMultiple()) { // If the type isn't a List<T>, this is an error in the option's declaration. if (!(fieldType instanceof ParameterizedType)) { - throw new AssertionError("Type of multiple occurrence option must be a List<...>"); + throw new ConstructionException("Type of multiple occurrence option must be a List<...>"); } ParameterizedType pfieldType = (ParameterizedType) fieldType; if (pfieldType.getRawType() != List.class) { - throw new AssertionError("Type of multiple occurrence option must be a List<...>"); + throw new ConstructionException("Type of multiple occurrence option must be a List<...>"); } fieldType = pfieldType.getActualTypeArguments()[0]; } @@ -234,7 +252,7 @@ public class IsolatedOptionsData extends OpaqueOptionsData { Type type = getFieldSingularType(optionField, annotation); Converter<?> converter = Converters.DEFAULT_CONVERTERS.get(type); if (converter == null) { - throw new AssertionError( + throw new ConstructionException( "No converter found for " + type + "; possible fix: add " @@ -251,7 +269,7 @@ public class IsolatedOptionsData extends OpaqueOptionsData { } catch (Exception e) { // This indicates an error in the Converter, and should be discovered the first time it is // used. - throw new AssertionError(e); + throw new ConstructionException(e); } } @@ -336,9 +354,11 @@ public class IsolatedOptionsData extends OpaqueOptionsData { Map<String, String> booleanAliasMap, String optionName) { // Check that the negating alias does not conflict with existing flags. + checkForCollisions(nameToFieldMap, "no_" + optionName, "boolean option alias"); checkForCollisions(nameToFieldMap, "no" + optionName, "boolean option alias"); // Record that the boolean option takes up additional namespace for its negating alias. + booleanAliasMap.put("no_" + optionName, optionName); booleanAliasMap.put("no" + optionName, optionName); } @@ -361,6 +381,8 @@ public class IsolatedOptionsData extends OpaqueOptionsData { // Maps the negated boolean flag aliases to the original option name. Map<String, String> booleanAliasMap = new HashMap<>(); + Map<Class<? extends OptionsBase>, Boolean> usesOnlyCoreTypesBuilder = new HashMap<>(); + // Read all Option annotations: for (Class<? extends OptionsBase> parsedOptionsClass : classes) { try { @@ -378,7 +400,13 @@ public class IsolatedOptionsData extends OpaqueOptionsData { Option annotation = field.getAnnotation(Option.class); String optionName = annotation.name(); if (optionName == null) { - throw new AssertionError("Option cannot have a null name"); + throw new ConstructionException("Option cannot have a null name"); + } + + if (DEPRECATED_CATEGORIES.contains(annotation.category())) { + throw new ConstructionException( + "Documentation level is no longer read from the option category. Category \"" + + annotation.category() + "\" in option \"" + optionName + "\" is disallowed."); } Type fieldType = getFieldSingularType(field, annotation); @@ -389,14 +417,14 @@ public class IsolatedOptionsData extends OpaqueOptionsData { if (converter == Converter.class) { Converter<?> actualConverter = Converters.DEFAULT_CONVERTERS.get(fieldType); if (actualConverter == null) { - throw new AssertionError("Cannot find converter for field of type " + throw new ConstructionException("Cannot find converter for field of type " + field.getType() + " named " + field.getName() + " in class " + field.getDeclaringClass().getName()); } converter = actualConverter.getClass(); } if (Modifier.isAbstract(converter.getModifiers())) { - throw new AssertionError("The converter type " + converter + throw new ConstructionException("The converter type " + converter + " must be a concrete type"); } Type converterResultType; @@ -404,8 +432,8 @@ public class IsolatedOptionsData extends OpaqueOptionsData { Method convertMethod = converter.getMethod("convert", String.class); converterResultType = GenericTypeHelper.getActualReturnType(converter, convertMethod); } catch (NoSuchMethodException e) { - throw new AssertionError("A known converter object doesn't implement the convert" - + " method"); + throw new ConstructionException( + "A known converter object doesn't implement the convert method"); } if (annotation.allowMultiple()) { @@ -413,22 +441,33 @@ public class IsolatedOptionsData extends OpaqueOptionsData { Type elementType = ((ParameterizedType) converterResultType).getActualTypeArguments()[0]; if (!GenericTypeHelper.isAssignableFrom(fieldType, elementType)) { - throw new AssertionError("If the converter return type of a multiple occurrence " + - "option is a list, then the type of list elements (" + fieldType + ") must be " + - "assignable from the converter list element type (" + elementType + ")"); + throw new ConstructionException( + "If the converter return type of a multiple occurrence option is a list, then " + + "the type of list elements (" + + fieldType + + ") must be assignable from the converter list element type (" + + elementType + + ")"); } } else { if (!GenericTypeHelper.isAssignableFrom(fieldType, converterResultType)) { - throw new AssertionError("Type of list elements (" + fieldType + - ") for multiple occurrence option must be assignable from the converter " + - "return type (" + converterResultType + ")"); + throw new ConstructionException( + "Type of list elements (" + + fieldType + + ") for multiple occurrence option must be assignable from the converter " + + "return type (" + + converterResultType + + ")"); } } } else { if (!GenericTypeHelper.isAssignableFrom(fieldType, converterResultType)) { - throw new AssertionError("Type of field (" + fieldType + - ") must be assignable from the converter " + - "return type (" + converterResultType + ")"); + throw new ConstructionException( + "Type of field (" + + fieldType + + ") must be assignable from the converter return type (" + + converterResultType + + ")"); } } @@ -461,7 +500,24 @@ public class IsolatedOptionsData extends OpaqueOptionsData { convertersBuilder.put(field, findConverter(field)); allowMultipleBuilder.put(field, annotation.allowMultiple()); + + } + + boolean usesOnlyCoreTypes = parsedOptionsClass.isAnnotationPresent(UsesOnlyCoreTypes.class); + if (usesOnlyCoreTypes) { + // Validate that @UsesOnlyCoreTypes was used correctly. + for (Field field : fields) { + // The classes in coreTypes are all final. But even if they weren't, we only want to check + // for exact matches; subclasses would not be considered core types. + if (!UsesOnlyCoreTypes.CORE_TYPES.contains(field.getType())) { + throw new ConstructionException( + "Options class '" + parsedOptionsClass.getName() + "' is marked as " + + "@UsesOnlyCoreTypes, but field '" + field.getName() + + "' has type '" + field.getType().getName() + "'"); + } + } } + usesOnlyCoreTypesBuilder.put(parsedOptionsClass, usesOnlyCoreTypes); } return new IsolatedOptionsData( @@ -471,7 +527,8 @@ public class IsolatedOptionsData extends OpaqueOptionsData { allOptionsFieldsBuilder, optionDefaultsBuilder, convertersBuilder, - allowMultipleBuilder); + allowMultipleBuilder, + usesOnlyCoreTypesBuilder); } } diff --git a/java/com/google/devtools/common/options/Option.java b/java/com/google/devtools/common/options/Option.java index c43966c..e040046 100644 --- a/java/com/google/devtools/common/options/Option.java +++ b/java/com/google/devtools/common/options/Option.java @@ -88,9 +88,6 @@ public @interface Option { */ String category() default "misc"; - // TODO(b/37353610) the old convention was to include documentation level in the category(), - // which is still permitted for backwards compatibility. This field should be used for any new - // options, as the old category use will be removed. /** * Options have multiple uses, some flags, some not. For user-visible flags, they are * "documented," but otherwise, there are 3 types of undocumented options. diff --git a/java/com/google/devtools/common/options/OptionsBase.java b/java/com/google/devtools/common/options/OptionsBase.java index 0ca9e44..315efe8 100644 --- a/java/com/google/devtools/common/options/OptionsBase.java +++ b/java/com/google/devtools/common/options/OptionsBase.java @@ -16,15 +16,15 @@ package com.google.devtools.common.options; import com.google.common.escape.CharEscaperBuilder; import com.google.common.escape.Escaper; - +import java.lang.reflect.Field; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; /** - * Base class for all options classes. Extend this class, adding public - * instance fields annotated with @Option. Then you can create instances - * either programmatically: + * Base class for all options classes. Extend this class, adding public instance fields annotated + * with {@link Option}. Then you can create instances either programmatically: * * <pre> * X x = Options.getDefaults(X.class); @@ -40,11 +40,10 @@ import java.util.Map.Entry; * X x = parser.getOptions(X.class); * </pre> * - * <p>Subclasses of OptionsBase <b>must</b> be constructed reflectively, - * i.e. using not {@code new MyOptions}, but one of the two methods above - * instead. (Direct construction creates an empty instance, not containing - * default values. This leads to surprising behavior and often - * NullPointerExceptions, etc.) + * <p>Subclasses of {@code OptionsBase} <i>must</i> be constructed reflectively, i.e. using not + * {@code new MyOptions()}, but one of the above methods instead. (Direct construction creates an + * empty instance, not containing default values. This leads to surprising behavior and often {@code + * NullPointerExceptions}, etc.) */ public abstract class OptionsBase { @@ -61,13 +60,25 @@ public abstract class OptionsBase { } /** - * Returns this options object in the form of a (new) mapping from option - * names, including inherited ones, to option values. If the public fields - * are mutated, this will be reflected in subsequent calls to {@code asMap}. - * Mutation of this map by the caller does not affect this options object. + * Returns a mapping from option names to values, for each option on this object, including + * inherited ones. The mapping is a copy, so subsequent mutations to it or to this object are + * independent. Entries are sorted alphabetically. */ - public final Map<String, Object> asMap() { - return OptionsParserImpl.optionsAsMap(this); + public final <O extends OptionsBase> Map<String, Object> asMap() { + // Generic O is needed to tell the type system that the toMap() call is safe. + // The casts are safe because "this" is an instance of "getClass()" + // which subclasses OptionsBase. + @SuppressWarnings("unchecked") + O castThis = (O) this; + @SuppressWarnings("unchecked") + Class<O> castClass = (Class<O>) getClass(); + + Map<String, Object> map = new LinkedHashMap<>(); + for (Map.Entry<Field, Object> entry : OptionsParser.toMap(castClass, castThis).entrySet()) { + String name = entry.getKey().getAnnotation(Option.class).name(); + map.put(name, entry.getValue()); + } + return map; } @Override diff --git a/java/com/google/devtools/common/options/OptionsParser.java b/java/com/google/devtools/common/options/OptionsParser.java index 9574a90..c6bd002 100644 --- a/java/com/google/devtools/common/options/OptionsParser.java +++ b/java/com/google/devtools/common/options/OptionsParser.java @@ -21,13 +21,17 @@ import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; import com.google.common.collect.ListMultimap; import com.google.common.escape.Escaper; +import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.nio.file.FileSystem; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import javax.annotation.Nullable; @@ -444,8 +448,7 @@ public class OptionsParser implements OptionsProvider { } private OptionUsageRestrictions optionUsageRestrictions() { - Option option = field.getAnnotation(Option.class); - return OptionsParser.documentationLevel(option); + return field.getAnnotation(Option.class).optionUsageRestrictions(); } public boolean isDocumented() { @@ -556,12 +559,12 @@ public class OptionsParser implements OptionsProvider { if (description == null) { description = "Options category '" + category + "'"; } - if (documentationLevel(option) == OptionUsageRestrictions.DOCUMENTED) { + if (option.optionUsageRestrictions() == OptionUsageRestrictions.DOCUMENTED) { desc.append("\n").append(description).append(":\n"); } } - if (documentationLevel(option) == OptionUsageRestrictions.DOCUMENTED) { + if (option.optionUsageRestrictions() == OptionUsageRestrictions.DOCUMENTED) { OptionsUsage.getUsage(optionField, desc, helpVerbosity, impl.getOptionsData()); } } @@ -594,8 +597,8 @@ public class OptionsParser implements OptionsProvider { for (Field optionField : allFields) { Option option = optionField.getAnnotation(Option.class); String category = option.category(); - OptionUsageRestrictions level = documentationLevel(option); - if (!category.equals(prevCategory) && level == OptionUsageRestrictions.DOCUMENTED) { + if (!category.equals(prevCategory) + && option.optionUsageRestrictions() == OptionUsageRestrictions.DOCUMENTED) { String description = categoryDescriptions.get(category); if (description == null) { description = "Options category '" + category + "'"; @@ -608,7 +611,7 @@ public class OptionsParser implements OptionsProvider { prevCategory = category; } - if (level == OptionUsageRestrictions.DOCUMENTED) { + if (option.optionUsageRestrictions() == OptionUsageRestrictions.DOCUMENTED) { OptionsUsage.getUsageHtml(optionField, desc, escaper, impl.getOptionsData()); } } @@ -642,7 +645,7 @@ public class OptionsParser implements OptionsProvider { }); for (Field optionField : allFields) { Option option = optionField.getAnnotation(Option.class); - if (documentationLevel(option) == OptionUsageRestrictions.DOCUMENTED) { + if (option.optionUsageRestrictions() == OptionUsageRestrictions.DOCUMENTED) { OptionsUsage.getCompletion(optionField, desc); } } @@ -674,30 +677,6 @@ public class OptionsParser implements OptionsProvider { return impl.getOptionValueDescription(name); } - @Deprecated - // TODO(b/37353610) the old convention was to include documentation level in the category(), - // which is still permitted for backwards compatibility. The enum field should be used for any new - // options, as the old category, and this function, will be removed. - public static OptionUsageRestrictions documentationLevel(Option option) { - // Until all options use the new documentationLabel attribute of an option, only rely on it if - // it is not set to the default value. - if (option.optionUsageRestrictions() != OptionUsageRestrictions.DOCUMENTED) { - return option.optionUsageRestrictions(); - } - - // Otherwise, continue reading from the category. - String category = option.category(); - if ("undocumented".equals(category)) { - return OptionUsageRestrictions.UNDOCUMENTED; - } else if ("hidden".equals(category)) { - return OptionUsageRestrictions.HIDDEN; - } else if ("internal".equals(category)) { - return OptionUsageRestrictions.INTERNAL; - } else { - return OptionUsageRestrictions.DOCUMENTED; - } - } - /** * A convenience method, equivalent to * {@code parse(OptionPriority.COMMAND_LINE, null, Arrays.asList(args))}. @@ -806,4 +785,124 @@ public class OptionsParser implements OptionsProvider { public List<String> canonicalize() { return impl.asCanonicalizedList(); } + + /** Returns all options fields of the given options class, in alphabetic order. */ + public static Collection<Field> getFields(Class<? extends OptionsBase> optionsClass) { + OptionsData data = OptionsParser.getOptionsDataInternal(optionsClass); + return data.getFieldsForClass(optionsClass); + } + + /** + * Returns whether the given options class uses only the core types listed in {@link + * OptionsBase#coreTypes}. These are guaranteed to be deeply immutable and serializable. + */ + public static boolean getUsesOnlyCoreTypes(Class<? extends OptionsBase> optionsClass) { + OptionsData data = OptionsParser.getOptionsDataInternal(optionsClass); + return data.getUsesOnlyCoreTypes(optionsClass); + } + + /** + * Returns a mapping from each option {@link Field} in {@code optionsClass} (including inherited + * ones) to its value in {@code options}. + * + * <p>The map is a mutable copy; changing the map won't affect {@code options} and vice versa. + * The map entries appear sorted alphabetically by option name. + * + * If {@code options} is an instance of a subclass of {@code optionsClass}, any options defined + * by the subclass are not included in the map. + * + * @throws IllegalArgumentException if {@code options} is not an instance of {@code optionsClass} + */ + public static <O extends OptionsBase> Map<Field, Object> toMap(Class<O> optionsClass, O options) { + OptionsData data = getOptionsDataInternal(optionsClass); + // Alphabetized due to getFieldsForClass()'s order. + Map<Field, Object> map = new LinkedHashMap<>(); + for (Field field : data.getFieldsForClass(optionsClass)) { + try { + map.put(field, field.get(options)); + } catch (IllegalAccessException e) { + // All options fields of options classes should be public. + throw new IllegalStateException(e); + } catch (IllegalArgumentException e) { + // This would indicate an inconsistency in the cached OptionsData. + throw new IllegalStateException(e); + } + } + return map; + } + + /** + * Given a mapping as returned by {@link #toMap}, and the options class it that its entries + * correspond to, this constructs the corresponding instance of the options class. + * + * @throws IllegalArgumentException if {@code map} does not contain exactly the fields of {@code + * optionsClass}, with values of the appropriate type + */ + public static <O extends OptionsBase> O fromMap(Class<O> optionsClass, Map<Field, Object> map) { + // Instantiate the options class. + OptionsData data = getOptionsDataInternal(optionsClass); + O optionsInstance; + try { + Constructor<O> constructor = data.getConstructor(optionsClass); + Preconditions.checkNotNull(constructor, "No options class constructor available"); + optionsInstance = constructor.newInstance(); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Error while instantiating options class", e); + } + + List<Field> fields = data.getFieldsForClass(optionsClass); + // Ensure all fields are covered, no extraneous fields. + validateFieldsSets(new LinkedHashSet<>(fields), new LinkedHashSet<>(map.keySet())); + // Populate the instance. + for (Field field : fields) { + // Non-null as per above check. + Object value = map.get(field); + try { + field.set(optionsInstance, value); + } catch (IllegalAccessException e) { + throw new IllegalStateException(e); + } + // May also throw IllegalArgumentException if map value is ill typed. + } + return optionsInstance; + } + + /** + * Raises a pretty {@link IllegalArgumentException} if the two sets of fields are not equal. + * + * <p>The entries in {@code fieldsFromMap} may be ill formed by being null or lacking an {@link + * Option} annotation. (This isn't done for {@code fieldsFromClass} because they come from an + * {@link OptionsData} object.) + */ + private static void validateFieldsSets( + LinkedHashSet<Field> fieldsFromClass, LinkedHashSet<Field> fieldsFromMap) { + if (!fieldsFromClass.equals(fieldsFromMap)) { + List<String> extraNamesFromClass = new ArrayList<>(); + List<String> extraNamesFromMap = new ArrayList<>(); + for (Field field : fieldsFromClass) { + if (!fieldsFromMap.contains(field)) { + extraNamesFromClass.add("'" + field.getAnnotation(Option.class).name() + "'"); + } + } + for (Field field : fieldsFromMap) { + // Extra validation on the map keys since they don't come from OptionsData. + if (!fieldsFromClass.contains(field)) { + if (field == null) { + extraNamesFromMap.add("<null field>"); + } else { + Option annotation = field.getAnnotation(Option.class); + if (annotation == null) { + extraNamesFromMap.add("<non-Option field>"); + } else { + extraNamesFromMap.add("'" + annotation.name() + "'"); + } + } + } + } + throw new IllegalArgumentException( + "Map keys do not match fields of options class; extra map keys: {" + + Joiner.on(", ").join(extraNamesFromMap) + "}; extra options class options: {" + + Joiner.on(", ").join(extraNamesFromClass) + "}"); + } + } } diff --git a/java/com/google/devtools/common/options/OptionsParserImpl.java b/java/com/google/devtools/common/options/OptionsParserImpl.java index 5c635fb..a4d905a 100644 --- a/java/com/google/devtools/common/options/OptionsParserImpl.java +++ b/java/com/google/devtools/common/options/OptionsParserImpl.java @@ -85,7 +85,7 @@ class OptionsParserImpl { private final List<String> warnings = new ArrayList<>(); private boolean allowSingleDashLongOptions = false; - + private ArgsPreProcessor argsPreProcessor = new ArgsPreProcessor() { @Override @@ -93,7 +93,7 @@ class OptionsParserImpl { return args; } }; - + /** * Create a new parser object */ @@ -112,32 +112,13 @@ class OptionsParserImpl { void setAllowSingleDashLongOptions(boolean allowSingleDashLongOptions) { this.allowSingleDashLongOptions = allowSingleDashLongOptions; } - + /** Sets the ArgsPreProcessor for manipulations of the options before parsing. */ void setArgsPreProcessor(ArgsPreProcessor preProcessor) { this.argsPreProcessor = Preconditions.checkNotNull(preProcessor); } /** - * The implementation of {@link OptionsBase#asMap}. - */ - static Map<String, Object> optionsAsMap(OptionsBase optionsInstance) { - Class<? extends OptionsBase> optionsClass = optionsInstance.getClass(); - OptionsData data = OptionsParser.getOptionsDataInternal(optionsClass); - Map<String, Object> map = new HashMap<>(); - for (Field field : data.getFieldsForClass(optionsClass)) { - try { - String name = field.getAnnotation(Option.class).name(); - Object value = field.get(optionsInstance); - map.put(name, value); - } catch (IllegalAccessException e) { - throw new IllegalStateException(e); // unreachable - } - } - return map; - } - - /** * Implements {@link OptionsParser#asListOfUnparsedOptions()}. */ List<UnparsedOptionValueDescription> asListOfUnparsedOptions() { @@ -639,18 +620,14 @@ class OptionsParserImpl { // Look for a "no"-prefixed option name: "no<optionName>". if (field == null && name.startsWith("no")) { // Give a nice error if someone is using the deprecated --no_ prefix. - // Note: With this check in place, is impossible to specify "--no_foo" for a flag named - // "--_foo", if a --foo flag also exists, since that'll be interpreted as the "no_" - // negating prefix for "--foo". Let that be a warning to anyone wanting to make flags that - // start with underscores. // TODO(Bazel-team): Remove the --no_ check when sufficient time has passed for users of // that feature to have stopped using it. - if (name.startsWith("no_") && optionsData.getFieldFromName(name.substring(3)) != null) { - throw new OptionsParsingException( - "'no_' prefixes are no longer accepted, --no<flag> is an accepted alternative.", - name.substring(3)); - } name = name.substring(2); + if (name.startsWith("_") && optionsData.getFieldFromName(name.substring(1)) != null) { + name = name.substring(1); + warnings.add("Option '" + name + "' is specified using the deprecated --no_ prefix. " + + "Use --no without the underscore instead."); + } field = optionsData.getFieldFromName(name); booleanValue = false; if (field != null) { @@ -674,7 +651,7 @@ class OptionsParserImpl { Option option = field == null ? null : field.getAnnotation(Option.class); if (option == null - || OptionsParser.documentationLevel(option) == OptionUsageRestrictions.INTERNAL) { + || option.optionUsageRestrictions() == OptionUsageRestrictions.INTERNAL) { // This also covers internal options, which are treated as if they did not exist. throw new OptionsParsingException("Unrecognized option: " + arg, arg); } @@ -707,8 +684,8 @@ class OptionsParserImpl { return null; } optionsInstance = constructor.newInstance(); - } catch (Exception e) { - throw new IllegalStateException(e); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Error while instantiating options class", e); } // Set the fields diff --git a/java/com/google/devtools/common/options/UsesOnlyCoreTypes.java b/java/com/google/devtools/common/options/UsesOnlyCoreTypes.java new file mode 100644 index 0000000..6f2714f --- /dev/null +++ b/java/com/google/devtools/common/options/UsesOnlyCoreTypes.java @@ -0,0 +1,57 @@ +// Copyright 2017 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.devtools.common.options; + +import com.google.common.collect.ImmutableList; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.List; + +/** + * Applied to an {@link OptionsBase} subclass to indicate that all of its options fields have types + * chosen from {@link #coreTypes}. Any subclasses of the class to which it's applied must also + * satisfy the same property. + * + * <p>Options classes with this annotation are serializable and deeply immutable, except that the + * fields of the options class can be reassigned (although this is bad practice). + * + * <p>Note that {@link Option#allowMultiple} is not allowed for options in classes with this + * annotation, since their type is {@link List}. + */ +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +public @interface UsesOnlyCoreTypes { + + /** + * These are the core options field types. They all have default converters, are deeply immutable, + * and are serializable. + * + * Lists are not considered core types, so {@link Option#allowMultiple} options are not permitted. + */ + public static final ImmutableList<Class<?>> CORE_TYPES = ImmutableList.of( + // 1:1 correspondence with Converters.DEFAULT_CONVERTERS. + String.class, + int.class, + long.class, + double.class, + boolean.class, + TriState.class, + Void.class + ); +} |