#!/usr/bin/env python from __future__ import print_function import os import re import sys def itervalues(obj): if hasattr(obj, 'itervalues'): return obj.itervalues() return obj.values() 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 itervalues(deps)] # transitive closure of dependencies for dep in itervalues(deps): recurse(dep, dep, []) # disallow everything from the low level components for dep in itervalues(deps): if dep.lowlevel: for d in itervalues(deps): 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 itervalues(deps): if dep.top and not dep.legacy: for d in itervalues(deps): if dep != d and not d.legacy: d.transitive.add(dep.lower) for dep in itervalues(deps): dep.transitive = set([x+"." for x in dep.transitive]) if False: for dep in itervalues(deps): 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 = open(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 = open(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 = open(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)