Merge remote-tracking branch 'aosp/upstream-master'

* aosp/upstream-master: (38 commits)
  Support empty bootclasspaths
  Tolerate empty params files in turbine
  Don't crash on private interface methods
  Change the strict deps plugin to read jar owner from manifest.
  Remove support for --rule_kind
  Remove deprecated rule_kind argument from Turbine
  Use a different date time when normalizing zip entries
  Accept --target_label, --injecting_rule_kind in Turbine.
  Stop skipping module-infos
  Don't error out if modules can't be resolved
  Refactor TurbineOptions to make jarToTarget/directJars the source of truth
  Change Turbine command lines to not require CustomMultiArgv.
  Initial end-to-end support for module-infos
  Class writing support for module attributes
  Reduce coupling between ConstBinder and SourceTypeBoundClass
  Class reading support for module attributes
  Initial support for parsing module-infos.
  Allow --bootclasspath and --release to be used together
  Propagate --release flags from --javacopts to turbine's --release flag
  Add JDK 9 travis build
  ...

Bug: 74332665
Bug: 74339924
Test: m checkbuild
Change-Id: I26280b1fd483e92b5075f985d3169edb63303c5b
diff --git a/.travis.yml b/.travis.yml
index 37f3cc8..273556e 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,7 +1,15 @@
 language: java
 
-jdk:
-  - oraclejdk8
+matrix:
+  allow_failures:
+    - jdk: oraclejdk9
+  include:
+# JDK 8
+    - jdk: oraclejdk8
+      env: JDK_RELEASE='8'
+# JDK 9
+    - jdk: oraclejdk9
+      env: JDK_RELEASE='9'
 
 # use travis-ci docker based infrastructure
 sudo: false
diff --git a/appveyor.yml b/appveyor.yml
new file mode 100644
index 0000000..bcbf5dc
--- /dev/null
+++ b/appveyor.yml
@@ -0,0 +1,26 @@
+os: Visual Studio 2015
+
+install:
+  - ps: |
+      Add-Type -AssemblyName System.IO.Compression.FileSystem
+      if (!(Test-Path -Path "C:\maven" )) {
+        (new-object System.Net.WebClient).DownloadFile(
+          'http://www.us.apache.org/dist/maven/maven-3/3.3.9/binaries/apache-maven-3.3.9-bin.zip',
+          'C:\maven-bin.zip'
+        )
+        [System.IO.Compression.ZipFile]::ExtractToDirectory("C:\maven-bin.zip", "C:\maven")
+      }
+  - cmd: SET PATH=C:\maven\apache-maven-3.3.9\bin;%JAVA_HOME%\bin;%PATH%
+  - cmd: SET MAVEN_OPTS=-XX:MaxPermSize=2g -Xmx4g
+  - cmd: SET JAVA_OPTS=-XX:MaxPermSize=2g -Xmx4g
+
+build_script:
+  - mvn install -DskipTests=true -Dmaven.javadoc.skip=true -B -V
+
+test_script:
+  - mvn test -B
+
+cache:
+  - C:\maven\
+  - C:\Users\appveyor\.m2
+
diff --git a/java/com/google/turbine/binder/Binder.java b/java/com/google/turbine/binder/Binder.java
index 205326a..cbafdef 100644
--- a/java/com/google/turbine/binder/Binder.java
+++ b/java/com/google/turbine/binder/Binder.java
@@ -16,7 +16,6 @@
 
 package com.google.turbine.binder;
 
-import static com.google.common.base.Verify.verifyNotNull;
 
 import com.google.common.base.Optional;
 import com.google.common.base.Splitter;
@@ -27,7 +26,9 @@
 import com.google.turbine.binder.Resolve.CanonicalResolver;
 import com.google.turbine.binder.bound.BoundClass;
 import com.google.turbine.binder.bound.HeaderBoundClass;
+import com.google.turbine.binder.bound.ModuleInfo;
 import com.google.turbine.binder.bound.PackageSourceBoundClass;
+import com.google.turbine.binder.bound.PackageSourceBoundModule;
 import com.google.turbine.binder.bound.SourceBoundClass;
 import com.google.turbine.binder.bound.SourceHeaderBoundClass;
 import com.google.turbine.binder.bound.SourceTypeBoundClass;
@@ -40,22 +41,25 @@
 import com.google.turbine.binder.env.SimpleEnv;
 import com.google.turbine.binder.lookup.CanonicalSymbolResolver;
 import com.google.turbine.binder.lookup.CompoundScope;
+import com.google.turbine.binder.lookup.CompoundTopLevelIndex;
 import com.google.turbine.binder.lookup.ImportIndex;
 import com.google.turbine.binder.lookup.ImportScope;
 import com.google.turbine.binder.lookup.MemberImportIndex;
 import com.google.turbine.binder.lookup.Scope;
+import com.google.turbine.binder.lookup.SimpleTopLevelIndex;
 import com.google.turbine.binder.lookup.TopLevelIndex;
 import com.google.turbine.binder.lookup.WildImportIndex;
 import com.google.turbine.binder.sym.ClassSymbol;
 import com.google.turbine.binder.sym.FieldSymbol;
+import com.google.turbine.binder.sym.ModuleSymbol;
+import com.google.turbine.diag.TurbineError;
+import com.google.turbine.diag.TurbineError.ErrorKind;
 import com.google.turbine.model.Const;
 import com.google.turbine.model.TurbineFlag;
 import com.google.turbine.tree.Tree;
 import com.google.turbine.tree.Tree.CompUnit;
+import com.google.turbine.tree.Tree.ModDecl;
 import com.google.turbine.type.Type;
-import java.io.IOException;
-import java.nio.file.Path;
-import java.util.Collection;
 import java.util.List;
 
 /** The entry point for analysis. */
@@ -63,31 +67,35 @@
 
   /** Binds symbols and types to the given compilation units. */
   public static BindingResult bind(
-      List<CompUnit> units, Collection<Path> classpath, Collection<Path> bootclasspath)
-      throws IOException {
+      List<CompUnit> units,
+      ClassPath classpath,
+      ClassPath bootclasspath,
+      Optional<String> moduleVersion) {
 
     ImmutableList<PreprocessedCompUnit> preProcessedUnits = CompUnitPreprocessor.preprocess(units);
 
-    TopLevelIndex.Builder tliBuilder = TopLevelIndex.builder();
-
-    SimpleEnv<ClassSymbol, SourceBoundClass> ienv =
-        bindSourceBoundClasses(preProcessedUnits, tliBuilder);
+    SimpleEnv<ClassSymbol, SourceBoundClass> ienv = bindSourceBoundClasses(preProcessedUnits);
 
     ImmutableSet<ClassSymbol> syms = ienv.asMap().keySet();
 
+    CompoundTopLevelIndex tli =
+        CompoundTopLevelIndex.of(
+            SimpleTopLevelIndex.of(ienv.asMap().keySet()),
+            bootclasspath.index(),
+            classpath.index());
+
     CompoundEnv<ClassSymbol, BytecodeBoundClass> classPathEnv =
-        ClassPathBinder.bind(classpath, bootclasspath, tliBuilder);
+        CompoundEnv.of(classpath.env()).append(bootclasspath.env());
 
-    // Insertion order into the top-level index is important:
-    // * the first insert into the TLI wins
-    // * we search sources, bootclasspath, and classpath in that order
-    // * the first entry within a location wins.
+    CompoundEnv<ModuleSymbol, ModuleInfo> classPathModuleEnv =
+        CompoundEnv.of(classpath.moduleEnv()).append(bootclasspath.moduleEnv());
 
-    TopLevelIndex tli = tliBuilder.build();
-
-    SimpleEnv<ClassSymbol, PackageSourceBoundClass> psenv =
+    BindPackagesResult bindPackagesResult =
         bindPackages(ienv, tli, preProcessedUnits, classPathEnv);
 
+    SimpleEnv<ClassSymbol, PackageSourceBoundClass> psenv = bindPackagesResult.classes;
+    SimpleEnv<ModuleSymbol, PackageSourceBoundModule> modules = bindPackagesResult.modules;
+
     Env<ClassSymbol, SourceHeaderBoundClass> henv = bindHierarchy(syms, psenv, classPathEnv);
 
     Env<ClassSymbol, SourceTypeBoundClass> tenv =
@@ -104,36 +112,64 @@
         canonicalizeTypes(
             syms, tenv, CompoundEnv.<ClassSymbol, TypeBoundClass>of(classPathEnv).append(tenv));
 
+    ImmutableList<ModuleInfo> boundModules =
+        bindModules(
+            modules,
+            CompoundEnv.<ClassSymbol, TypeBoundClass>of(classPathEnv).append(tenv),
+            classPathModuleEnv,
+            moduleVersion);
+
     ImmutableMap.Builder<ClassSymbol, SourceTypeBoundClass> result = ImmutableMap.builder();
     for (ClassSymbol sym : syms) {
       result.put(sym, tenv.get(sym));
     }
-    return new BindingResult(result.build(), classPathEnv);
+    return new BindingResult(result.build(), boundModules, classPathEnv);
   }
 
   /** Records enclosing declarations of member classes, and group classes by compilation unit. */
   static SimpleEnv<ClassSymbol, SourceBoundClass> bindSourceBoundClasses(
-      ImmutableList<PreprocessedCompUnit> units, TopLevelIndex.Builder tliBuilder) {
+      ImmutableList<PreprocessedCompUnit> units) {
     SimpleEnv.Builder<ClassSymbol, SourceBoundClass> envBuilder = SimpleEnv.builder();
     for (PreprocessedCompUnit unit : units) {
       for (SourceBoundClass type : unit.types()) {
-        envBuilder.put(type.sym(), type);
-        tliBuilder.insert(type.sym());
+        SourceBoundClass prev = envBuilder.put(type.sym(), type);
+        if (prev != null) {
+          throw TurbineError.format(
+              unit.source(), type.decl().position(), ErrorKind.DUPLICATE_DECLARATION, type.sym());
+        }
       }
     }
     return envBuilder.build();
   }
 
+  static class BindPackagesResult {
+    final SimpleEnv<ClassSymbol, PackageSourceBoundClass> classes;
+    final SimpleEnv<ModuleSymbol, PackageSourceBoundModule> modules;
+
+    BindPackagesResult(
+        SimpleEnv<ClassSymbol, PackageSourceBoundClass> classes,
+        SimpleEnv<ModuleSymbol, PackageSourceBoundModule> modules) {
+      this.classes = classes;
+      this.modules = modules;
+    }
+  }
+
   /** Initializes scopes for compilation unit and package-level lookup. */
-  private static SimpleEnv<ClassSymbol, PackageSourceBoundClass> bindPackages(
+  private static BindPackagesResult bindPackages(
       Env<ClassSymbol, SourceBoundClass> ienv,
       TopLevelIndex tli,
       ImmutableList<PreprocessedCompUnit> units,
       CompoundEnv<ClassSymbol, BytecodeBoundClass> classPathEnv) {
 
     SimpleEnv.Builder<ClassSymbol, PackageSourceBoundClass> env = SimpleEnv.builder();
-    Scope javaLang = verifyNotNull(tli.lookupPackage(ImmutableList.of("java", "lang")));
-    CompoundScope topLevel = CompoundScope.base(tli).append(javaLang);
+    SimpleEnv.Builder<ModuleSymbol, PackageSourceBoundModule> modules = SimpleEnv.builder();
+    Scope javaLang = tli.lookupPackage(ImmutableList.of("java", "lang"));
+    if (javaLang == null) {
+      // TODO(cushon): add support for diagnostics without a source position, and make this one
+      // of those
+      throw new IllegalArgumentException("Could not find java.lang on bootclasspath");
+    }
+    CompoundScope topLevel = CompoundScope.base(tli.scope()).append(javaLang);
     for (PreprocessedCompUnit unit : units) {
       ImmutableList<String> packagename =
           ImmutableList.copyOf(Splitter.on('/').omitEmptyStrings().split(unit.packageName()));
@@ -151,11 +187,17 @@
               .append(wildImportScope)
               .append(ImportScope.fromScope(packageScope))
               .append(importScope);
+      if (unit.module().isPresent()) {
+        ModDecl module = unit.module().get();
+        modules.put(
+            new ModuleSymbol(module.moduleName()),
+            new PackageSourceBoundModule(module, scope, memberImports, unit.source()));
+      }
       for (SourceBoundClass type : unit.types()) {
         env.put(type.sym(), new PackageSourceBoundClass(type, scope, memberImports, unit.source()));
       }
     }
-    return env.build();
+    return new BindPackagesResult(env.build(), modules.build());
   }
 
   /** Binds the type hierarchy (superclasses and interfaces) for all classes in the compilation. */
@@ -202,6 +244,43 @@
     return builder.build();
   }
 
