blob: b3aec2b1d5fc88e688383ecd27f0daeb11579b3c [file] [log] [blame]
#!/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)