/** * Copyright 2010 Wealthfront Inc. 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.kaching.platform.testing; import static com.google.common.collect.Iterables.concat; import static com.google.common.collect.Lists.transform; import static com.google.common.collect.Sets.newHashSet; import static com.google.common.io.Closeables.closeQuietly; import static com.kaching.platform.testing.VisibilityTestRunner.Intent.PRIVATE; import static java.lang.String.format; import static java.lang.annotation.ElementType.TYPE; import static java.lang.annotation.RetentionPolicy.RUNTIME; import static org.objectweb.asm.ClassReader.SKIP_DEBUG; import static org.objectweb.asm.ClassReader.SKIP_FRAMES; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.lang.annotation.Annotation; import java.lang.annotation.Retention; import java.lang.annotation.Target; import java.util.List; import java.util.Set; import org.objectweb.asm.AnnotationVisitor; import org.objectweb.asm.ClassReader; import org.objectweb.asm.FieldVisitor; import org.objectweb.asm.MethodVisitor; import org.objectweb.asm.commons.EmptyVisitor; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Function; import com.google.common.collect.ImmutableList; import com.kaching.platform.testing.ParsedElements.ParsedClass; import com.kaching.platform.testing.ParsedElements.ParsedConstructor; import com.kaching.platform.testing.ParsedElements.ParsedField; import com.kaching.platform.testing.ParsedElements.ParsedMethod; /** * Example: * <pre> * {@literal @}Visibilities( * {@literal @}Check(paths = "bin", visibilities = { * {@literal @}Visibility(value = Inject.class, intent = PRIVATE), * {@literal @}Visibility(value = VisibleForTesting.class, intent = PRIVATE) * })) * {@literal @}RunWith(VisibilityTestRunner.class) * public class VisibilityTest { * } * </pre> */ public class VisibilityTestRunner extends AbstractDeclarativeTestRunner<VisibilityTestRunner.Visibilities> { /** * Top level annotation describing a visibility test. */ @Target(TYPE) @Retention(RUNTIME) public @interface Visibilities { /** * Lists all the checks performed by this visibility test. */ public Check[] value(); } @Retention(RUNTIME) @Target({}) public @interface Check { public String[] paths(); public Visibility[] visibilities(); } @Retention(RUNTIME) @Target({}) public @interface Visibility { /** * An annotation used to describe a visibility, such as {@code @VisibleForTesting}. */ public Class<? extends Annotation> value(); /** * The intent of the annotation. Currently, {@code PRIVATE} is the only supported intent. */ public Intent intent() default PRIVATE; public String[] exceptions() default {}; } public enum Intent { PRIVATE, DEFAULT, PROTECTED } /** * Internal use only. */ public VisibilityTestRunner(Class<?> clazz) { super(clazz, Visibilities.class); } @Override protected void runTest(Visibilities annotation) throws IOException { CombinedAssertionFailedError error = new CombinedAssertionFailedError("visibility violations"); for (Check check : annotation.value()) { List<String> paths = ImmutableList.<String> copyOf(check.paths()); for (Visibility visibility : check.visibilities()) { checkVisibility(paths, visibility, error); } } error.throwIfHasErrors(); } private void checkVisibility( List<String> paths, Visibility visibility, CombinedAssertionFailedError error) throws IOException { new Tester(visibility, error).analyze( concat( transform( transform( paths, new Function<String, ClassTree>() { @Override public ClassTree apply(String path) { return new ClassTree(new File(path)); } }), new Function<ClassTree, List<File>>() { @Override public List<File> apply(ClassTree from) { return from.getClassFiles(); } }))); } /** * Is {@code element} visible by {@code currentClass}? */ @VisibleForTesting boolean isVisible(ParsedElement element, final ParsedClass currentClass, Intent intent) { switch (intent) { case PRIVATE: return element.visit(new DefaultParsedElementVisitor<Boolean>(true) { @Override public Boolean caseMethod(ParsedMethod element) { String name = element.getOwner().getOwner(); String className = currentClass.getOwner(); return name.equals(className); } @Override public Boolean caseField(ParsedField element) { String name = element.getOwner().getOwner(); String className = currentClass.getOwner(); return name.equals(className); } @Override public Boolean caseClass(ParsedClass element) { String name = element.getOwner(); String className = currentClass.getOwner(); return name.equals(className); } }); default: throw new UnsupportedOperationException("PRIVATE is the only supported intent"); } } private class Tester { private final Class<? extends Annotation> annotationClass; private final Intent intent; private final Set<String> exceptions; private final CombinedAssertionFailedError error; private final Set<String> spuriousExceptions; private final String annotationDescription; private final Set<ParsedElement> annotatedElements; Tester(Visibility visibility, CombinedAssertionFailedError error) { this.annotationClass = visibility.value(); this.intent = visibility.intent(); this.exceptions = newHashSet(visibility.exceptions()); this.error = error; this.spuriousExceptions = newHashSet(exceptions); this.annotatedElements = newHashSet(); this.annotationDescription = format("L%s;", annotationClass.getName().replace(".", "/")); } void analyze(Iterable<File> files) throws IOException { // find all the annotated elements for (File file : files) { FileInputStream in = null; try { in = new FileInputStream(file); new ClassReader(in).accept(new FindAnnotatedElements(), SKIP_FRAMES | SKIP_DEBUG); } finally { closeQuietly(in); } } // find all the references of the annotated elements and record violations for (File file : files) { FileInputStream in = null; try { in = new FileInputStream(file); new ClassReader(in).accept(new FindIllegalCalls(), SKIP_FRAMES | SKIP_DEBUG); } finally { closeQuietly(in); } } for (String spuriousException : spuriousExceptions) { error.addError(format( "%s marked as an exception for @%s but didn't occur", spuriousException, annotationClass.getSimpleName())); } } private class FindAnnotatedElements extends EmptyVisitor { private ParsedClass currentClass; private ParsedMethod currentMethod; private ParsedField currentField; private ParsedConstructor currentConstructor; @Override public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { currentClass = new ParsedClass(name); } @Override public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { currentConstructor = null; currentMethod = null; currentField = null; if (name.equals("<clinit>")) { // interface initialization method } else if (name.equals("<init>")) { currentConstructor = new ParsedConstructor(); } else { currentMethod = new ParsedMethod(currentClass, name, descriptor); } return this; } @Override public FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) { currentField = new ParsedField(currentClass, name); currentMethod = null; currentConstructor = null; return this; } @Override public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) { if (annotationDescription.equals(descriptor)) { if (currentField != null) { // annotated field annotatedElements.add(currentField); } else if (currentMethod != null) { // annotated method annotatedElements.add(currentMethod); } else if (currentConstructor != null) { // TODO annotated constructors are not supported } else if (currentClass != null) { // annotated class annotatedElements.add(currentClass); } } return this; } } private class FindIllegalCalls extends EmptyVisitor { private String currentMethodName; private ParsedClass currentClass; @Override public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { currentClass = new ParsedClass(name); } @Override public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { currentMethodName = name; return this; } @Override public void visitMethodInsn(int opcode, String owner, String name, String descriptor) { check(new ParsedClass(owner)); if (name.equals("<clinit>")) { // interface initialization method } else if (name.equals("<init>")) { // TODO handle constructors } else { check(new ParsedMethod(new ParsedClass(owner), name, descriptor)); } } @Override public void visitFieldInsn(int opcode, String owner, String name, String descriptor) { check(new ParsedClass(owner)); check(new ParsedField(new ParsedClass(owner), name)); } private void check(ParsedElement element) { if (annotatedElements.contains(element)) { if (!isVisible(element, currentClass, intent)) { if (exceptions.contains(currentClass.toString())) { spuriousExceptions.remove(currentClass.toString()); } else { error.addError(format("%s.%s uses %s", currentClass, currentMethodName, element)); } } } } } } }