+  private static ImmutableList<ModuleInfo> bindModules(
+      SimpleEnv<ModuleSymbol, PackageSourceBoundModule> modules,
+      CompoundEnv<ClassSymbol, TypeBoundClass> env,
+      CompoundEnv<ModuleSymbol, ModuleInfo> moduleEnv,
+      Optional<String> moduleVersion) {
+    // Allow resolution of modules in the current compilation. Currently this is only needed for
+    // version strings in requires directives.
+    moduleEnv =
+        moduleEnv.append(
+            new Env<ModuleSymbol, ModuleInfo>() {
+              @Override
+              public ModuleInfo get(ModuleSymbol sym) {
+                if (modules.asMap().containsKey(sym)) {
+                  PackageSourceBoundModule info = modules.get(sym);
+                  if (info != null) {
+                    return new ModuleInfo(
+                        info.module().moduleName(),
+                        moduleVersion.orNull(),
+                        /* flags= */ 0,
+                        /* annos= */ ImmutableList.of(),
+                        /* requires= */ ImmutableList.of(),
+                        /* exports= */ ImmutableList.of(),
+                        /* opens= */ ImmutableList.of(),
+                        /* uses= */ ImmutableList.of(),
+                        /* provides= */ ImmutableList.of());
+                  }
+                }
+                return null;
+              }
+            });
+    ImmutableList.Builder<ModuleInfo> bound = ImmutableList.builder();
+    for (PackageSourceBoundModule module : modules.asMap().values()) {
+      bound.add(ModuleBinder.bind(module, env, moduleEnv, moduleVersion));
+    }
+    return bound.build();
+  }
+
   private static Env<ClassSymbol, SourceTypeBoundClass> constants(
       ImmutableSet<ClassSymbol> syms,
       Env<ClassSymbol, SourceTypeBoundClass> env,
@@ -224,7 +303,14 @@
               @Override
               public Const.Value complete(Env<FieldSymbol, Const.Value> env1, FieldSymbol k) {
                 try {
-                  return new ConstEvaluator(sym, sym, info, info.scope(), env1, baseEnv)
+                  return new ConstEvaluator(
+                          sym,
+                          sym,
+                          info.memberImports(),
+                          info.source(),
+                          info.scope(),
+                          env1,
+                          baseEnv)
                       .evalFieldInitializer(field.decl().init().get(), field.type());
                 } catch (LazyEnv.LazyBindingError e) {
                   // fields initializers are allowed to reference the field being initialized,
@@ -292,12 +378,15 @@
   /** The result of binding: bound nodes for sources in the compilation, and the classpath. */
   public static class BindingResult {
     private final ImmutableMap<ClassSymbol, SourceTypeBoundClass> units;
+    private final ImmutableList<ModuleInfo> modules;
     private final CompoundEnv<ClassSymbol, BytecodeBoundClass> classPathEnv;
 
     public BindingResult(
         ImmutableMap<ClassSymbol, SourceTypeBoundClass> units,
+        ImmutableList<ModuleInfo> modules,
         CompoundEnv<ClassSymbol, BytecodeBoundClass> classPathEnv) {
       this.units = units;
+      this.modules = modules;
       this.classPathEnv = classPathEnv;
     }
 
@@ -306,6 +395,10 @@
       return units;
     }
 
+    public ImmutableList<ModuleInfo> modules() {
+      return modules;
+    }
+
     /** The classpath. */
     public CompoundEnv<ClassSymbol, BytecodeBoundClass> classPathEnv() {
       return classPathEnv;
diff --git a/java/com/google/turbine/binder/ClassPath.java b/java/com/google/turbine/binder/ClassPath.java
new file mode 100644
index 0000000..d3461bf
--- /dev/null
+++ b/java/com/google/turbine/binder/ClassPath.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2018 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.turbine.binder;
+
+import com.google.turbine.binder.bound.ModuleInfo;
+import com.google.turbine.binder.bytecode.BytecodeBoundClass;
+import com.google.turbine.binder.env.Env;
+import com.google.turbine.binder.lookup.TopLevelIndex;
+import com.google.turbine.binder.sym.ClassSymbol;
+import com.google.turbine.binder.sym.ModuleSymbol;
+
+/**
+ * A compilation classpath, e.g. the user or platform class path. Maybe backed by a search path of
+ * jar files, or a jrtfs filesystem.
+ */
+public interface ClassPath {
+  /** The classpath's environment. */
+  Env<ClassSymbol, BytecodeBoundClass> env();
+
+  /** The classpath's module environment. */
+  Env<ModuleSymbol, ModuleInfo> moduleEnv();
+
+  /** The classpath's top level index. */
+  TopLevelIndex index();
+}
diff --git a/java/com/google/turbine/binder/ClassPathBinder.java b/java/com/google/turbine/binder/ClassPathBinder.java
index 4542bbe..2b3a921 100644
--- a/java/com/google/turbine/binder/ClassPathBinder.java
+++ b/java/com/google/turbine/binder/ClassPathBinder.java
@@ -20,12 +20,15 @@
 import com.google.common.base.Supplier;
 import com.google.common.base.Suppliers;
 import com.google.common.collect.ImmutableMap;
+import com.google.turbine.binder.bound.ModuleInfo;
+import com.google.turbine.binder.bytecode.BytecodeBinder;
 import com.google.turbine.binder.bytecode.BytecodeBoundClass;
-import com.google.turbine.binder.env.CompoundEnv;
 import com.google.turbine.binder.env.Env;
 import com.google.turbine.binder.env.SimpleEnv;
+import com.google.turbine.binder.lookup.SimpleTopLevelIndex;
 import com.google.turbine.binder.lookup.TopLevelIndex;
 import com.google.turbine.binder.sym.ClassSymbol;
+import com.google.turbine.binder.sym.ModuleSymbol;
 import com.google.turbine.zip.Zip;
 import java.io.IOException;
 import java.nio.file.Path;
@@ -43,24 +46,13 @@
    */
   public static final String TRANSITIVE_PREFIX = "META-INF/TRANSITIVE/";
 
-  /**
-   * Creates an environment containing symbols in the given classpath and bootclasspath, and adds
-   * them to the top-level index.
-   */
-  public static CompoundEnv<ClassSymbol, BytecodeBoundClass> bind(
-      Collection<Path> classpath, Collection<Path> bootclasspath, TopLevelIndex.Builder tli)
-      throws IOException {
+  /** Creates an environment containing symbols in the given classpath. */
+  public static ClassPath bindClasspath(Collection<Path> paths) throws IOException {
     // TODO(cushon): this is going to require an env eventually,
     // e.g. to look up type parameters in enclosing declarations
-    Env<ClassSymbol, BytecodeBoundClass> cp = bindClasspath(tli, classpath);
-    Env<ClassSymbol, BytecodeBoundClass> bcp = bindClasspath(tli, bootclasspath);
-    return CompoundEnv.of(cp).append(bcp);
-  }
-
-  private static Env<ClassSymbol, BytecodeBoundClass> bindClasspath(
-      TopLevelIndex.Builder tli, Collection<Path> paths) throws IOException {
     Map<ClassSymbol, BytecodeBoundClass> transitive = new LinkedHashMap<>();
     Map<ClassSymbol, BytecodeBoundClass> map = new HashMap<>();
+    Map<ModuleSymbol, ModuleInfo> modules = new HashMap<>();
     Env<ClassSymbol, BytecodeBoundClass> benv =
         new Env<ClassSymbol, BytecodeBoundClass>() {
           @Override
@@ -70,7 +62,7 @@
         };
     for (Path path : paths) {
       try {
-        bindJar(tli, path, map, benv, transitive);
+        bindJar(path, map, modules, benv, transitive);
       } catch (IOException e) {
         throw new IOException("error reading " + path, e);
       }
@@ -79,16 +71,33 @@
       ClassSymbol symbol = entry.getKey();
       if (!map.containsKey(symbol)) {
         map.put(symbol, entry.getValue());
-        tli.insert(symbol);
       }
     }
-    return new SimpleEnv<>(ImmutableMap.copyOf(map));
+    SimpleEnv<ClassSymbol, BytecodeBoundClass> env = new SimpleEnv<>(ImmutableMap.copyOf(map));
+    SimpleEnv<ModuleSymbol, ModuleInfo> moduleEnv = new SimpleEnv<>(ImmutableMap.copyOf(modules));
+    TopLevelIndex index = SimpleTopLevelIndex.of(env.asMap().keySet());
+    return new ClassPath() {
+      @Override
+      public Env<ClassSymbol, BytecodeBoundClass> env() {
+        return env;
+      }
+
+      @Override
+      public Env<ModuleSymbol, ModuleInfo> moduleEnv() {
+        return moduleEnv;
+      }
+
+      @Override
+      public TopLevelIndex index() {
+        return index;
+      }
+    };
   }
 
   private static void bindJar(
-      TopLevelIndex.Builder tli,
       Path path,
       Map<ClassSymbol, BytecodeBoundClass> env,
+      Map<ModuleSymbol, ModuleInfo> modules,
       Env<ClassSymbol, BytecodeBoundClass> benv,
       Map<ClassSymbol, BytecodeBoundClass> transitive)
       throws IOException {
@@ -112,10 +121,15 @@
             });
         continue;
       }
+      if (name.substring(name.lastIndexOf('/') + 1).equals("module-info.class")) {
+        ModuleInfo moduleInfo =
+            BytecodeBinder.bindModuleInfo(path.toString(), toByteArrayOrDie(ze));
+        modules.put(new ModuleSymbol(moduleInfo.name()), moduleInfo);
+        continue;
+      }
       ClassSymbol sym = new ClassSymbol(name.substring(0, name.length() - ".class".length()));
       if (!env.containsKey(sym)) {
         env.put(sym, new BytecodeBoundClass(sym, toByteArrayOrDie(ze), benv, path.toString()));
-        tli.insert(sym);
       }
     }
   }
diff --git a/java/com/google/turbine/binder/CompUnitPreprocessor.java b/java/com/google/turbine/binder/CompUnitPreprocessor.java
index 4ec2d96..121b18d 100644
--- a/java/com/google/turbine/binder/CompUnitPreprocessor.java
+++ b/java/com/google/turbine/binder/CompUnitPreprocessor.java
@@ -25,15 +25,20 @@
 import com.google.turbine.binder.bound.SourceBoundClass;
 import com.google.turbine.binder.sym.ClassSymbol;
 import com.google.turbine.diag.SourceFile;
+import com.google.turbine.diag.TurbineError;
+import com.google.turbine.diag.TurbineError.ErrorKind;
 import com.google.turbine.model.TurbineFlag;
 import com.google.turbine.model.TurbineTyKind;
 import com.google.turbine.tree.Tree;
 import com.google.turbine.tree.Tree.CompUnit;
 import com.google.turbine.tree.Tree.ImportDecl;
+import com.google.turbine.tree.Tree.ModDecl;
 import com.google.turbine.tree.Tree.PkgDecl;
 import com.google.turbine.tree.Tree.TyDecl;
 import com.google.turbine.tree.TurbineModifier;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
 
 /**
  * Processes compilation units before binding, creating symbols for type declarations and desugaring
@@ -45,16 +50,19 @@
   public static class PreprocessedCompUnit {
     private final ImmutableList<Tree.ImportDecl> imports;
     private final ImmutableList<SourceBoundClass> types;
+    private final Optional<ModDecl> module;
     private final SourceFile source;
     private final String packageName;
 
     public PreprocessedCompUnit(
         ImmutableList<ImportDecl> imports,
         ImmutableList<SourceBoundClass> types,
+        Optional<ModDecl> module,
         SourceFile source,
         String packageName) {
       this.imports = imports;
       this.types = types;
+      this.module = module;
       this.source = source;
       this.packageName = packageName;
     }
@@ -67,6 +75,10 @@
       return types;
     }
 
+    Optional<ModDecl> module() {
+      return module;
+    }
+
     public SourceFile source() {
       return source;
     }
@@ -104,28 +116,35 @@
           new ClassSymbol((!packageName.isEmpty() ? packageName + "/" : "") + decl.name());
       int access = access(decl.mods(), decl.tykind());
       ImmutableMap<String, ClassSymbol> children =
-          preprocessChildren(types, sym, decl.members(), access);
+          preprocessChildren(unit.source(), types, sym, decl.members(), access);
       types.add(new SourceBoundClass(sym, null, children, access, decl));
     }
-    return new PreprocessedCompUnit(unit.imports(), types.build(), unit.source(), packageName);
+    return new PreprocessedCompUnit(
+        unit.imports(), types.build(), unit.mod(), unit.source(), packageName);
   }
 
   private static ImmutableMap<String, ClassSymbol> preprocessChildren(
+      SourceFile source,
       ImmutableList.Builder<SourceBoundClass> types,
       ClassSymbol owner,
       ImmutableList<Tree> members,
       int enclosing) {
     ImmutableMap.Builder<String, ClassSymbol> result = ImmutableMap.builder();
+    Set<String> seen = new HashSet<>();
     for (Tree member : members) {
       if (member.kind() == Tree.Kind.TY_DECL) {
         Tree.TyDecl decl = (Tree.TyDecl) member;
         ClassSymbol sym = new ClassSymbol(owner.binaryName() + '$' + decl.name());
+        if (!seen.add(decl.name())) {
+          throw TurbineError.format(
+              source, member.position(), ErrorKind.DUPLICATE_DECLARATION, sym);
+        }
         result.put(decl.name(), sym);
 
         int access = innerClassAccess(enclosing, decl);
 
         ImmutableMap<String, ClassSymbol> children =
-            preprocessChildren(types, sym, decl.members(), access);
+            preprocessChildren(source, types, sym, decl.members(), access);
         types.add(new SourceBoundClass(sym, owner, children, access, decl));
       }
     }
diff --git a/java/com/google/turbine/binder/ConstBinder.java b/java/com/google/turbine/binder/ConstBinder.java
index 3ac4d0a..4cb40a2 100644
--- a/java/com/google/turbine/binder/ConstBinder.java
+++ b/java/com/google/turbine/binder/ConstBinder.java
@@ -72,12 +72,21 @@
     this.origin = origin;
     this.base = base;
     this.env = env;
-    this.constEvaluator = new ConstEvaluator(origin, origin, base, base.scope(), constantEnv, env);
+    this.constEvaluator =
+        new ConstEvaluator(
+            origin, origin, base.memberImports(), base.source(), base.scope(), constantEnv, env);
   }
 
   public SourceTypeBoundClass bind() {
     ImmutableList<AnnoInfo> annos =
-        new ConstEvaluator(origin, base.owner(), base, base.enclosingScope(), constantEnv, env)
+        new ConstEvaluator(
+                origin,
+                base.owner(),
+                base.memberImports(),
+                base.source(),
+                base.enclosingScope(),
+                constantEnv,
+                env)
             .evaluateAnnotations(base.annotations());
     ImmutableList<TypeBoundClass.FieldInfo> fields = fields(base.fields());
     ImmutableList<MethodInfo> methods = bindMethods(base.methods());
diff --git a/java/com/google/turbine/binder/ConstEvaluator.java b/java/com/google/turbine/binder/ConstEvaluator.java
index 0598d8e..d661fb9 100644
--- a/java/com/google/turbine/binder/ConstEvaluator.java
+++ b/java/com/google/turbine/binder/ConstEvaluator.java
@@ -24,17 +24,18 @@
 import com.google.turbine.binder.bound.AnnotationValue;
 import com.google.turbine.binder.bound.ClassValue;
 import com.google.turbine.binder.bound.EnumConstantValue;
-import com.google.turbine.binder.bound.SourceTypeBoundClass;
 import com.google.turbine.binder.bound.TypeBoundClass;
 import com.google.turbine.binder.bound.TypeBoundClass.FieldInfo;
 import com.google.turbine.binder.bound.TypeBoundClass.MethodInfo;
 import com.google.turbine.binder.env.CompoundEnv;
 import com.google.turbine.binder.env.Env;
-import com.google.turbine.binder.lookup.CompoundScope;
 import com.google.turbine.binder.lookup.LookupKey;
 import com.google.turbine.binder.lookup.LookupResult;
+import com.google.turbine.binder.lookup.MemberImportIndex;
+import com.google.turbine.binder.lookup.Scope;
 import com.google.turbine.binder.sym.ClassSymbol;
 import com.google.turbine.binder.sym.FieldSymbol;
+import com.google.turbine.diag.SourceFile;
 import com.google.turbine.diag.TurbineError;
 import com.google.turbine.diag.TurbineError.ErrorKind;
 import com.google.turbine.model.Const;
@@ -72,8 +73,11 @@
   /** The symbol of the enclosing class, for lexical field lookups. */
   private final ClassSymbol owner;
 
-  /** The bound node of the enclosing class. */
-  private final SourceTypeBoundClass base;
+  /** Member imports of the enclosing compilation unit. */
+  private final MemberImportIndex memberImports;
+
+  /** The current source file. */
+  private final SourceFile source;
 
   /** The constant variable environment. */
   private final Env<FieldSymbol, Const.Value> values;
@@ -81,19 +85,21 @@
   /** The class environment. */
   private final CompoundEnv<ClassSymbol, TypeBoundClass> env;
 
-  private final CompoundScope scope;
+  private final Scope scope;
 
   public ConstEvaluator(
       ClassSymbol origin,
       ClassSymbol owner,
-      SourceTypeBoundClass base,
-      CompoundScope scope,
+      MemberImportIndex memberImports,
+      SourceFile source,
+      Scope scope,
       Env<FieldSymbol, Const.Value> values,
       CompoundEnv<ClassSymbol, TypeBoundClass> env) {
 
     this.origin = origin;
     this.owner = owner;
-    this.base = base;
+    this.memberImports = memberImports;
+    this.source = source;
     this.values = values;
     this.env = env;
     this.scope = scope;
@@ -230,14 +236,14 @@
     if (field != null) {
       return field;
     }
-    ClassSymbol classSymbol = base.memberImports().singleMemberImport(simpleName);
+    ClassSymbol classSymbol = memberImports.singleMemberImport(simpleName);
     if (classSymbol != null) {
       field = Resolve.resolveField(env, origin, classSymbol, simpleName);
       if (field != null) {
         return field;
       }
     }
-    Iterator<ClassSymbol> it = base.memberImports().onDemandImports();
+    Iterator<ClassSymbol> it = memberImports.onDemandImports();
     while (it.hasNext()) {
       field = Resolve.resolveField(env, origin, it.next(), simpleName);
       if (field == null) {
@@ -925,7 +931,7 @@
     for (String name : result.remaining()) {
       sym = Resolve.resolve(env, sym, sym, name);
     }
-    AnnoInfo annoInfo = evaluateAnnotation(new AnnoInfo(base.source(), sym, t, null));
+    AnnoInfo annoInfo = evaluateAnnotation(new AnnoInfo(source, sym, t, null));
     return new AnnotationValue(annoInfo.sym(), annoInfo.values());
   }
 
@@ -974,7 +980,7 @@
   }
 
   private TurbineError error(int position, ErrorKind kind, Object... args) {
-    return TurbineError.format(base.source(), position, kind, args);
+    return TurbineError.format(source, position, kind, args);
   }
 
   public Const.Value evalFieldInitializer(Expression expression, Type type) {
diff --git a/java/com/google/turbine/binder/CtSymClassBinder.java b/java/com/google/turbine/binder/CtSymClassBinder.java
new file mode 100644
index 0000000..0d71b8d
--- /dev/null
+++ b/java/com/google/turbine/binder/CtSymClassBinder.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2018 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.turbine.binder;
+
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+import com.google.common.collect.ImmutableMap;
+import com.google.turbine.binder.bound.ModuleInfo;
+import com.google.turbine.binder.bytecode.BytecodeBoundClass;
+import com.google.turbine.binder.env.Env;
+import com.google.turbine.binder.env.SimpleEnv;
+import com.google.turbine.binder.lookup.SimpleTopLevelIndex;
+import com.google.turbine.binder.lookup.TopLevelIndex;
+import com.google.turbine.binder.sym.ClassSymbol;
+import com.google.turbine.binder.sym.ModuleSymbol;
+import com.google.turbine.zip.Zip;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.HashMap;
+import java.util.Map;
+import javax.annotation.Nullable;
+
+/** Constructs a platform {@link ClassPath} from the current JDK's ct.sym file. */
+public class CtSymClassBinder {
+
+  @Nullable
+  public static ClassPath bind(String version) throws IOException {
+    Path javaHome = Paths.get(System.getProperty("java.home"));
+    Path ctSym = javaHome.resolve("lib/ct.sym");
+    if (!Files.exists(ctSym)) {
+      throw new IllegalStateException("lib/ct.sym does not exist in " + javaHome);
+    }
+    Map<ClassSymbol, BytecodeBoundClass> map = new HashMap<>();
+    Env<ClassSymbol, BytecodeBoundClass> benv =
+        new Env<ClassSymbol, BytecodeBoundClass>() {
+          @Override
+          public BytecodeBoundClass get(ClassSymbol sym) {
+            return map.get(sym);
+          }
+        };
+    // ct.sym contains directories whose names are the concatentation of a list of target versions
+    // (e.g. 789) and which contain interface class files with a .sig extension.
+    for (Zip.Entry ze : new Zip.ZipIterable(ctSym)) {
+      String name = ze.name();
+      if (!name.endsWith(".sig")) {
+        continue;
+      }
+      int idx = name.indexOf('/');
+      if (idx == -1) {
+        continue;
+      }
+      // check if the directory matches the desired release
+      // TODO(cushon): what happens when version numbers contain more than one digit?
+      if (!ze.name().substring(0, idx).contains(version)) {
+        continue;
+      }
+      ClassSymbol sym = new ClassSymbol(name.substring(idx + 1, name.length() - ".sig".length()));
+      if (!map.containsKey(sym)) {
+        map.put(
+            sym, new BytecodeBoundClass(sym, toByteArrayOrDie(ze), benv, ctSym + "!" + ze.name()));
+      }
+    }
+    if (map.isEmpty()) {
+      // we didn't find any classes for the desired release
+      return null;
+    }
+    SimpleEnv<ClassSymbol, BytecodeBoundClass> env = new SimpleEnv<>(ImmutableMap.copyOf(map));
+    // TODO(cushon): support ct.sym module-infos once they exist (JDK 10?)
+    Env<ModuleSymbol, ModuleInfo> moduleEnv = new SimpleEnv<>(ImmutableMap.of());
+    TopLevelIndex index = SimpleTopLevelIndex.of(env.asMap().keySet());
+    return new ClassPath() {
+      @Override
+      public Env<ClassSymbol, BytecodeBoundClass> env() {
+        return env;
+      }
+
+      @Override
+      public Env<ModuleSymbol, ModuleInfo> moduleEnv() {
+        return moduleEnv;
+      }
+
+      @Override
+      public TopLevelIndex index() {
+        return index;
+      }
+    };
+  }
+
+  private static Supplier<byte[]> toByteArrayOrDie(Zip.Entry ze) {
+    return Suppliers.memoize(
+        new Supplier<byte[]>() {
+          @Override
+          public byte[] get() {
+            return ze.data();
+          }
+        });
+  }
+}
diff --git a/java/com/google/turbine/binder/JimageClassBinder.java b/java/com/google/turbine/binder/JimageClassBinder.java
new file mode 100644
index 0000000..40be3a3
--- /dev/null
+++ b/java/com/google/turbine/binder/JimageClassBinder.java
@@ -0,0 +1,266 @@
+/*
+ * Copyright 2018 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.turbine.binder;
+
+import com.google.common.base.Joiner;
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableMultimap;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Table;
+import com.google.turbine.binder.bound.ModuleInfo;
+import com.google.turbine.binder.bytecode.BytecodeBinder;
+import com.google.turbine.binder.bytecode.BytecodeBoundClass;
+import com.google.turbine.binder.env.Env;
+import com.google.turbine.binder.lookup.LookupKey;
+import com.google.turbine.binder.lookup.LookupResult;
+import com.google.turbine.binder.lookup.Scope;
+import com.google.turbine.binder.lookup.TopLevelIndex;
+import com.google.turbine.binder.sym.ClassSymbol;
+import com.google.turbine.binder.sym.ModuleSymbol;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.net.URI;
+import java.nio.file.DirectoryStream;
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import javax.annotation.Nullable;
+
+/** Constructs a platform {@link ClassPath} from the current JDK's jimage file using jrtfs. */
+public class JimageClassBinder {
+
+  static JimageClassBinder create(FileSystem fileSystem) throws IOException {
+    Path modules = fileSystem.getPath("/modules");
+    Path packages = fileSystem.getPath("/packages");
+    ImmutableMultimap.Builder<String, String> packageMap = ImmutableMultimap.builder();
+    try (DirectoryStream<Path> ps = Files.newDirectoryStream(packages)) {
+      for (Path p : ps) {
+        String packageName = packages.relativize(p).toString().replace('.', '/');
+        try (DirectoryStream<Path> ms = Files.newDirectoryStream(p)) {
+          for (Path m : ms) {
+            packageMap.put(packageName, p.relativize(m).toString());
+          }
+        }
+      }
+    }
+    return new JimageClassBinder(packageMap.build(), modules);
+  }
+
+  /** Returns a platform classpath for the host JDK's jimage file. */
+  public static ClassPath bindDefault() throws IOException {
+    return JimageClassBinder.create(FileSystems.getFileSystem(URI.create("jrt:/")))
+    .new JimageClassPath();
+  }
+
+  /** Returns a platform classpath for the given JDK's jimage file. */
+  public static ClassPath bind(String javaHome) throws IOException {
+    if (javaHome.equals(System.getProperty("java.home"))) {
+      return bindDefault();
+    }
+    FileSystem fileSystem =
+        FileSystems.newFileSystem(URI.create("jrt:/"), ImmutableMap.of("java.home", javaHome));
+    return JimageClassBinder.create(fileSystem).new JimageClassPath();
+  }
+
+  private final Multimap<String, String> packageMap;
+  private final Path modulesRoot;
+
+  private final Set<String> loadedPackages = new HashSet<>();
+  private final Table<String, String, ClassSymbol> packageClassesBySimpleName =
+      HashBasedTable.create();
+  private final Map<String, ModuleInfo> moduleMap = new HashMap<>();
+  private final Map<ClassSymbol, BytecodeBoundClass> env = new HashMap<>();
+
+  public JimageClassBinder(ImmutableMultimap<String, String> packageMap, Path modules) {
+    this.packageMap = packageMap;
+    this.modulesRoot = modules;
+  }
+
+  Path modulePath(String moduleName) {
+    Path path = modulesRoot.resolve(moduleName);
+    return Files.exists(path) ? path : null;
+  }
+
+  ModuleInfo module(String moduleName) {
+    ModuleInfo result = moduleMap.get(moduleName);
+    if (result == null) {
+      Path path = modulePath(moduleName);
+      if (path == null) {
+        return null;
+      }
+      try {
+        path = path.resolve("module-info.class");
+        result = BytecodeBinder.bindModuleInfo(path.toString(), toByteArrayOrDie(path));
+      } catch (IOException e) {
+        throw new UncheckedIOException(e);
+      }
+      moduleMap.put(moduleName, result);
+    }
+    return result;
+  }
+
+  boolean initPackage(String packageName) {
+    Collection<String> moduleNames = packageMap.get(packageName);
+    if (moduleNames.isEmpty()) {
+      return false;
+    }
+    if (!loadedPackages.add(packageName)) {
+      return true;
+    }
+    Env<ClassSymbol, BytecodeBoundClass> env =
+        new Env<ClassSymbol, BytecodeBoundClass>() {
+          @Override
+          public BytecodeBoundClass get(ClassSymbol sym) {
+            return JimageClassBinder.this.env.get(sym);
+          }
+        };
+    for (String moduleName : moduleNames) {
+      if (moduleName != null) {
+        Path modulePath = modulePath(moduleName);
+        Path modulePackagePath = modulePath.resolve(packageName);
+        try (DirectoryStream<Path> ds = Files.newDirectoryStream(modulePackagePath)) {
+          for (Path path : ds) {
+            if (!Files.isRegularFile(path)
+                || path.getFileName().toString().equals("module-info.class")) {
+              continue;
+            }
+            String binaryName = modulePath.relativize(path).toString();
+            binaryName = binaryName.substring(0, binaryName.length() - ".class".length());
+            ClassSymbol sym = new ClassSymbol(binaryName);
+            packageClassesBySimpleName.put(packageName, simpleName(sym), sym);
+            JimageClassBinder.this.env.put(
+                sym, new BytecodeBoundClass(sym, toByteArrayOrDie(path), env, path.toString()));
+          }
+        } catch (IOException e) {
+          throw new UncheckedIOException(e);
+        }
+      }
+    }
+    return true;
+  }
+
+  private static Supplier<byte[]> toByteArrayOrDie(Path path) {
+    return Suppliers.memoize(
+        new Supplier<byte[]>() {
+          @Override
+          public byte[] get() {
+            try {
+              return Files.readAllBytes(path);
+            } catch (IOException e) {
+              throw new UncheckedIOException(e);
+            }
+          }
+        });
+  }
+
+  private static String simpleName(ClassSymbol sym) {
+    int idx = sym.binaryName().lastIndexOf('/');
+    return idx != -1 ? sym.binaryName().substring(idx + 1) : sym.binaryName();
+  }
+
+  private static String packageName(ClassSymbol sym) {
+    int idx = sym.binaryName().lastIndexOf('/');
+    return idx != -1 ? sym.binaryName().substring(0, idx) : "";
+  }
+
+  private class JimageTopLevelIndex implements TopLevelIndex {
+
+    final Scope topLevelScope =
+        new Scope() {
+          @Nullable
+          @Override
+          public LookupResult lookup(LookupKey lookupKey) {
+            // Find the longest prefix of the key that corresponds to a package name.
+            // TODO(cushon): SimpleTopLevelIndex uses a prefix map for this, does it matter?
+            Scope scope = null;
+            ImmutableList<String> names = lookupKey.simpleNames();
+            int idx = -1;
+            for (int i = 1; i < names.size(); i++) {
+              Scope cand = lookupPackage(names.subList(0, i));
+              if (cand != null) {
+                scope = cand;
+                idx = i;
+              }
+            }
+            return scope != null
+                ? scope.lookup(new LookupKey(names.subList(idx, names.size())))
+                : null;
+          }
+        };
+
+    @Override
+    public Scope scope() {
+      return topLevelScope;
+    }
+
+    @Override
+    public Scope lookupPackage(ImmutableList<String> name) {
+      String packageName = Joiner.on('/').join(name);
+      if (!initPackage(packageName)) {
+        return null;
+      }
+      return new Scope() {
+        @Nullable
+        @Override
+        public LookupResult lookup(LookupKey lookupKey) {
+          ClassSymbol sym = packageClassesBySimpleName.get(packageName, lookupKey.first());
+          return sym != null ? new LookupResult(sym, lookupKey) : null;
+        }
+      };
+    }
+  }
+
+  private class JimageClassPath implements ClassPath {
+
+    final TopLevelIndex index = new JimageTopLevelIndex();
+
+    @Override
+    public Env<ClassSymbol, BytecodeBoundClass> env() {
+      return new Env<ClassSymbol, BytecodeBoundClass>() {
+        @Override
+        public BytecodeBoundClass get(ClassSymbol sym) {
+          return initPackage(packageName(sym)) ? env.get(sym) : null;
+        }
+      };
+    }
+
+    @Override
+    public Env<ModuleSymbol, ModuleInfo> moduleEnv() {
+      return new Env<ModuleSymbol, ModuleInfo>() {
+        @Override
+        public ModuleInfo get(ModuleSymbol module) {
+          return module(module.name());
+        }
+      };
+    }
+
+    @Override
+    public TopLevelIndex index() {
+      return index;
+    }
+  }
+}
diff --git a/java/com/google/turbine/binder/ModuleBinder.java b/java/com/google/turbine/binder/ModuleBinder.java
new file mode 100644
index 0000000..23c9624
--- /dev/null
+++ b/java/com/google/turbine/binder/ModuleBinder.java
@@ -0,0 +1,222 @@
+/*
+ * Copyright 2018 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.turbine.binder;
+
+import static com.google.common.base.Verify.verifyNotNull;
+
+import com.google.common.base.Joiner;
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.turbine.binder.bound.ModuleInfo;
+import com.google.turbine.binder.bound.ModuleInfo.ExportInfo;
+import com.google.turbine.binder.bound.ModuleInfo.OpenInfo;
+import com.google.turbine.binder.bound.ModuleInfo.ProvideInfo;
+import com.google.turbine.binder.bound.ModuleInfo.RequireInfo;
+import com.google.turbine.binder.bound.ModuleInfo.UseInfo;
+import com.google.turbine.binder.bound.PackageSourceBoundModule;
+import com.google.turbine.binder.bound.TypeBoundClass;
+import com.google.turbine.binder.env.CompoundEnv;
+import com.google.turbine.binder.env.Env;
+import com.google.turbine.binder.env.SimpleEnv;
+import com.google.turbine.binder.lookup.CompoundScope;
+import com.google.turbine.binder.lookup.LookupKey;
+import com.google.turbine.binder.lookup.LookupResult;
+import com.google.turbine.binder.sym.ClassSymbol;
+import com.google.turbine.binder.sym.ModuleSymbol;
+import com.google.turbine.diag.TurbineError;
+import com.google.turbine.diag.TurbineError.ErrorKind;
+import com.google.turbine.model.TurbineFlag;
+import com.google.turbine.tree.Tree;
+import com.google.turbine.tree.Tree.ModDirective;
+import com.google.turbine.tree.Tree.ModExports;
+import com.google.turbine.tree.Tree.ModOpens;
+import com.google.turbine.tree.Tree.ModProvides;
+import com.google.turbine.tree.Tree.ModRequires;
+import com.google.turbine.tree.Tree.ModUses;
+import com.google.turbine.tree.TurbineModifier;
+import com.google.turbine.type.AnnoInfo;
+
+/** Binding pass for modules. */
+public class ModuleBinder {
+
+  public static ModuleInfo bind(
+      PackageSourceBoundModule module,
+      CompoundEnv<ClassSymbol, TypeBoundClass> env,
+      Env<ModuleSymbol, ModuleInfo> moduleEnv,
+      Optional<String> moduleVersion) {
+    return new ModuleBinder(module, env, moduleEnv, moduleVersion).bind();
+  }
+
+  private final PackageSourceBoundModule module;
+  private final CompoundEnv<ClassSymbol, TypeBoundClass> env;
+  private final Env<ModuleSymbol, ModuleInfo> moduleEnv;
+  private final Optional<String> moduleVersion;
+  private final CompoundScope scope;
+
+  public ModuleBinder(
+      PackageSourceBoundModule module,
+      CompoundEnv<ClassSymbol, TypeBoundClass> env,
+      Env<ModuleSymbol, ModuleInfo> moduleEnv,
+      Optional<String> moduleVersion) {
+    this.module = module;
+    this.env = env;
+    this.moduleEnv = moduleEnv;
+    this.moduleVersion = moduleVersion;
+    this.scope = module.scope().toScope(Resolve.resolveFunction(env, /* origin= */ null));
+  }
+
+  private ModuleInfo bind() {
+    // bind annotations; constant fields are already bound
+    ConstEvaluator constEvaluator =
+        new ConstEvaluator(
+            /* origin= */ null,
+            /* owner= */ null,
+            module.memberImports(),
+            module.source(),
+            scope,
+            /* values= */ new SimpleEnv<>(ImmutableMap.of()),
+            env);
+    ImmutableList.Builder<AnnoInfo> annoInfos = ImmutableList.builder();
+    for (Tree.Anno annoTree : module.module().annos()) {
+      ClassSymbol sym = resolve(annoTree.position(), annoTree.name());
+      annoInfos.add(new AnnoInfo(module.source(), sym, annoTree, null));
+    }
+    ImmutableList<AnnoInfo> annos = constEvaluator.evaluateAnnotations(annoInfos.build());
+
+    int flags = module.module().open() ? TurbineFlag.ACC_OPEN : 0;
+
+    // bind directives
+    ImmutableList.Builder<ModuleInfo.RequireInfo> requires = ImmutableList.builder();
+    ImmutableList.Builder<ModuleInfo.ExportInfo> exports = ImmutableList.builder();
+    ImmutableList.Builder<ModuleInfo.OpenInfo> opens = ImmutableList.builder();
+    ImmutableList.Builder<ModuleInfo.UseInfo> uses = ImmutableList.builder();
+    ImmutableList.Builder<ModuleInfo.ProvideInfo> provides = ImmutableList.builder();
+    boolean requiresJavaBase = false;
+    for (ModDirective directive : module.module().directives()) {
+      switch (directive.directiveKind()) {
+        case REQUIRES:
+          {
+            ModRequires require = (ModRequires) directive;
+            requiresJavaBase |= require.moduleName().equals(ModuleSymbol.JAVA_BASE.name());
+            requires.add(bindRequires(require));
+            break;
+          }
+        case EXPORTS:
+          exports.add(bindExports((ModExports) directive));
+          break;
+        case OPENS:
+          opens.add(bindOpens((ModOpens) directive));
+          break;
+        case USES:
+          uses.add(bindUses((ModUses) directive));
+          break;
+        case PROVIDES:
+          provides.add(bindProvides((ModProvides) directive));
+          break;
+        default:
+          throw new AssertionError(directive.kind());
+      }
+    }
+    if (!requiresJavaBase) {
+      // everything requires java.base, either explicitly or implicitly
+      ModuleInfo javaBaseModule = moduleEnv.get(ModuleSymbol.JAVA_BASE);
+      verifyNotNull(javaBaseModule, ModuleSymbol.JAVA_BASE.name());
+      requires =
+          ImmutableList.<RequireInfo>builder()
+              .add(
+                  new RequireInfo(
+                      ModuleSymbol.JAVA_BASE.name(),
+                      TurbineFlag.ACC_MANDATED,
+                      javaBaseModule.version()))
+              .addAll(requires.build());
+    }
+
+    return new ModuleInfo(
+        module.module().moduleName(),
+        moduleVersion.orNull(),
+        flags,
+        annos,
+        requires.build(),
+        exports.build(),
+        opens.build(),
+        uses.build(),
+        provides.build());
+  }
+
+  private RequireInfo bindRequires(ModRequires directive) {
+    String moduleName = directive.moduleName();
+    int flags = 0;
+    for (TurbineModifier mod : directive.mods()) {
+      switch (mod) {
+        case TRANSITIVE:
+          flags |= mod.flag();
+          break;
+        case STATIC:
+          // the 'static' modifier on requires translates to ACC_STATIC_PHASE, not ACC_STATIC
+          flags |= TurbineFlag.ACC_STATIC_PHASE;
+          break;
+        default:
+          throw new AssertionError(mod);
+      }
+    }
+    ModuleInfo requires = moduleEnv.get(new ModuleSymbol(moduleName));
+    return new RequireInfo(moduleName, flags, requires != null ? requires.version() : null);
+  }
+
+  private ExportInfo bindExports(ModExports directive) {
+    return new ExportInfo(directive.packageName(), directive.moduleNames());
+  }
+
+  private OpenInfo bindOpens(ModOpens directive) {
+    return new OpenInfo(directive.packageName(), directive.moduleNames());
+  }
+
+  private UseInfo bindUses(ModUses directive) {
+    return new UseInfo(resolve(directive.position(), directive.typeName()));
+  }
+
+  private ProvideInfo bindProvides(ModProvides directive) {
+    ClassSymbol sym = resolve(directive.position(), directive.typeName());
+    ImmutableList.Builder<ClassSymbol> impls = ImmutableList.builder();
+    for (ImmutableList<String> impl : directive.implNames()) {
+      impls.add(resolve(directive.position(), impl));
+    }
+    return new ProvideInfo(sym, impls.build());
+  }
+
+  /* Resolves qualified class names. */
+  private ClassSymbol resolve(int pos, ImmutableList<String> simpleNames) {
+    LookupKey key = new LookupKey(simpleNames);
+    LookupResult result = scope.lookup(key);
+    if (result == null) {
+      throw error(ErrorKind.SYMBOL_NOT_FOUND, pos, Joiner.on('.').join(simpleNames));
+    }
+    ClassSymbol sym = (ClassSymbol) result.sym();
+    for (String name : result.remaining()) {
+      sym = Resolve.resolve(env, /* origin= */ null, sym, name);
+      if (sym == null) {
+        throw error(ErrorKind.SYMBOL_NOT_FOUND, pos, name);
+      }
+    }
+    return sym;
+  }
+
+  private TurbineError error(ErrorKind kind, int pos, Object... args) {
+    return TurbineError.format(module.source(), pos, kind, args);
+  }
+}
diff --git a/java/com/google/turbine/binder/TypeBinder.java b/java/com/google/turbine/binder/TypeBinder.java
index b8f6ecd..964958b 100644
--- a/java/com/google/turbine/binder/TypeBinder.java
+++ b/java/com/google/turbine/binder/TypeBinder.java
@@ -476,7 +476,10 @@
     switch (base.kind()) {
       case INTERFACE:
       case ANNOTATION:
-        access |= TurbineFlag.ACC_PUBLIC;
+        // interface members have default public visibility
+        if ((access & TurbineVisibility.VISIBILITY_MASK) == 0) {
+          access |= TurbineFlag.ACC_PUBLIC;
+        }
         if ((access
                 & (TurbineFlag.ACC_DEFAULT | TurbineFlag.ACC_STATIC | TurbineFlag.ACC_SYNTHETIC))
             == 0) {
diff --git a/java/com/google/turbine/binder/bound/ModuleInfo.java b/java/com/google/turbine/binder/bound/ModuleInfo.java
new file mode 100644
index 0000000..9afd474
--- /dev/null
+++ b/java/com/google/turbine/binder/bound/ModuleInfo.java
@@ -0,0 +1,194 @@
+/*
+ * Copyright 2018 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.turbine.binder.bound;
+
+import com.google.common.collect.ImmutableList;
+import com.google.turbine.binder.sym.ClassSymbol;
+import com.google.turbine.type.AnnoInfo;
+import javax.annotation.Nullable;
+
+/** A bound module declaration (see JLS §7.7). */
+public class ModuleInfo {
+
+  private final String name;
+  @Nullable private final String version;
+  private final int flags;
+  private final ImmutableList<AnnoInfo> annos;
+  private final ImmutableList<RequireInfo> requires;
+  private final ImmutableList<ExportInfo> exports;
+  private final ImmutableList<OpenInfo> opens;
+  private final ImmutableList<UseInfo> uses;
+  private final ImmutableList<ProvideInfo> provides;
+
+  public ModuleInfo(
+      String name,
+      @Nullable String version,
+      int flags,
+      ImmutableList<AnnoInfo> annos,
+      ImmutableList<RequireInfo> requires,
+      ImmutableList<ExportInfo> exports,
+      ImmutableList<OpenInfo> opens,
+      ImmutableList<UseInfo> uses,
+      ImmutableList<ProvideInfo> provides) {
+    this.name = name;
+    this.version = version;
+    this.flags = flags;
+    this.annos = annos;
+    this.requires = requires;
+    this.exports = exports;
+    this.opens = opens;
+    this.uses = uses;
+    this.provides = provides;
+  }
+
+  public String name() {
+    return name;
+  }
+
+  @Nullable
+  public String version() {
+    return version;
+  }
+
+  public int flags() {
+    return flags;
+  }
+
+  public ImmutableList<AnnoInfo> annos() {
+    return annos;
+  }
+
+  public ImmutableList<RequireInfo> requires() {
+    return requires;
+  }
+
+  public ImmutableList<ExportInfo> exports() {
+    return exports;
+  }
+
+  public ImmutableList<OpenInfo> opens() {
+    return opens;
+  }
+
+  public ImmutableList<UseInfo> uses() {
+    return uses;
+  }
+
+  public ImmutableList<ProvideInfo> provides() {
+    return provides;
+  }
+
+  /** A JLS §7.7.1 requires directive. */
+  public static class RequireInfo {
+
+    private final String moduleName;
+    private final int flags;
+    private final String version;
+
+    public RequireInfo(String moduleName, int flags, String version) {
+      this.moduleName = moduleName;
+      this.flags = flags;
+      this.version = version;
+    }
+
+    public String moduleName() {
+      return moduleName;
+    }
+
+    public int flags() {
+      return flags;
+    }
+
+    public String version() {
+      return version;
+    }
+  }
+
+  /** A JLS §7.7.2 exports directive. */
+  public static class ExportInfo {
+
+    private final String packageName;
+    private final ImmutableList<String> modules;
+
+    public ExportInfo(String packageName, ImmutableList<String> modules) {
+      this.packageName = packageName;
+      this.modules = modules;
+    }
+
+    public String packageName() {
+      return packageName;
+    }
+
+    public ImmutableList<String> modules() {
+      return modules;
+    }
+  }
+
+  /** A JLS §7.7.2 opens directive. */
+  public static class OpenInfo {
+
+    private final String packageName;
+    private final ImmutableList<String> modules;
+
+    public OpenInfo(String packageName, ImmutableList<String> modules) {
+      this.packageName = packageName;
+      this.modules = modules;
+    }
+
+    public String packageName() {
+      return packageName;
+    }
+
+    public ImmutableList<String> modules() {
+      return modules;
+    }
+  }
+
+  /** A JLS §7.7.3 uses directive. */
+  public static class UseInfo {
+
+    private final ClassSymbol sym;
+
+    public UseInfo(ClassSymbol sym) {
+      this.sym = sym;
+    }
+
+    public ClassSymbol sym() {
+      return sym;
+    }
+  }
+
+  /** A JLS §7.7.4 provides directive. */
+  public static class ProvideInfo {
+
+    private final ClassSymbol sym;
+    private final ImmutableList<ClassSymbol> impls;
+
+    public ProvideInfo(ClassSymbol sym, ImmutableList<ClassSymbol> impls) {
+      this.sym = sym;
+      this.impls = impls;
+    }
+
+    public ClassSymbol sym() {
+      return sym;
+    }
+
+    public ImmutableList<ClassSymbol> impls() {
+      return impls;
+    }
+  }
+}
diff --git a/java/com/google/turbine/binder/bound/PackageSourceBoundModule.java b/java/com/google/turbine/binder/bound/PackageSourceBoundModule.java
new file mode 100644
index 0000000..c412b06
--- /dev/null
+++ b/java/com/google/turbine/binder/bound/PackageSourceBoundModule.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2018 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.turbine.binder.bound;
+
+import com.google.turbine.binder.lookup.ImportScope;
+import com.google.turbine.binder.lookup.MemberImportIndex;
+import com.google.turbine.diag.SourceFile;
+import com.google.turbine.tree.Tree.ModDecl;
+
+/** Wraps a {@link ModDecl} with lookup scopes for the current compilation unit and package. */
+public class PackageSourceBoundModule {
+
+  private final ModDecl module;
+  private final ImportScope scope;
+  private final MemberImportIndex memberImports;
+  private final SourceFile source;
+
+  public PackageSourceBoundModule(
+      ModDecl module, ImportScope scope, MemberImportIndex memberImports, SourceFile source) {
+    this.module = module;
+    this.scope = scope;
+    this.memberImports = memberImports;
+    this.source = source;
+  }
+
+  public ModDecl module() {
+    return module;
+  }
+
+  public ImportScope scope() {
+    return scope;
+  }
+
+  public MemberImportIndex memberImports() {
+    return memberImports;
+  }
+
+  public SourceFile source() {
+    return source;
+  }
+}
diff --git a/java/com/google/turbine/binder/bytecode/BytecodeBinder.java b/java/com/google/turbine/binder/bytecode/BytecodeBinder.java
index ca66046..4b92f11 100644
--- a/java/com/google/turbine/binder/bytecode/BytecodeBinder.java
+++ b/java/com/google/turbine/binder/bytecode/BytecodeBinder.java
@@ -17,8 +17,11 @@
 package com.google.turbine.binder.bytecode;
 
 import com.google.common.collect.ImmutableList;
+import com.google.turbine.binder.bound.ModuleInfo;
 import com.google.turbine.binder.sym.ClassSymbol;
 import com.google.turbine.binder.sym.TyVarSymbol;
+import com.google.turbine.bytecode.ClassFile;
+import com.google.turbine.bytecode.ClassReader;
 import com.google.turbine.bytecode.sig.Sig;
 import com.google.turbine.bytecode.sig.Sig.LowerBoundTySig;
 import com.google.turbine.bytecode.sig.Sig.UpperBoundTySig;
@@ -26,9 +29,11 @@
 import com.google.turbine.type.Type;
 import com.google.turbine.type.Type.ArrayTy;
 import com.google.turbine.type.Type.TyVar;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.function.Function;
+import java.util.function.Supplier;
 
 /** Bind {@link Type}s from bytecode. */
 public class BytecodeBinder {
@@ -88,4 +93,24 @@
   private static Type bindArrayTy(Sig.ArrayTySig arrayTySig, Function<String, TyVarSymbol> scope) {
     return new ArrayTy(bindTy(arrayTySig.elementType(), scope), ImmutableList.of());
   }
+
+  /**
+   * Returns a {@link ModuleInfo} given a module-info class file. Currently only the module's name,
+   * version, and flags are populated, since the directives are not needed by turbine at compile
+   * time.
+   */
+  public static ModuleInfo bindModuleInfo(String path, Supplier<byte[]> bytes) throws IOException {
+    ClassFile classFile = ClassReader.read(path, bytes.get());
+    ClassFile.ModuleInfo module = classFile.module();
+    return new ModuleInfo(
+        module.name(),
+        module.version(),
+        module.flags(),
+        /* annos= */ ImmutableList.of(),
+        /* requires= */ ImmutableList.of(),
+        /* exports= */ ImmutableList.of(),
+        /* opens= */ ImmutableList.of(),
+        /* uses= */ ImmutableList.of(),
+        /* provides= */ ImmutableList.of());
+  }
 }
diff --git a/java/com/google/turbine/binder/env/CompoundEnv.java b/java/com/google/turbine/binder/env/CompoundEnv.java
index 44f34c7..43ce768 100644
--- a/java/com/google/turbine/binder/env/CompoundEnv.java
+++ b/java/com/google/turbine/binder/env/CompoundEnv.java
@@ -16,7 +16,10 @@
 
 package com.google.turbine.binder.env;
 
+import static java.util.Objects.requireNonNull;
+
 import com.google.turbine.binder.sym.Symbol;
+import javax.annotation.Nullable;
 
 /** An {@link Env} that chains two existing envs together. */
 public class CompoundEnv<S extends Symbol, V> implements Env<S, V> {
@@ -24,9 +27,9 @@
   private final Env<S, ? extends V> base;
   private final Env<S, ? extends V> env;
 
-  private CompoundEnv(Env<S, ? extends V> base, Env<S, ? extends V> env) {
+  private CompoundEnv(@Nullable Env<S, ? extends V> base, Env<S, ? extends V> env) {
     this.base = base;
-    this.env = env;
+    this.env = requireNonNull(env);
   }
 
   @Override
diff --git a/java/com/google/turbine/binder/env/SimpleEnv.java b/java/com/google/turbine/binder/env/SimpleEnv.java
index 0b413ab..b07bf5f 100644
--- a/java/com/google/turbine/binder/env/SimpleEnv.java
+++ b/java/com/google/turbine/binder/env/SimpleEnv.java
@@ -18,6 +18,8 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.turbine.binder.sym.Symbol;
+import java.util.LinkedHashMap;
+import java.util.Map;
 
 /** A simple {@link ImmutableMap}-backed {@link Env}. */
 public class SimpleEnv<K extends Symbol, V> implements Env<K, V> {
@@ -38,14 +40,14 @@
 
   /** A builder for {@link SimpleEnv}static. */
   public static class Builder<K extends Symbol, V> {
-    private final ImmutableMap.Builder<K, V> map = ImmutableMap.builder();
+    private final Map<K, V> map = new LinkedHashMap<>();
 
-    public void put(K sym, V v) {
-      map.put(sym, v);
+    public V put(K sym, V v) {
+      return map.put(sym, v);
     }
 
     public SimpleEnv<K, V> build() {
-      return new SimpleEnv<>(map.build());
+      return new SimpleEnv<>(ImmutableMap.copyOf(map));
     }
   }
 
diff --git a/java/com/google/turbine/binder/lookup/CompoundTopLevelIndex.java b/java/com/google/turbine/binder/lookup/CompoundTopLevelIndex.java
new file mode 100644
index 0000000..526e493
--- /dev/null
+++ b/java/com/google/turbine/binder/lookup/CompoundTopLevelIndex.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2018 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.turbine.binder.lookup;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.ImmutableList;
+import javax.annotation.Nullable;
+
+/** A {@link TopLevelIndex} that aggregates multiple indices into one. */
+// Note: this implementation doesn't detect if the indices contain incompatible information,
+// e.g. a class name in one index that is a prefix of a package name in another index. This
+// shouldn't matter in practice because we rely on javac to reject invalid input, but it would
+// be nice to report an error in that case.
+// TODO(cushon): improve error handling
+public class CompoundTopLevelIndex implements TopLevelIndex {
+
+  private final ImmutableList<TopLevelIndex> indexes;
+
+  private CompoundTopLevelIndex(ImmutableList<TopLevelIndex> indexes) {
+    this.indexes = checkNotNull(indexes);
+  }
+
+  /** Creates a {@link CompoundTopLevelIndex}. */
+  public static CompoundTopLevelIndex of(TopLevelIndex... indexes) {
+    return new CompoundTopLevelIndex(ImmutableList.copyOf(indexes));
+  }
+
+  private final Scope scope =
+      new Scope() {
+        @Nullable
+        @Override
+        public LookupResult lookup(LookupKey lookupKey) {
+          // Return the first matching symbol.
+          for (TopLevelIndex index : indexes) {
+            LookupResult result = index.scope().lookup(lookupKey);
+            if (result != null) {
+              return result;
+            }
+          }
+          return null;
+        }
+      };
+
+  @Override
+  public Scope scope() {
+    return scope;
+  }
+
+  @Override
+  public Scope lookupPackage(ImmutableList<String> packagename) {
+    // When returning package scopes, build up a compound scope containing entries from all
+    // indices with matching packages.
+    CompoundScope result = null;
+    for (TopLevelIndex index : indexes) {
+      Scope packageScope = index.lookupPackage(packagename);
+      if (packageScope != null) {
+        result = result == null ? CompoundScope.base(packageScope) : result.append(packageScope);
+      }
+    }
+    return result;
+  }
+}
diff --git a/java/com/google/turbine/binder/lookup/ImportIndex.java b/java/com/google/turbine/binder/lookup/ImportIndex.java
index 48f9cc0..305bbfd 100644
--- a/java/com/google/turbine/binder/lookup/ImportIndex.java
+++ b/java/com/google/turbine/binder/lookup/ImportIndex.java
@@ -100,7 +100,7 @@
   /** Fully resolve the canonical name of a non-static named import. */
   private static ImportScope namedImport(
       SourceFile source, TopLevelIndex cpi, ImportDecl i, CanonicalSymbolResolver resolve) {
-    LookupResult result = cpi.lookup(new LookupKey(i.type()));
+    LookupResult result = cpi.scope().lookup(new LookupKey(i.type()));
     if (result == null) {
       throw TurbineError.format(
           source, i.position(), ErrorKind.SYMBOL_NOT_FOUND, Joiner.on('.').join(i.type()));
@@ -122,7 +122,7 @@
    * defer the rest.
    */
   private static ImportScope staticNamedImport(SourceFile source, TopLevelIndex cpi, ImportDecl i) {
-    LookupResult base = cpi.lookup(new LookupKey(i.type()));
+    LookupResult base = cpi.scope().lookup(new LookupKey(i.type()));
     if (base == null) {
       return null;
     }
diff --git a/java/com/google/turbine/binder/lookup/MemberImportIndex.java b/java/com/google/turbine/binder/lookup/MemberImportIndex.java
index ed1a9bd..e2ce5cb 100644
--- a/java/com/google/turbine/binder/lookup/MemberImportIndex.java
+++ b/java/com/google/turbine/binder/lookup/MemberImportIndex.java
@@ -52,7 +52,7 @@
                 new Supplier<ClassSymbol>() {
                   @Override
                   public ClassSymbol get() {
-                    LookupResult result = tli.lookup(new LookupKey(i.type()));
+                    LookupResult result = tli.scope().lookup(new LookupKey(i.type()));
                     return result != null ? resolve.resolve(source, i.position(), result) : null;
                   }
                 }));
@@ -63,13 +63,13 @@
                 new Supplier<ClassSymbol>() {
                   @Override
                   public ClassSymbol get() {
-                    LookupResult result1 = tli.lookup(new LookupKey(i.type()));
-                    if (result1 == null) {
+                    LookupResult result = tli.scope().lookup(new LookupKey(i.type()));
+                    if (result == null) {
                       return null;
                     }
-                    ClassSymbol sym = (ClassSymbol) result1.sym();
-                    for (int i = 0; i < result1.remaining().size() - 1; i++) {
-                      sym = resolve.resolveOne(sym, result1.remaining().get(i));
+                    ClassSymbol sym = (ClassSymbol) result.sym();
+                    for (int i = 0; i < result.remaining().size() - 1; i++) {
+                      sym = resolve.resolveOne(sym, result.remaining().get(i));
                     }
                     return sym;
                   }
diff --git a/java/com/google/turbine/binder/lookup/SimpleTopLevelIndex.java b/java/com/google/turbine/binder/lookup/SimpleTopLevelIndex.java
new file mode 100644
index 0000000..403c53f
--- /dev/null
+++ b/java/com/google/turbine/binder/lookup/SimpleTopLevelIndex.java
@@ -0,0 +1,185 @@
+/*
+ * Copyright 2018 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.turbine.binder.lookup;
+
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.turbine.binder.sym.ClassSymbol;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Objects;
+import javax.annotation.Nullable;
+
+/**
+ * An index of canonical type names where all members are known statically.
+ *
+ * <p>Qualified names are represented internally as a tree, where each package name part or class
+ * name is a node.
+ */
+public class SimpleTopLevelIndex implements TopLevelIndex {
+
+  /** A class symbol or package. */
+  public static class Node {
+
+    public Node lookup(String bit) {
+      return children.get(bit);
+    }
+
+    @Nullable private final ClassSymbol sym;
+
+    // TODO(cushon): the set of children is typically going to be small, consider optimizing this
+    // to use a denser representation where appropriate.
+    private final Map<String, Node> children = new HashMap<>();
+
+    Node(ClassSymbol sym) {
+      this.sym = sym;
+    }
+
+    /**
+     * Add a child with the given simple name. The given symbol will be null if a package is being
+     * inserted.
+     *
+     * @return {@code null} if an existing symbol with the same name has already been inserted.
+     */
+    private Node insert(String name, ClassSymbol sym) {
+      Node child;
+      if (children.containsKey(name)) {
+        child = children.get(name);
+        if (child.sym != null) {
+          return null;
+        }
+      } else {
+        child = new Node(sym);
+        children.put(name, child);
+      }
+      return child;
+    }
+  }
+
+  /** A builder for {@link TopLevelIndex}es. */
+  public static class Builder {
+
+    public TopLevelIndex build() {
+      // Freeze the index. The immutability of nodes is enforced by making insert private, doing
+      // a deep copy here isn't necessary.
+      return new SimpleTopLevelIndex(root);
+    }
+
+    /** The root of the lookup tree, effectively the package node of the default package. */
+    final Node root = new Node(null);
+
+    /** Inserts a {@link ClassSymbol} into the index, creating any needed packages. */
+    public boolean insert(ClassSymbol sym) {
+      Iterator<String> it = Splitter.on('/').split(sym.toString()).iterator();
+      Node curr = root;
+      while (it.hasNext()) {
+        String simpleName = it.next();
+        // if this is the last simple name in the qualified name of the top-level class being
+        // inserted, we are creating a node for the class symbol
+        ClassSymbol nodeSym = it.hasNext() ? null : sym;
+        curr = curr.insert(simpleName, nodeSym);
+        // If we've already inserted something with the current name (either a package or another
+        // symbol), bail out. When inserting elements from the classpath, this results in the
+        // expected first-match-wins semantics.
+        if (curr == null || !Objects.equals(curr.sym, nodeSym)) {
+          return false;
+        }
+      }
+      return true;
+    }
+  }
+
+  /** Returns a builder for {@link TopLevelIndex}es. */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  /** Creates an index over the given symbols. */
+  public static TopLevelIndex of(Iterable<ClassSymbol> syms) {
+    Builder builder = builder();
+    for (ClassSymbol sym : syms) {
+      builder.insert(sym);
+    }
+    return builder.build();
+  }
+
+  private SimpleTopLevelIndex(Node root) {
+    this.root = root;
+  }
+
+  final Node root;
+
+  /** Looks up top-level qualified type names. */
+  final Scope scope =
+      new Scope() {
+        @Override
+        @Nullable
+        public LookupResult lookup(LookupKey lookupKey) {
+          Node curr = root;
+          while (true) {
+            curr = curr.lookup(lookupKey.first());
+            if (curr == null) {
+              return null;
+            }
+            if (curr.sym != null) {
+              return new LookupResult(curr.sym, lookupKey);
+            }
+            if (!lookupKey.hasNext()) {
+              return null;
+            }
+            lookupKey = lookupKey.rest();
+          }
+        }
+      };
+
+  @Override
+  public Scope scope() {
+    return scope;
+  }
+
+  /** Returns a {@link Scope} that performs lookups in the given qualified package name. */
+  @Override
+  public Scope lookupPackage(ImmutableList<String> packagename) {
+    Node curr = root;
+    for (String bit : packagename) {
+      curr = curr.lookup(bit);
+      if (curr == null || curr.sym != null) {
+        return null;
+      }
+    }
+    return new PackageIndex(curr);
+  }
+
+  static class PackageIndex implements Scope {
+
+    private final Node node;
+
+    public PackageIndex(Node node) {
+      this.node = node;
+    }
+
+    @Override
+    public LookupResult lookup(LookupKey lookupKey) {
+      Node result = node.lookup(lookupKey.first());
+      if (result != null && result.sym != null) {
+        return new LookupResult(result.sym, lookupKey);
+      }
+      return null;
+    }
+  }
+}
diff --git a/java/com/google/turbine/binder/lookup/TopLevelIndex.java b/java/com/google/turbine/binder/lookup/TopLevelIndex.java
index 2784e4c..95782f5 100644
--- a/java/com/google/turbine/binder/lookup/TopLevelIndex.java
+++ b/java/com/google/turbine/binder/lookup/TopLevelIndex.java
@@ -16,14 +16,7 @@
 
 package com.google.turbine.binder.lookup;
 
-import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
-import com.google.turbine.binder.sym.ClassSymbol;
-import java.util.HashMap;
-import java.util.Iterator;
-import java.util.Map;
-import java.util.Objects;
-import javax.annotation.Nullable;
 
 /**
  * An index of canonical type names.
@@ -36,141 +29,12 @@
  * are resolved separately with appropriate handling of non-canonical names. For bytecode we may end
  * up storing desugared nested classes (e.g. {@code Map$Entry}), but we can't tell until the class
  * file has been read and we have access to the InnerClasses attribtue.
- *
- * <p>Qualified names are represented internally as a tree, where each package name part or class
- * name is a node.
  */
-public class TopLevelIndex implements Scope {
+public interface TopLevelIndex {
 
-  /** A class symbol or package. */
-  public static class Node {
+  /** Returns a scope to look up top-level qualified type names. */
+  Scope scope();
 
-    public Node lookup(String bit) {
-      return children.get(bit);
-    }
-
-    @Nullable private final ClassSymbol sym;
-
-    // TODO(cushon): the set of children is typically going to be small, consider optimizing this
-    // to use a denser representation where appropriate.
-    private final Map<String, Node> children = new HashMap<>();
-
-    Node(ClassSymbol sym) {
-      this.sym = sym;
-    }
-
-    /**
-     * Add a child with the given simple name. The given symbol will be null if a package is being
-     * inserted.
-     *
-     * @return {@code null} if an existing symbol with the same name has already been inserted.
-     */
-    private Node insert(String name, ClassSymbol sym) {
-      Node child;
-      if (children.containsKey(name)) {
-        child = children.get(name);
-        if (child.sym != null) {
-          return null;
-        }
-      } else {
-        child = new Node(sym);
-        children.put(name, child);
-      }
-      return child;
-    }
-  }
-
-  /** A builder for {@link TopLevelIndex}es. */
-  public static class Builder {
-
-    public TopLevelIndex build() {
-      // Freeze the index. The immutability of nodes is enforced by making insert private, doing
-      // a deep copy here isn't necessary.
-      return new TopLevelIndex(root);
-    }
-
-    /** The root of the lookup tree, effectively the package node of the default package. */
-    final Node root = new Node(null);
-
-    /** Inserts a {@link ClassSymbol} into the index, creating any needed packages. */
-    public boolean insert(ClassSymbol sym) {
-      Iterator<String> it = Splitter.on('/').split(sym.toString()).iterator();
-      Node curr = root;
-      while (it.hasNext()) {
-        String simpleName = it.next();
-        // if this is the last simple name in the qualified name of the top-level class being
-        // inserted, we are creating a node for the class symbol
-        ClassSymbol nodeSym = it.hasNext() ? null : sym;
-        curr = curr.insert(simpleName, nodeSym);
-        // If we've already inserted something with the current name (either a package or another
-        // symbol), bail out. When inserting elements from the classpath, this results in the
-        // expected first-match-wins semantics.
-        if (curr == null || !Objects.equals(curr.sym, nodeSym)) {
-          return false;
-        }
-      }
-      return true;
-    }
-  }
-
-  /** Returns a builder for {@link TopLevelIndex}es. */
-  public static Builder builder() {
-    return new Builder();
-  }
-
-  private TopLevelIndex(Node root) {
-    this.root = root;
-  }
-
-  final Node root;
-
-  /** Looks up top-level qualified type names. */
-  @Override
-  @Nullable
-  public LookupResult lookup(LookupKey lookupKey) {
-    Node curr = root;
-    while (true) {
-      curr = curr.lookup(lookupKey.first());
-      if (curr == null) {
-        return null;
-      }
-      if (curr.sym != null) {
-        return new LookupResult(curr.sym, lookupKey);
-      }
-      if (!lookupKey.hasNext()) {
-        return null;
-      }
-      lookupKey = lookupKey.rest();
-    }
-  }
-
-  /** Returns a {@link Scope} that performs lookups in the given qualified package name. */
-  public Scope lookupPackage(ImmutableList<String> packagename) {
-    Node curr = root;
-    for (String bit : packagename) {
-      curr = curr.lookup(bit);
-      if (curr == null || curr.sym != null) {
-        return null;
-      }
-    }
-    return new PackageIndex(curr);
-  }
-
-  static class PackageIndex implements Scope {
-
-    private final Node node;
-
-    public PackageIndex(Node node) {
-      this.node = node;
-    }
-
-    @Override
-    public LookupResult lookup(LookupKey lookupKey) {
-      Node result = node.children.get(lookupKey.first());
-      if (result != null && result.sym != null) {
-        return new LookupResult(result.sym, lookupKey);
-      }
-      return null;
-    }
-  }
+  /** Returns a scope to look up members of the given package. */
+  Scope lookupPackage(ImmutableList<String> packagename);
 }
diff --git a/java/com/google/turbine/binder/lookup/WildImportIndex.java b/java/com/google/turbine/binder/lookup/WildImportIndex.java
index 85019aa..21e995d 100644
--- a/java/com/google/turbine/binder/lookup/WildImportIndex.java
+++ b/java/com/google/turbine/binder/lookup/WildImportIndex.java
@@ -79,7 +79,7 @@
         }
       };
     }
-    LookupResult result = cpi.lookup(new LookupKey(i.type()));
+    LookupResult result = cpi.scope().lookup(new LookupKey(i.type()));
     if (result == null) {
       return null;
     }
@@ -104,7 +104,7 @@
       TopLevelIndex cpi,
       ImportDecl i,
       final CanonicalSymbolResolver importResolver) {
-    LookupResult result = cpi.lookup(new LookupKey(i.type()));
+    LookupResult result = cpi.scope().lookup(new LookupKey(i.type()));
     if (result == null) {
       return null;
     }
diff --git a/java/com/google/turbine/binder/sym/ModuleSymbol.java b/java/com/google/turbine/binder/sym/ModuleSymbol.java
new file mode 100644
index 0000000..e442353
--- /dev/null
+++ b/java/com/google/turbine/binder/sym/ModuleSymbol.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2018 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.turbine.binder.sym;
+
+import com.google.errorprone.annotations.Immutable;
+
+/** A module symbol. */
+@Immutable
+public class ModuleSymbol implements Symbol {
+
+  private final String name;
+
+  public ModuleSymbol(String name) {
+    this.name = name;
+  }
+
+  public String name() {
+    return name;
+  }
+
+  @Override
+  public Kind symKind() {
+    return Kind.MODULE;
+  }
+
+  @Override
+  public int hashCode() {
+    return name.hashCode();
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    return other instanceof ModuleSymbol && name.equals(((ModuleSymbol) other).name);
+  }
+
+  public static final ModuleSymbol JAVA_BASE = new ModuleSymbol("java.base");
+}
diff --git a/java/com/google/turbine/binder/sym/Symbol.java b/java/com/google/turbine/binder/sym/Symbol.java
index 51b5fe3..b2a7723 100644
--- a/java/com/google/turbine/binder/sym/Symbol.java
+++ b/java/com/google/turbine/binder/sym/Symbol.java
@@ -26,7 +26,8 @@
     CLASS,
     TY_PARAM,
     METHOD,
-    FIELD
+    FIELD,
+    MODULE
   }
 
   /** The symbol kind. */
diff --git a/java/com/google/turbine/bytecode/Attribute.java b/java/com/google/turbine/bytecode/Attribute.java
index 0700744..29efb60 100644
--- a/java/com/google/turbine/bytecode/Attribute.java
+++ b/java/com/google/turbine/bytecode/Attribute.java
@@ -19,6 +19,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.turbine.bytecode.ClassFile.AnnotationInfo;
 import com.google.turbine.bytecode.ClassFile.MethodInfo.ParameterInfo;
+import com.google.turbine.bytecode.ClassFile.ModuleInfo;
 import com.google.turbine.bytecode.ClassFile.TypeAnnotationInfo;
 import com.google.turbine.model.Const.Value;
 import java.util.List;
@@ -39,7 +40,8 @@
     DEPRECATED("Deprecated"),
     RUNTIME_VISIBLE_TYPE_ANNOTATIONS("RuntimeVisibleTypeAnnotations"),
     RUNTIME_INVISIBLE_TYPE_ANNOTATIONS("RuntimeInvisibleTypeAnnotations"),
-    METHOD_PARAMETERS("MethodParameters");
+    METHOD_PARAMETERS("MethodParameters"),
+    MODULE("Module");
 
     private final String signature;
 
@@ -288,4 +290,23 @@
       return Kind.METHOD_PARAMETERS;
     }
   }
+
+  /** A JVMS §4.7.25 Module attribute. */
+  class Module implements Attribute {
+
+    private final ModuleInfo module;
+
+    public Module(ModuleInfo module) {
+      this.module = module;
+    }
+
+    @Override
+    public Kind kind() {
+      return Kind.MODULE;
+    }
+
+    public ModuleInfo module() {
+      return module;
+    }
+  }
 }
diff --git a/java/com/google/turbine/bytecode/AttributeWriter.java b/java/com/google/turbine/bytecode/AttributeWriter.java
index 4eece56..5e3ea95 100644
--- a/java/com/google/turbine/bytecode/AttributeWriter.java
+++ b/java/com/google/turbine/bytecode/AttributeWriter.java
@@ -27,6 +27,12 @@
 import com.google.turbine.bytecode.Attribute.TypeAnnotations;
 import com.google.turbine.bytecode.ClassFile.AnnotationInfo;
 import com.google.turbine.bytecode.ClassFile.MethodInfo.ParameterInfo;
+import com.google.turbine.bytecode.ClassFile.ModuleInfo;
+import com.google.turbine.bytecode.ClassFile.ModuleInfo.ExportInfo;
+import com.google.turbine.bytecode.ClassFile.ModuleInfo.OpenInfo;
+import com.google.turbine.bytecode.ClassFile.ModuleInfo.ProvideInfo;
+import com.google.turbine.bytecode.ClassFile.ModuleInfo.RequireInfo;
+import com.google.turbine.bytecode.ClassFile.ModuleInfo.UseInfo;
 import com.google.turbine.bytecode.ClassFile.TypeAnnotationInfo;
 import com.google.turbine.model.Const;
 import java.util.List;
@@ -78,6 +84,9 @@
       case METHOD_PARAMETERS:
         writeMethodParameters((Attribute.MethodParameters) attribute);
         break;
+      case MODULE:
+        writeModule((Attribute.Module) attribute);
+        break;
       default:
         throw new AssertionError(attribute.kind());
     }
@@ -203,4 +212,60 @@
       output.writeShort(parameter.access());
     }
   }
+
+  private void writeModule(Attribute.Module attribute) {
+    ModuleInfo module = attribute.module();
+
+    ByteArrayDataOutput tmp = ByteStreams.newDataOutput();
+
+    tmp.writeShort(pool.moduleInfo(module.name()));
+    tmp.writeShort(module.flags());
+    tmp.writeShort(module.version() != null ? pool.utf8(module.version()) : 0);
+
+    tmp.writeShort(module.requires().size());
+    for (RequireInfo require : module.requires()) {
+      tmp.writeShort(pool.moduleInfo(require.moduleName()));
+      tmp.writeShort(require.flags());
+      tmp.writeShort(require.version() != null ? pool.utf8(require.version()) : 0);
+    }
+
+    tmp.writeShort(module.exports().size());
+    for (ExportInfo export : module.exports()) {
+      tmp.writeShort(pool.packageInfo(export.moduleName()));
+      tmp.writeShort(export.flags());
+      tmp.writeShort(export.modules().size());
+      for (String exportedModule : export.modules()) {
+        tmp.writeShort(pool.moduleInfo(exportedModule));
+      }
+    }
+
+    tmp.writeShort(module.opens().size());
+    for (OpenInfo opens : module.opens()) {
+      tmp.writeShort(pool.packageInfo(opens.moduleName()));
+      tmp.writeShort(opens.flags());
+      tmp.writeShort(opens.modules().size());
+      for (String openModule : opens.modules()) {
+        tmp.writeShort(pool.moduleInfo(openModule));
+      }
+    }
+
+    tmp.writeShort(module.uses().size());
+    for (UseInfo use : module.uses()) {
+      tmp.writeShort(pool.classInfo(use.descriptor()));
+    }
+
+    tmp.writeShort(module.provides().size());
+    for (ProvideInfo provide : module.provides()) {
+      tmp.writeShort(pool.classInfo(provide.descriptor()));
+      tmp.writeShort(provide.implDescriptors().size());
+      for (String impl : provide.implDescriptors()) {
+        tmp.writeShort(pool.classInfo(impl));
+      }
+    }
+
+    byte[] data = tmp.toByteArray();
+    output.writeShort(pool.utf8(attribute.kind().signature()));
+    output.writeInt(data.length);
+    output.write(data);
+  }
 }
diff --git a/java/com/google/turbine/bytecode/ClassFile.java b/java/com/google/turbine/bytecode/ClassFile.java
index 2761fec..502e295 100644
--- a/java/com/google/turbine/bytecode/ClassFile.java
+++ b/java/com/google/turbine/bytecode/ClassFile.java
@@ -41,6 +41,7 @@
   private final List<AnnotationInfo> annotations;
   private final List<InnerClass> innerClasses;
   private final ImmutableList<TypeAnnotationInfo> typeAnnotations;
+  @Nullable private final ModuleInfo module;
 
   public ClassFile(
       int access,
@@ -52,7 +53,8 @@
       List<FieldInfo> fields,
       List<AnnotationInfo> annotations,
       List<InnerClass> innerClasses,
-      ImmutableList<TypeAnnotationInfo> typeAnnotations) {
+      ImmutableList<TypeAnnotationInfo> typeAnnotations,
+      @Nullable ModuleInfo module) {
     this.access = access;
     this.name = name;
     this.signature = signature;
@@ -63,6 +65,7 @@
     this.annotations = annotations;
     this.innerClasses = innerClasses;
     this.typeAnnotations = typeAnnotations;
+    this.module = module;
   }
 
   /** Class access and property flags. */
@@ -115,6 +118,12 @@
     return typeAnnotations;
   }
 
+  /** A module attribute. */
+  @Nullable
+  public ModuleInfo module() {
+    return module;
+  }
+
   /** The contents of a JVMS §4.5 field_info structure. */
   public static class FieldInfo {
 
@@ -749,4 +758,180 @@
       }
     }
   }
