From c72b1403e43884dc7dc9f749be51a8d890d62e27 Mon Sep 17 00:00:00 2001 From: Joe Onorato Date: Sun, 30 Oct 2011 21:37:35 -0700 Subject: [PATCH] Add a tool to let you enforce layering between packages in a java module. And build system support for it too. Change-Id: I4dd5ed0b9edab6e8884b0d00cfeeae5fa38d967a --- core/clear_vars.mk | 1 + core/definitions.mk | 2 + core/java.mk | 10 +- tools/java-layers.py | 257 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 268 insertions(+), 2 deletions(-) create mode 100755 tools/java-layers.py diff --git a/core/clear_vars.mk b/core/clear_vars.mk index d9f3372c5..f48c35d93 100644 --- a/core/clear_vars.mk +++ b/core/clear_vars.mk @@ -58,6 +58,7 @@ LOCAL_INTERMEDIATE_SOURCES:= LOCAL_INTERMEDIATE_SOURCE_DIR:= LOCAL_JAVACFLAGS:= LOCAL_JAVA_LIBRARIES:= +LOCAL_JAVA_LAYERS_FILE:= LOCAL_NO_STANDARD_LIBRARIES:= LOCAL_CLASSPATH:= LOCAL_DROIDDOC_USE_STANDARD_DOCLET:= diff --git a/core/definitions.mk b/core/definitions.mk index 683ae161f..867575ad7 100644 --- a/core/definitions.mk +++ b/core/definitions.mk @@ -1455,6 +1455,8 @@ $(hide) $(1) -encoding UTF-8 \ $(PRIVATE_JAVACFLAGS) \ \@$(PRIVATE_CLASS_INTERMEDIATES_DIR)/java-source-list-uniq \ || ( rm -rf $(PRIVATE_CLASS_INTERMEDIATES_DIR) ; exit 41 ) +$(if $(PRIVATE_JAVA_LAYERS_FILE), $(hide) build/tools/java-layers.py \ + $(PRIVATE_JAVA_LAYERS_FILE) \@$(PRIVATE_CLASS_INTERMEDIATES_DIR)/java-source-list-uniq,) $(hide) rm -f $(PRIVATE_CLASS_INTERMEDIATES_DIR)/java-source-list $(hide) rm -f $(PRIVATE_CLASS_INTERMEDIATES_DIR)/java-source-list-uniq $(hide) jar $(if $(strip $(PRIVATE_JAR_MANIFEST)),-cfm,-cf) \ diff --git a/core/java.mk b/core/java.mk index 1cde62b35..0c51da302 100644 --- a/core/java.mk +++ b/core/java.mk @@ -238,13 +238,19 @@ $(full_classes_stubs_jar) : $(LOCAL_BUILT_MODULE) | $(ACP) $(hide) $(ACP) -fp $(PRIVATE_SOURCE_FILE) $@ ALL_MODULES.$(LOCAL_MODULE).STUBS := $(full_classes_stubs_jar) +# The layers file allows you to enforce a layering between java packages. +# Run build/tools/java-layers.py for more details. +layers_file := $(addprefix $(LOCAL_PATH)/, $(LOCAL_JAVA_LAYERS_FILE)) +$(full_classes_compiled_jar): PRIVATE_JAVA_LAYERS_FILE := $(layers_file) + # Compile the java files to a .jar file. # This intentionally depends on java_sources, not all_java_sources. # Deps for generated source files must be handled separately, # via deps on the target that generates the sources. $(full_classes_compiled_jar): PRIVATE_JAVACFLAGS := $(LOCAL_JAVACFLAGS) -$(full_classes_compiled_jar): $(java_sources) $(java_resource_sources) $(full_java_lib_deps) $(jar_manifest_file) \ - $(RenderScript_file_stamp) $(proto_java_sources_file_stamp) +$(full_classes_compiled_jar): $(java_sources) $(java_resource_sources) $(full_java_lib_deps)\ + $(jar_manifest_file) $(layers_file) \ + $(RenderScript_file_stamp) $(proto_java_sources_file_stamp) $(transform-java-to-classes.jar) # All of the rules after full_classes_compiled_jar are very unlikely diff --git a/tools/java-layers.py b/tools/java-layers.py new file mode 100755 index 000000000..b3aec2b1d --- /dev/null +++ b/tools/java-layers.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python + +import os +import re +import sys + +def fail_with_usage(): + sys.stderr.write("usage: java-layers.py DEPENDENCY_FILE SOURCE_DIRECTORIES...\n") + sys.stderr.write("\n") + sys.stderr.write("Enforces layering between java packages. Scans\n") + sys.stderr.write("DIRECTORY and prints errors when the packages violate\n") + sys.stderr.write("the rules defined in the DEPENDENCY_FILE.\n") + sys.stderr.write("\n") + sys.stderr.write("Prints a warning when an unknown package is encountered\n") + sys.stderr.write("on the assumption that it should fit somewhere into the\n") + sys.stderr.write("layering.\n") + sys.stderr.write("\n") + sys.stderr.write("DEPENDENCY_FILE format\n") + sys.stderr.write(" - # starts comment\n") + sys.stderr.write(" - Lines consisting of two java package names: The\n") + sys.stderr.write(" first package listed must not contain any references\n") + sys.stderr.write(" to any classes present in the second package, or any\n") + sys.stderr.write(" of its dependencies.\n") + sys.stderr.write(" - Lines consisting of one java package name: The\n") + sys.stderr.write(" packge is assumed to be a high level package and\n") + sys.stderr.write(" nothing may depend on it.\n") + sys.stderr.write(" - Lines consisting of a dash (+) followed by one java\n") + sys.stderr.write(" package name: The package is considered a low level\n") + sys.stderr.write(" package and may not import any of the other packages\n") + sys.stderr.write(" listed in the dependency file.\n") + sys.stderr.write(" - Lines consisting of a plus (-) followed by one java\n") + sys.stderr.write(" package name: The package is considered \'legacy\'\n") + sys.stderr.write(" and excluded from errors.\n") + sys.stderr.write("\n") + sys.exit(1) + +class Dependency: + def __init__(self, filename, lineno, lower, top, lowlevel, legacy): + self.filename = filename + self.lineno = lineno + self.lower = lower + self.top = top + self.lowlevel = lowlevel + self.legacy = legacy + self.uppers = [] + self.transitive = set() + + def matches(self, imp): + for d in self.transitive: + if imp.startswith(d): + return True + return False + +class Dependencies: + def __init__(self, deps): + def recurse(obj, dep, visited): + global err + if dep in visited: + sys.stderr.write("%s:%d: Circular dependency found:\n" + % (dep.filename, dep.lineno)) + for v in visited: + sys.stderr.write("%s:%d: Dependency: %s\n" + % (v.filename, v.lineno, v.lower)) + err = True + return + visited.append(dep) + for upper in dep.uppers: + obj.transitive.add(upper) + if upper in deps: + recurse(obj, deps[upper], visited) + self.deps = deps + self.parts = [(dep.lower.split('.'),dep) for dep in deps.itervalues()] + # transitive closure of dependencies + for dep in deps.itervalues(): + recurse(dep, dep, []) + # disallow everything from the low level components + for dep in deps.itervalues(): + if dep.lowlevel: + for d in deps.itervalues(): + if dep != d and not d.legacy: + dep.transitive.add(d.lower) + # disallow the 'top' components everywhere but in their own package + for dep in deps.itervalues(): + if dep.top and not dep.legacy: + for d in deps.itervalues(): + if dep != d and not d.legacy: + d.transitive.add(dep.lower) + for dep in deps.itervalues(): + dep.transitive = set([x+"." for x in dep.transitive]) + if False: + for dep in deps.itervalues(): + print "-->", dep.lower, "-->", dep.transitive + + # Lookup the dep object for the given package. If pkg is a subpackage + # of one with a rule, that one will be returned. If no matches are found, + # None is returned. + def lookup(self, pkg): + # Returns the number of parts that match + def compare_parts(parts, pkg): + if len(parts) > len(pkg): + return 0 + n = 0 + for i in range(0, len(parts)): + if parts[i] != pkg[i]: + return 0 + n = n + 1 + return n + pkg = pkg.split(".") + matched = 0 + result = None + for (parts,dep) in self.parts: + x = compare_parts(parts, pkg) + if x > matched: + matched = x + result = dep + return result + +def parse_dependency_file(filename): + global err + f = file(filename) + lines = f.readlines() + f.close() + def lineno(s, i): + i[0] = i[0] + 1 + return (i[0],s) + n = [0] + lines = [lineno(x,n) for x in lines] + lines = [(n,s.split("#")[0].strip()) for (n,s) in lines] + lines = [(n,s) for (n,s) in lines if len(s) > 0] + lines = [(n,s.split()) for (n,s) in lines] + deps = {} + for n,words in lines: + if len(words) == 1: + lower = words[0] + top = True + legacy = False + lowlevel = False + if lower[0] == '+': + lower = lower[1:] + top = False + lowlevel = True + elif lower[0] == '-': + lower = lower[1:] + legacy = True + if lower in deps: + sys.stderr.write(("%s:%d: Package '%s' already defined on" + + " line %d.\n") % (filename, n, lower, deps[lower].lineno)) + err = True + else: + deps[lower] = Dependency(filename, n, lower, top, lowlevel, legacy) + elif len(words) == 2: + lower = words[0] + upper = words[1] + if lower in deps: + dep = deps[lower] + if dep.top: + sys.stderr.write(("%s:%d: Can't add dependency to top level package " + + "'%s'\n") % (filename, n, lower)) + err = True + else: + dep = Dependency(filename, n, lower, False, False, False) + deps[lower] = dep + dep.uppers.append(upper) + else: + sys.stderr.write("%s:%d: Too many words on line starting at \'%s\'\n" % ( + filename, n, words[2])) + err = True + return Dependencies(deps) + +def find_java_files(srcs): + result = [] + for d in srcs: + if d[0] == '@': + f = file(d[1:]) + result.extend([fn for fn in [s.strip() for s in f.readlines()] + if len(fn) != 0]) + f.close() + else: + for root, dirs, files in os.walk(d): + result.extend([os.sep.join((root,f)) for f in files + if f.lower().endswith(".java")]) + return result + +COMMENTS = re.compile("//.*?\n|/\*.*?\*/", re.S) +PACKAGE = re.compile("package\s+(.*)") +IMPORT = re.compile("import\s+(.*)") + +def examine_java_file(deps, filename): + global err + # Yes, this is a crappy java parser. Write a better one if you want to. + f = file(filename) + text = f.read() + f.close() + text = COMMENTS.sub("", text) + index = text.find("{") + if index < 0: + sys.stderr.write(("%s: Error: Unable to parse java. Can't find class " + + "declaration.\n") % filename) + err = True + return + text = text[0:index] + statements = [s.strip() for s in text.split(";")] + # First comes the package declaration. Then iterate while we see import + # statements. Anything else is either bad syntax that we don't care about + # because the compiler will fail, or the beginning of the class declaration. + m = PACKAGE.match(statements[0]) + if not m: + sys.stderr.write(("%s: Error: Unable to parse java. Missing package " + + "statement.\n") % filename) + err = True + return + pkg = m.group(1) + imports = [] + for statement in statements[1:]: + m = IMPORT.match(statement) + if not m: + break + imports.append(m.group(1)) + # Do the checking + if False: + print filename + print "'%s' --> %s" % (pkg, imports) + dep = deps.lookup(pkg) + if not dep: + sys.stderr.write(("%s: Error: Package does not appear in dependency file: " + + "%s\n") % (filename, pkg)) + err = True + return + for imp in imports: + if dep.matches(imp): + sys.stderr.write("%s: Illegal import in package '%s' of '%s'\n" + % (filename, pkg, imp)) + err = True + +err = False + +def main(argv): + if len(argv) < 3: + fail_with_usage() + deps = parse_dependency_file(argv[1]) + + if err: + sys.exit(1) + + java = find_java_files(argv[2:]) + for filename in java: + examine_java_file(deps, filename) + + if err: + sys.stderr.write("%s: Using this file as dependency file.\n" % argv[1]) + sys.exit(1) + + sys.exit(0) + +if __name__ == "__main__": + main(sys.argv) + -- 2.11.0