Skip to content

Commit

Permalink
Merge pull request #2885 from robolectric/check-api-changes-plugin
Browse files Browse the repository at this point in the history
Check api changes plugin
  • Loading branch information
xian committed Feb 8, 2017
2 parents a15742a + 28cc3ed commit 83d4242
Show file tree
Hide file tree
Showing 10 changed files with 378 additions and 10 deletions.
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ allprojects {
}

group = "org.robolectric"
version = "3.3-SNAPSHOT"
version = thisVersion
}

buildscript {
Expand Down
2 changes: 2 additions & 0 deletions buildSrc/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ repositories {
dependencies {
compile gradleApi()
compile localGroovy()

compile "org.ow2.asm:asm-tree:5.0.1"
}
337 changes: 337 additions & 0 deletions buildSrc/src/main/groovy/CheckApiChangesPlugin.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,337 @@
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.objectweb.asm.ClassReader
import org.objectweb.asm.tree.AnnotationNode
import org.objectweb.asm.tree.ClassNode
import org.objectweb.asm.tree.MethodNode

import java.util.jar.JarEntry
import java.util.jar.JarInputStream
import java.util.regex.Pattern

import static org.objectweb.asm.Opcodes.ACC_PROTECTED
import static org.objectweb.asm.Opcodes.ACC_PUBLIC
import static org.objectweb.asm.Opcodes.ACC_SYNTHETIC

class CheckApiChangesPlugin implements Plugin<Project> {
@Override
void apply(Project project) {
project.extensions.create("checkApiChanges", CheckApiChangesExtension)

project.configurations {
checkApiChangesFrom
checkApiChangesTo
}

project.afterEvaluate {
project.checkApiChanges.from.each {
project.dependencies.checkApiChangesFrom(it) {
transitive = false
force = true
}
}

project.checkApiChanges.to.findAll { it instanceof String }.each {
project.dependencies.checkApiChangesTo(it) {
transitive = false
force = true
}
}
}

project.task('checkForApiChanges', dependsOn: 'jar') {
doLast {
Map<ClassMethod, Change> changedClassMethods = new TreeMap<>()

def fromUrls = project.configurations.checkApiChangesFrom*.toURI()*.toURL()
println "fromUrls = ${fromUrls*.toString()*.replaceAll("^.*/", "")}"

def jarUrls = project.checkApiChanges.to
.findAll { it instanceof Project }
.collect { it.jar.archivePath.toURL() }
def toUrls = jarUrls + project.configurations.checkApiChangesTo*.toURI()*.toURL()
println "toUrls = ${toUrls*.toString()*.replaceAll("^.*/", "")}"

Analysis prev = new Analysis(fromUrls)
Analysis cur = new Analysis(toUrls)

Set<String> allMethods = new TreeSet<>(prev.classMethods.keySet())
allMethods.addAll(cur.classMethods.keySet())

for (String classMethodName : allMethods) {
ClassMethod prevClassMethod = prev.classMethods.get(classMethodName)
ClassMethod curClassMethod = cur.classMethods.get(classMethodName)

if (prevClassMethod == null) {
// added
if (curClassMethod.visible) {
changedClassMethods.put(curClassMethod, Change.ADDED)
}
} else if (curClassMethod == null) {
def theClass = prevClassMethod.classNode.name.replace('/', '.')
def methodDesc = prevClassMethod.methodDesc
while (curClassMethod == null && cur.parents[theClass] != null) {
theClass = cur.parents[theClass]
def parentMethodName = "${theClass}#${methodDesc}"
curClassMethod = cur.classMethods[parentMethodName]
}

// removed
if (curClassMethod == null && prevClassMethod.visible && !prevClassMethod.deprecated) {
if (classMethodName.contains("getActivityTitle")) {
println "hi!"
}
changedClassMethods.put(prevClassMethod, Change.REMOVED)
}
} else {
// println "changed: $classMethodName"
}
}

String prevClassName = null
def introClass = { classMethod ->
if (classMethod.className != prevClassName) {
prevClassName = classMethod.className
println "\n$prevClassName:"
}
}

def entryPoints = project.checkApiChanges.entryPoints
Closure matchesEntryPoint = { ClassMethod classMethod ->
for (String entryPoint : entryPoints) {
if (classMethod.className.matches(entryPoint)) {
return true
}
}
return false
}

def expectedREs = project.checkApiChanges.expectedChanges.collect { Pattern.compile(it) }

for (Map.Entry<ClassMethod, Change> change : changedClassMethods.entrySet()) {
def classMethod = change.key
def changeType = change.value

def showAllChanges = true // todo: only show stuff that's interesting...
if (matchesEntryPoint(classMethod) || showAllChanges) {
String classMethodDesc = classMethod.desc
def expected = expectedREs.any { it.matcher(classMethodDesc).find() }
if (!expected) {
introClass(classMethod)

switch (changeType) {
case Change.ADDED:
println "+ ${classMethod.methodDesc}"
break
case Change.REMOVED:
println "- ${classMethod.methodDesc}"
break
}
}
}


}
}
}
}

static class Analysis {
final Map<String, String> parents = new HashMap<>()
final Map<String, ClassMethod> classMethods = new HashMap<>()

Analysis(List<URL> baseUrls) {
for (URL url : baseUrls) {
if (url.protocol == 'file') {
def file = new File(url.path)
def stream = new FileInputStream(file)
def jarStream = new JarInputStream(stream)
while (true) {
JarEntry entry = jarStream.nextJarEntry
if (entry == null) break

if (!entry.directory && entry.name.endsWith(".class")) {
def reader = new ClassReader(jarStream)
def classNode = new ClassNode()
reader.accept(classNode, ClassReader.SKIP_CODE | ClassReader.SKIP_FRAMES)

def superName = classNode.superName.replace('/', '.')
if (!"java.lang.Object".equals(superName)) {
parents[classNode.name.replace('/', '.')] = superName
}

if (bitSet(classNode.access, ACC_PUBLIC) || bitSet(classNode.access, ACC_PROTECTED)) {
for (MethodNode method : classNode.methods) {
def classMethod = new ClassMethod(classNode, method, url)
if (!bitSet(method.access, ACC_SYNTHETIC)) {
classMethods.put(classMethod.desc, classMethod)
}
}
}
}
}
stream.close()
}
}
classMethods
}

}

static enum Change {
REMOVED,
ADDED,
}

static class ClassMethod implements Comparable<ClassMethod> {
final ClassNode classNode
final MethodNode methodNode
final URL originUrl

ClassMethod(ClassNode classNode, MethodNode methodNode, URL originUrl) {
this.classNode = classNode
this.methodNode = methodNode
this.originUrl = originUrl
}

boolean equals(o) {
if (this.is(o)) return true
if (getClass() != o.class) return false

ClassMethod that = (ClassMethod) o

if (classNode.name != that.classNode.name) return false
if (methodNode.name != that.methodNode.name) return false
if (methodNode.signature != that.methodNode.signature) return false

return true
}

int hashCode() {
int result
result = (classNode.name != null ? classNode.name.hashCode() : 0)
result = 31 * result + (methodNode.name != null ? methodNode.name.hashCode() : 0)
result = 31 * result + (methodNode.signature != null ? methodNode.signature.hashCode() : 0)
return result
}

public String getDesc() {
return "$className#$methodDesc"
}

boolean hasParent() {
parentClassName() != "java/lang/Object"
}

String parentClassName() {
classNode.superName
}

private String getMethodDesc() {
def args = new StringBuilder()
def returnType = new StringBuilder()
def buf = args

int arrayDepth = 0
def write = { typeName ->
if (buf.size() > 0) buf.append(", ")
buf.append(typeName)
for (; arrayDepth > 0; arrayDepth--) {
buf.append("[]")
}
}

def chars = methodNode.desc.toCharArray()
def i = 0

def readObj = {
if (buf.size() > 0) buf.append(", ")
for (; i < chars.length; i++) {
char c = chars[i]
if (c == ';' as char) break
buf.append((c == '/' as char) ? '.' : c)
}
}

for (; i < chars.length;) {
def c = chars[i++]
switch (c) {
case '(': break;
case ')': buf = returnType; break;
case '[': arrayDepth++; break;
case 'Z': write('boolean'); break;
case 'B': write('byte'); break;
case 'S': write('short'); break;
case 'I': write('int'); break;
case 'J': write('long'); break;
case 'F': write('float'); break;
case 'D': write('double'); break;
case 'C': write('char'); break;
case 'L': readObj(); break;
case 'V': write('void'); break;
}
}
"$methodNode.name(${args.toString()}): ${returnType.toString()}"
}

@Override
public String toString() {
internalName
}

private String getInternalName() {
classNode.name + "#$methodInternalName"
}

private String getMethodInternalName() {
"$methodNode.name$methodNode.desc"
}

private String getSignature() {
methodNode.signature == null ? "()V" : methodNode.signature
}

private String getClassName() {
classNode.name.replace('/', '.')
}

boolean isDeprecated() {
containsAnnotation(classNode.visibleAnnotations, "Ljava/lang/Deprecated;") ||
containsAnnotation(methodNode.visibleAnnotations, "Ljava/lang/Deprecated;")
}

boolean isVisible() {
(bitSet(classNode.access, ACC_PUBLIC) || bitSet(classNode.access, ACC_PROTECTED)) &&
(bitSet(methodNode.access, ACC_PUBLIC) || bitSet(methodNode.access, ACC_PROTECTED)) &&
!bitSet(classNode.access, ACC_SYNTHETIC) &&
!(classNode.name =~ /\$[0-9]/) &&
!(methodNode.name =~ /^access\$/ || methodNode.name == '<clinit>')
}

private static boolean containsAnnotation(List<AnnotationNode> annotations, String annotationInternalName) {
for (AnnotationNode annotationNode : annotations) {
if (annotationNode.desc == annotationInternalName) {
return true
}
}
return false
}

@Override
int compareTo(ClassMethod o) {
internalName <=> o.internalName
}
}

private static boolean bitSet(int field, int bit) {
(field & bit) == bit
}
}

class CheckApiChangesExtension {
String[] from
Object[] to

String[] entryPoints
String[] expectedChanges
}
2 changes: 1 addition & 1 deletion buildSrc/src/main/groovy/ProvideBuildClasspathTask.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class ProvideBuildClasspathTask extends DefaultTask {
project.rootProject.allprojects.each { Project otherProject ->
def match = otherProject.name =~ /robolectric-shadows\/(.*)/
if (match.matches()) {
def artifactName = "${otherProject.group}:${otherProject.mavenArtifactName()}:${otherProject.version}"
def artifactName = "${otherProject.group}:${otherProject.mavenArtifactName}:${otherProject.version}"
File classesDir = otherProject.sourceSets['main'].output.classesDir
File resourcesDir = otherProject.sourceSets['main'].output.resourcesDir
paths << "${artifactName.replaceAll(/:/, '\\\\:')}: ${classesDir}:${resourcesDir}"
Expand Down
7 changes: 4 additions & 3 deletions buildSrc/src/main/groovy/RoboJavaModulePlugin.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,16 @@ class RoboJavaModulePlugin implements Plugin<Project> {
test.compileClasspath += project.configurations.compileOnly
}

ext.mavenArtifactName = {
def mavenArtifactName = {
def projNameParts = project.name.split(/\//) as List
if (projNameParts[0] == "robolectric-shadows") {
projNameParts = projNameParts.drop(1)
return projNameParts.join("-")
} else {
return project.name
}
}
}()
ext.mavenArtifactName = mavenArtifactName

task('provideBuildClasspath', type: ProvideBuildClasspathTask) {
File outDir = project.sourceSets['test'].output.resourcesDir
Expand Down Expand Up @@ -109,7 +110,7 @@ class RoboJavaModulePlugin implements Plugin<Project> {
uploadArchives {
repositories {
mavenDeployer {
pom.artifactId = mavenArtifactName()
pom.artifactId = mavenArtifactName
pom.project {
name project.name
description = "An alternative Android testing framework."
Expand Down
3 changes: 3 additions & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
thisVersion=3.3-SNAPSHOT

apiCompatVersion=3.2.2
Loading

0 comments on commit 83d4242

Please sign in to comment.