+
+  /** A JVMS 4.7.25 module attribute. */
+  public static class ModuleInfo {
+
+    private final String name;
+    private final String version;
+    private final int flags;
+    private final ImmutableList<RequireInfo> requires;
+    private final ImmutableList<ExportInfo> exports;
+    private final ImmutableList<OpenInfo> opens;
+    private final ImmutableList<UseInfo> uses;
+    private final ImmutableList<ProvideInfo> provides;
+
+    public ModuleInfo(
+        String name,
+        int flags,
+        String version,
+        ImmutableList<RequireInfo> requires,
+        ImmutableList<ExportInfo> exports,
+        ImmutableList<OpenInfo> opens,
+        ImmutableList<UseInfo> uses,
+        ImmutableList<ProvideInfo> provides) {
+      this.name = name;
+      this.flags = flags;
+      this.version = version;
+      this.requires = requires;
+      this.exports = exports;
+      this.opens = opens;
+      this.uses = uses;
+      this.provides = provides;
+    }
+
+    public String name() {
+      return name;
+    }
+
+    public int flags() {
+      return flags;
+    }
+
+    public String version() {
+      return version;
+    }
+
+    public ImmutableList<RequireInfo> requires() {
+      return requires;
+    }
+
+    public ImmutableList<ExportInfo> exports() {
+      return exports;
+    }
+
+    public ImmutableList<OpenInfo> opens() {
+      return opens;
+    }
+
+    public ImmutableList<UseInfo> uses() {
+      return uses;
+    }
+
+    public ImmutableList<ProvideInfo> provides() {
+      return provides;
+    }
+
+    /** A JVMS 4.7.25 module requires directive. */
+    public static class RequireInfo {
+
+      private final String moduleName;
+      private final int flags;
+      private final String version;
+
+      public RequireInfo(String moduleName, int flags, String version) {
+        this.moduleName = moduleName;
+        this.flags = flags;
+        this.version = version;
+      }
+
+      public String moduleName() {
+        return moduleName;
+      }
+
+      public int flags() {
+        return flags;
+      }
+
+      public String version() {
+        return version;
+      }
+    }
+
+    /** A JVMS 4.7.25 module exports directive. */
+    public static class ExportInfo {
+
+      private final String moduleName;
+      private final int flags;
+      private final ImmutableList<String> modules;
+
+      public ExportInfo(String moduleName, int flags, ImmutableList<String> modules) {
+        this.moduleName = moduleName;
+        this.flags = flags;
+        this.modules = modules;
+      }
+
+      public String moduleName() {
+        return moduleName;
+      }
+
+      public int flags() {
+        return flags;
+      }
+
+      public ImmutableList<String> modules() {
+        return modules;
+      }
+    }
+
+    /** A JVMS 4.7.25 module opens directive. */
+    public static class OpenInfo {
+
+      private final String moduleName;
+      private final int flags;
+      private final ImmutableList<String> modules;
+
+      public OpenInfo(String moduleName, int flags, ImmutableList<String> modules) {
+        this.moduleName = moduleName;
+        this.flags = flags;
+        this.modules = modules;
+      }
+
+      public String moduleName() {
+        return moduleName;
+      }
+
+      public int flags() {
+        return flags;
+      }
+
+      public ImmutableList<String> modules() {
+        return modules;
+      }
+    }
+
+    /** A JVMS 4.7.25 module uses directive. */
+    public static class UseInfo {
+
+      private final String descriptor;
+
+      public UseInfo(String descriptor) {
+        this.descriptor = descriptor;
+      }
+
+      public String descriptor() {
+        return descriptor;
+      }
+    }
+
+    /** A JVMS 4.7.25 module provides directive. */
+    public static class ProvideInfo {
+
+      private final String descriptor;
+      private final ImmutableList<String> implDescriptors;
+
+      public ProvideInfo(String descriptor, ImmutableList<String> implDescriptors) {
+        this.descriptor = descriptor;
+        this.implDescriptors = implDescriptors;
+      }
+
+      public String descriptor() {
+        return descriptor;
+      }
+
+      public ImmutableList<String> implDescriptors() {
+        return implDescriptors;
+      }
+    }
+  }
 }
diff --git a/java/com/google/turbine/bytecode/ClassReader.java b/java/com/google/turbine/bytecode/ClassReader.java
index 96ad454..c8b4734 100644
--- a/java/com/google/turbine/bytecode/ClassReader.java
+++ b/java/com/google/turbine/bytecode/ClassReader.java
@@ -21,6 +21,12 @@
 import com.google.turbine.bytecode.ClassFile.AnnotationInfo.ElementValue;
 import com.google.turbine.bytecode.ClassFile.AnnotationInfo.ElementValue.ConstClassValue;
 import com.google.turbine.bytecode.ClassFile.AnnotationInfo.ElementValue.EnumConstValue;
+import com.google.turbine.bytecode.ClassFile.ModuleInfo;
+import com.google.turbine.bytecode.ClassFile.ModuleInfo.ExportInfo;
+import com.google.turbine.bytecode.ClassFile.ModuleInfo.OpenInfo;
+import com.google.turbine.bytecode.ClassFile.ModuleInfo.ProvideInfo;
+import com.google.turbine.bytecode.ClassFile.ModuleInfo.RequireInfo;
+import com.google.turbine.bytecode.ClassFile.ModuleInfo.UseInfo;
 import com.google.turbine.model.Const;
 import com.google.turbine.model.TurbineFlag;
 import java.util.ArrayList;
@@ -94,6 +100,7 @@
     String signature = null;
     List<ClassFile.InnerClass> innerclasses = Collections.emptyList();
     List<ClassFile.AnnotationInfo> annotations = Collections.emptyList();
+    ClassFile.ModuleInfo module = null;
     int attributesCount = reader.u2();
     for (int j = 0; j < attributesCount; j++) {
       int attributeNameIndex = reader.u2();
@@ -108,6 +115,9 @@
         case "InnerClasses":
           innerclasses = readInnerClasses(constantPool, thisClass);
           break;
+        case "Module":
+          module = readModule(constantPool);
+          break;
         default:
           reader.skip(reader.u4());
           break;
@@ -124,7 +134,8 @@
         fieldinfos,
         annotations,
         innerclasses,
-        ImmutableList.of());
+        ImmutableList.of(),
+        module);
   }
 
   /** Reads a JVMS 4.7.9 Signature attribute. */
@@ -182,6 +193,84 @@
     return annotations;
   }
 
+  /** Processes a JVMS 4.7.25 Module attribute. */
+  private ModuleInfo readModule(ConstantPoolReader constantPool) {
+    reader.u4(); // length
+    String name = constantPool.moduleInfo(reader.u2());
+    int flags = reader.u2();
+    int versionIndex = reader.u2();
+    String version = (versionIndex != 0) ? constantPool.utf8(versionIndex) : null;
+
+    ImmutableList.Builder<ClassFile.ModuleInfo.RequireInfo> requires = ImmutableList.builder();
+    int numRequires = reader.u2();
+    for (int i = 0; i < numRequires; i++) {
+      String requiresModule = constantPool.moduleInfo(reader.u2());
+      int requiresFlags = reader.u2();
+      int requiresVersionIndex = reader.u2();
+      String requiresVersion =
+          (requiresVersionIndex != 0) ? constantPool.utf8(requiresVersionIndex) : null;
+      requires.add(new RequireInfo(requiresModule, requiresFlags, requiresVersion));
+    }
+
+    ImmutableList.Builder<ClassFile.ModuleInfo.ExportInfo> exports = ImmutableList.builder();
+    int numExports = reader.u2();
+    for (int i = 0; i < numExports; i++) {
+      String exportsModule = constantPool.packageInfo(reader.u2());
+      int exportsFlags = reader.u2();
+      int numExportsTo = reader.u2();
+      ImmutableList.Builder<String> exportsToModules = ImmutableList.builder();
+      for (int n = 0; n < numExportsTo; n++) {
+        String exportsToModule = constantPool.moduleInfo(reader.u2());
+        exportsToModules.add(exportsToModule);
+      }
+      exports.add(new ExportInfo(exportsModule, exportsFlags, exportsToModules.build()));
+    }
+
+    ImmutableList.Builder<ClassFile.ModuleInfo.OpenInfo> opens = ImmutableList.builder();
+    int numOpens = reader.u2();
+    for (int i = 0; i < numOpens; i++) {
+      String opensModule = constantPool.packageInfo(reader.u2());
+      int opensFlags = reader.u2();
+      int numOpensTo = reader.u2();
+      ImmutableList.Builder<String> opensToModules = ImmutableList.builder();
+      for (int n = 0; n < numOpensTo; n++) {
+        String opensToModule = constantPool.moduleInfo(reader.u2());
+        opensToModules.add(opensToModule);
+      }
+      opens.add(new OpenInfo(opensModule, opensFlags, opensToModules.build()));
+    }
+
+    ImmutableList.Builder<ClassFile.ModuleInfo.UseInfo> uses = ImmutableList.builder();
+    int numUses = reader.u2();
+    for (int i = 0; i < numUses; i++) {
+      String use = constantPool.classInfo(reader.u2());
+      uses.add(new UseInfo(use));
+    }
+
+    ImmutableList.Builder<ClassFile.ModuleInfo.ProvideInfo> provides = ImmutableList.builder();
+    int numProvides = reader.u2();
+    for (int i = 0; i < numProvides; i++) {
+      String typeName = constantPool.classInfo(reader.u2());
+      int numProvidesWith = reader.u2();
+      ImmutableList.Builder<String> impls = ImmutableList.builder();
+      for (int n = 0; n < numProvidesWith; n++) {
+        String impl = constantPool.classInfo(reader.u2());
+        impls.add(impl);
+      }
+      provides.add(new ProvideInfo(typeName, impls.build()));
+    }
+
+    return new ClassFile.ModuleInfo(
+        name,
+        flags,
+        version,
+        requires.build(),
+        exports.build(),
+        opens.build(),
+        uses.build(),
+        provides.build());
+  }
+
   /**
    * Extracts an {@link @Retention} or {@link ElementType} {@link ClassFile.AnnotationInfo}, or else
    * skips over the annotation.
diff --git a/java/com/google/turbine/bytecode/ClassWriter.java b/java/com/google/turbine/bytecode/ClassWriter.java
index 42aff6c..4a89ec8 100644
--- a/java/com/google/turbine/bytecode/ClassWriter.java
+++ b/java/com/google/turbine/bytecode/ClassWriter.java
@@ -31,8 +31,10 @@
 
   private static final int MAGIC = 0xcafebabe;
   private static final int MINOR_VERSION = 0;
-  // TODO(cushon): configuration?
+  // use the lowest classfile version possible given the class file features
+  // TODO(cushon): is there a reason to support --release?
   private static final int MAJOR_VERSION = 52;
+  private static final int MODULE_MAJOR_VERSION = 53;
 
   /** Writes a {@link ClassFile} to bytecode. */
   public static byte[] writeClass(ClassFile classfile) {
@@ -54,7 +56,7 @@
       writeMethod(pool, output, m);
     }
     writeAttributes(pool, output, LowerAttributes.classAttributes(classfile));
