Joe Onorato | 0eccce9 | 2011-10-30 21:37:35 -0700 | [diff] [blame] | 1 | #!/usr/bin/env python |
| 2 | |
| 3 | import os |
| 4 | import re |
| 5 | import sys |
| 6 | |
| 7 | def fail_with_usage(): |
| 8 | sys.stderr.write("usage: java-layers.py DEPENDENCY_FILE SOURCE_DIRECTORIES...\n") |
| 9 | sys.stderr.write("\n") |
| 10 | sys.stderr.write("Enforces layering between java packages. Scans\n") |
| 11 | sys.stderr.write("DIRECTORY and prints errors when the packages violate\n") |
| 12 | sys.stderr.write("the rules defined in the DEPENDENCY_FILE.\n") |
| 13 | sys.stderr.write("\n") |
| 14 | sys.stderr.write("Prints a warning when an unknown package is encountered\n") |
| 15 | sys.stderr.write("on the assumption that it should fit somewhere into the\n") |
| 16 | sys.stderr.write("layering.\n") |
| 17 | sys.stderr.write("\n") |
| 18 | sys.stderr.write("DEPENDENCY_FILE format\n") |
| 19 | sys.stderr.write(" - # starts comment\n") |
| 20 | sys.stderr.write(" - Lines consisting of two java package names: The\n") |
| 21 | sys.stderr.write(" first package listed must not contain any references\n") |
| 22 | sys.stderr.write(" to any classes present in the second package, or any\n") |
| 23 | sys.stderr.write(" of its dependencies.\n") |
| 24 | sys.stderr.write(" - Lines consisting of one java package name: The\n") |
| 25 | sys.stderr.write(" packge is assumed to be a high level package and\n") |
| 26 | sys.stderr.write(" nothing may depend on it.\n") |
| 27 | sys.stderr.write(" - Lines consisting of a dash (+) followed by one java\n") |
| 28 | sys.stderr.write(" package name: The package is considered a low level\n") |
| 29 | sys.stderr.write(" package and may not import any of the other packages\n") |
| 30 | sys.stderr.write(" listed in the dependency file.\n") |
| 31 | sys.stderr.write(" - Lines consisting of a plus (-) followed by one java\n") |
| 32 | sys.stderr.write(" package name: The package is considered \'legacy\'\n") |
| 33 | sys.stderr.write(" and excluded from errors.\n") |
| 34 | sys.stderr.write("\n") |
| 35 | sys.exit(1) |
| 36 | |
| 37 | class Dependency: |
| 38 | def __init__(self, filename, lineno, lower, top, lowlevel, legacy): |
| 39 | self.filename = filename |
| 40 | self.lineno = lineno |
| 41 | self.lower = lower |
| 42 | self.top = top |
| 43 | self.lowlevel = lowlevel |
| 44 | self.legacy = legacy |
| 45 | self.uppers = [] |
| 46 | self.transitive = set() |
| 47 | |
| 48 | def matches(self, imp): |
| 49 | for d in self.transitive: |
| 50 | if imp.startswith(d): |
| 51 | return True |
| 52 | return False |
| 53 | |
| 54 | class Dependencies: |
| 55 | def __init__(self, deps): |
| 56 | def recurse(obj, dep, visited): |
| 57 | global err |
| 58 | if dep in visited: |
| 59 | sys.stderr.write("%s:%d: Circular dependency found:\n" |
| 60 | % (dep.filename, dep.lineno)) |
| 61 | for v in visited: |
| 62 | sys.stderr.write("%s:%d: Dependency: %s\n" |
| 63 | % (v.filename, v.lineno, v.lower)) |
| 64 | err = True |
| 65 | return |
| 66 | visited.append(dep) |
| 67 | for upper in dep.uppers: |
| 68 | obj.transitive.add(upper) |
| 69 | if upper in deps: |
| 70 | recurse(obj, deps[upper], visited) |
| 71 | self.deps = deps |
| 72 | self.parts = [(dep.lower.split('.'),dep) for dep in deps.itervalues()] |
| 73 | # transitive closure of dependencies |
| 74 | for dep in deps.itervalues(): |
| 75 | recurse(dep, dep, []) |
| 76 | # disallow everything from the low level components |
| 77 | for dep in deps.itervalues(): |
| 78 | if dep.lowlevel: |
| 79 | for d in deps.itervalues(): |
| 80 | if dep != d and not d.legacy: |
| 81 | dep.transitive.add(d.lower) |
| 82 | # disallow the 'top' components everywhere but in their own package |
| 83 | for dep in deps.itervalues(): |
| 84 | if dep.top and not dep.legacy: |
| 85 | for d in deps.itervalues(): |
| 86 | if dep != d and not d.legacy: |
| 87 | d.transitive.add(dep.lower) |
| 88 | for dep in deps.itervalues(): |
| 89 | dep.transitive = set([x+"." for x in dep.transitive]) |
| 90 | if False: |
| 91 | for dep in deps.itervalues(): |
| 92 | print "-->", dep.lower, "-->", dep.transitive |
| 93 | |
| 94 | # Lookup the dep object for the given package. If pkg is a subpackage |
| 95 | # of one with a rule, that one will be returned. If no matches are found, |
| 96 | # None is returned. |
| 97 | def lookup(self, pkg): |
| 98 | # Returns the number of parts that match |
| 99 | def compare_parts(parts, pkg): |
| 100 | if len(parts) > len(pkg): |
| 101 | return 0 |
| 102 | n = 0 |
| 103 | for i in range(0, len(parts)): |
| 104 | if parts[i] != pkg[i]: |
| 105 | return 0 |
| 106 | n = n + 1 |
| 107 | return n |
| 108 | pkg = pkg.split(".") |
| 109 | matched = 0 |
| 110 | result = None |
| 111 | for (parts,dep) in self.parts: |
| 112 | x = compare_parts(parts, pkg) |
| 113 | if x > matched: |
| 114 | matched = x |
| 115 | result = dep |
| 116 | return result |
| 117 | |
| 118 | def parse_dependency_file(filename): |
| 119 | global err |
| 120 | f = file(filename) |
| 121 | lines = f.readlines() |
| 122 | f.close() |
| 123 | def lineno(s, i): |
| 124 | i[0] = i[0] + 1 |
| 125 | return (i[0],s) |
| 126 | n = [0] |
| 127 | lines = [lineno(x,n) for x in lines] |
| 128 | lines = [(n,s.split("#")[0].strip()) for (n,s) in lines] |
| 129 | lines = [(n,s) for (n,s) in lines if len(s) > 0] |
| 130 | lines = [(n,s.split()) for (n,s) in lines] |
| 131 | deps = {} |
| 132 | for n,words in lines: |
| 133 | if len(words) == 1: |
| 134 | lower = words[0] |
| 135 | top = True |
| 136 | legacy = False |
| 137 | lowlevel = False |
| 138 | if lower[0] == '+': |
| 139 | lower = lower[1:] |
| 140 | top = False |
| 141 | lowlevel = True |
| 142 | elif lower[0] == '-': |
| 143 | lower = lower[1:] |
| 144 | legacy = True |
| 145 | if lower in deps: |
| 146 | sys.stderr.write(("%s:%d: Package '%s' already defined on" |
| 147 | + " line %d.\n") % (filename, n, lower, deps[lower].lineno)) |
| 148 | err = True |
| 149 | else: |
| 150 | deps[lower] = Dependency(filename, n, lower, top, lowlevel, legacy) |
| 151 | elif len(words) == 2: |
| 152 | lower = words[0] |
| 153 | upper = words[1] |
| 154 | if lower in deps: |
| 155 | dep = deps[lower] |
| 156 | if dep.top: |
| 157 | sys.stderr.write(("%s:%d: Can't add dependency to top level package " |
| 158 | + "'%s'\n") % (filename, n, lower)) |
| 159 | err = True |
| 160 | else: |
| 161 | dep = Dependency(filename, n, lower, False, False, False) |
| 162 | deps[lower] = dep |
| 163 | dep.uppers.append(upper) |
| 164 | else: |
| 165 | sys.stderr.write("%s:%d: Too many words on line starting at \'%s\'\n" % ( |
| 166 | filename, n, words[2])) |
| 167 | err = True |
| 168 | return Dependencies(deps) |
| 169 | |
| 170 | def find_java_files(srcs): |
| 171 | result = [] |
| 172 | for d in srcs: |
| 173 | if d[0] == '@': |
| 174 | f = file(d[1:]) |
| 175 | result.extend([fn for fn in [s.strip() for s in f.readlines()] |
| 176 | if len(fn) != 0]) |
| 177 | f.close() |
| 178 | else: |
| 179 | for root, dirs, files in os.walk(d): |
| 180 | result.extend([os.sep.join((root,f)) for f in files |
| 181 | if f.lower().endswith(".java")]) |
| 182 | return result |
| 183 | |
| 184 | COMMENTS = re.compile("//.*?\n|/\*.*?\*/", re.S) |
| 185 | PACKAGE = re.compile("package\s+(.*)") |
| 186 | IMPORT = re.compile("import\s+(.*)") |
| 187 | |
| 188 | def examine_java_file(deps, filename): |
| 189 | global err |
| 190 | # Yes, this is a crappy java parser. Write a better one if you want to. |
| 191 | f = file(filename) |
| 192 | text = f.read() |
| 193 | f.close() |
| 194 | text = COMMENTS.sub("", text) |
| 195 | index = text.find("{") |
| 196 | if index < 0: |
| 197 | sys.stderr.write(("%s: Error: Unable to parse java. Can't find class " |
| 198 | + "declaration.\n") % filename) |
| 199 | err = True |
| 200 | return |
| 201 | text = text[0:index] |
| 202 | statements = [s.strip() for s in text.split(";")] |
| 203 | # First comes the package declaration. Then iterate while we see import |
| 204 | # statements. Anything else is either bad syntax that we don't care about |
| 205 | # because the compiler will fail, or the beginning of the class declaration. |
| 206 | m = PACKAGE.match(statements[0]) |
| 207 | if not m: |
| 208 | sys.stderr.write(("%s: Error: Unable to parse java. Missing package " |
| 209 | + "statement.\n") % filename) |
| 210 | err = True |
| 211 | return |
| 212 | pkg = m.group(1) |
| 213 | imports = [] |
| 214 | for statement in statements[1:]: |
| 215 | m = IMPORT.match(statement) |
| 216 | if not m: |
| 217 | break |
| 218 | imports.append(m.group(1)) |
| 219 | # Do the checking |
| 220 | if False: |
| 221 | print filename |
| 222 | print "'%s' --> %s" % (pkg, imports) |
| 223 | dep = deps.lookup(pkg) |
| 224 | if not dep: |
| 225 | sys.stderr.write(("%s: Error: Package does not appear in dependency file: " |
| 226 | + "%s\n") % (filename, pkg)) |
| 227 | err = True |
| 228 | return |
| 229 | for imp in imports: |
| 230 | if dep.matches(imp): |
| 231 | sys.stderr.write("%s: Illegal import in package '%s' of '%s'\n" |
| 232 | % (filename, pkg, imp)) |
| 233 | err = True |
| 234 | |
| 235 | err = False |
| 236 | |
| 237 | def main(argv): |
| 238 | if len(argv) < 3: |
| 239 | fail_with_usage() |
| 240 | deps = parse_dependency_file(argv[1]) |
| 241 | |
| 242 | if err: |
| 243 | sys.exit(1) |
| 244 | |
| 245 | java = find_java_files(argv[2:]) |
| 246 | for filename in java: |
| 247 | examine_java_file(deps, filename) |
| 248 | |
| 249 | if err: |
| 250 | sys.stderr.write("%s: Using this file as dependency file.\n" % argv[1]) |
| 251 | sys.exit(1) |
| 252 | |
| 253 | sys.exit(0) |
| 254 | |
| 255 | if __name__ == "__main__": |
| 256 | main(sys.argv) |
| 257 | |