-    return finishClass(pool, output);
+    return finishClass(pool, output, classfile);
   }
 
   private static void writeMethod(
@@ -89,6 +91,8 @@
       switch (e.kind()) {
         case CLASS_INFO:
         case STRING:
+        case MODULE:
+        case PACKAGE:
           output.writeShort(((IntValue) value).value());
           break;
         case INTEGER:
@@ -112,11 +116,12 @@
     }
   }
 
-  private static byte[] finishClass(ConstantPool pool, ByteArrayDataOutput body) {
+  private static byte[] finishClass(
+      ConstantPool pool, ByteArrayDataOutput body, ClassFile classfile) {
     ByteArrayDataOutput result = ByteStreams.newDataOutput();
     result.writeInt(MAGIC);
     result.writeShort(MINOR_VERSION);
-    result.writeShort(MAJOR_VERSION);
+    result.writeShort(classfile.module() != null ? MODULE_MAJOR_VERSION : MAJOR_VERSION);
     writeConstantPool(pool, result);
     result.write(body.toByteArray());
     return result.toByteArray();
diff --git a/java/com/google/turbine/bytecode/ConstantPool.java b/java/com/google/turbine/bytecode/ConstantPool.java
index 2f3141a..b423cfc 100644
--- a/java/com/google/turbine/bytecode/ConstantPool.java
+++ b/java/com/google/turbine/bytecode/ConstantPool.java
@@ -40,6 +40,8 @@
   private final Map<Double, Integer> doublePool = new HashMap<>();
   private final Map<Float, Integer> floatPool = new HashMap<>();
   private final Map<Long, Integer> longPool = new HashMap<>();
+  private final Map<Integer, Integer> modulePool = new HashMap<>();
+  private final Map<Integer, Integer> packagePool = new HashMap<>();
 
   private final List<Entry> constants = new ArrayList<>();
 
@@ -56,6 +58,8 @@
       case INTEGER:
       case UTF8:
       case FLOAT:
+      case MODULE:
+      case PACKAGE:
         return 1;
       case LONG:
       case DOUBLE:
@@ -158,6 +162,30 @@
     return index;
   }
 
+  /** Adds a CONSTANT_Module_info entry to the pool. */
+  int moduleInfo(String value) {
+    Objects.requireNonNull(value);
+    int utf8 = utf8(value);
+    if (modulePool.containsKey(utf8)) {
+      return modulePool.get(utf8);
+    }
+    int index = insert(new Entry(Kind.MODULE, new IntValue(utf8)));
+    modulePool.put(utf8, index);
+    return index;
+  }
+
+  /** Adds a CONSTANT_Package_info entry to the pool. */
+  int packageInfo(String value) {
+    Objects.requireNonNull(value);
+    int utf8 = utf8(value);
+    if (packagePool.containsKey(utf8)) {
+      return packagePool.get(utf8);
+    }
+    int index = insert(new Entry(Kind.PACKAGE, new IntValue(utf8)));
+    packagePool.put(utf8, index);
+    return index;
+  }
+
   private int insert(Entry key) {
     int entry = nextEntry;
     constants.add(key);
@@ -176,7 +204,9 @@
     DOUBLE(6),
     FLOAT(4),
     LONG(5),
-    UTF8(1);
+    UTF8(1),
+    MODULE(19),
+    PACKAGE(20);
 
     private final short tag;
 
diff --git a/java/com/google/turbine/bytecode/ConstantPoolReader.java b/java/com/google/turbine/bytecode/ConstantPoolReader.java
index cea034e..b6a2091 100644
--- a/java/com/google/turbine/bytecode/ConstantPoolReader.java
+++ b/java/com/google/turbine/bytecode/ConstantPoolReader.java
@@ -37,6 +37,8 @@
   static final int CONSTANT_METHOD_HANDLE = 15;
   static final int CONSTANT_METHOD_TYPE = 16;
   static final int CONSTANT_INVOKE_DYNAMIC = 18;
+  static final int CONSTANT_MODULE = 19;
+  static final int CONSTANT_PACKAGE = 20;
 
   /** A table that maps constant pool entries to byte offsets in {@link #byteReader}. */
   private final int[] constantPool;
@@ -70,6 +72,8 @@
       case CONSTANT_CLASS:
       case CONSTANT_METHOD_TYPE:
       case CONSTANT_STRING:
+      case CONSTANT_MODULE:
+      case CONSTANT_PACKAGE:
         reader.skip(2);
         return 1;
       case CONSTANT_DOUBLE:
@@ -119,6 +123,28 @@
     return reader.readUTF();
   }
 
+  /** Reads the CONSTANT_Module_info at the given index. */
+  public String moduleInfo(int index) {
+    ByteArrayDataInput reader = byteReader.seek(constantPool[index - 1]);
+    byte tag = reader.readByte();
+    if (tag != CONSTANT_MODULE) {
+      throw new AssertionError(String.format("bad tag: %x", tag));
+    }
+    int nameIndex = reader.readUnsignedShort();
+    return utf8(nameIndex);
+  }
+
+  /** Reads the CONSTANT_Package_info at the given index. */
+  public String packageInfo(int index) {
+    ByteArrayDataInput reader = byteReader.seek(constantPool[index - 1]);
+    byte tag = reader.readByte();
+    if (tag != CONSTANT_PACKAGE) {
+      throw new AssertionError(String.format("bad tag: %x", tag));
+    }
+    int nameIndex = reader.readUnsignedShort();
+    return utf8(nameIndex);
+  }
+
   /**
    * Reads a constant value at the given index, which must be one of CONSTANT_String_info,
    * CONSTANT_Integer_info, CONSTANT_Float_info, CONSTANT_Long_info, or CONSTANT_Double_info.
diff --git a/java/com/google/turbine/bytecode/LowerAttributes.java b/java/com/google/turbine/bytecode/LowerAttributes.java
index 1752456..67ef2b4 100644
--- a/java/com/google/turbine/bytecode/LowerAttributes.java
+++ b/java/com/google/turbine/bytecode/LowerAttributes.java
@@ -42,6 +42,9 @@
     if (classfile.signature() != null) {
       attributes.add(new Signature(classfile.signature()));
     }
+    if (classfile.module() != null) {
+      attributes.add(new Attribute.Module(classfile.module()));
+    }
     return attributes;
   }
 
diff --git a/java/com/google/turbine/deps/Dependencies.java b/java/com/google/turbine/deps/Dependencies.java
index a6ac05f..a3c654e 100644
--- a/java/com/google/turbine/deps/Dependencies.java
+++ b/java/com/google/turbine/deps/Dependencies.java
@@ -20,9 +20,9 @@
 import com.google.common.base.Predicates;
 import com.google.common.collect.Collections2;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.turbine.binder.Binder.BindingResult;
+import com.google.turbine.binder.ClassPath;
 import com.google.turbine.binder.bound.TypeBoundClass;
 import com.google.turbine.binder.bytecode.BytecodeBoundClass;
 import com.google.turbine.binder.env.CompoundEnv;
@@ -46,10 +46,7 @@
 public class Dependencies {
   /** Creates a jdeps proto for the current compilation. */
   public static DepsProto.Dependencies collectDeps(
-      Optional<String> targetLabel,
-      ImmutableSet<String> bootClassPath,
-      BindingResult bound,
-      Lowered lowered) {
+      Optional<String> targetLabel, ClassPath bootclasspath, BindingResult bound, Lowered lowered) {
     DepsProto.Dependencies.Builder deps = DepsProto.Dependencies.newBuilder();
     Set<ClassSymbol> closure = superTypeClosure(bound, lowered);
     addPackageInfos(closure, bound);
@@ -61,7 +58,7 @@
         continue;
       }
       String jarFile = info.jarFile();
-      if (bootClassPath.contains(jarFile)) {
+      if (bootclasspath.env().get(sym) != null) {
         // bootclasspath deps are not tracked
         continue;
       }
@@ -133,13 +130,13 @@
    */
   public static Collection<String> reduceClasspath(
       ImmutableList<String> transitiveClasspath,
-      ImmutableMap<String, String> directJarsToTargets,
+      ImmutableSet<String> directJars,
       ImmutableList<String> depsArtifacts) {
-    if (directJarsToTargets.isEmpty()) {
+    if (directJars.isEmpty()) {
       // the compilation doesn't support strict deps (e.g. proto libraries)
       return transitiveClasspath;
     }
-    Set<String> reduced = new HashSet<>(directJarsToTargets.keySet());
+    Set<String> reduced = new HashSet<>(directJars);
     for (String path : depsArtifacts) {
       DepsProto.Dependencies.Builder deps = DepsProto.Dependencies.newBuilder();
       try (InputStream is = new BufferedInputStream(Files.newInputStream(Paths.get(path)))) {
diff --git a/java/com/google/turbine/deps/Transitive.java b/java/com/google/turbine/deps/Transitive.java
index 5023159..f9a29a1 100644
--- a/java/com/google/turbine/deps/Transitive.java
+++ b/java/com/google/turbine/deps/Transitive.java
@@ -19,8 +19,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableList.Builder;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
 import com.google.turbine.binder.Binder.BindingResult;
+import com.google.turbine.binder.ClassPath;
 import com.google.turbine.binder.bound.TypeBoundClass;
 import com.google.turbine.binder.bytecode.BytecodeBoundClass;
 import com.google.turbine.binder.env.CompoundEnv;
@@ -43,7 +43,7 @@
 public class Transitive {
 
   public static ImmutableMap<String, byte[]> collectDeps(
-      ImmutableSet<String> bootClassPath, BindingResult bound) {
+      ClassPath bootClassPath, BindingResult bound) {
     ImmutableMap.Builder<String, byte[]> transitive = ImmutableMap.builder();
     for (ClassSymbol sym : superClosure(bound)) {
       BytecodeBoundClass info = bound.classPathEnv().get(sym);
@@ -51,8 +51,7 @@
         // the symbol wasn't loaded from the classpath
         continue;
       }
-      String jarFile = info.jarFile();
-      if (bootClassPath.contains(jarFile)) {
+      if (bootClassPath.env().get(sym) != null) {
         // don't export symbols loaded from the bootclasspath
         continue;
       }
@@ -97,7 +96,8 @@
         // well-known @interface meta-annotations (e.g. @Retention, etc.)
         cf.annotations(),
         innerClasses.build(),
-        cf.typeAnnotations());
+        cf.typeAnnotations(),
+        /* module= */ null);
   }
 
   private static Set<ClassSymbol> superClosure(BindingResult bound) {
diff --git a/java/com/google/turbine/diag/TurbineError.java b/java/com/google/turbine/diag/TurbineError.java
index 22abd3e..b8d6b65 100644
--- a/java/com/google/turbine/diag/TurbineError.java
+++ b/java/com/google/turbine/diag/TurbineError.java
@@ -41,7 +41,8 @@
     CYCLIC_HIERARCHY("cycle in class hierarchy: %s"),
     NOT_AN_ANNOTATION("%s is not an annotation"),
     NONREPEATABLE_ANNOTATION("%s is not @Repeatable"),
-    DUPLICATE_DECLARATION("duplicate declaration of %s");
+    DUPLICATE_DECLARATION("duplicate declaration of %s"),
+    BAD_MODULE_INFO("unexpected declaration found in module-info");
 
     private final String message;
 
diff --git a/java/com/google/turbine/lower/Lower.java b/java/com/google/turbine/lower/Lower.java
index 31079c4..d8b464b 100644
--- a/java/com/google/turbine/lower/Lower.java
+++ b/java/com/google/turbine/lower/Lower.java
@@ -16,6 +16,7 @@
 
 package com.google.turbine.lower;
 
+import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.turbine.binder.DisambiguateTypeAnnotations.groupRepeated;
 
 import com.google.common.base.Function;
@@ -26,6 +27,12 @@
 import com.google.turbine.binder.bound.AnnotationValue;
 import com.google.turbine.binder.bound.ClassValue;
 import com.google.turbine.binder.bound.EnumConstantValue;
+import com.google.turbine.binder.bound.ModuleInfo;
+import com.google.turbine.binder.bound.ModuleInfo.ExportInfo;
+import com.google.turbine.binder.bound.ModuleInfo.OpenInfo;
+import com.google.turbine.binder.bound.ModuleInfo.ProvideInfo;
+import com.google.turbine.binder.bound.ModuleInfo.RequireInfo;
+import com.google.turbine.binder.bound.ModuleInfo.UseInfo;
 import com.google.turbine.binder.bound.SourceTypeBoundClass;
 import com.google.turbine.binder.bound.TypeBoundClass;
 import com.google.turbine.binder.bound.TypeBoundClass.FieldInfo;
@@ -102,7 +109,8 @@
   /** Lowers all given classes to bytecode. */
   public static Lowered lowerAll(
       ImmutableMap<ClassSymbol, SourceTypeBoundClass> units,
-      CompoundEnv<ClassSymbol, BytecodeBoundClass> classpath) {
+      ImmutableList<ModuleInfo> modules,
+      Env<ClassSymbol, BytecodeBoundClass> classpath) {
     CompoundEnv<ClassSymbol, TypeBoundClass> env =
         CompoundEnv.<ClassSymbol, TypeBoundClass>of(classpath).append(new SimpleEnv<>(units));
     ImmutableMap.Builder<String, byte[]> result = ImmutableMap.builder();
@@ -110,6 +118,16 @@
     for (ClassSymbol sym : units.keySet()) {
       result.put(sym.binaryName(), lower(units.get(sym), env, sym, symbols));
     }
+    if (modules.size() == 1) {
+      // single module mode: the module-info.class file is at the root
+      result.put("module-info", lower(getOnlyElement(modules), env, symbols));
+    } else {
+      // multi-module mode: the output module-info.class are in a directory corresponding to their
+      // package
+      for (ModuleInfo module : modules) {
+        result.put(module.name().replace('.', '/') + "/module-info", lower(module, env, symbols));
+      }
+    }
     return new Lowered(result.build(), ImmutableSet.copyOf(symbols));
   }
 
@@ -122,6 +140,11 @@
     return new Lower(env).lower(info, sym, symbols);
   }
 
+  private static byte[] lower(
+      ModuleInfo module, CompoundEnv<ClassSymbol, TypeBoundClass> env, Set<ClassSymbol> symbols) {
+    return new Lower(env).lower(module, symbols);
+  }
+
   private final LowerSignature sig = new LowerSignature();
   private final Env<ClassSymbol, TypeBoundClass> env;
 
@@ -129,6 +152,82 @@
     this.env = env;
   }
 
+  private byte[] lower(ModuleInfo module, Set<ClassSymbol> symbols) {
+    String name = "module-info";
+    ImmutableList<AnnotationInfo> annotations = lowerAnnotations(module.annos());
+    ClassFile.ModuleInfo moduleInfo = lowerModule(module);
+
+    ImmutableList.Builder<ClassFile.InnerClass> innerClasses = ImmutableList.builder();
+    {
+      Set<ClassSymbol> all = new LinkedHashSet<>();
+      for (ClassSymbol sym : sig.classes) {
+        addEnclosing(env, all, sym);
+      }
+      for (ClassSymbol innerSym : all) {
+        innerClasses.add(innerClass(env, innerSym));
+      }
+    }
+
+    ClassFile classfile =
+        new ClassFile(
+            /* access= */ TurbineFlag.ACC_MODULE,
+            name,
+            /* signature= */ null,
+            /* superClass= */ null,
+            /* interfaces= */ ImmutableList.of(),
+            /* methods= */ ImmutableList.of(),
+            /* fields= */ ImmutableList.of(),
+            annotations,
+            innerClasses.build(),
+            /* typeAnnotations= */ ImmutableList.of(),
+            moduleInfo);
+    symbols.addAll(sig.classes);
+    return ClassWriter.writeClass(classfile);
+  }
+
+  private ClassFile.ModuleInfo lowerModule(ModuleInfo module) {
+    ImmutableList.Builder<ClassFile.ModuleInfo.RequireInfo> requires = ImmutableList.builder();
+    for (RequireInfo require : module.requires()) {
+      requires.add(
+          new ClassFile.ModuleInfo.RequireInfo(
+              require.moduleName(), require.flags(), require.version()));
+    }
+    ImmutableList.Builder<ClassFile.ModuleInfo.ExportInfo> exports = ImmutableList.builder();
+    for (ExportInfo export : module.exports()) {
+      int exportAccess = 0; // not synthetic or mandated
+      exports.add(
+          new ClassFile.ModuleInfo.ExportInfo(
+              export.packageName(), exportAccess, export.modules()));
+    }
+    ImmutableList.Builder<ClassFile.ModuleInfo.OpenInfo> opens = ImmutableList.builder();
+    for (OpenInfo open : module.opens()) {
+      int openAccess = 0; // not synthetic or mandated
+      opens.add(new ClassFile.ModuleInfo.OpenInfo(open.packageName(), openAccess, open.modules()));
+    }
+    ImmutableList.Builder<ClassFile.ModuleInfo.UseInfo> uses = ImmutableList.builder();
+    for (UseInfo use : module.uses()) {
+      uses.add(new ClassFile.ModuleInfo.UseInfo(sig.descriptor(use.sym())));
+    }
+    ImmutableList.Builder<ClassFile.ModuleInfo.ProvideInfo> provides = ImmutableList.builder();
+    for (ProvideInfo provide : module.provides()) {
+      ImmutableList.Builder<String> impls = ImmutableList.builder();
+      for (ClassSymbol impl : provide.impls()) {
+        impls.add(sig.descriptor(impl));
+      }
+      provides.add(
+          new ClassFile.ModuleInfo.ProvideInfo(sig.descriptor(provide.sym()), impls.build()));
+    }
+    return new ClassFile.ModuleInfo(
+        module.name(),
+        module.flags(),
+        module.version(),
+        requires.build(),
+        exports.build(),
+        opens.build(),
+        uses.build(),
+        provides.build());
+  }
+
   private byte[] lower(SourceTypeBoundClass info, ClassSymbol sym, Set<ClassSymbol> symbols) {
     int access = classAccess(info);
     String name = sig.descriptor(sym);
@@ -174,7 +273,8 @@
             fields.build(),
             annotations,
             inners,
-            typeAnnotations);
+            typeAnnotations,
+            /* module= */ null);
 
     symbols.addAll(sig.classes);
 
@@ -307,6 +407,10 @@
    */
   private void addEnclosing(
       Env<ClassSymbol, TypeBoundClass> env, Set<ClassSymbol> all, ClassSymbol sym) {
+    TypeBoundClass info = env.get(sym);
+    if (info == null) {
+      throw new AssertionError(sym);
+    }
     ClassSymbol owner = env.get(sym).owner();
     if (owner != null) {
       addEnclosing(env, all, owner);
diff --git a/java/com/google/turbine/main/Main.java b/java/com/google/turbine/main/Main.java
index 0fa2f83..31a36c4 100644
--- a/java/com/google/turbine/main/Main.java
+++ b/java/com/google/turbine/main/Main.java
@@ -18,11 +18,15 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.base.Optional;
 import com.google.common.collect.ImmutableList;
 import com.google.common.hash.Hashing;
 import com.google.turbine.binder.Binder;
 import com.google.turbine.binder.Binder.BindingResult;
+import com.google.turbine.binder.ClassPath;
 import com.google.turbine.binder.ClassPathBinder;
+import com.google.turbine.binder.CtSymClassBinder;
+import com.google.turbine.binder.JimageClassBinder;
 import com.google.turbine.deps.Dependencies;
 import com.google.turbine.deps.Transitive;
 import com.google.turbine.diag.SourceFile;
@@ -35,16 +39,22 @@
 import com.google.turbine.tree.Tree.CompUnit;
 import com.google.turbine.zip.Zip;
 import java.io.BufferedOutputStream;
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Map;
+import java.util.jar.Attributes;
 import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
 import java.util.jar.JarOutputStream;
+import java.util.jar.Manifest;
 import java.util.zip.ZipEntry;
 
 /** Main entry point for the turbine CLI. */
@@ -52,6 +62,13 @@
 
   private static final int BUFFER_SIZE = 65536;
 
+  // These attributes are used by JavaBuilder, Turbine, and ijar.
+  // They must all be kept in sync.
+  static final String MANIFEST_DIR = "META-INF/";
+  static final String MANIFEST_NAME = JarFile.MANIFEST_NAME;
+  static final Attributes.Name TARGET_LABEL = new Attributes.Name("Target-Label");
+  static final Attributes.Name INJECTING_RULE_KIND = new Attributes.Name("Injecting-Rule-Kind");
+
   public static void main(String[] args) throws IOException {
     compile(args);
   }
@@ -68,51 +85,75 @@
 
     ImmutableList<CompUnit> units = parseAll(options);
 
+    ClassPath bootclasspath = bootclasspath(options);
+
     Collection<String> reducedClasspath =
         Dependencies.reduceClasspath(
-            options.classPath(), options.directJarsToTargets(), options.depsArtifacts());
+            options.classPath(), options.directJars(), options.depsArtifacts());
+    ClassPath classpath = ClassPathBinder.bindClasspath(toPaths(reducedClasspath));
 
     BindingResult bound =
-        Binder.bind(units, toPaths(reducedClasspath), toPaths(options.bootClassPath()));
+        Binder.bind(units, classpath, bootclasspath, /* moduleVersion=*/ Optional.absent());
 
     // TODO(cushon): parallelize
-    Lowered lowered = Lower.lowerAll(bound.units(), bound.classPathEnv());
+    Lowered lowered = Lower.lowerAll(bound.units(), bound.modules(), bound.classPathEnv());
 
-    Map<String, byte[]> transitive = Transitive.collectDeps(options.bootClassPath(), bound);
+    Map<String, byte[]> transitive = Transitive.collectDeps(bootclasspath, bound);
 
     if (options.outputDeps().isPresent()) {
       DepsProto.Dependencies deps =
-          Dependencies.collectDeps(options.targetLabel(), options.bootClassPath(), bound, lowered);
+          Dependencies.collectDeps(options.targetLabel(), bootclasspath, bound, lowered);
       try (OutputStream os =
           new BufferedOutputStream(Files.newOutputStream(Paths.get(options.outputDeps().get())))) {
         deps.writeTo(os);
       }
     }
 
-    writeOutput(Paths.get(options.outputFile()), lowered.bytes(), transitive);
+    writeOutput(options, lowered.bytes(), transitive);
     return true;
   }
 
+  private static ClassPath bootclasspath(TurbineOptions options) throws IOException {
+    // if both --release and --bootclasspath are specified, --release wins
+    if (options.release().isPresent() && options.system().isPresent()) {
+      throw new IllegalArgumentException("expected at most one of --release and --system");
+    }
+
+    if (options.release().isPresent()) {
+      String release = options.release().get();
+      if (release.equals(System.getProperty("java.specification.version"))) {
+        // if --release matches the host JDK, use its jimage instead of ct.sym
+        return JimageClassBinder.bindDefault();
+      }
+      // ... otherwise, search ct.sym for a matching release
+      ClassPath bootclasspath = CtSymClassBinder.bind(release);
+      if (bootclasspath == null) {
+        throw new IllegalArgumentException("not a supported release: " + release);
+      }
+      return bootclasspath;
+    }
+
+    if (options.system().isPresent()) {
+      // look for a jimage in the given JDK
+      return JimageClassBinder.bind(options.system().get());
+    }
+
+    // the bootclasspath might be empty, e.g. when compiling java.lang
+    return ClassPathBinder.bindClasspath(toPaths(options.bootClassPath()));
+  }
+
   /** Parse all source files and source jars. */
   // TODO(cushon): parallelize
   private static ImmutableList<CompUnit> parseAll(TurbineOptions options) throws IOException {
     ImmutableList.Builder<CompUnit> units = ImmutableList.builder();
     for (String source : options.sources()) {
       Path path = Paths.get(source);
-      if (path.getFileName().toString().equals(MODULE_INFO_FILE_NAME)) {
-        continue;
-      }
       units.add(Parser.parse(new SourceFile(source, new String(Files.readAllBytes(path), UTF_8))));
     }
     for (String sourceJar : options.sourceJars()) {
       for (Zip.Entry ze : new Zip.ZipIterable(Paths.get(sourceJar))) {
         if (ze.name().endsWith(".java")) {
           String name = ze.name();
-          int idx = name.lastIndexOf('/');
-          String fileName = idx != -1 ? name.substring(idx + 1) : name;
-          if (fileName.equals(MODULE_INFO_FILE_NAME)) {
-            continue;
-          }
           String source = new String(ze.data(), UTF_8);
           units.add(Parser.parse(new SourceFile(name, source)));
         }
@@ -121,14 +162,11 @@
     return units.build();
   }
 
-  // turbine currently ignores module-info.java files, because they are not needed for header
-  // compilation.
-  // TODO(b/36109466): understand requirements for full Java 9 source support (e.g. module paths)
-  static final String MODULE_INFO_FILE_NAME = "module-info.java";
-
   /** Write bytecode to the output jar. */
   private static void writeOutput(
-      Path path, Map<String, byte[]> lowered, Map<String, byte[]> transitive) throws IOException {
+      TurbineOptions options, Map<String, byte[]> lowered, Map<String, byte[]> transitive)
+      throws IOException {
+    Path path = Paths.get(options.outputFile());
     try (OutputStream os = Files.newOutputStream(path);
         BufferedOutputStream bos = new BufferedOutputStream(os, BUFFER_SIZE);
         JarOutputStream jos = new JarOutputStream(bos)) {
@@ -139,12 +177,24 @@
         addEntry(
             jos, ClassPathBinder.TRANSITIVE_PREFIX + entry.getKey() + ".class", entry.getValue());
       }
+      if (options.targetLabel().isPresent()) {
+        addEntry(jos, MANIFEST_DIR, new byte[] {});
+        addEntry(jos, MANIFEST_NAME, manifestContent(options));
+      }
     }
   }
 
+  /** Normalize timestamps. */
+  static final long DEFAULT_TIMESTAMP =
+      LocalDateTime.of(2010, 1, 1, 0, 0, 0)
+          .atZone(ZoneId.systemDefault())
+          .toInstant()
+          .toEpochMilli();
+
   private static void addEntry(JarOutputStream jos, String name, byte[] bytes) throws IOException {
     JarEntry je = new JarEntry(name);
-    je.setTime(0L); // normalize timestamps to the DOS epoch
+    // TODO(cushon): switch to setLocalTime after we migrate to JDK 9
+    je.setTime(DEFAULT_TIMESTAMP);
     je.setMethod(ZipEntry.STORED);
     je.setSize(bytes.length);
     je.setCrc(Hashing.crc32().hashBytes(bytes).padToLong());
@@ -152,6 +202,25 @@
     jos.write(bytes);
   }
 
+  private static byte[] manifestContent(TurbineOptions turbineOptions) throws IOException {
+    Manifest manifest = new Manifest();
+    Attributes attributes = manifest.getMainAttributes();
+    attributes.put(Attributes.Name.MANIFEST_VERSION, "1.0");
+    Attributes.Name createdBy = new Attributes.Name("Created-By");
+    if (attributes.getValue(createdBy) == null) {
+      attributes.put(createdBy, "bazel");
+    }
+    if (turbineOptions.targetLabel().isPresent()) {
+      attributes.put(TARGET_LABEL, turbineOptions.targetLabel().get());
+    }
+    if (turbineOptions.injectingRuleKind().isPresent()) {
+      attributes.put(INJECTING_RULE_KIND, turbineOptions.injectingRuleKind().get());
+    }
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    manifest.write(out);
+    return out.toByteArray();
+  }
+
   private static ImmutableList<Path> toPaths(Iterable<String> paths) {
     ImmutableList.Builder<Path> result = ImmutableList.builder();
     for (String path : paths) {
diff --git a/java/com/google/turbine/model/TurbineFlag.java b/java/com/google/turbine/model/TurbineFlag.java
index 18fc81e..48e88e7 100644
--- a/java/com/google/turbine/model/TurbineFlag.java
+++ b/java/com/google/turbine/model/TurbineFlag.java
@@ -29,7 +29,10 @@
   public static final int ACC_STATIC = 0x0008;
   public static final int ACC_FINAL = 0x0010;
   public static final int ACC_SYNCHRONIZED = 0x0020;
+  public static final int ACC_OPEN = 0x0020;
   public static final int ACC_SUPER = 0x0020;
+  public static final int ACC_TRANSITIVE = 0x0020;
+  public static final int ACC_STATIC_PHASE = 0x0040;
   public static final int ACC_BRIDGE = 0x0040;
   public static final int ACC_VOLATILE = 0x0040;
   public static final int ACC_VARARGS = 0x0080;
@@ -41,6 +44,7 @@
   public static final int ACC_SYNTHETIC = 0x1000;
   public static final int ACC_ANNOTATION = 0x2000;
   public static final int ACC_ENUM = 0x4000;
+  public static final int ACC_MODULE = 0x8000;
   public static final int ACC_MANDATED = 0x8000;
 
   // TODO(cushon): the rest of these aren't spec'd access bits, put them somewhere else?
diff --git a/java/com/google/turbine/model/TurbineVisibility.java b/java/com/google/turbine/model/TurbineVisibility.java
index ce901ee..4f250c7 100644
--- a/java/com/google/turbine/model/TurbineVisibility.java
+++ b/java/com/google/turbine/model/TurbineVisibility.java
@@ -39,9 +39,11 @@
     return flag;
   }
 
+  public static final int VISIBILITY_MASK =
+      TurbineFlag.ACC_PUBLIC | TurbineFlag.ACC_PRIVATE | TurbineFlag.ACC_PROTECTED;
+
   public static TurbineVisibility fromAccess(int access) {
-    switch (access
-        & (TurbineFlag.ACC_PUBLIC | TurbineFlag.ACC_PRIVATE | TurbineFlag.ACC_PROTECTED)) {
+    switch (access & VISIBILITY_MASK) {
       case TurbineFlag.ACC_PUBLIC:
         return PUBLIC;
       case TurbineFlag.ACC_PRIVATE:
diff --git a/java/com/google/turbine/options/TurbineOptions.java b/java/com/google/turbine/options/TurbineOptions.java
index ba9396f..20d81fe 100644
--- a/java/com/google/turbine/options/TurbineOptions.java
+++ b/java/com/google/turbine/options/TurbineOptions.java
@@ -20,7 +20,6 @@
 
 import com.google.common.base.Optional;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import javax.annotation.Nullable;
 
@@ -30,16 +29,17 @@
   private final String output;
   private final ImmutableList<String> classPath;
   private final ImmutableSet<String> bootClassPath;
+  private final Optional<String> release;
+  private final Optional<String> system;
   private final ImmutableList<String> sources;
   private final ImmutableList<String> processorPath;
   private final ImmutableSet<String> processors;
   private final ImmutableList<String> sourceJars;
   private final Optional<String> outputDeps;
-  private final ImmutableMap<String, String> directJarsToTargets;
-  private final ImmutableMap<String, String> indirectJarsToTargets;
+  private final ImmutableSet<String> directJars;
   private final Optional<String> targetLabel;
+  private final Optional<String> injectingRuleKind;
   private final ImmutableList<String> depsArtifacts;
-  private final Optional<String> ruleKind;
   private final boolean javacFallback;
   private final ImmutableList<String> javacOpts;
   private final boolean shouldReduceClassPath;
@@ -48,34 +48,34 @@
       String output,
       ImmutableList<String> classPath,
       ImmutableSet<String> bootClassPath,
+      String release,
+      String system,
       ImmutableList<String> sources,
       ImmutableList<String> processorPath,
       ImmutableSet<String> processors,
       ImmutableList<String> sourceJars,
       @Nullable String outputDeps,
-      ImmutableMap<String, String> directJarsToTargets,
-      ImmutableMap<String, String> indirectJarsToTargets,
+      ImmutableSet<String> directJars,
       @Nullable String targetLabel,
+      @Nullable String injectingRuleKind,
       ImmutableList<String> depsArtifacts,
-      @Nullable String ruleKind,
       boolean javacFallback,
       ImmutableList<String> javacOpts,
       boolean shouldReduceClassPath) {
     this.output = checkNotNull(output, "output must not be null");
     this.classPath = checkNotNull(classPath, "classPath must not be null");
     this.bootClassPath = checkNotNull(bootClassPath, "bootClassPath must not be null");
+    this.release = Optional.fromNullable(release);
+    this.system = Optional.fromNullable(system);
     this.sources = checkNotNull(sources, "sources must not be null");
     this.processorPath = checkNotNull(processorPath, "processorPath must not be null");
     this.processors = checkNotNull(processors, "processors must not be null");
     this.sourceJars = checkNotNull(sourceJars, "sourceJars must not be null");
     this.outputDeps = Optional.fromNullable(outputDeps);
-    this.directJarsToTargets =
-        checkNotNull(directJarsToTargets, "directJarsToTargets must not be null");
-    this.indirectJarsToTargets =
-        checkNotNull(indirectJarsToTargets, "indirectJarsToTargets must not be null");
+    this.directJars = checkNotNull(directJars, "directJars must not be null");
     this.targetLabel = Optional.fromNullable(targetLabel);
+    this.injectingRuleKind = Optional.fromNullable(injectingRuleKind);
     this.depsArtifacts = checkNotNull(depsArtifacts, "depsArtifacts must not be null");
-    this.ruleKind = Optional.fromNullable(ruleKind);
     this.javacFallback = javacFallback;
     this.javacOpts = checkNotNull(javacOpts, "javacOpts must not be null");
     this.shouldReduceClassPath = shouldReduceClassPath;
@@ -96,6 +96,16 @@
     return bootClassPath;
   }
 
+  /** The target platform version. */
+  public Optional<String> release() {
+    return release;
+  }
+
+  /** The target platform's system modules. */
+  public Optional<String> system() {
+    return system;
+  }
+
   /** The output jar. */
   public String outputFile() {
     return output;
@@ -121,14 +131,9 @@
     return outputDeps;
   }
 
-  /** The mapping from the path to a direct dependency to its build label. */
-  public ImmutableMap<String, String> directJarsToTargets() {
-    return directJarsToTargets;
-  }
-
-  /** The mapping from the path to an indirect dependency to its build label. */
-  public ImmutableMap<String, String> indirectJarsToTargets() {
-    return indirectJarsToTargets;
+  /** The direct dependencies. */
+  public ImmutableSet<String> directJars() {
+    return directJars;
   }
 
   /** The label of the target being compiled. */
@@ -136,16 +141,20 @@
     return targetLabel;
   }
 
+  /**
+   * If present, the name of the rule that injected an aspect that compiles this target.
+   *
+   * <p>Note that this rule will have a completely different label to {@link #targetLabel} above.
+   */
+  public Optional<String> injectingRuleKind() {
+    return injectingRuleKind;
+  }
+
   /** The .jdeps artifacts for direct dependencies. */
   public ImmutableList<String> depsArtifacts() {
     return depsArtifacts;
   }
 
-  /** The kind of the build rule being compiled (e.g. {@code java_library}). */
-  public Optional<String> ruleKind() {
-    return ruleKind;
-  }
-
   /** Fall back to javac-turbine for error reporting. */
   public boolean javacFallback() {
     return javacFallback;
@@ -175,13 +184,13 @@
     private final ImmutableSet.Builder<String> processors = ImmutableSet.builder();
     private final ImmutableList.Builder<String> sourceJars = ImmutableList.builder();
     private final ImmutableSet.Builder<String> bootClassPath = ImmutableSet.builder();
+    @Nullable private String release;
+    @Nullable private String system;
     private String outputDeps;
-    private final ImmutableMap.Builder<String, String> directJarsToTargets = ImmutableMap.builder();
-    private final ImmutableMap.Builder<String, String> indirectJarsToTargets =
-        ImmutableMap.builder();
+    private final ImmutableSet.Builder<String> directJars = ImmutableSet.builder();
     @Nullable private String targetLabel;
+    @Nullable private String injectingRuleKind;
     private final ImmutableList.Builder<String> depsArtifacts = ImmutableList.builder();
-    @Nullable private String ruleKind;
     private boolean javacFallback = true;
     private final ImmutableList.Builder<String> javacOpts = ImmutableList.builder();
     private boolean shouldReduceClassPath = true;
@@ -191,16 +200,17 @@
           output,
           classPath.build(),
           bootClassPath.build(),
+          release,
+          system,
           sources.build(),
           processorPath.build(),
           processors.build(),
           sourceJars.build(),
           outputDeps,
-          directJarsToTargets.build(),
-          indirectJarsToTargets.build(),
+          directJars.build(),
           targetLabel,
+          injectingRuleKind,
           depsArtifacts.build(),
-          ruleKind,
           javacFallback,
           javacOpts.build(),
           shouldReduceClassPath);
@@ -221,6 +231,16 @@
       return this;
     }
 
+    public Builder setRelease(String release) {
+      this.release = release;
+      return this;
+    }
+
+    public Builder setSystem(String system) {
+      this.system = system;
+      return this;
+    }
+
     public Builder addSources(Iterable<String> sources) {
       this.sources.addAll(sources);
       return this;
@@ -251,13 +271,9 @@
       return this;
     }
 
-    public Builder addDirectJarToTarget(String jar, String target) {
-      directJarsToTargets.put(jar, target);
-      return this;
-    }
-
-    public Builder addIndirectJarToTarget(String jar, String target) {
-      indirectJarsToTargets.put(jar, target);
+    // TODO(b/72379900): Remove this
+    public Builder addDirectJarToTarget(String jar) {
+      directJars.add(jar);
       return this;
     }
 
@@ -266,13 +282,13 @@
       return this;
     }
 
-    public Builder addAllDepsArtifacts(Iterable<String> depsArtifacts) {
-      this.depsArtifacts.addAll(depsArtifacts);
+    public Builder setInjectingRuleKind(String injectingRuleKind) {
+      this.injectingRuleKind = injectingRuleKind;
       return this;
     }
 
-    public Builder setRuleKind(String ruleKind) {
-      this.ruleKind = ruleKind;
+    public Builder addAllDepsArtifacts(Iterable<String> depsArtifacts) {
+      this.depsArtifacts.addAll(depsArtifacts);
       return this;
     }
 
@@ -290,5 +306,10 @@
       this.shouldReduceClassPath = shouldReduceClassPath;
       return this;
     }
+
+    public Builder addDirectJars(ImmutableList<String> jars) {
+      this.directJars.addAll(jars);
+      return this;
+    }
   }
 }
diff --git a/java/com/google/turbine/options/TurbineOptionsParser.java b/java/com/google/turbine/options/TurbineOptionsParser.java
index 5d78201..419a04e 100644
--- a/java/com/google/turbine/options/TurbineOptionsParser.java
+++ b/java/com/google/turbine/options/TurbineOptionsParser.java
@@ -27,6 +27,7 @@
 import java.nio.file.Paths;
 import java.util.ArrayDeque;
 import java.util.Deque;
+import java.util.Iterator;
 import javax.annotation.Nullable;
 
 /** A command line options parser for {@link TurbineOptions}. */
@@ -52,31 +53,6 @@
     parse(builder, argumentDeque);
   }
 
-  private static final Splitter ARG_SPLITTER =
-      Splitter.on(CharMatcher.breakingWhitespace()).omitEmptyStrings().trimResults();
-
-  /**
-   * Pre-processes an argument list, expanding arguments of the form {@code @filename} by reading
-   * the content of the file and appending whitespace-delimited options to {@code argumentDeque}.
-   */
-  private static void expandParamsFiles(Deque<String> argumentDeque, Iterable<String> args)
-      throws IOException {
-    for (String arg : args) {
-      if (arg.isEmpty()) {
-        continue;
-      }
-      if (arg.startsWith("@@")) {
-        argumentDeque.addLast(arg.substring(1));
-      } else if (arg.startsWith("@")) {
-        Path paramsPath = Paths.get(arg.substring(1));
-        expandParamsFiles(
-            argumentDeque, ARG_SPLITTER.split(new String(Files.readAllBytes(paramsPath), UTF_8)));
-      } else {
-        argumentDeque.addLast(arg);
-      }
-    }
-  }
-
   private static void parse(TurbineOptions.Builder builder, Deque<String> argumentDeque) {
     while (!argumentDeque.isEmpty()) {
       String next = argumentDeque.pollFirst();
@@ -95,35 +71,56 @@
           builder.addProcessors(readList(argumentDeque));
           break;
         case "--processorpath":
-          builder.addProcessorPathEntries(splitClasspath(readList(argumentDeque)));
+          builder.addProcessorPathEntries(readList(argumentDeque));
           break;
+          // TODO(b/72379900): Remove this
         case "--classpath":
-          builder.addClassPathEntries(splitClasspath(readList(argumentDeque)));
+          builder.addClassPathEntries(readList(argumentDeque));
           break;
         case "--bootclasspath":
-          builder.addBootClassPathEntries(splitClasspath(readList(argumentDeque)));
+          builder.addBootClassPathEntries(readList(argumentDeque));
+          break;
+        case "--release":
+          builder.setRelease(readOne(argumentDeque));
+          break;
+        case "--system":
+          builder.setSystem(readOne(argumentDeque));
           break;
         case "--javacopts":
-          builder.addAllJavacOpts(readList(argumentDeque));
-          break;
+          {
+            ImmutableList<String> javacopts = readJavacopts(argumentDeque);
+            setReleaseFromJavacopts(builder, javacopts);
+            builder.addAllJavacOpts(javacopts);
+            break;
+          }
         case "--sources":
           builder.addSources(readList(argumentDeque));
           break;
         case "--output_deps":
           builder.setOutputDeps(readOne(argumentDeque));
           break;
+        case "--direct_dependencies":
+          builder.addDirectJars(readList(argumentDeque));
+          break;
         case "--direct_dependency":
           {
+            // TODO(b/72379900): Remove this
             String jar = readOne(argumentDeque);
-            String target = readOne(argumentDeque);
-            builder.addDirectJarToTarget(jar, target);
+            readOne(argumentDeque);
+            builder.addDirectJarToTarget(jar);
+            if (!argumentDeque.isEmpty() && !argumentDeque.peekFirst().startsWith("--")) {
+              argumentDeque.removeFirst(); // the aspect that created the dependency
+            }
             break;
           }
         case "--indirect_dependency":
           {
-            String jar = readOne(argumentDeque);
-            String target = readOne(argumentDeque);
-            builder.addIndirectJarToTarget(jar, target);
+            // TODO(b/72379900): Remove this
+            readOne(argumentDeque);
+            readOne(argumentDeque);
+            if (!argumentDeque.isEmpty() && !argumentDeque.peekFirst().startsWith("--")) {
+              argumentDeque.removeFirst(); // the aspect that created the dependency
+            }
             break;
           }
         case "--deps_artifacts":
@@ -132,8 +129,8 @@
         case "--target_label":
           builder.setTargetLabel(readOne(argumentDeque));
           break;
-        case "--rule_kind":
-          builder.setRuleKind(readOne(argumentDeque));
+        case "--injecting_rule_kind":
+          builder.setInjectingRuleKind(readOne(argumentDeque));
           break;
         case "--javac_fallback":
           builder.setJavacFallback(true);
@@ -142,9 +139,35 @@
           builder.setJavacFallback(false);
           break;
         default:
-          if (next.isEmpty() && !argumentDeque.isEmpty()) {
-            throw new IllegalArgumentException("unknown option: " + next);
-          }
+          throw new IllegalArgumentException("unknown option: " + next);
+      }
+    }
+  }
+
+  private static final Splitter ARG_SPLITTER =
+      Splitter.on(CharMatcher.breakingWhitespace()).omitEmptyStrings().trimResults();
+
+  /**
+   * Pre-processes an argument list, expanding arguments of the form {@code @filename} by reading
+   * the content of the file and appending whitespace-delimited options to {@code argumentDeque}.
+   */
+  private static void expandParamsFiles(Deque<String> argumentDeque, Iterable<String> args)
+      throws IOException {
+    for (String arg : args) {
+      if (arg.isEmpty()) {
+        continue;
+      }
+      if (arg.startsWith("@@")) {
+        argumentDeque.addLast(arg.substring(1));
+      } else if (arg.startsWith("@")) {
+        Path paramsPath = Paths.get(arg.substring(1));
+        if (!Files.exists(paramsPath)) {
+          throw new AssertionError("params file does not exist: " + paramsPath);
+        }
+        expandParamsFiles(
+            argumentDeque, ARG_SPLITTER.split(new String(Files.readAllBytes(paramsPath), UTF_8)));
+      } else {
+        argumentDeque.addLast(arg);
       }
     }
   }
@@ -167,15 +190,33 @@
     return result.build();
   }
 
-  private static final Splitter CLASSPATH_SPLITTER =
-      Splitter.on(':').trimResults().omitEmptyStrings();
-
-  // TODO(cushon): stop splitting classpaths once cl/127006119 is released
-  private static ImmutableList<String> splitClasspath(Iterable<String> paths) {
-    ImmutableList.Builder<String> classpath = ImmutableList.builder();
-    for (String path : paths) {
-      classpath.addAll(CLASSPATH_SPLITTER.split(path));
+  /**
+   * Returns a list of javacopts. Reads options until a terminating {@code "--"} is reached, to
+   * support parsing javacopts that start with {@code --} (e.g. --release).
+   */
+  private static ImmutableList<String> readJavacopts(Deque<String> argumentDeque) {
+    ImmutableList.Builder<String> result = ImmutableList.builder();
+    while (!argumentDeque.isEmpty()) {
+      String arg = argumentDeque.pollFirst();
+      if (arg.equals("--")) {
+        return result.build();
+      }
+      result.add(arg);
     }
-    return classpath.build();
+    throw new IllegalArgumentException("javacopts should be terminated by `--`");
+  }
+
+  /**
+   * Parses the given javacopts for {@code --release}, and if found sets turbine's {@code --release}
+   * flag.
+   */
+  private static void setReleaseFromJavacopts(
+      TurbineOptions.Builder builder, ImmutableList<String> javacopts) {
+    Iterator<String> it = javacopts.iterator();
+    while (it.hasNext()) {
+      if (it.next().equals("--release") && it.hasNext()) {
+        builder.setRelease(it.next());
+      }
+    }
   }
 }
diff --git a/java/com/google/turbine/parse/ConstExpressionParser.java b/java/com/google/turbine/parse/ConstExpressionParser.java
index 36a701f..e6a7f97 100644
--- a/java/com/google/turbine/parse/ConstExpressionParser.java
+++ b/java/com/google/turbine/parse/ConstExpressionParser.java
@@ -565,9 +565,10 @@
     if (token == Token.LPAREN) {
       eat();
       while (token != Token.RPAREN) {
+        int pos = position;
         Tree.Expression expression = expression();
         if (expression == null) {
-          throw new AssertionError("invalid annotation expression");
+          throw TurbineError.format(lexer.source(), pos, ErrorKind.INVALID_ANNOTATION_ARGUMENT);
         }
         args.add(expression);
         if (token != Token.COMMA) {
diff --git a/java/com/google/turbine/parse/Parser.java b/java/com/google/turbine/parse/Parser.java
index 88529de..7b3a92e 100644
--- a/java/com/google/turbine/parse/Parser.java
+++ b/java/com/google/turbine/parse/Parser.java
@@ -17,12 +17,15 @@
 package com.google.turbine.parse;
 
 import static com.google.turbine.parse.Token.COMMA;
+import static com.google.turbine.parse.Token.IDENT;
 import static com.google.turbine.parse.Token.INTERFACE;
 import static com.google.turbine.parse.Token.LPAREN;
 import static com.google.turbine.parse.Token.RPAREN;
+import static com.google.turbine.parse.Token.STATIC;
 import static com.google.turbine.tree.TurbineModifier.PROTECTED;
 import static com.google.turbine.tree.TurbineModifier.PUBLIC;
 
+import com.google.common.base.Joiner;
 import com.google.common.base.Optional;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -40,6 +43,13 @@
 import com.google.turbine.tree.Tree.ImportDecl;
 import com.google.turbine.tree.Tree.Kind;
 import com.google.turbine.tree.Tree.MethDecl;
+import com.google.turbine.tree.Tree.ModDecl;
+import com.google.turbine.tree.Tree.ModDirective;
+import com.google.turbine.tree.Tree.ModExports;
+import com.google.turbine.tree.Tree.ModOpens;
+import com.google.turbine.tree.Tree.ModProvides;
+import com.google.turbine.tree.Tree.ModRequires;
+import com.google.turbine.tree.Tree.ModUses;
 import com.google.turbine.tree.Tree.PkgDecl;
 import com.google.turbine.tree.Tree.PrimTy;
 import com.google.turbine.tree.Tree.TyDecl;
@@ -52,6 +62,7 @@
 import java.util.Deque;
 import java.util.EnumSet;
 import java.util.List;
+import javax.annotation.CheckReturnValue;
 import javax.annotation.Nullable;
 
 /**
@@ -85,6 +96,7 @@
     // and make it bug-compatible with javac:
     // http://mail.openjdk.java.net/pipermail/compiler-dev/2013-August/006968.html
     Optional<PkgDecl> pkg = Optional.absent();
+    Optional<ModDecl> mod = Optional.absent();
     EnumSet<TurbineModifier> access = EnumSet.noneOf(TurbineModifier.class);
     ImmutableList.Builder<ImportDecl> imports = ImmutableList.builder();
     ImmutableList.Builder<TyDecl> decls = ImmutableList.builder();
@@ -165,11 +177,30 @@
           break;
         case EOF:
           // TODO(cushon): check for dangling modifiers?
-          return new CompUnit(position, pkg, imports.build(), decls.build(), lexer.source());
+          return new CompUnit(position, pkg, mod, imports.build(), decls.build(), lexer.source());
         case SEMI:
           // TODO(cushon): check for dangling modifiers?
           next();
           continue;
+        case IDENT:
+          {
+            String ident = lexer.stringValue();
+            if (access.isEmpty() && (ident.equals("module") || ident.equals("open"))) {
+              boolean open = false;
+              if (ident.equals("open")) {
+                ident = eatIdent();
+                open = true;
+              }
+              if (!ident.equals("module")) {
+                throw error(token);
+              }
+              next();
+              mod = Optional.of(moduleDeclaration(open, annos.build()));
+              annos = ImmutableList.builder();
+              break;
+            }
+          }
+          // fall through
         default:
           throw error(token);
       }
@@ -183,6 +214,7 @@
 
   private TyDecl interfaceDeclaration(EnumSet<TurbineModifier> access, ImmutableList<Anno> annos) {
     eat(Token.INTERFACE);
+    int pos = position;
     String name = eatIdent();
     ImmutableList<TyParam> typarams;
     if (token == Token.LT) {
@@ -201,7 +233,7 @@
     ImmutableList<Tree> members = classMembers();
     eat(Token.RBRACE);
     return new TyDecl(
-        position,
+        pos,
         access,
         annos,
         name,
@@ -214,12 +246,13 @@
 
   private TyDecl annotationDeclaration(EnumSet<TurbineModifier> access, ImmutableList<Anno> annos) {
     eat(Token.INTERFACE);
+    int pos = position;
     String name = eatIdent();
     eat(Token.LBRACE);
     ImmutableList<Tree> members = classMembers();
     eat(Token.RBRACE);
     return new TyDecl(
-        position,
+        pos,
         access,
         annos,
         name,
@@ -232,6 +265,7 @@
 
   private TyDecl enumDeclaration(EnumSet<TurbineModifier> access, ImmutableList<Anno> annos) {
     eat(Token.ENUM);
+    int pos = position;
     String name = eatIdent();
     ImmutableList.Builder<ClassTy> interfaces = ImmutableList.builder();
     if (token == Token.IMPLEMENTS) {
@@ -245,7 +279,7 @@
         ImmutableList.<Tree>builder().addAll(enumMembers(name)).addAll(classMembers()).build();
     eat(Token.RBRACE);
     return new TyDecl(
-        position,
+        pos,
         access,
         annos,
         name,
@@ -256,6 +290,129 @@
         TurbineTyKind.ENUM);
   }
 
+  private String moduleName() {
+    return Joiner.on('.').join(qualIdent());
+  }
+
+  private String packageName() {
+    return Joiner.on('/').join(qualIdent());
+  }
+
+  private ModDecl moduleDeclaration(boolean open, ImmutableList<Anno> annos) {
+    int pos = position;
+    String moduleName = moduleName();
+    eat(Token.LBRACE);
+    ImmutableList.Builder<ModDirective> directives = ImmutableList.builder();
+    OUTER:
+    while (true) {
+      switch (token) {
+        case IDENT:
+          {
+            String ident = lexer.stringValue();
+            next();
+            switch (ident) {
+              case "requires":
+                directives.add(moduleRequires());
+                break;
+              case "exports":
+                directives.add(moduleExports());
+                break;
+              case "opens":
+                directives.add(moduleOpens());
+                break;
+              case "uses":
+                directives.add(moduleUses());
+                break;
+              case "provides":
+                directives.add(moduleProvides());
+                break;
+              default: // fall out
+            }
+            break;
+          }
+        case RBRACE:
+          break OUTER;
+        default:
+          throw error(token);
+      }
+    }
+    eat(Token.RBRACE);
+    return new ModDecl(pos, annos, open, moduleName, directives.build());
+  }
+
+  private ModRequires moduleRequires() {
+    int pos = position;
+    EnumSet<TurbineModifier> access = EnumSet.noneOf(TurbineModifier.class);
+    while (true) {
+      if (token == Token.IDENT && lexer.stringValue().equals("transitive")) {
+        next();
+        access.add(TurbineModifier.TRANSITIVE);
+        break;
+      }
+      if (token == Token.STATIC) {
+        next();
+        access.add(TurbineModifier.STATIC);
+        break;
+      }
+      break;
+    }
+    String moduleName = moduleName();
+    eat(Token.SEMI);
+    return new ModRequires(pos, ImmutableSet.copyOf(access), moduleName);
+  }
+
+  private ModExports moduleExports() {
+    int pos = position;
+    String packageName = packageName();
+    ImmutableList.Builder<String> moduleNames = ImmutableList.builder();
+    if (lexer.stringValue().equals("to")) {
+      next();
+      do {
+        String moduleName = moduleName();
+        moduleNames.add(moduleName);
+      } while (maybe(Token.COMMA));
+    }
+    eat(Token.SEMI);
+    return new ModExports(pos, packageName, moduleNames.build());
+  }
+
+  private ModOpens moduleOpens() {
+    int pos = position;
+    String packageName = packageName();
+    ImmutableList.Builder<String> moduleNames = ImmutableList.builder();
+    if (lexer.stringValue().equals("to")) {
+      next();
+      do {
+        String moduleName = moduleName();
+        moduleNames.add(moduleName);
+      } while (maybe(Token.COMMA));
+    }
+    eat(Token.SEMI);
+    return new ModOpens(pos, packageName, moduleNames.build());
+  }
+
+  private ModUses moduleUses() {
+    int pos = position;
+    ImmutableList<String> uses = qualIdent();
+    eat(Token.SEMI);
+    return new ModUses(pos, uses);
+  }
+
+  private ModProvides moduleProvides() {
+    int pos = position;
+    ImmutableList<String> typeName = qualIdent();
+    if (!eatIdent().equals("with")) {
+      throw error(token);
+    }
+    ImmutableList.Builder<ImmutableList<String>> implNames = ImmutableList.builder();
+    do {
+      ImmutableList<String> implName = qualIdent();
+      implNames.add(implName);
+    } while (maybe(Token.COMMA));
+    eat(Token.SEMI);
+    return new ModProvides(pos, typeName, implNames.build());
+  }
+
   private static final ImmutableSet<TurbineModifier> ENUM_CONSTANT_MODIFIERS =
       ImmutableSet.of(
           TurbineModifier.PUBLIC,
@@ -316,6 +473,7 @@
 
   private TyDecl classDeclaration(EnumSet<TurbineModifier> access, ImmutableList<Anno> annos) {
     eat(Token.CLASS);
+    int pos = position;
     String name = eatIdent();
     ImmutableList<TyParam> tyParams = ImmutableList.of();
     if (token == Token.LT) {
@@ -337,7 +495,7 @@
     ImmutableList<Tree> members = classMembers();
     eat(Token.RBRACE);
     return new TyDecl(
-        position,
+        pos,
         access,
         annos,
         name,
@@ -1177,6 +1335,7 @@
     return false;
   }
 
+  @CheckReturnValue
   TurbineError error(Token token) {
     switch (token) {
       case IDENT:
@@ -1188,6 +1347,7 @@
     }
   }
 
+  @CheckReturnValue
   private TurbineError error(ErrorKind kind, Object... args) {
     return TurbineError.format(
         lexer.source(),
diff --git a/java/com/google/turbine/tree/Pretty.java b/java/com/google/turbine/tree/Pretty.java
index 820126d..978be8d 100644
--- a/java/com/google/turbine/tree/Pretty.java
+++ b/java/com/google/turbine/tree/Pretty.java
@@ -22,6 +22,13 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.turbine.tree.Tree.Anno;
 import com.google.turbine.tree.Tree.ClassLiteral;
+import com.google.turbine.tree.Tree.ModDecl;
+import com.google.turbine.tree.Tree.ModDirective;
+import com.google.turbine.tree.Tree.ModExports;
+import com.google.turbine.tree.Tree.ModOpens;
+import com.google.turbine.tree.Tree.ModProvides;
+import com.google.turbine.tree.Tree.ModRequires;
+import com.google.turbine.tree.Tree.ModUses;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -237,6 +244,10 @@
     for (Tree.ImportDecl i : compUnit.imports()) {
       i.accept(this, null);
     }
+    if (compUnit.mod().isPresent()) {
+      printLine();
+      compUnit.mod().get().accept(this, null);
+    }
     for (Tree.TyDecl decl : compUnit.decls()) {
       printLine();
       decl.accept(this, null);
@@ -472,6 +483,7 @@
         case NATIVE:
         case TRANSIENT:
         case DEFAULT:
+        case TRANSITIVE:
           append(mod.toString()).append(' ');
           break;
         case ACC_SUPER:
@@ -515,4 +527,109 @@
     append("package ").append(Joiner.on('.').join(pkgDecl.name())).append(';');
     return null;
   }
+
+  @Override
+  public Void visitModDecl(ModDecl modDecl, Void input) {
+    for (Tree.Anno anno : modDecl.annos()) {
+      anno.accept(this, null);
+      printLine();
+    }
+    if (modDecl.open()) {
+      append("open ");
+    }
+    append("module ").append(modDecl.moduleName()).append(" {");
+    indent++;
+    append('\n');
+    for (ModDirective directive : modDecl.directives()) {
+      directive.accept(this, null);
+    }
+    indent--;
+    append("}\n");
+    return null;
+  }
+
+  @Override
+  public Void visitModRequires(ModRequires modRequires, Void input) {
+    append("requires ");
+    printModifiers(modRequires.mods());
+    append(modRequires.moduleName());
+    append(";");
+    append('\n');
+    return null;
+  }
+
+  @Override
+  public Void visitModExports(ModExports modExports, Void input) {
+    append("exports ");
+    append(modExports.packageName().replace('/', '.'));
+    if (!modExports.moduleNames().isEmpty()) {
+      append(" to").append('\n');
+      indent += 2;
+      boolean first = true;
+      for (String moduleName : modExports.moduleNames()) {
+        if (!first) {
+          append(',').append('\n');
+        }
+        append(moduleName);
+        first = false;
+      }
+      indent -= 2;
+    }
+    append(";");
+    append('\n');
+    return null;
+  }
+
+  @Override
+  public Void visitModOpens(ModOpens modOpens, Void input) {
+    append("opens ");
+    append(modOpens.packageName().replace('/', '.'));
+    if (!modOpens.moduleNames().isEmpty()) {
+      append(" to").append('\n');
+      indent += 2;
+      boolean first = true;
+      for (String moduleName : modOpens.moduleNames()) {
+        if (!first) {
+          append(',').append('\n');
+        }
+        append(moduleName);
+        first = false;
+      }
+      indent -= 2;
+    }
+    append(";");
+    append('\n');
+    return null;
+  }
+
+  @Override
+  public Void visitModUses(ModUses modUses, Void input) {
+    append("uses ");
+    append(Joiner.on('.').join(modUses.typeName()));
+    append(";");
+    append('\n');
+    return null;
+  }
+
+  @Override
+  public Void visitModProvides(ModProvides modProvides, Void input) {
+    append("provides ");
+    append(Joiner.on('.').join(modProvides.typeName()));
+    if (!modProvides.implNames().isEmpty()) {
+      append(" with").append('\n');
+      indent += 2;
+      boolean first = true;
+      for (ImmutableList<String> implName : modProvides.implNames()) {
+        if (!first) {
+          append(',').append('\n');
+        }
+        append(Joiner.on('.').join(implName));
+        first = false;
+      }
+      indent -= 2;
+    }
+    append(";");
+    append('\n');
+    return null;
+  }
 }
diff --git a/java/com/google/turbine/tree/Tree.java b/java/com/google/turbine/tree/Tree.java
index 6f46df5..a84c776 100644
--- a/java/com/google/turbine/tree/Tree.java
+++ b/java/com/google/turbine/tree/Tree.java
@@ -71,7 +71,13 @@
     ANNO_EXPR,
     TY_DECL,
     TY_PARAM,
-    PKG_DECL
+    PKG_DECL,
+    MOD_DECL,
+    MOD_REQUIRES,
+    MOD_EXPORTS,
+    MOD_OPENS,
+    MOD_USES,
+    MOD_PROVIDES
   }
 
   /** A type use. */
@@ -524,6 +530,7 @@
   /** A JLS 7.3 compilation unit. */
   public static class CompUnit extends Tree {
     private final Optional<PkgDecl> pkg;
+    private final Optional<ModDecl> mod;
     private final ImmutableList<ImportDecl> imports;
     private final ImmutableList<TyDecl> decls;
     private final SourceFile source;
@@ -531,11 +538,13 @@
     public CompUnit(
         int position,
         Optional<PkgDecl> pkg,
+        Optional<ModDecl> mod,
         ImmutableList<ImportDecl> imports,
         ImmutableList<TyDecl> decls,
         SourceFile source) {
       super(position);
       this.pkg = pkg;
+      this.mod = mod;
       this.imports = imports;
       this.decls = decls;
       this.source = source;
@@ -555,6 +564,10 @@
       return pkg;
     }
 
+    public Optional<ModDecl> mod() {
+      return mod;
+    }
+
     public ImmutableList<ImportDecl> imports() {
       return imports;
     }
@@ -936,6 +949,250 @@
     }
   }
 
+  /** A JLS 7.7 module declaration. */
+  public static class ModDecl extends Tree {
+
+    private final ImmutableList<Anno> annos;
+    private final boolean open;
+    private final String moduleName;
+    private final ImmutableList<ModDirective> directives;
+
+    public ModDecl(
+        int position,
+        ImmutableList<Anno> annos,
+        boolean open,
+        String moduleName,
+        ImmutableList<ModDirective> directives) {
+      super(position);
+      this.annos = annos;
+      this.open = open;
+      this.moduleName = moduleName;
+      this.directives = directives;
+    }
+
+    public boolean open() {
+      return open;
+    }
+
+    public ImmutableList<Anno> annos() {
+      return annos;
+    }
+
+    public String moduleName() {
+      return moduleName;
+    }
+
+    public ImmutableList<ModDirective> directives() {
+      return directives;
+    }
+
+    @Override
+    public Kind kind() {
+      return Kind.MOD_DECL;
+    }
+
+    @Override
+    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+      return visitor.visitModDecl(this, input);
+    }
+  }
+
+  /** A kind of module directive. */
+  public abstract static class ModDirective extends Tree {
+
+    /** A module directive kind. */
+    public enum DirectiveKind {
+      REQUIRES,
+      EXPORTS,
+      OPENS,
+      USES,
+      PROVIDES
+    }
+
+    public abstract DirectiveKind directiveKind();
+
+    protected ModDirective(int position) {
+      super(position);
+    }
+  }
+
+  /** A JLS 7.7.1 module requires directive. */
+  public static class ModRequires extends ModDirective {
+
+    private final ImmutableSet<TurbineModifier> mods;
+    private final String moduleName;
+
+    @Override
+    public Kind kind() {
+      return Kind.MOD_REQUIRES;
+    }
+
+    @Override
+    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+      return visitor.visitModRequires(this, input);
+    }
+
+    public ModRequires(int position, ImmutableSet<TurbineModifier> mods, String moduleName) {
+      super(position);
+      this.mods = mods;
+      this.moduleName = moduleName;
+    }
+
+    public ImmutableSet<TurbineModifier> mods() {
+      return mods;
+    }
+
+    public String moduleName() {
+      return moduleName;
+    }
+
+    @Override
+    public DirectiveKind directiveKind() {
+      return DirectiveKind.REQUIRES;
+    }
+  }
+
+  /** A JLS 7.7.2 module exports directive. */
+  public static class ModExports extends ModDirective {
+
+    private final String packageName;
+    private final ImmutableList<String> moduleNames;
+
+    @Override
+    public Kind kind() {
+      return Kind.MOD_EXPORTS;
+    }
+
+    @Override
+    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+      return visitor.visitModExports(this, input);
+    }
+
+    public ModExports(int position, String packageName, ImmutableList<String> moduleNames) {
+      super(position);
+      this.packageName = packageName;
+      this.moduleNames = moduleNames;
+    }
+
+    public String packageName() {
+      return packageName;
+    }
+
+    public ImmutableList<String> moduleNames() {
+      return moduleNames;
+    }
+
+    @Override
+    public DirectiveKind directiveKind() {
+      return DirectiveKind.EXPORTS;
+    }
+  }
+
+  /** A JLS 7.7.2 module opens directive. */
+  public static class ModOpens extends ModDirective {
+
+    private final String packageName;
+    private final ImmutableList<String> moduleNames;
+
+    public ModOpens(int position, String packageName, ImmutableList<String> moduleNames) {
+      super(position);
+      this.packageName = packageName;
+      this.moduleNames = moduleNames;
+    }
+
+    public String packageName() {
+      return packageName;
+    }
+
+    public ImmutableList<String> moduleNames() {
+      return moduleNames;
+    }
+
+    @Override
+    public Kind kind() {
+      return Kind.MOD_OPENS;
+    }
+
+    @Override
+    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+      return visitor.visitModOpens(this, input);
+    }
+
+    @Override
+    public DirectiveKind directiveKind() {
+      return DirectiveKind.OPENS;
+    }
+  }
+
+  /** A JLS 7.7.3 module uses directive. */
+  public static class ModUses extends ModDirective {
+
+    private final ImmutableList<String> typeName;
+
+    public ModUses(int position, ImmutableList<String> typeName) {
+      super(position);
+      this.typeName = typeName;
+    }
+
+    public ImmutableList<String> typeName() {
+      return typeName;
+    }
+
+    @Override
+    public Kind kind() {
+      return Kind.MOD_USES;
+    }
+
+    @Override
+    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+      return visitor.visitModUses(this, input);
+    }
+
+    @Override
+    public DirectiveKind directiveKind() {
+      return DirectiveKind.USES;
+    }
+  }
+
+  /** A JLS 7.7.4 module uses directive. */
+  public static class ModProvides extends ModDirective {
+
+    private final ImmutableList<String> typeName;
+    private final ImmutableList<ImmutableList<String>> implNames;
+
+    public ModProvides(
+        int position,
+        ImmutableList<String> typeName,
+        ImmutableList<ImmutableList<String>> implNames) {
+      super(position);
+      this.typeName = typeName;
+      this.implNames = implNames;
+    }
+
+    public ImmutableList<String> typeName() {
+      return typeName;
+    }
+
+    public ImmutableList<ImmutableList<String>> implNames() {
+      return implNames;
+    }
+
+    @Override
+    public Kind kind() {
+      return Kind.MOD_PROVIDES;
+    }
+
+    @Override
+    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+      return visitor.visitModProvides(this, input);
+    }
+
+    @Override
+    public DirectiveKind directiveKind() {
+      return DirectiveKind.PROVIDES;
+    }
+  }
+
   /** A visitor for {@link Tree}s. */
   public interface Visitor<I, O> {
     O visitWildTy(WildTy visitor, I input);
@@ -981,5 +1238,17 @@
     O visitTyParam(TyParam tyParam, I input);
 
     O visitPkgDecl(PkgDecl pkgDecl, I input);
+
+    O visitModDecl(ModDecl modDecl, I input);
+
+    O visitModRequires(ModRequires modRequires, I input);
+
+    O visitModExports(ModExports modExports, I input);
+
+    O visitModOpens(ModOpens modOpens, I input);
+
+    O visitModUses(ModUses modUses, I input);
+
+    O visitModProvides(ModProvides modProvides, I input);
   }
 }
diff --git a/java/com/google/turbine/tree/TurbineModifier.java b/java/com/google/turbine/tree/TurbineModifier.java
index 3d0e60d..35dc11c 100644
--- a/java/com/google/turbine/tree/TurbineModifier.java
+++ b/java/com/google/turbine/tree/TurbineModifier.java
@@ -44,7 +44,8 @@
   ACC_ANNOTATION(TurbineFlag.ACC_ANNOTATION),
   ACC_SYNTHETIC(TurbineFlag.ACC_SYNTHETIC),
   ACC_BRIDGE(TurbineFlag.ACC_BRIDGE),
-  DEFAULT(TurbineFlag.ACC_DEFAULT);
+  DEFAULT(TurbineFlag.ACC_DEFAULT),
+  TRANSITIVE(TurbineFlag.ACC_TRANSITIVE);
 
   private final int flag;
 
diff --git a/java/com/google/turbine/types/Canonicalize.java b/java/com/google/turbine/types/Canonicalize.java
index b4e320d..fc5d907 100644
--- a/java/com/google/turbine/types/Canonicalize.java
+++ b/java/com/google/turbine/types/Canonicalize.java
@@ -195,7 +195,8 @@
         }
         break;
       }
-      curr = canon(env, curr.sym(), env.get(curr.sym()).superClassType());
+      TypeBoundClass info = env.get(curr.sym());
+      curr = canon(env, info.owner(), info.superClassType());
     }
     simples.add(ty);
     return new ClassTy(simples.build());
diff --git a/javatests/com/google/turbine/binder/BinderErrorTest.java b/javatests/com/google/turbine/binder/BinderErrorTest.java
index 102db93..a4c2e25 100644
--- a/javatests/com/google/turbine/binder/BinderErrorTest.java
+++ b/javatests/com/google/turbine/binder/BinderErrorTest.java
@@ -17,15 +17,15 @@
 package com.google.turbine.binder;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.turbine.testing.TestClassPaths.TURBINE_BOOTCLASSPATH;
 import static org.junit.Assert.fail;
 
 import com.google.common.base.Joiner;
+import com.google.common.base.Optional;
 import com.google.common.collect.ImmutableList;
 import com.google.turbine.diag.TurbineError;
 import com.google.turbine.parse.Parser;
 import com.google.turbine.tree.Tree.CompUnit;
-import java.nio.file.Path;
-import java.nio.file.Paths;
 import java.util.Arrays;
 import java.util.Collections;
 import org.junit.Test;
@@ -36,9 +36,6 @@
 @RunWith(Parameterized.class)
 public class BinderErrorTest {
 
-  private static final ImmutableList<Path> BOOTCLASSPATH =
-      ImmutableList.of(Paths.get(System.getProperty("java.home")).resolve("lib/rt.jar"));
-
   @Parameters
   public static Iterable<Object[]> parameters() {
     String[][][] testCases = {
@@ -337,6 +334,32 @@
           "                      ^",
         },
       },
+      {
+        {
+          "class Test {", //
+          "}",
+          "class Test {",
+          "}",
+        },
+        {
+          "<>:3: error: duplicate declaration of Test", //
+          "class Test {",
+          "      ^",
+        },
+      },
+      {
+        {
+          "public class Test {", //
+          "  static class Inner {}",
+          "  static class Inner {}",
+          "}",
+        },
+        {
+          "<>:3: error: duplicate declaration of Test$Inner", //
+          "  static class Inner {}",
+          "               ^",
+        },
+      },
     };
     return Arrays.asList((Object[][]) testCases);
   }
@@ -352,7 +375,11 @@
   @Test
   public void test() throws Exception {
     try {
-      Binder.bind(ImmutableList.of(parseLines(source)), Collections.emptyList(), BOOTCLASSPATH)
+      Binder.bind(
+              ImmutableList.of(parseLines(source)),
+              ClassPathBinder.bindClasspath(Collections.emptyList()),
+              TURBINE_BOOTCLASSPATH,
+              /* moduleVersion=*/ Optional.absent())
           .units();
       fail();
     } catch (TurbineError e) {
@@ -365,6 +392,6 @@
   }
 
   private static String lines(String... lines) {
-    return Joiner.on('\n').join(lines);
+    return Joiner.on(System.lineSeparator()).join(lines);
   }
 }
diff --git a/javatests/com/google/turbine/binder/BinderTest.java b/javatests/com/google/turbine/binder/BinderTest.java
index 89a7c33..9ba5705 100644
--- a/javatests/com/google/turbine/binder/BinderTest.java
+++ b/javatests/com/google/turbine/binder/BinderTest.java
@@ -17,9 +17,11 @@
 package com.google.turbine.binder;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.turbine.testing.TestClassPaths.TURBINE_BOOTCLASSPATH;
 import static org.junit.Assert.fail;
 
 import com.google.common.base.Joiner;
+import com.google.common.base.Optional;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.turbine.binder.bound.SourceTypeBoundClass;
@@ -33,7 +35,6 @@
 import java.lang.annotation.ElementType;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -51,9 +52,6 @@
 
   @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder();
 
-  private static final ImmutableList<Path> BOOTCLASSPATH =
-      ImmutableList.of(Paths.get(System.getProperty("java.home")).resolve("lib/rt.jar"));
-
   @Test
   public void hello() throws Exception {
     List<Tree.CompUnit> units = new ArrayList<>();
@@ -74,7 +72,12 @@
             "}"));
 
     ImmutableMap<ClassSymbol, SourceTypeBoundClass> bound =
-        Binder.bind(units, Collections.emptyList(), BOOTCLASSPATH).units();
+        Binder.bind(
+                units,
+                ClassPathBinder.bindClasspath(Collections.emptyList()),
+                TURBINE_BOOTCLASSPATH,
+                /* moduleVersion=*/ Optional.absent())
+            .units();
 
     assertThat(bound.keySet())
         .containsExactly(
@@ -115,7 +118,12 @@
             "}"));
 
     ImmutableMap<ClassSymbol, SourceTypeBoundClass> bound =
-        Binder.bind(units, Collections.emptyList(), BOOTCLASSPATH).units();
+        Binder.bind(
+                units,
+                ClassPathBinder.bindClasspath(Collections.emptyList()),
+                TURBINE_BOOTCLASSPATH,
+                /* moduleVersion=*/ Optional.absent())
+            .units();
 
     assertThat(bound.keySet())
         .containsExactly(
@@ -150,7 +158,12 @@
             "}"));
 
     ImmutableMap<ClassSymbol, SourceTypeBoundClass> bound =
-        Binder.bind(units, Collections.emptyList(), BOOTCLASSPATH).units();
+        Binder.bind(
+                units,
+                ClassPathBinder.bindClasspath(Collections.emptyList()),
+                TURBINE_BOOTCLASSPATH,
+                /* moduleVersion=*/ Optional.absent())
+            .units();
 
     assertThat(bound.get(new ClassSymbol("other/Foo")).superclass())
         .isEqualTo(new ClassSymbol("com/test/Test$Inner"));
@@ -175,7 +188,11 @@
             "}"));
 
     try {
-      Binder.bind(units, Collections.emptyList(), BOOTCLASSPATH);
+      Binder.bind(
+          units,
+          ClassPathBinder.bindClasspath(Collections.emptyList()),
+          TURBINE_BOOTCLASSPATH,
+          /* moduleVersion=*/ Optional.absent());
       fail();
     } catch (TurbineError e) {
       assertThat(e.getMessage()).contains("cycle in class hierarchy: a/A -> b/B -> a/A");
@@ -192,7 +209,12 @@
             "}"));
 
     ImmutableMap<ClassSymbol, SourceTypeBoundClass> bound =
-        Binder.bind(units, Collections.emptyList(), BOOTCLASSPATH).units();
+        Binder.bind(
+                units,
+                ClassPathBinder.bindClasspath(Collections.emptyList()),
+                TURBINE_BOOTCLASSPATH,
+                /* moduleVersion=*/ Optional.absent())
+            .units();
 
     SourceTypeBoundClass a = bound.get(new ClassSymbol("com/test/Annotation"));
     assertThat(a.access())
@@ -216,7 +238,12 @@
             "}"));
 
     ImmutableMap<ClassSymbol, SourceTypeBoundClass> bound =
-        Binder.bind(units, Collections.emptyList(), BOOTCLASSPATH).units();
+        Binder.bind(
+                units,
+                ClassPathBinder.bindClasspath(Collections.emptyList()),
+                TURBINE_BOOTCLASSPATH,
+                /* moduleVersion=*/ Optional.absent())
+            .units();
 
     SourceTypeBoundClass a = bound.get(new ClassSymbol("a/A"));
     assertThat(a.interfaces()).containsExactly(new ClassSymbol("java/util/Map$Entry"));
@@ -230,8 +257,7 @@
             ImmutableMap.of(
                 "A.java", "class A {}",
                 "B.java", "class B extends A {}"),
-            ImmutableList.of(),
-            BOOTCLASSPATH);
+            ImmutableList.of());
 
     // create a jar containing only B
     Path libJar = temporaryFolder.newFile("lib.jar").toPath();
@@ -252,7 +278,12 @@
             "}"));
 
     ImmutableMap<ClassSymbol, SourceTypeBoundClass> bound =
-        Binder.bind(units, ImmutableList.of(libJar), BOOTCLASSPATH).units();
+        Binder.bind(
+                units,
+                ClassPathBinder.bindClasspath(ImmutableList.of(libJar)),
+                TURBINE_BOOTCLASSPATH,
+                /* moduleVersion=*/ Optional.absent())
+            .units();
 
     SourceTypeBoundClass a = bound.get(new ClassSymbol("C$A"));
     assertThat(a.annotationMetadata().target()).containsExactly(ElementType.TYPE_USE);
diff --git a/javatests/com/google/turbine/binder/ClassPathBinderTest.java b/javatests/com/google/turbine/binder/ClassPathBinderTest.java
index 0c95885..bd9bde3 100644
--- a/javatests/com/google/turbine/binder/ClassPathBinderTest.java
+++ b/javatests/com/google/turbine/binder/ClassPathBinderTest.java
@@ -17,6 +17,7 @@
 package com.google.turbine.binder;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.turbine.testing.TestClassPaths.TURBINE_BOOTCLASSPATH;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.junit.Assert.fail;
 
@@ -25,11 +26,10 @@
 import com.google.common.io.ByteStreams;
 import com.google.turbine.binder.bound.HeaderBoundClass;
 import com.google.turbine.binder.bytecode.BytecodeBoundClass;
-import com.google.turbine.binder.env.CompoundEnv;
+import com.google.turbine.binder.env.Env;
 import com.google.turbine.binder.lookup.LookupKey;
 import com.google.turbine.binder.lookup.LookupResult;
 import com.google.turbine.binder.lookup.Scope;
-import com.google.turbine.binder.lookup.TopLevelIndex;
 import com.google.turbine.binder.sym.ClassSymbol;
 import com.google.turbine.model.TurbineFlag;
 import com.google.turbine.model.TurbineTyKind;
@@ -37,33 +37,22 @@
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.nio.file.Paths;
 import java.util.Arrays;
-import java.util.jar.JarOutputStream;
-import java.util.zip.ZipEntry;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.rules.TemporaryFolder;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
-import org.objectweb.asm.ClassWriter;
-import org.objectweb.asm.Opcodes;
 
 @RunWith(JUnit4.class)
 public class ClassPathBinderTest {
 
   @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder();
 
-  private static final ImmutableList<Path> BOOTCLASSPATH =
-      ImmutableList.of(Paths.get(System.getProperty("java.home")).resolve("lib/rt.jar"));
-
   @Test
   public void classPathLookup() throws IOException {
-    TopLevelIndex.Builder tliBuilder = TopLevelIndex.builder();
-    ClassPathBinder.bind(ImmutableList.of(), BOOTCLASSPATH, tliBuilder);
-    TopLevelIndex tli = tliBuilder.build();
 
-    Scope javaLang = tli.lookupPackage(ImmutableList.of("java", "lang"));
+    Scope javaLang = TURBINE_BOOTCLASSPATH.index().lookupPackage(ImmutableList.of("java", "lang"));
 
     LookupResult result = javaLang.lookup(new LookupKey(Arrays.asList("String")));
     assertThat(result.remaining()).isEmpty();
@@ -76,8 +65,7 @@
 
   @Test
   public void classPathClasses() throws IOException {
-    CompoundEnv<ClassSymbol, BytecodeBoundClass> env =
-        ClassPathBinder.bind(ImmutableList.of(), BOOTCLASSPATH, TopLevelIndex.builder());
+    Env<ClassSymbol, BytecodeBoundClass> env = TURBINE_BOOTCLASSPATH.env();
 
     HeaderBoundClass c = env.get(new ClassSymbol("java/util/Map$Entry"));
     assertThat(c.owner()).isEqualTo(new ClassSymbol("java/util/Map"));
@@ -120,50 +108,13 @@
     }
   }
 
-  // symbols can be located on the regular and boot-classpaths, and the bootclasspath wins
-  @Test
-  public void bootClassPathWins() throws Exception {
-    Path lib = temporaryFolder.newFile("lib.jar").toPath();
-    Path bcp = temporaryFolder.newFile("bcp.jar").toPath();
-
-    try (JarOutputStream jos = new JarOutputStream(Files.newOutputStream(lib))) {
-      {
-        jos.putNextEntry(new ZipEntry("foo/Bar.class"));
-        ClassWriter cw = new ClassWriter(0);
-        cw.visit(52, Opcodes.ACC_PUBLIC, "foo/Bar", null, "bar/A", null);
-        jos.write(cw.toByteArray());
-      }
-      {
-        jos.putNextEntry(new ZipEntry("foo/Baz.class"));
-        ClassWriter cw = new ClassWriter(0);
-        cw.visit(52, Opcodes.ACC_PUBLIC, "foo/Baz", null, "bar/A", null);
-        jos.write(cw.toByteArray());
-      }
-    }
-
-    try (JarOutputStream jos = new JarOutputStream(Files.newOutputStream(bcp))) {
-      jos.putNextEntry(new ZipEntry("foo/Bar.class"));
-      ClassWriter cw = new ClassWriter(0);
-      cw.visit(52, Opcodes.ACC_PUBLIC, "foo/Bar", null, "bar/B", null);
-      jos.write(cw.toByteArray());
-    }
-
-    CompoundEnv<ClassSymbol, BytecodeBoundClass> env =
-        ClassPathBinder.bind(ImmutableList.of(lib), ImmutableList.of(bcp), TopLevelIndex.builder());
-
-    assertThat(env.get(new ClassSymbol("foo/Bar")).superclass())
-        .isEqualTo(new ClassSymbol("bar/B"));
-    assertThat(env.get(new ClassSymbol("foo/Baz")).superclass())
-        .isEqualTo(new ClassSymbol("bar/A"));
-  }
-
   @Test
   public void nonJarFile() throws Exception {
     Path lib = temporaryFolder.newFile("NOT_A_JAR").toPath();
     Files.write(lib, "hello".getBytes(UTF_8));
 
     try {
-      ClassPathBinder.bind(ImmutableList.of(lib), ImmutableList.of(), TopLevelIndex.builder());
+      ClassPathBinder.bindClasspath(ImmutableList.of(lib));
       fail();
     } catch (IOException e) {
       assertThat(e.getMessage()).contains("NOT_A_JAR");
diff --git a/javatests/com/google/turbine/binder/JimageClassBinderTest.java b/javatests/com/google/turbine/binder/JimageClassBinderTest.java
new file mode 100644
index 0000000..ffbbf87
--- /dev/null
+++ b/javatests/com/google/turbine/binder/JimageClassBinderTest.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2018 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.turbine.binder;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.turbine.binder.bytecode.BytecodeBoundClass;
+import com.google.turbine.binder.lookup.LookupKey;
+import com.google.turbine.binder.lookup.LookupResult;
+import com.google.turbine.binder.sym.ClassSymbol;
+import java.io.IOException;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class JimageClassBinderTest {
+  @Test
+  public void testDefaultJimage() throws IOException {
+    if (Double.parseDouble(System.getProperty("java.class.version")) < 53) {
+      // only run on JDK 9 and later
+      return;
+    }
+    ClassPath binder = JimageClassBinder.bindDefault();
+
+    BytecodeBoundClass objectInfo = binder.env().get(new ClassSymbol("java/lang/Object"));
+    assertThat(objectInfo).isNotNull();
+    assertThat(objectInfo.jarFile()).isEqualTo("/modules/java.base/java/lang/Object.class");
+    assertThat(binder.env().get(new ClassSymbol("java/lang/NoSuch"))).isNull();
+
+    assertThat(binder.index().lookupPackage(ImmutableList.of("java", "nosuch"))).isNull();
+
+    LookupResult objectSym =
+        binder
+            .index()
+            .lookupPackage(ImmutableList.of("java", "lang"))
+            .lookup(new LookupKey(ImmutableList.of("Object")));
+    assertThat(((ClassSymbol) objectSym.sym()).binaryName()).isEqualTo("java/lang/Object");
+    assertThat(objectSym.remaining()).isEmpty();
+
+    LookupResult entrySym =
+        binder
+            .index()
+            .lookupPackage(ImmutableList.of("java", "util"))
+            .lookup(new LookupKey(ImmutableList.of("Map", "Entry")));
+    assertThat(((ClassSymbol) entrySym.sym()).binaryName()).isEqualTo("java/util/Map");
+    assertThat(entrySym.remaining()).containsExactly("Entry");
+
+    entrySym =
+        binder
+            .index()
+            .scope()
+            .lookup(new LookupKey(ImmutableList.of("java", "util", "Map", "Entry")));
+    assertThat(((ClassSymbol) entrySym.sym()).binaryName()).isEqualTo("java/util/Map");
+    assertThat(entrySym.remaining()).containsExactly("Entry");
+  }
+}
diff --git a/javatests/com/google/turbine/binder/lookup/TopLevelIndexTest.java b/javatests/com/google/turbine/binder/lookup/TopLevelIndexTest.java
index 2e4d02a..3dcf127 100644
--- a/javatests/com/google/turbine/binder/lookup/TopLevelIndexTest.java
+++ b/javatests/com/google/turbine/binder/lookup/TopLevelIndexTest.java
@@ -32,16 +32,17 @@
   private static final TopLevelIndex index = buildIndex();
 
   private static TopLevelIndex buildIndex() {
-    TopLevelIndex.Builder builder = TopLevelIndex.builder();
-    builder.insert(new ClassSymbol("java/util/Map"));
-    builder.insert(new ClassSymbol("java/util/List"));
-    builder.insert(new ClassSymbol("com/google/common/base/Optional"));
-    return builder.build();
+    return SimpleTopLevelIndex.of(
+        ImmutableList.of(
+            new ClassSymbol("java/util/Map"),
+            new ClassSymbol("java/util/List"),
+            new ClassSymbol("com/google/common/base/Optional")));
   }
 
   @Test
   public void simple() {
-    LookupResult result = index.lookup(new LookupKey(ImmutableList.of("java", "util", "Map")));
+    LookupResult result =
+        index.scope().lookup(new LookupKey(ImmutableList.of("java", "util", "Map")));
     assertThat(result.sym()).isEqualTo(new ClassSymbol("java/util/Map"));
     assertThat(result.remaining()).isEmpty();
   }
@@ -49,14 +50,15 @@
   @Test
   public void nested() {
     LookupResult result =
-        index.lookup(new LookupKey(ImmutableList.of("java", "util", "Map", "Entry")));
+        index.scope().lookup(new LookupKey(ImmutableList.of("java", "util", "Map", "Entry")));
     assertThat(result.sym()).isEqualTo(new ClassSymbol("java/util/Map"));
     assertThat(result.remaining()).containsExactly("Entry");
   }
 
   @Test
   public void empty() {
-    assertThat(index.lookup(new LookupKey(ImmutableList.of("java", "NoSuch", "Entry")))).isNull();
+    assertThat(index.scope().lookup(new LookupKey(ImmutableList.of("java", "NoSuch", "Entry"))))
+        .isNull();
     assertThat(index.lookupPackage(ImmutableList.of("java", "math"))).isNull();
     assertThat(index.lookupPackage(ImmutableList.of("java", "util", "Map"))).isNull();
   }
@@ -76,23 +78,21 @@
   public void overrideClass() {
     {
       // the use of Foo as a class name in the package java is "sticky"
-      TopLevelIndex.Builder builder = TopLevelIndex.builder();
-      builder.insert(new ClassSymbol("java/Foo"));
-      assertThat(builder.insert(new ClassSymbol("java/Foo/Bar"))).isFalse();
-      TopLevelIndex index = builder.build();
+      TopLevelIndex index =
+          SimpleTopLevelIndex.of(
+              ImmutableList.of(new ClassSymbol("java/Foo"), new ClassSymbol("java/Foo/Bar")));
 
-      LookupResult result = index.lookup(new LookupKey(ImmutableList.of("java", "Foo")));
+      LookupResult result = index.scope().lookup(new LookupKey(ImmutableList.of("java", "Foo")));
       assertThat(result.sym()).isEqualTo(new ClassSymbol("java/Foo"));
       assertThat(result.remaining()).isEmpty();
     }
     {
       // the use of Foo as a package name under java is "sticky"
-      TopLevelIndex.Builder builder = TopLevelIndex.builder();
-      builder.insert(new ClassSymbol("java/Foo/Bar"));
-      assertThat(builder.insert(new ClassSymbol("java/Foo"))).isFalse();
-      TopLevelIndex index = builder.build();
+      TopLevelIndex index =
+          SimpleTopLevelIndex.of(
+              ImmutableList.of(new ClassSymbol("java/Foo/Bar"), new ClassSymbol("java/Foo")));
 
-      assertThat(index.lookup(new LookupKey(ImmutableList.of("java", "Foo")))).isNull();
+      assertThat(index.scope().lookup(new LookupKey(ImmutableList.of("java", "Foo")))).isNull();
       LookupResult packageResult =
           index
               .lookupPackage(ImmutableList.of("java", "Foo"))
diff --git a/javatests/com/google/turbine/bytecode/ClassReaderTest.java b/javatests/com/google/turbine/bytecode/ClassReaderTest.java
index dde37d5..5bce03d 100644
--- a/javatests/com/google/turbine/bytecode/ClassReaderTest.java
+++ b/javatests/com/google/turbine/bytecode/ClassReaderTest.java
@@ -16,11 +16,17 @@
 
 package com.google.turbine.bytecode;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
 import com.google.turbine.bytecode.ClassFile.AnnotationInfo.ElementValue;
+import com.google.turbine.bytecode.ClassFile.ModuleInfo;
+import com.google.turbine.bytecode.ClassFile.ModuleInfo.ExportInfo;
+import com.google.turbine.bytecode.ClassFile.ModuleInfo.OpenInfo;
+import com.google.turbine.bytecode.ClassFile.ModuleInfo.ProvideInfo;
+import com.google.turbine.bytecode.ClassFile.ModuleInfo.RequireInfo;
 import com.google.turbine.model.Const;
 import com.google.turbine.model.TurbineConstantTypeKind;
 import com.google.turbine.model.TurbineFlag;
@@ -29,6 +35,7 @@
 import org.junit.runners.JUnit4;
 import org.objectweb.asm.AnnotationVisitor;
 import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.ModuleVisitor;
 import org.objectweb.asm.Opcodes;
 
 @RunWith(JUnit4.class)
@@ -235,4 +242,93 @@
     ClassFile cf = ClassReader.read(null, cw.toByteArray());
     assertThat(cf.name()).isEqualTo("Hello");
   }
+
+  @Test
+  public void module() {
+    ClassWriter cw = new ClassWriter(0);
+
+    cw.visit(53, /* access= */ 53, "module-info", null, null, null);
+
+    ModuleVisitor mv = cw.visitModule("mod", Opcodes.ACC_OPEN, "mod-ver");
+
+    mv.visitRequire("r1", Opcodes.ACC_TRANSITIVE, "r1-ver");
+    mv.visitRequire("r2", Opcodes.ACC_STATIC_PHASE, "r2-ver");
+    mv.visitRequire("r3", Opcodes.ACC_STATIC_PHASE | Opcodes.ACC_TRANSITIVE, "r3-ver");
+
+    mv.visitExport("e1", Opcodes.ACC_SYNTHETIC, "e1m1", "e1m2", "e1m3");
+    mv.visitExport("e2", Opcodes.ACC_MANDATED, "e2m1", "e2m2");
+    mv.visitExport("e3", /* access= */ 0, "e3m1");
+
+    mv.visitOpen("o1", Opcodes.ACC_SYNTHETIC, "o1m1", "o1m2", "o1m3");
+    mv.visitOpen("o2", Opcodes.ACC_MANDATED, "o2m1", "o2m2");
+    mv.visitOpen("o3", /* access= */ 0, "o3m1");
+
+    mv.visitUse("u1");
+    mv.visitUse("u2");
+    mv.visitUse("u3");
+    mv.visitUse("u4");
+
+    mv.visitProvide("p1", "p1i1", "p1i2");
+    mv.visitProvide("p2", "p2i1", "p2i2", "p2i3");
+
+    ClassFile cf = ClassReader.read(null, cw.toByteArray());
+    ModuleInfo module = cf.module();
+    assertThat(module.name()).isEqualTo("mod");
+    assertThat(module.flags()).isEqualTo(Opcodes.ACC_OPEN);
+    assertThat(module.version()).isEqualTo("mod-ver");
+
+    assertThat(module.requires()).hasSize(3);
+    RequireInfo r1 = module.requires().get(0);
+    assertThat(r1.moduleName()).isEqualTo("r1");
+    assertThat(r1.flags()).isEqualTo(Opcodes.ACC_TRANSITIVE);
+    assertThat(r1.version()).isEqualTo("r1-ver");
+    RequireInfo r2 = module.requires().get(1);
+    assertThat(r2.moduleName()).isEqualTo("r2");
+    assertThat(r2.flags()).isEqualTo(Opcodes.ACC_STATIC_PHASE);
+    assertThat(r2.version()).isEqualTo("r2-ver");
+    RequireInfo r3 = module.requires().get(2);
+    assertThat(r3.moduleName()).isEqualTo("r3");
+    assertThat(r3.flags()).isEqualTo(Opcodes.ACC_STATIC_PHASE | Opcodes.ACC_TRANSITIVE);
+    assertThat(r3.version()).isEqualTo("r3-ver");
+
+    assertThat(module.exports()).hasSize(3);
+    ExportInfo e1 = module.exports().get(0);
+    assertThat(e1.moduleName()).isEqualTo("e1");
+    assertThat(e1.flags()).isEqualTo(Opcodes.ACC_SYNTHETIC);
+    assertThat(e1.modules()).containsExactly("e1m1", "e1m2", "e1m3").inOrder();
+    ExportInfo e2 = module.exports().get(1);
+    assertThat(e2.moduleName()).isEqualTo("e2");
+    assertThat(e2.flags()).isEqualTo(Opcodes.ACC_MANDATED);
+    assertThat(e2.modules()).containsExactly("e2m1", "e2m2").inOrder();
+    ExportInfo e3 = module.exports().get(2);
+    assertThat(e3.moduleName()).isEqualTo("e3");
+    assertThat(e3.flags()).isEqualTo(0);
+    assertThat(e3.modules()).containsExactly("e3m1").inOrder();
+
+    assertThat(module.opens()).hasSize(3);
+    OpenInfo o1 = module.opens().get(0);
+    assertThat(o1.moduleName()).isEqualTo("o1");
+    assertThat(o1.flags()).isEqualTo(Opcodes.ACC_SYNTHETIC);
+    assertThat(o1.modules()).containsExactly("o1m1", "o1m2", "o1m3").inOrder();
+    OpenInfo o2 = module.opens().get(1);
+    assertThat(o2.moduleName()).isEqualTo("o2");
+    assertThat(o2.flags()).isEqualTo(Opcodes.ACC_MANDATED);
+    assertThat(o2.modules()).containsExactly("o2m1", "o2m2").inOrder();
+    OpenInfo o3 = module.opens().get(2);
+    assertThat(o3.moduleName()).isEqualTo("o3");
+    assertThat(o3.flags()).isEqualTo(0);
+    assertThat(o3.modules()).containsExactly("o3m1").inOrder();
+
+    assertThat(module.uses().stream().map(u -> u.descriptor()).collect(toImmutableList()))
+        .containsExactly("u1", "u2", "u3", "u4")
+        .inOrder();
+
+    assertThat(module.provides()).hasSize(2);
+    ProvideInfo p1 = module.provides().get(0);
+    assertThat(p1.descriptor()).isEqualTo("p1");
+    assertThat(p1.implDescriptors()).containsExactly("p1i1", "p1i2");
+    ProvideInfo p2 = module.provides().get(1);
+    assertThat(p2.descriptor()).isEqualTo("p2");
+    assertThat(p2.implDescriptors()).containsExactly("p2i1", "p2i2", "p2i3");
+  }
 }
diff --git a/javatests/com/google/turbine/bytecode/ClassWriterTest.java b/javatests/com/google/turbine/bytecode/ClassWriterTest.java
index a2544fc..e544c15 100644
--- a/javatests/com/google/turbine/bytecode/ClassWriterTest.java
+++ b/javatests/com/google/turbine/bytecode/ClassWriterTest.java
@@ -24,6 +24,7 @@
 import com.google.common.io.ByteStreams;
 import com.google.common.jimfs.Configuration;
 import com.google.common.jimfs.Jimfs;
+import com.google.turbine.testing.AsmUtils;
 import com.sun.source.util.JavacTask;
 import com.sun.tools.javac.api.JavacTool;
 import com.sun.tools.javac.file.JavacFileManager;
@@ -42,6 +43,8 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
+import org.objectweb.asm.ModuleVisitor;
+import org.objectweb.asm.Opcodes;
 
 @RunWith(JUnit4.class)
 public class ClassWriterTest {
@@ -77,8 +80,8 @@
                     new BufferedWriter(new OutputStreamWriter(System.err, UTF_8)), true),
                 fileManager,
                 collector,
-                ImmutableList.of(),
-                ImmutableList.of(),
+                ImmutableList.of("-source", "8", "-target", "8"),
+                /* classes= */ null,
                 fileManager.getJavaFileObjects(path));
 
     assertThat(task.call()).named(collector.getDiagnostics().toString()).isTrue();
@@ -108,4 +111,43 @@
       assertThat(reader.classInfo(entry.getKey())).isEqualTo(entry.getValue());
     }
   }
+
+  @Test
+  public void module() throws Exception {
+
+    org.objectweb.asm.ClassWriter cw = new org.objectweb.asm.ClassWriter(0);
+
+    cw.visit(53, /* access= */ 53, "module-info", null, null, null);
+
+    ModuleVisitor mv = cw.visitModule("mod", Opcodes.ACC_OPEN, "mod-ver");
+
+    mv.visitRequire("r1", Opcodes.ACC_TRANSITIVE, "r1-ver");
+    mv.visitRequire("r2", Opcodes.ACC_STATIC_PHASE, "r2-ver");
+    mv.visitRequire("r3", Opcodes.ACC_STATIC_PHASE | Opcodes.ACC_TRANSITIVE, "r3-ver");
+
+    mv.visitExport("e1", Opcodes.ACC_SYNTHETIC, "e1m1", "e1m2", "e1m3");
+    mv.visitExport("e2", Opcodes.ACC_MANDATED, "e2m1", "e2m2");
+    mv.visitExport("e3", /* access= */ 0, "e3m1");
+
+    mv.visitOpen("o1", Opcodes.ACC_SYNTHETIC, "o1m1", "o1m2", "o1m3");
+    mv.visitOpen("o2", Opcodes.ACC_MANDATED, "o2m1", "o2m2");
+    mv.visitOpen("o3", /* access= */ 0, "o3m1");
+
+    mv.visitUse("u1");
+    mv.visitUse("u2");
+    mv.visitUse("u3");
+    mv.visitUse("u4");
+
+    mv.visitProvide("p1", "p1i1", "p1i2");
+    mv.visitProvide("p2", "p2i1", "p2i2", "p2i3");
+
+    byte[] inputBytes = cw.toByteArray();
+    byte[] outputBytes = ClassWriter.writeClass(ClassReader.read("module-info", inputBytes));
+
+    assertThat(AsmUtils.textify(inputBytes)).isEqualTo(AsmUtils.textify(outputBytes));
+
+    // test a round trip
+    outputBytes = ClassWriter.writeClass(ClassReader.read("module-info", outputBytes));
+    assertThat(AsmUtils.textify(inputBytes)).isEqualTo(AsmUtils.textify(outputBytes));
+  }
 }
diff --git a/javatests/com/google/turbine/bytecode/sig/SigIntegrationTest.java b/javatests/com/google/turbine/bytecode/sig/SigIntegrationTest.java
index 1a3e70b..1c0d5c4 100644
--- a/javatests/com/google/turbine/bytecode/sig/SigIntegrationTest.java
+++ b/javatests/com/google/turbine/bytecode/sig/SigIntegrationTest.java
@@ -16,12 +16,26 @@
 
 package com.google.turbine.bytecode.sig;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Streams;
+import java.io.File;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.net.URI;
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.nio.file.Paths;
-import java.util.Enumeration;
-import java.util.jar.JarEntry;
-import java.util.jar.JarFile;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Consumer;
+import java.util.stream.Stream;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
@@ -32,67 +46,101 @@
 import org.objectweb.asm.Opcodes;
 
 /**
- * Reads all field, class, and method signatures in rt.jar, and round-trips them through {@link
- * SigWriter} and {@link SigParser}.
+ * Reads all field, class, and method signatures in the bootclasspath, and round-trips them through
+ * {@link SigWriter} and {@link SigParser}.
  */
 @RunWith(JUnit4.class)
 public class SigIntegrationTest {
 
+  private static final Splitter CLASS_PATH_SPLITTER =
+      Splitter.on(File.pathSeparatorChar).omitEmptyStrings();
+
+  void forEachBootclass(Consumer<Path> consumer) throws IOException {
+    ImmutableList<Path> bootclasspath =
+        Streams.stream(
+                CLASS_PATH_SPLITTER.split(
+                    Optional.ofNullable(System.getProperty("sun.boot.class.path")).orElse("")))
+            .map(Paths::get)
+            .filter(Files::exists)
+            .collect(toImmutableList());
+    if (!bootclasspath.isEmpty()) {
+      for (Path path : bootclasspath) {
+        Map<String, ?> env = new HashMap<>();
+        try (FileSystem jarfs = FileSystems.newFileSystem(URI.create("jar:" + path.toUri()), env);
+            Stream<Path> stream = Files.walk(jarfs.getPath("/"))) {
+          stream
+              .filter(Files::isRegularFile)
+              .filter(p -> p.getFileName().toString().endsWith(".class"))
+              .forEachOrdered(consumer);
+        }
+      }
+      return;
+    }
+    {
+      Map<String, ?> env = new HashMap<>();
+      try (FileSystem fileSystem = FileSystems.newFileSystem(URI.create("jrt:/"), env);
+          Stream<Path> stream = Files.walk(fileSystem.getPath("/modules"))) {
+        stream.filter(p -> p.getFileName().toString().endsWith(".class")).forEachOrdered(consumer);
+      }
+    }
+  }
+
   @Test
   public void roundTrip() throws Exception {
     int[] totalSignatures = {0};
-    try (JarFile jarFile =
-        new JarFile(Paths.get(System.getProperty("java.home")).resolve("lib/rt.jar").toFile())) {
-      Enumeration<JarEntry> entries = jarFile.entries();
-      while (entries.hasMoreElements()) {
-        JarEntry entry = entries.nextElement();
-        if (!entry.getName().endsWith(".class")) {
-          continue;
-        }
-        new ClassReader(jarFile.getInputStream(entry))
-            .accept(
-                new ClassVisitor(Opcodes.ASM5) {
-                  @Override
-                  public void visit(
-                      int version,
-                      int access,
-                      String name,
-                      String signature,
-                      String superName,
-                      String[] interfaces) {
-                    if (signature != null) {
-                      assertThat(SigWriter.classSig(new SigParser(signature).parseClassSig()))
-                          .isEqualTo(signature);
-                      totalSignatures[0]++;
-                    }
-                  }
+    forEachBootclass(
+        path -> {
+          try {
+            new ClassReader(Files.newInputStream(path))
+                .accept(
+                    new ClassVisitor(Opcodes.ASM6) {
+                      @Override
+                      public void visit(
+                          int version,
+                          int access,
+                          String name,
+                          String signature,
+                          String superName,
+                          String[] interfaces) {
+                        if (signature != null) {
+                          assertThat(SigWriter.classSig(new SigParser(signature).parseClassSig()))
+                              .isEqualTo(signature);
+                          totalSignatures[0]++;
+                        }
+                      }
 
-                  @Override
-                  public FieldVisitor visitField(
-                      int access, String name, String desc, String signature, Object value) {
-                    if (signature != null) {
-                      assertThat(SigWriter.type(new SigParser(signature).parseFieldSig()))
-                          .isEqualTo(signature);
-                      totalSignatures[0]++;
-                    }
-                    return super.visitField(access, name, desc, signature, value);
-                  }
+                      @Override
+                      public FieldVisitor visitField(
+                          int access, String name, String desc, String signature, Object value) {
+                        if (signature != null) {
+                          assertThat(SigWriter.type(new SigParser(signature).parseFieldSig()))
+                              .isEqualTo(signature);
+                          totalSignatures[0]++;
+                        }
+                        return super.visitField(access, name, desc, signature, value);
+                      }
 
-                  @Override
-                  public MethodVisitor visitMethod(
-                      int access, String name, String desc, String signature, String[] exceptions) {
-                    if (signature != null) {
-                      assertThat(SigWriter.method(new SigParser(signature).parseMethodSig()))
-                          .isEqualTo(signature);
-                      totalSignatures[0]++;
-                    }
-                    return super.visitMethod(access, name, desc, signature, exceptions);
-                  }
-                },
-                ClassReader.SKIP_CODE | ClassReader.SKIP_FRAMES | ClassReader.SKIP_DEBUG);
-      }
-    }
-    // sanity-check that rt.jar contains a plausible number of signatures; 8u60 has >18k
+                      @Override
+                      public MethodVisitor visitMethod(
+                          int access,
+                          String name,
+                          String desc,
+                          String signature,
+                          String[] exceptions) {
+                        if (signature != null) {
+                          assertThat(SigWriter.method(new SigParser(signature).parseMethodSig()))
+                              .isEqualTo(signature);
+                          totalSignatures[0]++;
+                        }
+                        return super.visitMethod(access, name, desc, signature, exceptions);
+                      }
+                    },
+                    ClassReader.SKIP_CODE | ClassReader.SKIP_FRAMES | ClassReader.SKIP_DEBUG);
+          } catch (IOException e) {
+            throw new UncheckedIOException(e);
+          }
+        });
+    // sanity-check that the bootclasspath contains a plausible number of signatures; 8u60 has >18k
     assertThat(totalSignatures[0]).isGreaterThan(10000);
   }
 }
diff --git a/javatests/com/google/turbine/deps/AbstractTransitiveTest.java b/javatests/com/google/turbine/deps/AbstractTransitiveTest.java
index 6335216..1bd9349 100644
--- a/javatests/com/google/turbine/deps/AbstractTransitiveTest.java
+++ b/javatests/com/google/turbine/deps/AbstractTransitiveTest.java
@@ -19,6 +19,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.turbine.testing.TestClassPaths.optionsWithBootclasspath;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableList;
@@ -28,12 +29,10 @@
 import com.google.turbine.bytecode.ClassFile.InnerClass;
 import com.google.turbine.bytecode.ClassReader;
 import com.google.turbine.main.Main;
-import com.google.turbine.options.TurbineOptions;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.nio.file.Paths;
 import java.util.Arrays;
 import java.util.Enumeration;
 import java.util.LinkedHashMap;
@@ -53,9 +52,6 @@
   protected abstract Path runTurbine(ImmutableList<Path> sources, ImmutableList<Path> classpath)
       throws IOException;
 
-  protected static final ImmutableList<Path> BOOTCLASSPATH =
-      ImmutableList.of(Paths.get(System.getProperty("java.home")).resolve("lib/rt.jar"));
-
   @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder();
 
   class SourceBuilder {
@@ -162,11 +158,10 @@
             .collect(toImmutableList());
     boolean ok =
         Main.compile(
-            TurbineOptions.builder()
+            optionsWithBootclasspath()
                 .addSources(sources)
                 .addClassPathEntries(
                     ImmutableList.of(libb).stream().map(Path::toString).collect(toImmutableList()))
-                .addBootClassPathEntries(Iterables.transform(BOOTCLASSPATH, Path::toString))
                 .setOutput(libc.toString())
                 .build());
     assertThat(ok).isTrue();
diff --git a/javatests/com/google/turbine/deps/DependenciesTest.java b/javatests/com/google/turbine/deps/DependenciesTest.java
index dd94650..70377fb 100644
--- a/javatests/com/google/turbine/deps/DependenciesTest.java
+++ b/javatests/com/google/turbine/deps/DependenciesTest.java
@@ -23,15 +23,16 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
 import com.google.turbine.binder.Binder;
 import com.google.turbine.binder.Binder.BindingResult;
+import com.google.turbine.binder.ClassPathBinder;
 import com.google.turbine.diag.SourceFile;
 import com.google.turbine.lower.IntegrationTestSupport;
 import com.google.turbine.lower.Lower;
 import com.google.turbine.lower.Lower.Lowered;
 import com.google.turbine.parse.Parser;
 import com.google.turbine.proto.DepsProto;
+import com.google.turbine.testing.TestClassPaths;
 import com.google.turbine.tree.Tree.CompUnit;
 import java.io.BufferedOutputStream;
 import java.io.IOException;
@@ -56,9 +57,6 @@
 @RunWith(JUnit4.class)
 public class DependenciesTest {
 
-  static final ImmutableSet<Path> BOOTCLASSPATH =
-      ImmutableSet.of(Paths.get(System.getProperty("java.home")).resolve("lib/rt.jar"));
-
   @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder();
 
   class LibraryBuilder {
@@ -77,8 +75,7 @@
 
     Path compileToJar(String path) throws Exception {
       Path lib = temporaryFolder.newFile(path).toPath();
-      Map<String, byte[]> classes =
-          IntegrationTestSupport.runJavac(sources, classpath, BOOTCLASSPATH);
+      Map<String, byte[]> classes = IntegrationTestSupport.runJavac(sources, classpath);
       try (JarOutputStream jos = new JarOutputStream(Files.newOutputStream(lib))) {
         for (Map.Entry<String, byte[]> entry : classes.entrySet()) {
           jos.putNextEntry(new JarEntry(entry.getKey() + ".class"));
@@ -104,15 +101,17 @@
     }
 
     DepsProto.Dependencies run() throws IOException {
-      BindingResult bound = Binder.bind(units, classpath, BOOTCLASSPATH);
+      BindingResult bound =
+          Binder.bind(
+              units,
+              ClassPathBinder.bindClasspath(classpath),
+              TestClassPaths.TURBINE_BOOTCLASSPATH,
+              /* moduleVersion=*/ Optional.absent());
 
-      Lowered lowered = Lower.lowerAll(bound.units(), bound.classPathEnv());
+      Lowered lowered = Lower.lowerAll(bound.units(), bound.modules(), bound.classPathEnv());
 
       return Dependencies.collectDeps(
-          Optional.of("//test"),
-          ImmutableSet.copyOf(Iterables.transform(BOOTCLASSPATH, Path::toString)),
-          bound,
-          lowered);
+          Optional.of("//test"), TestClassPaths.TURBINE_BOOTCLASSPATH, bound, lowered);
     }
   }
 
@@ -258,9 +257,9 @@
         ImmutableList.of(
             "a.jar", "b.jar", "c.jar", "d.jar", "e.jar", "f.jar", "g.jar", "h.jar", "i.jar",
             "j.jar");
-    ImmutableMap<String, String> directJarsToTargets = ImmutableMap.of();
+    ImmutableSet<String> directJars = ImmutableSet.of();
     ImmutableList<String> depsArtifacts = ImmutableList.of();
-    assertThat(Dependencies.reduceClasspath(classpath, directJarsToTargets, depsArtifacts))
+    assertThat(Dependencies.reduceClasspath(classpath, directJars, depsArtifacts))
         .isEqualTo(classpath);
   }
 
@@ -273,11 +272,7 @@
         ImmutableList.of(
             "a.jar", "b.jar", "c.jar", "d.jar", "e.jar", "f.jar", "g.jar", "h.jar", "i.jar",
             "j.jar");
-    ImmutableMap<String, String> directJarsToTargets =
-        ImmutableMap.of(
-            "c.jar", "//a",
-            "d.jar", "//d",
-            "g.jar", "//e");
+    ImmutableSet<String> directJars = ImmutableSet.of("c.jar", "d.jar", "g.jar");
     ImmutableList<String> depsArtifacts =
         ImmutableList.of(cdeps.toString(), ddeps.toString(), gdeps.toString());
     writeDeps(
@@ -291,7 +286,7 @@
             "f.jar", DepsProto.Dependency.Kind.UNUSED,
             "j.jar", DepsProto.Dependency.Kind.UNUSED));
     writeDeps(gdeps, ImmutableMap.of("i.jar", DepsProto.Dependency.Kind.IMPLICIT));
-    assertThat(Dependencies.reduceClasspath(classpath, directJarsToTargets, depsArtifacts))
+    assertThat(Dependencies.reduceClasspath(classpath, directJars, depsArtifacts))
         .containsExactly("b.jar", "c.jar", "d.jar", "e.jar", "g.jar", "i.jar")
         .inOrder();
   }
diff --git a/javatests/com/google/turbine/deps/TransitiveTest.java b/javatests/com/google/turbine/deps/TransitiveTest.java
index fd764f4..f8c5b50 100644
--- a/javatests/com/google/turbine/deps/TransitiveTest.java
+++ b/javatests/com/google/turbine/deps/TransitiveTest.java
@@ -18,11 +18,10 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.turbine.testing.TestClassPaths.optionsWithBootclasspath;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
 import com.google.turbine.main.Main;
-import com.google.turbine.options.TurbineOptions;
 import java.io.IOException;
 import java.nio.file.Path;
 import org.junit.runner.RunWith;
@@ -37,11 +36,10 @@
     Path out = temporaryFolder.newFolder().toPath().resolve("out.jar");
     boolean ok =
         Main.compile(
-            TurbineOptions.builder()
+            optionsWithBootclasspath()
                 .addSources(sources.stream().map(Path::toString).collect(toImmutableList()))
                 .addClassPathEntries(
                     classpath.stream().map(Path::toString).collect(toImmutableList()))
-                .addBootClassPathEntries(Iterables.transform(BOOTCLASSPATH, Path::toString))
                 .setOutput(out.toString())
                 .build());
     assertThat(ok).isTrue();
diff --git a/javatests/com/google/turbine/lower/IntegrationTestSupport.java b/javatests/com/google/turbine/lower/IntegrationTestSupport.java
index 4e64964..51b5a2e 100644
--- a/javatests/com/google/turbine/lower/IntegrationTestSupport.java
+++ b/javatests/com/google/turbine/lower/IntegrationTestSupport.java
@@ -17,19 +17,23 @@
 package com.google.turbine.lower;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.turbine.testing.TestClassPaths.TURBINE_BOOTCLASSPATH;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toCollection;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Joiner;
+import com.google.common.base.Optional;
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
 import com.google.common.jimfs.Configuration;
 import com.google.common.jimfs.Jimfs;
 import com.google.turbine.binder.Binder;
-import com.google.turbine.bytecode.AsmUtils;
+import com.google.turbine.binder.ClassPath;
+import com.google.turbine.binder.ClassPathBinder;
 import com.google.turbine.diag.SourceFile;
 import com.google.turbine.parse.Parser;
+import com.google.turbine.testing.AsmUtils;
 import com.google.turbine.tree.Tree;
 import com.sun.source.util.JavacTask;
 import com.sun.tools.javac.api.JavacTool;
@@ -400,7 +404,7 @@
     final Set<String> classes1 = classes;
     new SignatureReader(signature)
         .accept(
-            new SignatureVisitor(Opcodes.ASM5) {
+            new SignatureVisitor(Opcodes.ASM6) {
               private final Set<String> classes = classes1;
               // class signatures may contain type arguments that contain class signatures
               Deque<List<String>> pieces = new ArrayDeque<>();
@@ -424,8 +428,17 @@
             });
   }
 
+  static Map<String, byte[]> runTurbine(Map<String, String> input, ImmutableList<Path> classpath)
+      throws IOException {
+    return runTurbine(
+        input, classpath, TURBINE_BOOTCLASSPATH, /* moduleVersion= */ Optional.absent());
+  }
+
   static Map<String, byte[]> runTurbine(
-      Map<String, String> input, ImmutableList<Path> classpath, Collection<Path> bootclasspath)
+      Map<String, String> input,
+      ImmutableList<Path> classpath,
+      ClassPath bootClassPath,
+      Optional<String> moduleVersion)
       throws IOException {
     List<Tree.CompUnit> units =
         input
@@ -435,14 +448,19 @@
             .map(Parser::parse)
             .collect(toList());
 
-    Binder.BindingResult bound = Binder.bind(units, classpath, bootclasspath);
-    return Lower.lowerAll(bound.units(), bound.classPathEnv()).bytes();
+    Binder.BindingResult bound =
+        Binder.bind(units, ClassPathBinder.bindClasspath(classpath), bootClassPath, moduleVersion);
+    return Lower.lowerAll(bound.units(), bound.modules(), bound.classPathEnv()).bytes();
   }
 
   public static Map<String, byte[]> runJavac(
-      Map<String, String> sources,
-      Collection<Path> classpath,
-      Collection<? extends Path> bootclasspath)
+      Map<String, String> sources, Collection<Path> classpath) throws Exception {
+    return runJavac(
+        sources, classpath, ImmutableList.of("-parameters", "-source", "8", "-target", "8"));
+  }
+
+  public static Map<String, byte[]> runJavac(
+      Map<String, String> sources, Collection<Path> classpath, ImmutableList<String> options)
       throws Exception {
 
     FileSystem fs = Jimfs.newFileSystem(Configuration.unix());
@@ -465,16 +483,22 @@
     JavacTool compiler = JavacTool.create();
     DiagnosticCollector<JavaFileObject> collector = new DiagnosticCollector<>();
     JavacFileManager fileManager = new JavacFileManager(new Context(), true, UTF_8);
-    fileManager.setLocationFromPaths(StandardLocation.PLATFORM_CLASS_PATH, bootclasspath);
     fileManager.setLocationFromPaths(StandardLocation.CLASS_OUTPUT, ImmutableList.of(out));
     fileManager.setLocationFromPaths(StandardLocation.CLASS_PATH, classpath);
+    fileManager.setLocationFromPaths(StandardLocation.locationFor("MODULE_PATH"), classpath);
+    if (inputs.stream().filter(i -> i.getFileName().toString().equals("module-info.java")).count()
+        > 1) {
+      // multi-module mode
+      fileManager.setLocationFromPaths(
+          StandardLocation.locationFor("MODULE_SOURCE_PATH"), ImmutableList.of(srcs));
+    }
 
     JavacTask task =
         compiler.getTask(
             new PrintWriter(new BufferedWriter(new OutputStreamWriter(System.err, UTF_8)), true),
             fileManager,
             collector,
-            ImmutableList.of("-parameters"),
+            options,
             ImmutableList.of(),
             fileManager.getJavaFileObjectsFromPaths(inputs));
 
diff --git a/javatests/com/google/turbine/lower/LowerIntegrationTest.java b/javatests/com/google/turbine/lower/LowerIntegrationTest.java
index c5caea1..698627c 100644
--- a/javatests/com/google/turbine/lower/LowerIntegrationTest.java
+++ b/javatests/com/google/turbine/lower/LowerIntegrationTest.java
@@ -299,6 +299,9 @@
       "type_anno_cstyle_array_dims.test",
       "packagedecl.test",
       "static_member_type_import_recursive.test",
+      "B70953542.test",
+      // TODO(cushon): support for source level 9 in integration tests
+      // "B74332665.test",
     };
     List<Object[]> tests =
         ImmutableList.copyOf(testCases).stream().map(x -> new Object[] {x}).collect(toList());
@@ -326,9 +329,6 @@
     this.test = test;
   }
 
-  static final ImmutableList<Path> BOOTCLASSPATH =
-      ImmutableList.of(Paths.get(System.getProperty("java.home")).resolve("lib/rt.jar"));
-
   @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder();
 
   @Test
@@ -343,7 +343,7 @@
     ImmutableList<Path> classpathJar = ImmutableList.of();
     if (!input.classes.isEmpty()) {
       Map<String, byte[]> classpath =
-          IntegrationTestSupport.runJavac(input.classes, ImmutableList.of(), BOOTCLASSPATH);
+          IntegrationTestSupport.runJavac(input.classes, ImmutableList.of());
       Path lib = temporaryFolder.newFile("lib.jar").toPath();
       try (JarOutputStream jos = new JarOutputStream(Files.newOutputStream(lib))) {
         for (Map.Entry<String, byte[]> entry : classpath.entrySet()) {
@@ -354,11 +354,9 @@
       classpathJar = ImmutableList.of(lib);
     }
 
-    Map<String, byte[]> expected =
-        IntegrationTestSupport.runJavac(input.sources, classpathJar, BOOTCLASSPATH);
+    Map<String, byte[]> expected = IntegrationTestSupport.runJavac(input.sources, classpathJar);
 
-    Map<String, byte[]> actual =
-        IntegrationTestSupport.runTurbine(input.sources, classpathJar, BOOTCLASSPATH);
+    Map<String, byte[]> actual = IntegrationTestSupport.runTurbine(input.sources, classpathJar);
 
     assertThat(IntegrationTestSupport.dump(IntegrationTestSupport.sortMembers(actual)))
         .isEqualTo(IntegrationTestSupport.dump(IntegrationTestSupport.canonicalize(expected)));
diff --git a/javatests/com/google/turbine/lower/LowerTest.java b/javatests/com/google/turbine/lower/LowerTest.java
index 7d916fd..6409d4d 100644
--- a/javatests/com/google/turbine/lower/LowerTest.java
+++ b/javatests/com/google/turbine/lower/LowerTest.java
@@ -17,9 +17,11 @@
 package com.google.turbine.lower;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.turbine.testing.TestClassPaths.TURBINE_BOOTCLASSPATH;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Joiner;
+import com.google.common.base.Optional;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.io.ByteStreams;
@@ -27,27 +29,23 @@
 import com.google.turbine.binder.Binder.BindingResult;
 import com.google.turbine.binder.ClassPathBinder;
 import com.google.turbine.binder.bound.SourceTypeBoundClass;
-import com.google.turbine.binder.bytecode.BytecodeBoundClass;
-import com.google.turbine.binder.env.CompoundEnv;
 import com.google.turbine.binder.env.SimpleEnv;
-import com.google.turbine.binder.lookup.TopLevelIndex;
 import com.google.turbine.binder.sym.ClassSymbol;
 import com.google.turbine.binder.sym.FieldSymbol;
 import com.google.turbine.binder.sym.MethodSymbol;
 import com.google.turbine.binder.sym.TyVarSymbol;
-import com.google.turbine.bytecode.AsmUtils;
 import com.google.turbine.bytecode.ByteReader;
 import com.google.turbine.bytecode.ConstantPoolReader;
 import com.google.turbine.model.TurbineConstantTypeKind;
 import com.google.turbine.model.TurbineFlag;
 import com.google.turbine.model.TurbineTyKind;
 import com.google.turbine.parse.Parser;
+import com.google.turbine.testing.AsmUtils;
 import com.google.turbine.type.Type;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -72,13 +70,8 @@
 
   @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder();
 
-  private static final ImmutableList<Path> BOOTCLASSPATH =
-      ImmutableList.of(Paths.get(System.getProperty("java.home")).resolve("lib/rt.jar"));
-
   @Test
   public void hello() throws Exception {
-    CompoundEnv<ClassSymbol, BytecodeBoundClass> classpath =
-        ClassPathBinder.bind(ImmutableList.of(), BOOTCLASSPATH, TopLevelIndex.builder());
 
     ImmutableList<Type.ClassTy> interfaceTypes =
         ImmutableList.of(
@@ -219,7 +212,8 @@
         Lower.lowerAll(
                 ImmutableMap.of(
                     new ClassSymbol("test/Test"), c, new ClassSymbol("test/Test$Inner"), i),
-                classpath)
+                ImmutableList.of(),
+                TURBINE_BOOTCLASSPATH.env())
             .bytes();
 
     assertThat(AsmUtils.textify(bytes.get("test/Test")))
@@ -249,13 +243,15 @@
                             "    class InnerMost {}",
                             "  }",
                             "}"))),
-            ImmutableList.of(),
-            BOOTCLASSPATH);
-    Map<String, byte[]> lowered = Lower.lowerAll(bound.units(), bound.classPathEnv()).bytes();
+            ClassPathBinder.bindClasspath(ImmutableList.of()),
+            TURBINE_BOOTCLASSPATH,
+            /* moduleVersion=*/ Optional.absent());
+    Map<String, byte[]> lowered =
+        Lower.lowerAll(bound.units(), bound.modules(), bound.classPathEnv()).bytes();
     List<String> attributes = new ArrayList<>();
     new ClassReader(lowered.get("Test$Inner$InnerMost"))
         .accept(
-            new ClassVisitor(Opcodes.ASM5) {
+            new ClassVisitor(Opcodes.ASM6) {
               @Override
               public void visitInnerClass(
                   String name, String outerName, String innerName, int access) {
@@ -278,7 +274,7 @@
                 UTF_8));
 
     Map<String, byte[]> actual =
-        IntegrationTestSupport.runTurbine(input.sources, ImmutableList.of(), BOOTCLASSPATH);
+        IntegrationTestSupport.runTurbine(input.sources, ImmutableList.of());
 
     ByteReader reader = new ByteReader(actual.get("Test"), 0);
     assertThat(reader.u4()).isEqualTo(0xcafebabe); // magic
@@ -325,17 +321,19 @@
                             "class Test {",
                             "  public @Anno int[][] xs;",
                             "}"))),
-            ImmutableList.of(),
-            BOOTCLASSPATH);
-    Map<String, byte[]> lowered = Lower.lowerAll(bound.units(), bound.classPathEnv()).bytes();
+            ClassPathBinder.bindClasspath(ImmutableList.of()),
+            TURBINE_BOOTCLASSPATH,
+            /* moduleVersion=*/ Optional.absent());
+    Map<String, byte[]> lowered =
+        Lower.lowerAll(bound.units(), bound.modules(), bound.classPathEnv()).bytes();
     TypePath[] path = new TypePath[1];
     new ClassReader(lowered.get("Test"))
         .accept(
-            new ClassVisitor(Opcodes.ASM5) {
+            new ClassVisitor(Opcodes.ASM6) {
               @Override
               public FieldVisitor visitField(
                   int access, String name, String desc, String signature, Object value) {
-                return new FieldVisitor(Opcodes.ASM5) {
+                return new FieldVisitor(Opcodes.ASM6) {
                   @Override
                   public AnnotationVisitor visitTypeAnnotation(
                       int typeRef, TypePath typePath, String desc, boolean visible) {
@@ -377,13 +375,12 @@
                     "  static final boolean ZCONST = Lib.ZCONST || false;",
                     "}"));
 
-    Map<String, byte[]> actual =
-        IntegrationTestSupport.runTurbine(input, ImmutableList.of(lib), BOOTCLASSPATH);
+    Map<String, byte[]> actual = IntegrationTestSupport.runTurbine(input, ImmutableList.of(lib));
 
     Map<String, Object> values = new LinkedHashMap<>();
     new ClassReader(actual.get("Test"))
         .accept(
-            new ClassVisitor(Opcodes.ASM5) {
+            new ClassVisitor(Opcodes.ASM6) {
               @Override
               public FieldVisitor visitField(
                   int access, String name, String desc, String signature, Object value) {
@@ -402,13 +399,15 @@
     BindingResult bound =
         Binder.bind(
             ImmutableList.of(Parser.parse("@Deprecated class Test {}")),
-            ImmutableList.of(),
-            BOOTCLASSPATH);
-    Map<String, byte[]> lowered = Lower.lowerAll(bound.units(), bound.classPathEnv()).bytes();
+            ClassPathBinder.bindClasspath(ImmutableList.of()),
+            TURBINE_BOOTCLASSPATH,
+            /* moduleVersion=*/ Optional.absent());
+    Map<String, byte[]> lowered =
+        Lower.lowerAll(bound.units(), bound.modules(), bound.classPathEnv()).bytes();
     int[] acc = {0};
     new ClassReader(lowered.get("Test"))
         .accept(
-            new ClassVisitor(Opcodes.ASM5) {
+            new ClassVisitor(Opcodes.ASM6) {
               @Override
               public void visit(
                   int version,
@@ -475,10 +474,8 @@
       noImports = builder.build();
     }
 
-    Map<String, byte[]> expected =
-        IntegrationTestSupport.runJavac(noImports, ImmutableList.of(), BOOTCLASSPATH);
-    Map<String, byte[]> actual =
-        IntegrationTestSupport.runTurbine(sources, ImmutableList.of(), BOOTCLASSPATH);
+    Map<String, byte[]> expected = IntegrationTestSupport.runJavac(noImports, ImmutableList.of());
+    Map<String, byte[]> actual = IntegrationTestSupport.runTurbine(sources, ImmutableList.of());
     assertThat(IntegrationTestSupport.dump(IntegrationTestSupport.sortMembers(actual)))
         .isEqualTo(IntegrationTestSupport.dump(IntegrationTestSupport.canonicalize(expected)));
   }
diff --git a/javatests/com/google/turbine/lower/ModuleIntegrationTest.java b/javatests/com/google/turbine/lower/ModuleIntegrationTest.java
new file mode 100644
index 0000000..d1bcb74
--- /dev/null
+++ b/javatests/com/google/turbine/lower/ModuleIntegrationTest.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2018 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.turbine.lower;
+
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
+import static org.junit.Assert.assertEquals;
+
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableList;
+import com.google.common.io.ByteStreams;
+import com.google.turbine.binder.JimageClassBinder;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameters;
+
+@RunWith(Parameterized.class)
+public class ModuleIntegrationTest {
+
+  @Parameters(name = "{index}: {0}")
+  public static Iterable<Object[]> parameters() {
+    String[] testCases = {
+      "module-info.test", //
+      "classpath.test",
+      "multimodule.test",
+    };
+    return ImmutableList.copyOf(testCases).stream().map(x -> new Object[] {x}).collect(toList());
+  }
+
+  final String test;
+
+  public ModuleIntegrationTest(String test) {
+    this.test = test;
+  }
+
+  @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+  @Test
+  public void test() throws Exception {
+    if (Double.parseDouble(System.getProperty("java.class.version")) < 53) {
+      // only run on JDK 9 and later
+      return;
+    }
+
+    IntegrationTestSupport.TestInput input =
+        IntegrationTestSupport.TestInput.parse(
+            new String(
+                ByteStreams.toByteArray(getClass().getResourceAsStream("moduletestdata/" + test)),
+                UTF_8));
+
+    ImmutableList<Path> classpathJar = ImmutableList.of();
+    if (!input.classes.isEmpty()) {
+      Map<String, byte[]> classpath =
+          IntegrationTestSupport.runJavac(
+              input.classes,
+              /* classpath= */ ImmutableList.of(),
+              ImmutableList.of("--release", "9", "--module-version=43"));
+      Path lib = temporaryFolder.newFile("lib.jar").toPath();
+      try (JarOutputStream jos = new JarOutputStream(Files.newOutputStream(lib))) {
+        for (Map.Entry<String, byte[]> entry : classpath.entrySet()) {
+          jos.putNextEntry(new JarEntry(entry.getKey() + ".class"));
+          jos.write(entry.getValue());
+        }
+      }
+      classpathJar = ImmutableList.of(lib);
+    }
+
+    Map<String, byte[]> expected =
+        IntegrationTestSupport.runJavac(
+            input.sources, classpathJar, ImmutableList.of("--release", "9", "--module-version=42"));
+
+    Map<String, byte[]> actual =
+        IntegrationTestSupport.runTurbine(
+            input.sources, classpathJar, JimageClassBinder.bindDefault(), Optional.of("42"));
+
+    assertEquals(dump(expected), dump(actual));
+  }
+
+  private String dump(Map<String, byte[]> map) throws Exception {
+    return IntegrationTestSupport.dump(
+        map.entrySet()
+            .stream()
+            .filter(e -> e.getKey().endsWith("module-info"))
+            .collect(toImmutableMap(Map.Entry::getKey, Map.Entry::getValue)));
+  }
+}
diff --git a/javatests/com/google/turbine/lower/testdata/B70953542.test b/javatests/com/google/turbine/lower/testdata/B70953542.test
new file mode 100644
index 0000000..f9e7503
--- /dev/null
+++ b/javatests/com/google/turbine/lower/testdata/B70953542.test
@@ -0,0 +1,7 @@
+=== Test.java ===
+class Test<T> {
+  class B<U> extends Test<U> {}
+  class A<V> extends B<V> {
+    B<V> b;
+  }
+}
\ No newline at end of file
diff --git a/javatests/com/google/turbine/lower/testdata/B74332665.test b/javatests/com/google/turbine/lower/testdata/B74332665.test
new file mode 100644
index 0000000..93db33f
--- /dev/null
+++ b/javatests/com/google/turbine/lower/testdata/B74332665.test
@@ -0,0 +1,9 @@
+=== B74332665.java ===
+class B74332665 {
+  interface I {
+    private boolean f() {
+      return false;
+    }
+    boolean g();
+  }
+}
diff --git a/javatests/com/google/turbine/main/MainTest.java b/javatests/com/google/turbine/main/MainTest.java
index 64ac245..9307cb0 100644
--- a/javatests/com/google/turbine/main/MainTest.java
+++ b/javatests/com/google/turbine/main/MainTest.java
@@ -17,22 +17,27 @@
 package com.google.turbine.main;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.turbine.testing.TestClassPaths.optionsWithBootclasspath;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.junit.Assert.fail;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.io.ByteStreams;
+import com.google.turbine.diag.TurbineError;
 import com.google.turbine.options.TurbineOptions;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.nio.file.Paths;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
 import java.util.Enumeration;
 import java.util.LinkedHashMap;
 import java.util.Map;
+import java.util.jar.Attributes;
 import java.util.jar.JarEntry;
 import java.util.jar.JarFile;
 import java.util.jar.JarOutputStream;
+import java.util.jar.Manifest;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.rules.TemporaryFolder;
@@ -42,9 +47,6 @@
 @RunWith(JUnit4.class)
 public class MainTest {
 
-  static final ImmutableList<String> BOOTCLASSPATH =
-      ImmutableList.of(Paths.get(System.getProperty("java.home")).resolve("lib/rt.jar").toString());
-
   @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder();
 
   @Test
@@ -63,14 +65,13 @@
 
     try {
       Main.compile(
-          TurbineOptions.builder()
+          optionsWithBootclasspath()
               .setSourceJars(ImmutableList.of(sourcesa.toString(), sourcesb.toString()))
-              .addBootClassPathEntries(BOOTCLASSPATH)
               .setOutput(output.toString())
               .build());
       fail();
-    } catch (IllegalArgumentException e) {
-      assertThat(e.getMessage()).contains("Multiple entries with same key: Test");
+    } catch (TurbineError e) {
+      assertThat(e.getMessage()).contains("error: duplicate declaration of Test");
     }
   }
 
@@ -83,9 +84,8 @@
 
     boolean ok =
         Main.compile(
-            TurbineOptions.builder()
+            optionsWithBootclasspath()
                 .addSources(ImmutableList.of(src.toString()))
-                .addBootClassPathEntries(BOOTCLASSPATH)
                 .setOutput(output.toString())
                 .build());
     assertThat(ok).isTrue();
@@ -106,9 +106,8 @@
 
     boolean ok =
         Main.compile(
-            TurbineOptions.builder()
+            optionsWithBootclasspath()
                 .setSourceJars(ImmutableList.of(srcjar.toString()))
-                .addBootClassPathEntries(BOOTCLASSPATH)
                 .setOutput(output.toString())
                 .build());
     assertThat(ok).isTrue();
@@ -131,6 +130,11 @@
 
   @Test
   public void moduleInfos() throws IOException {
+    if (Double.parseDouble(System.getProperty("java.class.version")) < 53) {
+      // only run on JDK 9 and later
+      return;
+    }
+
     Path srcjar = temporaryFolder.newFile("lib.srcjar").toPath();
     try (JarOutputStream jos = new JarOutputStream(Files.newOutputStream(srcjar))) {
       jos.putNextEntry(new JarEntry("module-info.java"));
@@ -147,14 +151,82 @@
     boolean ok =
         Main.compile(
             TurbineOptions.builder()
+                .setRelease("9")
                 .addSources(ImmutableList.of(src.toString()))
                 .setSourceJars(ImmutableList.of(srcjar.toString()))
-                .addBootClassPathEntries(BOOTCLASSPATH)
                 .setOutput(output.toString())
                 .build());
     assertThat(ok).isTrue();
 
     Map<String, byte[]> data = readJar(output);
-    assertThat(data.keySet()).isEmpty();
+    assertThat(data.keySet())
+        .containsExactly("foo/module-info.class", "bar/module-info.class", "baz/module-info.class");
+  }
+
+  @Test
+  public void testManifest() throws IOException {
+    Path src = temporaryFolder.newFile("Foo.java").toPath();
+    Files.write(src, "class Foo {}".getBytes(UTF_8));
+
+    Path output = temporaryFolder.newFile("output.jar").toPath();
+
+    boolean ok =
+        Main.compile(
+            optionsWithBootclasspath()
+                .addSources(ImmutableList.of(src.toString()))
+                .setTargetLabel("//foo:foo")
+                .setInjectingRuleKind("foo_library")
+                .setOutput(output.toString())
+                .build());
+    assertThat(ok).isTrue();
+
+    try (JarFile jarFile = new JarFile(output.toFile())) {
+      Manifest manifest = jarFile.getManifest();
+      Attributes attributes = manifest.getMainAttributes();
+      assertThat(attributes.getValue("Target-Label")).isEqualTo("//foo:foo");
+      assertThat(attributes.getValue("Injecting-Rule-Kind")).isEqualTo("foo_library");
+      assertThat(jarFile.getEntry(JarFile.MANIFEST_NAME).getLastModifiedTime().toInstant())
+          .isEqualTo(
+              LocalDateTime.of(2010, 1, 1, 0, 0, 0).atZone(ZoneId.systemDefault()).toInstant());
+    }
+  }
+
+  @Test
+  public void emptyBootClassPath() throws IOException {
+    Path src = temporaryFolder.newFolder().toPath().resolve("java/lang/Object.java");
+    Files.createDirectories(src.getParent());
+    Files.write(src, "package java.lang; public class Object {}".getBytes(UTF_8));
+
+    Path output = temporaryFolder.newFile("output.jar").toPath();
+
+    boolean ok =
+        Main.compile(
+            TurbineOptions.builder()
+                .addSources(ImmutableList.of(src.toString()))
+                .setOutput(output.toString())
+                .build());
+    assertThat(ok).isTrue();
+
+    Map<String, byte[]> data = readJar(output);
+    assertThat(data.keySet()).containsExactly("java/lang/Object.class");
+  }
+
+  @Test
+  public void emptyBootClassPath_noJavaLang() throws IOException {
+    Path src = temporaryFolder.newFile("Test.java").toPath();
+    Files.write(src, "public class Test {}".getBytes(UTF_8));
+
+    Path output = temporaryFolder.newFile("output.jar").toPath();
+
+    try {
+      Main.compile(
+          TurbineOptions.builder()
+              .addSources(ImmutableList.of(src.toString()))
+              .setOutput(output.toString())
+              .build());
+      fail();
+    } catch (IllegalArgumentException expected) {
+      assertThat(expected).hasMessageThat().contains("java.lang");
+    }
   }
 }
diff --git a/javatests/com/google/turbine/options/TurbineOptionsTest.java b/javatests/com/google/turbine/options/TurbineOptionsTest.java
index fcde432..8342ccc 100644
--- a/javatests/com/google/turbine/options/TurbineOptionsTest.java
+++ b/javatests/com/google/turbine/options/TurbineOptionsTest.java
@@ -20,7 +20,6 @@
 import static org.junit.Assert.fail;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
@@ -38,13 +37,7 @@
   @Rule public final TemporaryFolder tmpFolder = new TemporaryFolder();
 
   static final ImmutableList<String> BASE_ARGS =
-      ImmutableList.of(
-          "--output",
-          "out.jar",
-          "--target_label",
-          "//java/com/google/test",
-          "--rule_kind",
-          "java_library");
+      ImmutableList.of("--output", "out.jar", "--target_label", "//java/com/google/test");
 
   @Test
   public void exhaustiveArgs() throws Exception {
@@ -58,16 +51,20 @@
       "com.foo.MyProcessor",
       "com.foo.OtherProcessor",
       "--processorpath",
-      "libproc1.jar:libproc2.jar",
+      "libproc1.jar",
+      "libproc2.jar",
       "--classpath",
-      "lib1.jar:lib2.jar",
+      "lib1.jar",
+      "lib2.jar",
       "--bootclasspath",
-      "rt.jar:zipfs.jar",
+      "rt.jar",
+      "zipfs.jar",
       "--javacopts",
       "-source",
       "8",
       "-target",
       "8",
+      "--",
       "--sources",
       "Source1.java",
       "Source2.java",
@@ -75,8 +72,8 @@
       "out.jdeps",
       "--target_label",
       "//java/com/google/test",
-      "--rule_kind",
-      "java_library",
+      "--injecting_rule_kind",
+      "foo_library",
     };
 
     TurbineOptions options =
@@ -96,23 +93,19 @@
     assertThat(options.sources()).containsExactly("Source1.java", "Source2.java");
     assertThat(options.outputDeps()).hasValue("out.jdeps");
     assertThat(options.targetLabel()).hasValue("//java/com/google/test");
-    assertThat(options.ruleKind()).hasValue("java_library");
+    assertThat(options.injectingRuleKind()).hasValue("foo_library");
   }
 
   @Test
   public void strictJavaDepsArgs() throws Exception {
     String[] lines = {
-      "--strict_java_deps",
-      "OFF",
-      "--direct_dependency",
+      "--classpath",
       "blaze-out/foo/libbar.jar",
-      "//foo/bar",
-      "--indirect_dependency",
       "blaze-out/foo/libbaz1.jar",
-      "//foo/baz1",
-      "--indirect_dependency",
       "blaze-out/foo/libbaz2.jar",
-      "//foo/baz2",
+      "blaze-out/proto/libproto.jar",
+      "--direct_dependencies",
+      "blaze-out/foo/libbar.jar",
       "--deps_artifacts",
       "foo.jdeps",
       "bar.jdeps",
@@ -123,15 +116,38 @@
         TurbineOptionsParser.parse(Iterables.concat(BASE_ARGS, Arrays.asList(lines)));
 
     assertThat(options.targetLabel()).hasValue("//java/com/google/test");
-    // TODO(cushon): containsExactlyEntriesIn once it makes a truth release
-    assertThat(options.directJarsToTargets())
-        .isEqualTo(ImmutableMap.of("blaze-out/foo/libbar.jar", "//foo/bar"));
-    // TODO(cushon): containsExactlyEntriesIn once it makes a truth release
-    assertThat(options.indirectJarsToTargets())
-        .isEqualTo(
-            ImmutableMap.of(
-                "blaze-out/foo/libbaz1.jar", "//foo/baz1",
-                "blaze-out/foo/libbaz2.jar", "//foo/baz2"));
+    assertThat(options.directJars()).containsExactly("blaze-out/foo/libbar.jar");
+    assertThat(options.depsArtifacts()).containsExactly("foo.jdeps", "bar.jdeps");
+  }
+
+  /** Makes sure turbine accepts old-style arguments. */
+  // TODO(b/72379900): Remove this.
+  @Test
+  public void testLegacyStrictJavaDepsArgs() throws Exception {
+    String[] lines = {
+      "--direct_dependency",
+      "blaze-out/foo/libbar.jar",
+      "//foo/bar",
+      "--indirect_dependency",
+      "blaze-out/foo/libbaz1.jar",
+      "//foo/baz1",
+      "--indirect_dependency",
+      "blaze-out/foo/libbaz2.jar",
+      "//foo/baz2",
+      "--indirect_dependency",
+      "blaze-out/proto/libproto.jar",
+      "//proto",
+      "java_proto_library",
+      "--deps_artifacts",
+      "foo.jdeps",
+      "bar.jdeps",
+      "",
+    };
+
+    TurbineOptions options =
+        TurbineOptionsParser.parse(Iterables.concat(BASE_ARGS, Arrays.asList(lines)));
+
+    assertThat(options.targetLabel()).hasValue("//java/com/google/test");
     assertThat(options.depsArtifacts()).containsExactly("foo.jdeps", "bar.jdeps");
   }
 
@@ -139,26 +155,9 @@
   public void classpathArgs() throws Exception {
     String[] lines = {
       "--classpath",
-      "liba.jar:libb.jar:libc.jar",
-      "--processorpath",
-      "libpa.jar:libpb.jar:libpc.jar",
-    };
-
-    TurbineOptions options =
-        TurbineOptionsParser.parse(Iterables.concat(BASE_ARGS, Arrays.asList(lines)));
-
-    assertThat(options.classPath()).containsExactly("liba.jar", "libb.jar", "libc.jar").inOrder();
-    assertThat(options.processorPath())
-        .containsExactly("libpa.jar", "libpb.jar", "libpc.jar")
-        .inOrder();
-  }
-
-  @Test
-  public void repeatedClasspath() throws Exception {
-    String[] lines = {
-      "--classpath",
       "liba.jar",
-      "libb.jar:libc.jar",
+      "libb.jar",
+      "libc.jar",
       "--processorpath",
       "libpa.jar",
       "libpb.jar",
@@ -175,26 +174,53 @@
   }
 
   @Test
-  public void optionalTargetLabelAndRuleKind() throws Exception {
+  public void repeatedClasspath() throws Exception {
+    String[] lines = {
+      "--classpath",
+      "liba.jar",
+      "libb.jar",
+      "libc.jar",
+      "--processorpath",
+      "libpa.jar",
+      "libpb.jar",
+      "libpc.jar",
+    };
+
+    TurbineOptions options =
+        TurbineOptionsParser.parse(Iterables.concat(BASE_ARGS, Arrays.asList(lines)));
+
+    assertThat(options.classPath()).containsExactly("liba.jar", "libb.jar", "libc.jar").inOrder();
+    assertThat(options.processorPath())
+        .containsExactly("libpa.jar", "libpb.jar", "libpc.jar")
+        .inOrder();
+  }
+
+  @Test
+  public void optionalTargetLabel() throws Exception {
     String[] lines = {
       "--output",
       "out.jar",
       "--classpath",
-      "liba.jar:libb.jar:libc.jar",
+      "liba.jar",
+      "libb.jar",
+      "libc.jar",
       "--processorpath",
-      "libpa.jar:libpb.jar:libpc.jar",
+      "libpa.jar",
+      "libpb.jar",
+      "libpc.jar",
     };
 
     TurbineOptions options = TurbineOptionsParser.parse(Arrays.asList(lines));
 
-    assertThat(options.ruleKind()).isAbsent();
     assertThat(options.targetLabel()).isAbsent();
+    assertThat(options.injectingRuleKind()).isAbsent();
   }
 
   @Test
   public void paramsFile() throws Exception {
     Iterable<String> paramsArgs =
-        Iterables.concat(BASE_ARGS, Arrays.asList("--javacopts", "-source", "8", "-target", "8"));
+        Iterables.concat(
+            BASE_ARGS, Arrays.asList("--javacopts", "-source", "8", "-target", "8", "--"));
     Path params = tmpFolder.newFile("params.txt").toPath();
     Files.write(params, paramsArgs, StandardCharsets.UTF_8);
 
@@ -235,4 +261,89 @@
       assertThat(e).hasMessage("output must not be null");
     }
   }
+
+  @Test
+  public void paramsFileExists() throws Exception {
+    String[] lines = {
+      "@/NOSUCH", "--javacopts", "-source", "7", "--",
+    };
+    AssertionError expected = null;
+    try {
+      TurbineOptionsParser.parse(Arrays.asList(lines));
+    } catch (AssertionError e) {
+      expected = e;
+    }
+    if (expected == null) {
+      fail();
+    }
+    assertThat(expected).hasMessageThat().contains("params file does not exist");
+  }
+
+  @Test
+  public void emptyParamsFiles() throws Exception {
+    Path params = tmpFolder.newFile("params.txt").toPath();
+    Files.write(params, new byte[0]);
+    String[] lines = {
+      "--sources", "A.java", "@" + params.toAbsolutePath(), "B.java",
+    };
+    TurbineOptions options =
+        TurbineOptionsParser.parse(Iterables.concat(BASE_ARGS, Arrays.asList(lines)));
+    assertThat(options.sources()).containsExactly("A.java", "B.java").inOrder();
+  }
+
+  @Test
+  public void javacopts() throws Exception {
+    String[] lines = {
+      "--javacopts", "--release", "9", "--", "--sources", "Test.java",
+    };
+
+    TurbineOptions options =
+        TurbineOptionsParser.parse(Iterables.concat(BASE_ARGS, Arrays.asList(lines)));
+
+    assertThat(options.javacOpts()).containsExactly("--release", "9").inOrder();
+    assertThat(options.sources()).containsExactly("Test.java");
+  }
+
+  @Test
+  public void unknownOption() throws Exception {
+    try {
+      TurbineOptionsParser.parse(Iterables.concat(BASE_ARGS, Arrays.asList("--nosuch")));
+      fail();
+    } catch (IllegalArgumentException e) {
+      assertThat(e).hasMessageThat().contains("unknown option");
+    }
+  }
+
+  @Test
+  public void unterminatedJavacopts() throws Exception {
+    try {
+      TurbineOptionsParser.parse(
+          Iterables.concat(BASE_ARGS, Arrays.asList("--javacopts", "--release", "8")));
+      fail();
+    } catch (IllegalArgumentException e) {
+      assertThat(e).hasMessageThat().contains("javacopts should be terminated by `--`");
+    }
+  }
+
+  @Test
+  public void releaseJavacopts() throws Exception {
+    TurbineOptions options =
+        TurbineOptionsParser.parse(
+            Iterables.concat(
+                BASE_ARGS,
+                Arrays.asList(
+                    "--release",
+                    "9",
+                    "--javacopts",
+                    "--release",
+                    "8",
+                    "--release",
+                    "7",
+                    "--release",
+                    "--")));
+    assertThat(options.release()).hasValue("7");
+    assertThat(options.javacOpts())
+        .containsExactly("--release", "8", "--release", "7", "--release")
+        .inOrder();
+  }
 }
diff --git a/javatests/com/google/turbine/parse/ParseErrorTest.java b/javatests/com/google/turbine/parse/ParseErrorTest.java
index b2dc7a2..0ec6208 100644
--- a/javatests/com/google/turbine/parse/ParseErrorTest.java
+++ b/javatests/com/google/turbine/parse/ParseErrorTest.java
@@ -67,11 +67,10 @@
     } catch (TurbineError e) {
       assertThat(e.getMessage())
           .isEqualTo(
-              Joiner.on('\n')
-                  .join(
-                      "<>:1: error: unexpected token: void",
-                      "public static void main(String[] args) {}",
-                      "              ^"));
+              lines(
+                  "<>:1: error: unexpected token: void",
+                  "public static void main(String[] args) {}",
+                  "              ^"));
     }
   }
 
@@ -84,11 +83,10 @@
     } catch (TurbineError e) {
       assertThat(e.getMessage())
           .isEqualTo(
-              Joiner.on('\n')
-                  .join(
-                      "<>:1: error: unexpected identifier 'clas'", //
-                      "public clas Test {}",
-                      "       ^"));
+              lines(
+                  "<>:1: error: unexpected identifier 'clas'", //
+                  "public clas Test {}",
+                  "       ^"));
     }
   }
 
@@ -101,11 +99,30 @@
     } catch (TurbineError e) {
       assertThat(e.getMessage())
           .isEqualTo(
-              Joiner.on('\n')
-                  .join(
-                      "<>:2: error: unexpected end of input", //
-                      "",
-                      "^"));
+              lines(
+                  "<>:2: error: unexpected end of input", //
+                  "",
+                  "^"));
     }
   }
+
+  @Test
+  public void annotationArgument() {
+    String input = "@A(x = System.err.println()) class Test {}\n";
+    try {
+      Parser.parse(input);
+      fail("expected parsing to fail");
+    } catch (TurbineError e) {
+      assertThat(e.getMessage())
+          .isEqualTo(
+              lines(
+                  "<>:1: error: invalid annotation argument", //
+                  "@A(x = System.err.println()) class Test {}",
+                  "                         ^"));
+    }
+  }
+
+  private static String lines(String... lines) {
+    return Joiner.on(System.lineSeparator()).join(lines);
+  }
 }
diff --git a/javatests/com/google/turbine/parse/ParserIntegrationTest.java b/javatests/com/google/turbine/parse/ParserIntegrationTest.java
index d856cb3..2503553 100644
--- a/javatests/com/google/turbine/parse/ParserIntegrationTest.java
+++ b/javatests/com/google/turbine/parse/ParserIntegrationTest.java
@@ -21,6 +21,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Function;
+import com.google.common.base.Splitter;
 import com.google.common.collect.Iterables;
 import com.google.common.io.CharStreams;
 import com.google.turbine.tree.Tree;
@@ -28,6 +29,7 @@
 import java.io.InputStream;
 import java.io.InputStreamReader;
 import java.util.Arrays;
+import java.util.List;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.Parameterized;
@@ -72,6 +74,7 @@
       "packinfo1.input",
       "weirdstring.input",
       "type_annotations.input",
+      "module-info.input",
     };
     return Iterables.transform(
         Arrays.asList(tests),
@@ -97,9 +100,9 @@
     try (InputStreamReader in = new InputStreamReader(stream, UTF_8)) {
       result = CharStreams.toString(in);
     }
-    String[] pieces = result.split("===+");
-    String input = pieces[0].trim();
-    String expected = pieces.length > 1 ? pieces[1].trim() : input;
+    List<String> pieces = Splitter.onPattern("===+").splitToList(result);
+    String input = pieces.get(0).trim();
+    String expected = pieces.size() > 1 ? pieces.get(1).trim() : input;
     Tree.CompUnit unit = Parser.parse(input);
     assertThat(unit.toString().trim()).isEqualTo(expected);
   }
diff --git a/javatests/com/google/turbine/parse/testdata/module-info.input b/javatests/com/google/turbine/parse/testdata/module-info.input
new file mode 100644
index 0000000..892a3b1
--- /dev/null
+++ b/javatests/com/google/turbine/parse/testdata/module-info.input
@@ -0,0 +1,29 @@
+import a.A;
+import a.B;
+import com.google.Foo;
+import com.google.Baz;
+
+@A
+@B
+module com.google.m {
+  requires java.compiler;
+  requires transitive jdk.compiler;
+  requires static java.base;
+  exports com.google.p1;
+  exports com.google.p2 to
+      java.base;
+  exports com.google.p3 to
+      java.base,
+      java.compiler;
+  opens com.google.p1;
+  opens com.google.p2 to
+      java.base;
+  opens com.google.p3 to
+      java.base,
+      java.compiler;
+  uses Foo;
+  uses com.google.Bar;
+  provides com.google.Baz with
+      Foo,
+      com.google.Bar;
+}
diff --git a/javatests/com/google/turbine/bytecode/AsmUtils.java b/javatests/com/google/turbine/testing/AsmUtils.java
similarity index 97%
rename from javatests/com/google/turbine/bytecode/AsmUtils.java
rename to javatests/com/google/turbine/testing/AsmUtils.java
index 2591652..5b5e102 100644
--- a/javatests/com/google/turbine/bytecode/AsmUtils.java
+++ b/javatests/com/google/turbine/testing/AsmUtils.java
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package com.google.turbine.bytecode;
+package com.google.turbine.testing;
 
 import java.io.PrintWriter;
 import java.io.StringWriter;
diff --git a/javatests/com/google/turbine/testing/TestClassPaths.java b/javatests/com/google/turbine/testing/TestClassPaths.java
new file mode 100644
index 0000000..bf38913
--- /dev/null
+++ b/javatests/com/google/turbine/testing/TestClassPaths.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2018 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.turbine.testing;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Streams;
+import com.google.turbine.binder.ClassPath;
+import com.google.turbine.binder.ClassPathBinder;
+import com.google.turbine.binder.CtSymClassBinder;
+import com.google.turbine.options.TurbineOptions;
+import java.io.File;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Optional;
+
+public class TestClassPaths {
+
+  private static final Splitter CLASS_PATH_SPLITTER =
+      Splitter.on(File.pathSeparatorChar).omitEmptyStrings();
+
+  private static final ImmutableList<Path> BOOTCLASSPATH =
+      Streams.stream(
+              CLASS_PATH_SPLITTER.split(
+                  Optional.ofNullable(System.getProperty("sun.boot.class.path")).orElse("")))
+          .map(Paths::get)
+          .filter(Files::exists)
+          .collect(toImmutableList());
+
+  public static final ClassPath TURBINE_BOOTCLASSPATH = getTurbineBootclasspath();
+
+  private static ClassPath getTurbineBootclasspath() {
+    try {
+      if (!BOOTCLASSPATH.isEmpty()) {
+        return ClassPathBinder.bindClasspath(BOOTCLASSPATH);
+      }
+      return CtSymClassBinder.bind("8");
+    } catch (IOException e) {
+      e.printStackTrace();
+      throw new UncheckedIOException(e);
+    }
+  }
+
+  /**
+   * Return an {@link TurbineOptions} builder, with either {@code --bootclasspath} or {@link
+   * --release} set to a JDK 8 equivalent.
+   */
+  public static TurbineOptions.Builder optionsWithBootclasspath() {
+    TurbineOptions.Builder options = TurbineOptions.builder();
+    if (!BOOTCLASSPATH.isEmpty()) {
+      options.addBootClassPathEntries(
+          BOOTCLASSPATH.stream().map(Path::toString).collect(toImmutableList()));
+    } else {
+      options.setRelease("8");
+    }
+    return options;
+  }
+}
diff --git a/javatests/com/google/turbine/zip/ZipTest.java b/javatests/com/google/turbine/zip/ZipTest.java
index 90b2315..67dcfe7 100644
--- a/javatests/com/google/turbine/zip/ZipTest.java
+++ b/javatests/com/google/turbine/zip/ZipTest.java
@@ -127,7 +127,7 @@
     Files.delete(path);
     try (FileSystem fs =
         FileSystems.newFileSystem(
-            URI.create("jar:file:" + path.toAbsolutePath()), ImmutableMap.of("create", "true"))) {
+            URI.create("jar:" + path.toUri()), ImmutableMap.of("create", "true"))) {
       for (int i = 0; i < 3; i++) {
         String name = "entry" + i;
         byte[] bytes = name.getBytes(UTF_8);
diff --git a/pom.xml b/pom.xml
index 6829bce..58bc5f4 100644
--- a/pom.xml
+++ b/pom.xml
@@ -16,6 +16,7 @@
 
   <properties>
     <javac.version>9-dev-r3297-4</javac.version>
+    <asm.version>6.0</asm.version>
   </properties>
 
   <dependencies>
@@ -41,8 +42,20 @@
     </dependency>
     <dependency>
       <groupId>org.ow2.asm</groupId>
-      <artifactId>asm-debug-all</artifactId>
-      <version>5.1</version>
+      <artifactId>asm</artifactId>
+      <version>${asm.version}</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.ow2.asm</groupId>
+      <artifactId>asm-tree</artifactId>
+      <version>${asm.version}</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.ow2.asm</groupId>
+      <artifactId>asm-util</artifactId>
+      <version>${asm.version}</version>
       <scope>test</scope>
     </dependency>
     <dependency>
diff --git a/turbine.iml b/turbine.iml
deleted file mode 100644
index 3e9b3e9..0000000
--- a/turbine.iml
+++ /dev/null
@@ -1,32 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<module org.jetbrains.idea.maven.project.MavenProjectsManager.isMavenModule="true" type="JAVA_MODULE" version="4">
-  <component name="NewModuleRootManager" LANGUAGE_LEVEL="JDK_1_8" inherit-compiler-output="false">
-    <output url="file://$MODULE_DIR$/target/classes" />
-    <output-test url="file://$MODULE_DIR$/target/test-classes" />
-    <content url="file://$MODULE_DIR$">
-      <sourceFolder url="file://$MODULE_DIR$/java" isTestSource="false" />
-      <sourceFolder url="file://$MODULE_DIR$/javatests" isTestSource="true" />
-      <sourceFolder url="file://$MODULE_DIR$/target/generated-sources/protobuf/java" isTestSource="false" generated="true" />
-      <excludeFolder url="file://$MODULE_DIR$/target/classes" />
-      <excludeFolder url="file://$MODULE_DIR$/target/maven-archiver" />
-      <excludeFolder url="file://$MODULE_DIR$/target/maven-status" />
-      <excludeFolder url="file://$MODULE_DIR$/target/protoc-dependencies" />
-      <excludeFolder url="file://$MODULE_DIR$/target/protoc-plugins" />
-      <excludeFolder url="file://$MODULE_DIR$/target/surefire-reports" />
-      <excludeFolder url="file://$MODULE_DIR$/target/test-classes" />
-    </content>
-    <orderEntry type="inheritedJdk" />
-    <orderEntry type="sourceFolder" forTests="false" />
-    <orderEntry type="library" name="Maven: com.google.protobuf:protobuf-java:3.0.2" level="project" />
-    <orderEntry type="library" name="Maven: com.google.guava:guava:19.0" level="project" />
-    <orderEntry type="library" name="Maven: com.google.code.findbugs:jsr305:2.0.1" level="project" />
-    <orderEntry type="library" name="Maven: com.google.errorprone:error_prone_annotations:2.0.12" level="project" />
-    <orderEntry type="library" name="Maven: com.google.protobuf:protobuf-java:3.1.0" level="project" />
-    <orderEntry type="library" scope="TEST" name="Maven: org.ow2.asm:asm-debug-all:5.1" level="project" />
-    <orderEntry type="library" scope="TEST" name="Maven: com.google.errorprone:javac:1.9.0-dev-r2973-2" level="project" />
-    <orderEntry type="library" scope="TEST" name="Maven: junit:junit:4.12" level="project" />
-    <orderEntry type="library" scope="TEST" name="Maven: org.hamcrest:hamcrest-core:1.3" level="project" />
-    <orderEntry type="library" scope="TEST" name="Maven: com.google.truth:truth:0.25" level="project" />
-    <orderEntry type="library" scope="TEST" name="Maven: com.google.jimfs:jimfs:1.0" level="project" />
-  </component>
-</module>
\ No newline at end of file