Merge "Add peephole infrastructure to IR"
diff --git a/src/main/java/com/android/tools/r8/D8.java b/src/main/java/com/android/tools/r8/D8.java
index fd57823..bd63ae5 100644
--- a/src/main/java/com/android/tools/r8/D8.java
+++ b/src/main/java/com/android/tools/r8/D8.java
@@ -166,6 +166,7 @@
       options.enableMinification = false;
       options.enableInlining = false;
       options.enableClassInlining = false;
+      options.enableHorizontalClassMerging = false;
       options.enableVerticalClassMerging = false;
       options.enableClassStaticizer = false;
       options.outline.enabled = false;
diff --git a/src/main/java/com/android/tools/r8/D8Command.java b/src/main/java/com/android/tools/r8/D8Command.java
index 2441034..e0f330d 100644
--- a/src/main/java/com/android/tools/r8/D8Command.java
+++ b/src/main/java/com/android/tools/r8/D8Command.java
@@ -251,6 +251,8 @@
     internal.enableInlining = false;
     assert internal.enableClassInlining;
     internal.enableClassInlining = false;
+    assert internal.enableHorizontalClassMerging;
+    internal.enableHorizontalClassMerging = false;
     assert internal.enableVerticalClassMerging;
     internal.enableVerticalClassMerging = false;
     assert internal.enableClassStaticizer;
diff --git a/src/main/java/com/android/tools/r8/R8.java b/src/main/java/com/android/tools/r8/R8.java
index b7c538f..94921b7 100644
--- a/src/main/java/com/android/tools/r8/R8.java
+++ b/src/main/java/com/android/tools/r8/R8.java
@@ -44,6 +44,7 @@
 import com.android.tools.r8.shaking.ReasonPrinter;
 import com.android.tools.r8.shaking.RootSetBuilder;
 import com.android.tools.r8.shaking.RootSetBuilder.RootSet;
+import com.android.tools.r8.shaking.StaticClassMerger;
 import com.android.tools.r8.shaking.TreePruner;
 import com.android.tools.r8.shaking.VerticalClassMerger;
 import com.android.tools.r8.utils.AndroidApiLevel;
@@ -353,6 +354,14 @@
           timing.end();
         }
         appView.setGraphLense(new MemberRebindingAnalysis(appViewWithLiveness, options).run());
+        if (options.enableHorizontalClassMerging) {
+          StaticClassMerger staticClassMerger = new StaticClassMerger(appViewWithLiveness);
+          appView.setGraphLense(staticClassMerger.run());
+          appViewWithLiveness.setAppInfo(
+              appViewWithLiveness
+                  .appInfo()
+                  .rewrittenWithLense(application.asDirect(), appView.graphLense()));
+        }
         if (options.enableVerticalClassMerging) {
           timing.begin("ClassMerger");
           VerticalClassMerger verticalClassMerger =
diff --git a/src/main/java/com/android/tools/r8/R8Command.java b/src/main/java/com/android/tools/r8/R8Command.java
index 7b24770..b5eafcd 100644
--- a/src/main/java/com/android/tools/r8/R8Command.java
+++ b/src/main/java/com/android/tools/r8/R8Command.java
@@ -648,6 +648,7 @@
             ? LineNumberOptimization.ON
             : LineNumberOptimization.OFF;
 
+    assert internal.enableHorizontalClassMerging || !proguardConfiguration.isOptimizing();
     assert internal.enableVerticalClassMerging || !proguardConfiguration.isOptimizing();
     if (internal.debug) {
       internal.proguardConfiguration.getKeepAttributes().lineNumberTable = true;
@@ -656,6 +657,7 @@
       // TODO(zerny): Should we support inlining in debug mode? b/62937285
       internal.enableInlining = false;
       internal.enableClassInlining = false;
+      internal.enableHorizontalClassMerging = false;
       internal.enableVerticalClassMerging = false;
       internal.enableClassStaticizer = false;
       // TODO(zerny): Should we support outlining in debug mode? b/62937285
diff --git a/src/main/java/com/android/tools/r8/graph/AccessFlags.java b/src/main/java/com/android/tools/r8/graph/AccessFlags.java
index 626e5cc..afbb13f 100644
--- a/src/main/java/com/android/tools/r8/graph/AccessFlags.java
+++ b/src/main/java/com/android/tools/r8/graph/AccessFlags.java
@@ -72,6 +72,10 @@
     return visibilityOrdinal() > other.visibilityOrdinal();
   }
 
+  public boolean isAtLeastAsVisibleAs(AccessFlags other) {
+    return visibilityOrdinal() >= other.visibilityOrdinal();
+  }
+
   private int visibilityOrdinal() {
     // public > protected > package > private
     if (isPublic()) {
diff --git a/src/main/java/com/android/tools/r8/graph/DexEncodedMethod.java b/src/main/java/com/android/tools/r8/graph/DexEncodedMethod.java
index 22a771b..0ae75c5 100644
--- a/src/main/java/com/android/tools/r8/graph/DexEncodedMethod.java
+++ b/src/main/java/com/android/tools/r8/graph/DexEncodedMethod.java
@@ -157,6 +157,11 @@
     return compilationState != CompilationState.NOT_PROCESSED;
   }
 
+  public boolean isInitializer() {
+    checkIfObsolete();
+    return isInstanceInitializer() || isClassInitializer();
+  }
+
   public boolean isInstanceInitializer() {
     checkIfObsolete();
     return accessFlags.isConstructor() && !accessFlags.isStatic();
diff --git a/src/main/java/com/android/tools/r8/ir/conversion/IRConverter.java b/src/main/java/com/android/tools/r8/ir/conversion/IRConverter.java
index c7cd614..e188f29 100644
--- a/src/main/java/com/android/tools/r8/ir/conversion/IRConverter.java
+++ b/src/main/java/com/android/tools/r8/ir/conversion/IRConverter.java
@@ -34,6 +34,7 @@
 import com.android.tools.r8.ir.code.IRCode;
 import com.android.tools.r8.ir.code.Instruction;
 import com.android.tools.r8.ir.code.InstructionListIterator;
+import com.android.tools.r8.ir.code.InvokeMethod;
 import com.android.tools.r8.ir.code.Value;
 import com.android.tools.r8.ir.desugar.CovariantReturnTypeAnnotationTransformer;
 import com.android.tools.r8.ir.desugar.InterfaceMethodRewriter;
@@ -732,10 +733,8 @@
     printC1VisualizerHeader(method);
     printMethod(code, "Initial IR (SSA)");
 
-    DexClass holder = definitionFor(method.method.holder);
-    if (method.getCode() != null && method.getCode().isJarCode()
-        && holder.hasKotlinInfo()) {
-      computeKotlinNotNullParamHints(feedback, holder.getKotlinInfo(), method, code);
+    if (method.getCode() != null && method.getCode().isJarCode()) {
+      computeKotlinNotNullParamHints(feedback, method, code);
     }
 
     if (options.canHaveArtStringNewInitBug()) {
@@ -907,11 +906,19 @@
   }
 
   private void computeKotlinNotNullParamHints(
-      OptimizationFeedback feedback, KotlinInfo kotlinInfo, DexEncodedMethod method, IRCode code) {
+      OptimizationFeedback feedback, DexEncodedMethod method, IRCode code) {
+    DexMethod originalSignature = graphLense().getOriginalMethodSignature(method.method);
+    DexClass originalHolder = definitionFor(originalSignature.holder);
+    if (!originalHolder.hasKotlinInfo()) {
+      return;
+    }
+
     // Use non-null parameter hints in Kotlin metadata if available.
+    KotlinInfo kotlinInfo = originalHolder.getKotlinInfo();
     if (kotlinInfo.hasNonNullParameterHints()) {
-      BitSet hintFromMetadata = kotlinInfo.lookupNonNullParameterHint(
-          method.method.name.toString(), method.method.proto.toDescriptorString());
+      BitSet hintFromMetadata =
+          kotlinInfo.lookupNonNullParameterHint(
+              originalSignature.name.toString(), originalSignature.proto.toDescriptorString());
       if (hintFromMetadata != null) {
         if (hintFromMetadata.length() > 0) {
           feedback.setKotlinNotNullParamHints(method, hintFromMetadata);
@@ -934,12 +941,15 @@
         // to the mentioned intrinsic as the first argument. We do it only for
         // code coming from Java classfile, since after the method is rewritten
         // by R8 this call gets inlined.
-        if (!user.isInvokeStatic() ||
-            user.asInvokeMethod().getInvokedMethod() != checkParameterIsNotNull ||
-            user.inValues().indexOf(argument) != 0) {
+        if (!user.isInvokeStatic()) {
           continue;
         }
-        paramsCheckedForNull.set(index);
+        InvokeMethod invoke = user.asInvokeMethod();
+        DexMethod invokedMethod =
+            appView.graphLense().getOriginalMethodSignature(invoke.getInvokedMethod());
+        if (invokedMethod == checkParameterIsNotNull && user.inValues().indexOf(argument) == 0) {
+          paramsCheckedForNull.set(index);
+        }
       }
     }
     if (paramsCheckedForNull.length() > 0) {
diff --git a/src/main/java/com/android/tools/r8/ir/optimize/Inliner.java b/src/main/java/com/android/tools/r8/ir/optimize/Inliner.java
index 2a74cad..1660374 100644
--- a/src/main/java/com/android/tools/r8/ir/optimize/Inliner.java
+++ b/src/main/java/com/android/tools/r8/ir/optimize/Inliner.java
@@ -75,7 +75,7 @@
   }
 
   public boolean isBlackListed(DexEncodedMethod method) {
-    return blackList.contains(method.method)
+    return blackList.contains(appView.graphLense().getOriginalMethodSignature(method.method))
         || appView.appInfo().neverInline.contains(method.method)
         || TwrCloseResourceRewriter.isSynthesizedCloseResourceMethod(method.method, converter);
   }
diff --git a/src/main/java/com/android/tools/r8/shaking/ClassMergingRule.java b/src/main/java/com/android/tools/r8/shaking/ClassMergingRule.java
new file mode 100644
index 0000000..43bfe4b
--- /dev/null
+++ b/src/main/java/com/android/tools/r8/shaking/ClassMergingRule.java
@@ -0,0 +1,82 @@
+// Copyright (c) 2018, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+package com.android.tools.r8.shaking;
+
+import com.android.tools.r8.errors.Unreachable;
+import com.android.tools.r8.origin.Origin;
+import com.android.tools.r8.position.Position;
+import java.util.List;
+
+public class ClassMergingRule extends ProguardConfigurationRule {
+
+  public enum Type {
+    NEVER
+  }
+
+  public static class Builder extends ProguardConfigurationRule.Builder<ClassMergingRule, Builder> {
+
+    private Builder() {
+      super();
+    }
+
+    Type type;
+
+    @Override
+    public Builder self() {
+      return this;
+    }
+
+    public Builder setType(Type type) {
+      this.type = type;
+      return this;
+    }
+
+    @Override
+    public ClassMergingRule build() {
+      return new ClassMergingRule(origin, getPosition(), source, classAnnotation, classAccessFlags,
+          negatedClassAccessFlags, classTypeNegated, classType, classNames, inheritanceAnnotation,
+          inheritanceClassName, inheritanceIsExtends, memberRules, type);
+    }
+  }
+
+  private final Type type;
+
+  private ClassMergingRule(
+      Origin origin,
+      Position position,
+      String source,
+      ProguardTypeMatcher classAnnotation,
+      ProguardAccessFlags classAccessFlags,
+      ProguardAccessFlags negatedClassAccessFlags,
+      boolean classTypeNegated,
+      ProguardClassType classType,
+      ProguardClassNameList classNames,
+      ProguardTypeMatcher inheritanceAnnotation,
+      ProguardTypeMatcher inheritanceClassName,
+      boolean inheritanceIsExtends,
+      List<ProguardMemberRule> memberRules,
+      Type type) {
+    super(origin, position, source, classAnnotation, classAccessFlags, negatedClassAccessFlags,
+        classTypeNegated, classType, classNames, inheritanceAnnotation, inheritanceClassName,
+        inheritanceIsExtends, memberRules);
+    this.type = type;
+  }
+
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  public Type getType() {
+    return type;
+  }
+
+  @Override
+  String typeString() {
+    switch (type) {
+      case NEVER:
+        return "nevermerge";
+    }
+    throw new Unreachable("Unknown class merging type " + type);
+  }
+}
diff --git a/src/main/java/com/android/tools/r8/shaking/Enqueuer.java b/src/main/java/com/android/tools/r8/shaking/Enqueuer.java
index e0cf415..8574e6d 100644
--- a/src/main/java/com/android/tools/r8/shaking/Enqueuer.java
+++ b/src/main/java/com/android/tools/r8/shaking/Enqueuer.java
@@ -1813,6 +1813,10 @@
      */
     public final Set<DexMethod> neverInline;
     /**
+     * All types that *must* never be merged due to a configuration directive (testing only).
+     */
+    public final Set<DexType> neverMerge;
+    /**
      * All items with -identifiernamestring rule.
      * Bound boolean value indicates the rule is explicitly specified by users (<code>true</code>)
      * or not, i.e., implicitly added by R8 (<code>false</code>).
@@ -1871,6 +1875,7 @@
       this.alwaysInline = enqueuer.rootSet.alwaysInline;
       this.forceInline = enqueuer.rootSet.forceInline;
       this.neverInline = enqueuer.rootSet.neverInline;
+      this.neverMerge = enqueuer.rootSet.neverMerge;
       this.identifierNameStrings = joinIdentifierNameStrings(
           enqueuer.rootSet.identifierNameStrings, enqueuer.identifierNameStrings);
       this.prunedTypes = Collections.emptySet();
@@ -1913,6 +1918,7 @@
       this.alwaysInline = previous.alwaysInline;
       this.forceInline = previous.forceInline;
       this.neverInline = previous.neverInline;
+      this.neverMerge = previous.neverMerge;
       this.identifierNameStrings = previous.identifierNameStrings;
       this.prunedTypes = mergeSets(previous.prunedTypes, removedClasses);
       this.switchMaps = previous.switchMaps;
@@ -1965,6 +1971,10 @@
       this.alwaysInline = previous.alwaysInline;
       this.forceInline = lense.rewriteMethodsWithRenamedSignature(previous.forceInline);
       this.neverInline = lense.rewriteMethodsWithRenamedSignature(previous.neverInline);
+      assert lense.assertDefinitionNotModified(
+          previous.neverMerge.stream().map(this::definitionFor).filter(Objects::nonNull)
+              .collect(Collectors.toList()));
+      this.neverMerge = previous.neverMerge;
       this.identifierNameStrings =
           lense.rewriteReferencesConservatively(previous.identifierNameStrings);
       // Switchmap classes should never be affected by renaming.
@@ -2008,6 +2018,7 @@
       this.alwaysInline = previous.alwaysInline;
       this.forceInline = previous.forceInline;
       this.neverInline = previous.neverInline;
+      this.neverMerge = previous.neverMerge;
       this.identifierNameStrings = previous.identifierNameStrings;
       this.prunedTypes = previous.prunedTypes;
       this.switchMaps = switchMaps;
diff --git a/src/main/java/com/android/tools/r8/shaking/ProguardConfigurationParser.java b/src/main/java/com/android/tools/r8/shaking/ProguardConfigurationParser.java
index faf5317..7d5f9a5 100644
--- a/src/main/java/com/android/tools/r8/shaking/ProguardConfigurationParser.java
+++ b/src/main/java/com/android/tools/r8/shaking/ProguardConfigurationParser.java
@@ -364,6 +364,9 @@
       } else if (allowTestOptions && acceptString("neverinline")) {
         InlineRule rule = parseInlineRule(Type.NEVER, optionStart);
         configurationBuilder.addRule(rule);
+      } else if (allowTestOptions && acceptString("nevermerge")) {
+        ClassMergingRule rule = parseClassMergingRule(ClassMergingRule.Type.NEVER, optionStart);
+        configurationBuilder.addRule(rule);
       } else if (acceptString("useuniqueclassmembernames")) {
         configurationBuilder.setUseUniqueClassMemberNames(true);
       } else if (acceptString("adaptclassstrings")) {
@@ -594,6 +597,17 @@
       return keepRuleBuilder.build();
     }
 
+    private ClassMergingRule parseClassMergingRule(ClassMergingRule.Type type, Position start)
+        throws ProguardRuleParserException {
+      ClassMergingRule.Builder keepRuleBuilder =
+          ClassMergingRule.builder().setOrigin(origin).setStart(start).setType(type);
+      parseClassSpec(keepRuleBuilder, false);
+      Position end = getPosition();
+      keepRuleBuilder.setSource(getSourceSnippet(contents, start, end));
+      keepRuleBuilder.setEnd(end);
+      return keepRuleBuilder.build();
+    }
+
     private InlineRule parseInlineRule(InlineRule.Type type, Position start)
         throws ProguardRuleParserException {
       InlineRule.Builder keepRuleBuilder = InlineRule.builder()
diff --git a/src/main/java/com/android/tools/r8/shaking/RootSetBuilder.java b/src/main/java/com/android/tools/r8/shaking/RootSetBuilder.java
index 432ae10..19eb127 100644
--- a/src/main/java/com/android/tools/r8/shaking/RootSetBuilder.java
+++ b/src/main/java/com/android/tools/r8/shaking/RootSetBuilder.java
@@ -66,6 +66,7 @@
   private final Set<DexMethod> alwaysInline = Sets.newIdentityHashSet();
   private final Set<DexMethod> forceInline = Sets.newIdentityHashSet();
   private final Set<DexMethod> neverInline = Sets.newIdentityHashSet();
+  private final Set<DexType> neverMerge = Sets.newIdentityHashSet();
   private final Map<DexDefinition, Map<DexDefinition, ProguardKeepRule>> dependentNoShrinking =
       new IdentityHashMap<>();
   private final Map<DexDefinition, ProguardMemberRule> noSideEffects = new IdentityHashMap<>();
@@ -173,6 +174,10 @@
       } else if (rule instanceof ProguardAssumeNoSideEffectRule) {
         markMatchingVisibleMethods(clazz, memberKeepRules, rule, null);
         markMatchingFields(clazz, memberKeepRules, rule, null);
+      } else if (rule instanceof ClassMergingRule) {
+        if (allRulesSatisfied(memberKeepRules, clazz)) {
+          markClass(clazz, rule);
+        }
       } else if (rule instanceof InlineRule) {
         markMatchingMethods(clazz, memberKeepRules, rule, null);
       } else if (rule instanceof ProguardAssumeValuesRule) {
@@ -245,6 +250,7 @@
         alwaysInline,
         forceInline,
         neverInline,
+        neverMerge,
         noSideEffects,
         assumedValues,
         dependentNoShrinking,
@@ -844,6 +850,16 @@
         default:
           throw new Unreachable();
       }
+    } else if (context instanceof ClassMergingRule) {
+      switch (((ClassMergingRule) context).getType()) {
+        case NEVER:
+          if (item.isDexClass()) {
+            neverMerge.add(item.asDexClass().type);
+          }
+          break;
+        default:
+          throw new Unreachable();
+      }
     } else if (context instanceof ProguardIdentifierNameStringRule) {
       if (item.isDexEncodedField()) {
         identifierNameStrings.add(item.asDexEncodedField().field);
@@ -864,6 +880,7 @@
     public final Set<DexMethod> alwaysInline;
     public final Set<DexMethod> forceInline;
     public final Set<DexMethod> neverInline;
+    public final Set<DexType> neverMerge;
     public final Map<DexDefinition, ProguardMemberRule> noSideEffects;
     public final Map<DexDefinition, ProguardMemberRule> assumedValues;
     private final Map<DexDefinition, Map<DexDefinition, ProguardKeepRule>> dependentNoShrinking;
@@ -880,6 +897,7 @@
         Set<DexMethod> alwaysInline,
         Set<DexMethod> forceInline,
         Set<DexMethod> neverInline,
+        Set<DexType> neverMerge,
         Map<DexDefinition, ProguardMemberRule> noSideEffects,
         Map<DexDefinition, ProguardMemberRule> assumedValues,
         Map<DexDefinition, Map<DexDefinition, ProguardKeepRule>> dependentNoShrinking,
@@ -894,6 +912,7 @@
       this.alwaysInline = Collections.unmodifiableSet(alwaysInline);
       this.forceInline = Collections.unmodifiableSet(forceInline);
       this.neverInline = Collections.unmodifiableSet(neverInline);
+      this.neverMerge = Collections.unmodifiableSet(neverMerge);
       this.noSideEffects = Collections.unmodifiableMap(noSideEffects);
       this.assumedValues = Collections.unmodifiableMap(assumedValues);
       this.dependentNoShrinking = dependentNoShrinking;
diff --git a/src/main/java/com/android/tools/r8/shaking/StaticClassMerger.java b/src/main/java/com/android/tools/r8/shaking/StaticClassMerger.java
new file mode 100644
index 0000000..71a249f
--- /dev/null
+++ b/src/main/java/com/android/tools/r8/shaking/StaticClassMerger.java
@@ -0,0 +1,389 @@
+// Copyright (c) 2018, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+package com.android.tools.r8.shaking;
+
+import com.android.tools.r8.graph.AppView;
+import com.android.tools.r8.graph.DexClass;
+import com.android.tools.r8.graph.DexEncodedField;
+import com.android.tools.r8.graph.DexEncodedMethod;
+import com.android.tools.r8.graph.DexField;
+import com.android.tools.r8.graph.DexMethod;
+import com.android.tools.r8.graph.DexProgramClass;
+import com.android.tools.r8.graph.DexString;
+import com.android.tools.r8.graph.DexType;
+import com.android.tools.r8.graph.GraphLense;
+import com.android.tools.r8.graph.GraphLense.NestedGraphLense;
+import com.android.tools.r8.logging.Log;
+import com.android.tools.r8.shaking.Enqueuer.AppInfoWithLiveness;
+import com.android.tools.r8.shaking.VerticalClassMerger.IllegalAccessDetector;
+import com.android.tools.r8.utils.FieldSignatureEquivalence;
+import com.android.tools.r8.utils.MethodSignatureEquivalence;
+import com.google.common.base.Equivalence.Wrapper;
+import com.google.common.collect.BiMap;
+import com.google.common.collect.HashBiMap;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Streams;
+import java.util.ArrayDeque;
+import java.util.Arrays;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+
+/**
+ * This optimization merges all classes that only have static members and private virtual methods.
+ *
+ * <p>If a merge candidate does not access any package-private or protected members, then it is
+ * merged into a global representative. Otherwise it is merged into a representative for its
+ * package. If no such representatives exist, then the merge candidate is promoted to be the
+ * representative.
+ *
+ * <p>Note that, when merging a merge candidate X into Y, this optimization merely moves the members
+ * of X into Y -- it does not change all occurrences of X in the program into Y. This makes the
+ * optimization more applicable, because it would otherwise not be possible to merge two classes if
+ * they inherited from, say, X' and Y' (since multiple inheritance is not allowed).
+ */
+public class StaticClassMerger {
+
+  private final AppView<? extends AppInfoWithLiveness> appView;
+
+  private final Map<String, DexProgramClass> representatives = new HashMap<>();
+
+  private DexProgramClass globalRepresentative = null;
+
+  private final BiMap<DexField, DexField> fieldMapping = HashBiMap.create();
+  private final BiMap<DexMethod, DexMethod> methodMapping = HashBiMap.create();
+
+  public StaticClassMerger(AppView<? extends AppInfoWithLiveness> appView) {
+    this.appView = appView;
+  }
+
+  // TODO(christofferqa): Share top-down traversal with vertical class merger.
+  public GraphLense run() {
+    // Visit classes in top-down order.
+    Deque<DexProgramClass> worklist = new ArrayDeque<>();
+    Set<DexProgramClass> seenBefore = new HashSet<>();
+
+    Iterator<DexProgramClass> classIterator =
+        appView.appInfo().app.classesWithDeterministicOrder().iterator();
+
+    // Visit the program classes in a top-down order according to the class hierarchy.
+    while (classIterator.hasNext() || !worklist.isEmpty()) {
+      if (worklist.isEmpty()) {
+        // Add the ancestors of this class (including the class itself) to the worklist in such a
+        // way that all super types of the class come before the class itself.
+        addAncestorsToWorklist(classIterator.next(), worklist, seenBefore);
+        if (worklist.isEmpty()) {
+          continue;
+        }
+      }
+
+      DexProgramClass clazz = worklist.removeFirst();
+      if (!seenBefore.add(clazz)) {
+        continue;
+      }
+
+      if (satisfiesMergeCriteria(clazz)) {
+        merge(clazz);
+      }
+    }
+
+    BiMap<DexField, DexField> originalFieldSignatures = fieldMapping.inverse();
+    BiMap<DexMethod, DexMethod> originalMethodSignatures = methodMapping.inverse();
+    return new NestedGraphLense(
+        ImmutableMap.of(),
+        methodMapping,
+        fieldMapping,
+        originalFieldSignatures,
+        originalMethodSignatures,
+        appView.graphLense(),
+        appView.dexItemFactory());
+  }
+
+  private void addAncestorsToWorklist(
+      DexProgramClass clazz, Deque<DexProgramClass> worklist, Set<DexProgramClass> seenBefore) {
+    if (seenBefore.contains(clazz)) {
+      return;
+    }
+
+    worklist.addFirst(clazz);
+
+    // Add super classes to worklist.
+    if (clazz.superType != null) {
+      DexClass definition = appView.appInfo().definitionFor(clazz.superType);
+      if (definition != null && definition.isProgramClass()) {
+        addAncestorsToWorklist(definition.asProgramClass(), worklist, seenBefore);
+      }
+    }
+
+    // Add super interfaces to worklist.
+    for (DexType interfaceType : clazz.interfaces.values) {
+      DexClass definition = appView.appInfo().definitionFor(interfaceType);
+      if (definition != null && definition.isProgramClass()) {
+        addAncestorsToWorklist(definition.asProgramClass(), worklist, seenBefore);
+      }
+    }
+  }
+
+  public boolean satisfiesMergeCriteria(DexProgramClass clazz) {
+    if (appView.appInfo().neverMerge.contains(clazz.type)) {
+      return false;
+    }
+    if (clazz.accessFlags.isInterface()) {
+      return false;
+    }
+    if (clazz.staticFields().length + clazz.directMethods().length + clazz.virtualMethods().length
+        == 0) {
+      return false;
+    }
+    if (clazz.instanceFields().length > 0) {
+      return false;
+    }
+    if (Arrays.stream(clazz.staticFields())
+        .anyMatch(field -> appView.appInfo().isPinned(field.field))) {
+      return false;
+    }
+    if (Arrays.stream(clazz.directMethods()).anyMatch(DexEncodedMethod::isInitializer)) {
+      return false;
+    }
+    if (!Arrays.stream(clazz.virtualMethods()).allMatch(DexEncodedMethod::isPrivateMethod)) {
+      return false;
+    }
+    if (Streams.stream(clazz.methods())
+        .anyMatch(
+            method ->
+                method.accessFlags.isNative()
+                    || appView.appInfo().isPinned(method.method)
+                    // TODO(christofferqa): Remove the invariant that the graph lense should not
+                    // modify any methods from the sets alwaysInline and noSideEffects.
+                    || appView.appInfo().alwaysInline.contains(method.method)
+                    || appView.appInfo().noSideEffects.keySet().contains(method))) {
+      return false;
+    }
+    return true;
+  }
+
+  public boolean merge(DexProgramClass clazz) {
+    assert satisfiesMergeCriteria(clazz);
+
+    String pkg = clazz.type.getPackageDescriptor();
+    DexProgramClass representativeForPackage = representatives.get(pkg);
+
+    if (mayMergeAcrossPackageBoundaries(clazz)) {
+      if (globalRepresentative != null) {
+        // Merge this class into the global representative.
+        moveMembersFromSourceToTarget(clazz, globalRepresentative);
+        return true;
+      }
+
+      // Make the current class the global representative as well as the representative for this
+      // package.
+      if (Log.ENABLED) {
+        Log.info(getClass(), "Making %s a global representative", clazz.type.toSourceString(), pkg);
+        Log.info(getClass(), "Making %s a representative for %s", clazz.type.toSourceString(), pkg);
+      }
+      globalRepresentative = clazz;
+      representatives.put(pkg, clazz);
+
+      if (representativeForPackage != null) {
+        // If there was a previous representative for this package, we can merge it into the current
+        // class that has just become the global representative.
+        moveMembersFromSourceToTarget(representativeForPackage, clazz);
+        return true;
+      }
+
+      // No merge.
+      return false;
+    }
+
+    if (representativeForPackage != null) {
+      if (clazz.accessFlags.isMoreVisibleThan(representativeForPackage.accessFlags)) {
+        // Use `clazz` as a representative for this package instead.
+        representatives.put(pkg, clazz);
+        moveMembersFromSourceToTarget(representativeForPackage, clazz);
+        return true;
+      }
+
+      // Merge current class into the representative for this package.
+      moveMembersFromSourceToTarget(clazz, representativeForPackage);
+      return true;
+    }
+
+    // No merge.
+    if (Log.ENABLED) {
+      Log.info(getClass(), "Making %s a representative for %s", clazz.type.toSourceString(), pkg);
+    }
+    representatives.put(pkg, clazz);
+    return false;
+  }
+
+  private boolean mayMergeAcrossPackageBoundaries(DexProgramClass clazz) {
+    // Check that the class is public. Otherwise, accesses to `clazz` from within its current
+    // package may become illegal.
+    if (!clazz.accessFlags.isPublic()) {
+      return false;
+    }
+    // Check that all of the members are private or public.
+    if (!Arrays.stream(clazz.directMethods())
+        .allMatch(method -> method.accessFlags.isPrivate() || method.accessFlags.isPublic())) {
+      return false;
+    }
+    if (!Arrays.stream(clazz.staticFields())
+        .allMatch(method -> method.accessFlags.isPrivate() || method.accessFlags.isPublic())) {
+      return false;
+    }
+
+    // Note that a class is only considered a candidate if it has no instance fields and all of its
+    // virtual methods are private. Therefore, we don't need to consider check if there are any
+    // package-private or protected instance fields or virtual methods here.
+    assert Arrays.stream(clazz.instanceFields()).count() == 0;
+    assert Arrays.stream(clazz.virtualMethods()).allMatch(method -> method.accessFlags.isPrivate());
+
+    // Check that no methods access package-private or protected members.
+    IllegalAccessDetector registry = new IllegalAccessDetector(appView.appInfo(), clazz);
+    for (DexEncodedMethod method : clazz.methods()) {
+      method.registerCodeReferences(registry);
+      if (registry.foundIllegalAccess()) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  private void moveMembersFromSourceToTarget(
+      DexProgramClass sourceClass, DexProgramClass targetClass) {
+    if (Log.ENABLED) {
+      Log.info(
+          getClass(),
+          "Merging %s into %s",
+          sourceClass.type.toSourceString(),
+          targetClass.type.toSourceString());
+    }
+
+    assert targetClass.accessFlags.isAtLeastAsVisibleAs(sourceClass.accessFlags);
+    assert sourceClass.instanceFields().length == 0;
+    assert targetClass.instanceFields().length == 0;
+
+    // Move members from source to target.
+    targetClass.setDirectMethods(
+        mergeMethods(sourceClass.directMethods(), targetClass.directMethods(), targetClass));
+    targetClass.setVirtualMethods(
+        mergeMethods(sourceClass.virtualMethods(), targetClass.virtualMethods(), targetClass));
+    targetClass.setStaticFields(
+        mergeFields(sourceClass.staticFields(), targetClass.staticFields(), targetClass));
+
+    // Cleanup source.
+    sourceClass.setDirectMethods(DexEncodedMethod.EMPTY_ARRAY);
+    sourceClass.setVirtualMethods(DexEncodedMethod.EMPTY_ARRAY);
+    sourceClass.setStaticFields(DexEncodedField.EMPTY_ARRAY);
+  }
+
+  private DexEncodedMethod[] mergeMethods(
+      DexEncodedMethod[] sourceMethods,
+      DexEncodedMethod[] targetMethods,
+      DexProgramClass targetClass) {
+    DexEncodedMethod[] result = new DexEncodedMethod[sourceMethods.length + targetMethods.length];
+
+    // Move all target methods to result.
+    System.arraycopy(targetMethods, 0, result, 0, targetMethods.length);
+
+    // Move source methods to result one by one, renaming them if needed.
+    MethodSignatureEquivalence equivalence = MethodSignatureEquivalence.get();
+    Set<Wrapper<DexMethod>> existingMethods =
+        Arrays.stream(targetMethods)
+            .map(targetMethod -> equivalence.wrap(targetMethod.method))
+            .collect(Collectors.toSet());
+
+    Predicate<DexMethod> availableMethodSignatures =
+        method -> !existingMethods.contains(equivalence.wrap(method));
+
+    int i = targetMethods.length;
+    for (DexEncodedMethod sourceMethod : sourceMethods) {
+      DexEncodedMethod sourceMethodAfterMove =
+          renameMethodIfNeeded(sourceMethod, targetClass, availableMethodSignatures);
+      result[i++] = sourceMethodAfterMove;
+
+      DexMethod originalMethod =
+          methodMapping.inverse().getOrDefault(sourceMethod.method, sourceMethod.method);
+      methodMapping.put(originalMethod, sourceMethodAfterMove.method);
+    }
+
+    return result;
+  }
+
+  private DexEncodedField[] mergeFields(
+      DexEncodedField[] sourceFields, DexEncodedField[] targetFields, DexProgramClass targetClass) {
+    DexEncodedField[] result = new DexEncodedField[sourceFields.length + targetFields.length];
+
+    // Move all target fields to result.
+    System.arraycopy(targetFields, 0, result, 0, targetFields.length);
+
+    // Move source fields to result one by one, renaming them if needed.
+    FieldSignatureEquivalence equivalence = FieldSignatureEquivalence.get();
+    Set<Wrapper<DexField>> existingFields =
+        Arrays.stream(targetFields)
+            .map(targetField -> equivalence.wrap(targetField.field))
+            .collect(Collectors.toSet());
+
+    Predicate<DexField> availableFieldSignatures =
+        field -> !existingFields.contains(equivalence.wrap(field));
+
+    int i = targetFields.length;
+    for (DexEncodedField sourceField : sourceFields) {
+      DexEncodedField sourceFieldAfterMove =
+          renameFieldIfNeeded(sourceField, targetClass, availableFieldSignatures);
+      result[i++] = sourceFieldAfterMove;
+
+      DexField originalField =
+          fieldMapping.inverse().getOrDefault(sourceField.field, sourceField.field);
+      fieldMapping.put(originalField, sourceFieldAfterMove.field);
+    }
+
+    return result;
+  }
+
+  private DexEncodedMethod renameMethodIfNeeded(
+      DexEncodedMethod method,
+      DexProgramClass targetClass,
+      Predicate<DexMethod> availableMethodSignatures) {
+    assert !method.accessFlags.isConstructor();
+    DexString oldName = method.method.name;
+    DexMethod newSignature =
+        appView.dexItemFactory().createMethod(targetClass.type, method.method.proto, oldName);
+    if (!availableMethodSignatures.test(newSignature)) {
+      int count = 1;
+      do {
+        DexString newName = appView.dexItemFactory().createString(oldName.toSourceString() + count);
+        newSignature =
+            appView.dexItemFactory().createMethod(targetClass.type, method.method.proto, newName);
+        count++;
+      } while (!availableMethodSignatures.test(newSignature));
+    }
+    return method.toTypeSubstitutedMethod(newSignature);
+  }
+
+  private DexEncodedField renameFieldIfNeeded(
+      DexEncodedField field,
+      DexProgramClass targetClass,
+      Predicate<DexField> availableFieldSignatures) {
+    DexString oldName = field.field.name;
+    DexField newSignature =
+        appView.dexItemFactory().createField(targetClass.type, field.field.type, oldName);
+    if (!availableFieldSignatures.test(newSignature)) {
+      int count = 1;
+      do {
+        DexString newName = appView.dexItemFactory().createString(oldName.toSourceString() + count);
+        newSignature =
+            appView.dexItemFactory().createField(targetClass.type, field.field.type, newName);
+        count++;
+      } while (!availableFieldSignatures.test(newSignature));
+    }
+    return field.toTypeSubstitutedField(newSignature);
+  }
+}
diff --git a/src/main/java/com/android/tools/r8/shaking/VerticalClassMerger.java b/src/main/java/com/android/tools/r8/shaking/VerticalClassMerger.java
index d41bd69..df4c786 100644
--- a/src/main/java/com/android/tools/r8/shaking/VerticalClassMerger.java
+++ b/src/main/java/com/android/tools/r8/shaking/VerticalClassMerger.java
@@ -8,6 +8,7 @@
 
 import com.android.tools.r8.errors.CompilationError;
 import com.android.tools.r8.errors.Unreachable;
+import com.android.tools.r8.graph.AppInfo;
 import com.android.tools.r8.graph.AppInfo.ResolutionResult;
 import com.android.tools.r8.graph.AppView;
 import com.android.tools.r8.graph.Code;
@@ -18,7 +19,6 @@
 import com.android.tools.r8.graph.DexEncodedField;
 import com.android.tools.r8.graph.DexEncodedMethod;
 import com.android.tools.r8.graph.DexField;
-import com.android.tools.r8.graph.DexItemFactory;
 import com.android.tools.r8.graph.DexMethod;
 import com.android.tools.r8.graph.DexProgramClass;
 import com.android.tools.r8.graph.DexProto;
@@ -353,7 +353,8 @@
     if (appInfo.instantiatedTypes.contains(clazz.type)
         || appInfo.instantiatedLambdas.contains(clazz.type)
         || appInfo.isPinned(clazz.type)
-        || pinnedTypes.contains(clazz.type)) {
+        || pinnedTypes.contains(clazz.type)
+        || appInfo.neverMerge.contains(clazz.type)) {
       return false;
     }
     // Note that the property "singleSubtype == null" cannot change during merging, since we visit
@@ -477,7 +478,7 @@
     // Check that all accesses from [source] to classes or members from the current package of
     // [source] will continue to work. This is guaranteed if the methods of [source] do not access
     // any private or protected classes or members from the current package of [source].
-    IllegalAccessDetector registry = new IllegalAccessDetector(options.itemFactory, source);
+    IllegalAccessDetector registry = new IllegalAccessDetector(appInfo, source);
     for (DexEncodedMethod method : source.methods()) {
       method.registerCodeReferences(registry);
       if (registry.foundIllegalAccess()) {
@@ -1334,7 +1335,7 @@
   private class TreeFixer {
 
     private final Builder lense = GraphLense.builder();
-    Map<DexProto, DexProto> protoFixupCache = new IdentityHashMap<>();
+    private final Map<DexProto, DexProto> protoFixupCache = new IdentityHashMap<>();
 
     private GraphLense fixupTypeReferences(GraphLense graphLense) {
       // Globally substitute merged class types in protos and holders.
@@ -1345,7 +1346,7 @@
         clazz.setStaticFields(substituteTypesIn(clazz.staticFields()));
         clazz.setInstanceFields(substituteTypesIn(clazz.instanceFields()));
       }
-      // Record type renamings so instanceof and checkcast checks are also fixed.
+      // Record type renamings so check-cast and instance-of checks are also fixed.
       for (DexType type : mergedClasses.keySet()) {
         DexType fixed = fixupType(type);
         lense.map(type, fixed);
@@ -1649,12 +1650,16 @@
 
   // Searches for a reference to a non-public class, field or method declared in the same package
   // as [source].
-  private class IllegalAccessDetector extends UseRegistry {
-    private boolean foundIllegalAccess = false;
-    private DexClass source;
+  public static class IllegalAccessDetector extends UseRegistry {
 
-    public IllegalAccessDetector(DexItemFactory factory, DexClass source) {
-      super(factory);
+    private boolean foundIllegalAccess = false;
+
+    private final AppInfo appInfo;
+    private final DexClass source;
+
+    public IllegalAccessDetector(AppInfo appInfo, DexClass source) {
+      super(appInfo.dexItemFactory);
+      this.appInfo = appInfo;
       this.source = source;
     }
 
@@ -1664,7 +1669,8 @@
 
     private boolean checkFieldReference(DexField field) {
       if (!foundIllegalAccess) {
-        if (field.clazz.isSamePackage(source.type)) {
+        DexType baseType = field.clazz.toBaseType(appInfo.dexItemFactory);
+        if (baseType.isClassType() && baseType.isSamePackage(source.type)) {
           checkTypeReference(field.clazz);
           checkTypeReference(field.type);
 
@@ -1679,7 +1685,8 @@
 
     private boolean checkMethodReference(DexMethod method) {
       if (!foundIllegalAccess) {
-        if (method.holder.isSamePackage(source.type)) {
+        DexType baseType = method.holder.toBaseType(appInfo.dexItemFactory);
+        if (baseType.isClassType() && baseType.isSamePackage(source.type)) {
           checkTypeReference(method.holder);
           checkTypeReference(method.proto.returnType);
           for (DexType type : method.proto.parameters.values) {
@@ -1696,8 +1703,9 @@
 
     private boolean checkTypeReference(DexType type) {
       if (!foundIllegalAccess) {
-        if (type.isClassType() && type.isSamePackage(source.type)) {
-          DexClass clazz = appInfo.definitionFor(type);
+        DexType baseType = type.toBaseType(appInfo.dexItemFactory);
+        if (baseType.isClassType() && baseType.isSamePackage(source.type)) {
+          DexClass clazz = appInfo.definitionFor(baseType);
           if (clazz == null || !clazz.accessFlags.isPublic()) {
             foundIllegalAccess = true;
           }
diff --git a/src/main/java/com/android/tools/r8/utils/InternalOptions.java b/src/main/java/com/android/tools/r8/utils/InternalOptions.java
index 65d1e3a..c76841d 100644
--- a/src/main/java/com/android/tools/r8/utils/InternalOptions.java
+++ b/src/main/java/com/android/tools/r8/utils/InternalOptions.java
@@ -79,6 +79,7 @@
     itemFactory = proguardConfiguration.getDexItemFactory();
     // -dontoptimize disables optimizations by flipping related flags.
     if (!proguardConfiguration.isOptimizing()) {
+      enableHorizontalClassMerging = false;
       enableVerticalClassMerging = false;
       enableDevirtualization = false;
       enableNonNullTracking = false;
@@ -97,6 +98,7 @@
   public boolean passthroughDexCode = false;
 
   // Optimization-related flags. These should conform to -dontoptimize.
+  public boolean enableHorizontalClassMerging = true;
   public boolean enableVerticalClassMerging = true;
   public boolean enableDevirtualization = true;
   public boolean enableNonNullTracking = true;
diff --git a/src/test/java/com/android/tools/r8/D8TestBuilder.java b/src/test/java/com/android/tools/r8/D8TestBuilder.java
index 21a5e0e..4897701 100644
--- a/src/test/java/com/android/tools/r8/D8TestBuilder.java
+++ b/src/test/java/com/android/tools/r8/D8TestBuilder.java
@@ -5,19 +5,19 @@
 
 import com.android.tools.r8.D8Command.Builder;
 import com.android.tools.r8.TestBase.Backend;
+import com.android.tools.r8.utils.AndroidApp;
 import com.android.tools.r8.utils.InternalOptions;
 import java.nio.file.Path;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.function.Consumer;
+import java.util.function.Supplier;
 
-public class D8TestBuilder extends TestCompilerBuilder<D8Command, Builder, D8TestBuilder> {
+public class D8TestBuilder
+    extends TestCompilerBuilder<D8Command, Builder, D8TestCompileResult, D8TestBuilder> {
 
-  private final D8Command.Builder builder;
-
-  private D8TestBuilder(TestState state, D8Command.Builder builder) {
+  private D8TestBuilder(TestState state, Builder builder) {
     super(state, builder, Backend.DEX);
-    this.builder = builder;
   }
 
   public static D8TestBuilder create(TestState state) {
@@ -30,9 +30,11 @@
   }
 
   @Override
-  void internalCompile(Builder builder, Consumer<InternalOptions> optionsConsumer)
+  D8TestCompileResult internalCompile(
+      Builder builder, Consumer<InternalOptions> optionsConsumer, Supplier<AndroidApp> app)
       throws CompilationFailedException {
     ToolHelper.runD8(builder, optionsConsumer);
+    return new D8TestCompileResult(getState(), app.get());
   }
 
   public D8TestBuilder addClasspathClasses(Class<?>... classes) {
diff --git a/src/test/java/com/android/tools/r8/D8TestCompileResult.java b/src/test/java/com/android/tools/r8/D8TestCompileResult.java
new file mode 100644
index 0000000..482a611
--- /dev/null
+++ b/src/test/java/com/android/tools/r8/D8TestCompileResult.java
@@ -0,0 +1,18 @@
+// Copyright (c) 2018, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+package com.android.tools.r8;
+
+import com.android.tools.r8.TestBase.Backend;
+import com.android.tools.r8.utils.AndroidApp;
+
+public class D8TestCompileResult extends TestCompileResult {
+  D8TestCompileResult(TestState state, AndroidApp app) {
+    super(state, app);
+  }
+
+  @Override
+  public Backend getBackend() {
+    return Backend.DEX;
+  }
+}
diff --git a/src/test/java/com/android/tools/r8/NeverMerge.java b/src/test/java/com/android/tools/r8/NeverMerge.java
new file mode 100644
index 0000000..7fc97b9
--- /dev/null
+++ b/src/test/java/com/android/tools/r8/NeverMerge.java
@@ -0,0 +1,6 @@
+// Copyright (c) 2018, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+package com.android.tools.r8;
+
+public @interface NeverMerge {}
diff --git a/src/test/java/com/android/tools/r8/R8TestBuilder.java b/src/test/java/com/android/tools/r8/R8TestBuilder.java
index 74b40b0..3b01a71 100644
--- a/src/test/java/com/android/tools/r8/R8TestBuilder.java
+++ b/src/test/java/com/android/tools/r8/R8TestBuilder.java
@@ -6,6 +6,7 @@
 import com.android.tools.r8.R8Command.Builder;
 import com.android.tools.r8.TestBase.Backend;
 import com.android.tools.r8.origin.Origin;
+import com.android.tools.r8.utils.AndroidApp;
 import com.android.tools.r8.utils.InternalOptions;
 import com.android.tools.r8.utils.StringUtils;
 import java.util.ArrayList;
@@ -13,14 +14,13 @@
 import java.util.Collection;
 import java.util.List;
 import java.util.function.Consumer;
+import java.util.function.Supplier;
 
-public class R8TestBuilder extends TestCompilerBuilder<R8Command, Builder, R8TestBuilder> {
-
-  private final R8Command.Builder builder;
+public class R8TestBuilder
+    extends TestCompilerBuilder<R8Command, Builder, R8TestCompileResult, R8TestBuilder> {
 
   private R8TestBuilder(TestState state, Builder builder, Backend backend) {
     super(state, builder, backend);
-    this.builder = builder;
   }
 
   public static R8TestBuilder create(TestState state, Backend backend) {
@@ -35,12 +35,16 @@
   }
 
   @Override
-  void internalCompile(Builder builder, Consumer<InternalOptions> optionsConsumer)
+  R8TestCompileResult internalCompile(
+      Builder builder, Consumer<InternalOptions> optionsConsumer, Supplier<AndroidApp> app)
       throws CompilationFailedException {
     if (enableInliningAnnotations) {
       ToolHelper.allowTestProguardOptions(builder);
     }
+    StringBuilder proguardMapBuilder = new StringBuilder();
+    builder.setProguardMapConsumer((string, ignore) -> proguardMapBuilder.append(string));
     ToolHelper.runR8WithoutResult(builder.build(), optionsConsumer);
+    return new R8TestCompileResult(getState(), backend, app.get(), proguardMapBuilder.toString());
   }
 
   public R8TestBuilder addDataResources(List<DataEntryResource> resources) {
diff --git a/src/test/java/com/android/tools/r8/R8TestCompileResult.java b/src/test/java/com/android/tools/r8/R8TestCompileResult.java
new file mode 100644
index 0000000..45fb31c
--- /dev/null
+++ b/src/test/java/com/android/tools/r8/R8TestCompileResult.java
@@ -0,0 +1,32 @@
+// Copyright (c) 2018, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+package com.android.tools.r8;
+
+import com.android.tools.r8.TestBase.Backend;
+import com.android.tools.r8.utils.AndroidApp;
+import com.android.tools.r8.utils.codeinspector.CodeInspector;
+import java.io.IOException;
+import java.util.concurrent.ExecutionException;
+
+public class R8TestCompileResult extends TestCompileResult {
+
+  private final Backend backend;
+  private final String proguardMap;
+
+  R8TestCompileResult(TestState state, Backend backend, AndroidApp app, String proguardMap) {
+    super(state, app);
+    this.backend = backend;
+    this.proguardMap = proguardMap;
+  }
+
+  @Override
+  public Backend getBackend() {
+    return backend;
+  }
+
+  @Override
+  public CodeInspector inspector() throws IOException, ExecutionException {
+    return new CodeInspector(app, proguardMap);
+  }
+}
diff --git a/src/test/java/com/android/tools/r8/TestBuilder.java b/src/test/java/com/android/tools/r8/TestBuilder.java
index 6d5a158..7397c39 100644
--- a/src/test/java/com/android/tools/r8/TestBuilder.java
+++ b/src/test/java/com/android/tools/r8/TestBuilder.java
@@ -53,7 +53,15 @@
   }
 
   public T addProgramClassesAndInnerClasses(Collection<Class<?>> classes) throws IOException {
-    return addProgramFiles(getFilesForClassesAndInnerClasses(classes));
+    return addProgramClasses(classes).addInnerClasses(classes);
+  }
+
+  public T addInnerClasses(Class<?>... classes) throws IOException {
+    return addInnerClasses(Arrays.asList(classes));
+  }
+
+  public T addInnerClasses(Collection<Class<?>> classes) throws IOException {
+    return addProgramFiles(getFilesForInnerClasses(classes));
   }
 
   public abstract T addLibraryFiles(Collection<Path> files);
@@ -74,15 +82,14 @@
     return ListUtils.map(classes, ToolHelper::getClassFileForTestClass);
   }
 
-  static Collection<Path> getFilesForClassesAndInnerClasses(Collection<Class<?>> classes)
-      throws IOException {
+  static Collection<Path> getFilesForInnerClasses(Collection<Class<?>> classes) throws IOException {
     Set<Path> paths = new HashSet<>();
     for (Class clazz : classes) {
       Path path = ToolHelper.getClassFileForTestClass(clazz);
       String prefix = path.toString().replace(CLASS_EXTENSION, "$");
       paths.addAll(
           ToolHelper.getClassFilesForTestDirectory(
-              path.getParent(), p -> p.equals(path) || p.toString().startsWith(prefix)));
+              path.getParent(), p -> p.toString().startsWith(prefix)));
     }
     return paths;
   }
diff --git a/src/test/java/com/android/tools/r8/TestCompileResult.java b/src/test/java/com/android/tools/r8/TestCompileResult.java
index 4e7cf37..7505a83 100644
--- a/src/test/java/com/android/tools/r8/TestCompileResult.java
+++ b/src/test/java/com/android/tools/r8/TestCompileResult.java
@@ -12,19 +12,23 @@
 import java.nio.file.Path;
 import java.util.concurrent.ExecutionException;
 
-public class TestCompileResult {
-  private final TestState state;
-  private final Backend backend;
-  private final AndroidApp app;
+public abstract class TestCompileResult {
+  final TestState state;
+  final AndroidApp app;
 
-  public TestCompileResult(TestState state, Backend backend, AndroidApp app) {
+  TestCompileResult(TestState state, AndroidApp app) {
     this.state = state;
-    this.backend = backend;
     this.app = app;
   }
 
+  public abstract Backend getBackend();
+
+  public TestRunResult run(Class<?> mainClass) throws IOException {
+    return run(mainClass.getTypeName());
+  }
+
   public TestRunResult run(String mainClass) throws IOException {
-    switch (backend) {
+    switch (getBackend()) {
       case DEX:
         return runArt(mainClass);
       case CF:
@@ -34,6 +38,12 @@
     }
   }
 
+  public TestCompileResult writeToZip(Path file) throws IOException {
+    app.writeToZip(
+        file, getBackend() == Backend.DEX ? OutputMode.DexIndexed : OutputMode.ClassFile);
+    return this;
+  }
+
   public CodeInspector inspector() throws IOException, ExecutionException {
     return new CodeInspector(app);
   }
diff --git a/src/test/java/com/android/tools/r8/TestCompilerBuilder.java b/src/test/java/com/android/tools/r8/TestCompilerBuilder.java
index db25352..4706125 100644
--- a/src/test/java/com/android/tools/r8/TestCompilerBuilder.java
+++ b/src/test/java/com/android/tools/r8/TestCompilerBuilder.java
@@ -5,17 +5,21 @@
 
 import com.android.tools.r8.TestBase.Backend;
 import com.android.tools.r8.utils.AndroidApiLevel;
+import com.android.tools.r8.utils.AndroidApp;
 import com.android.tools.r8.utils.AndroidAppConsumers;
 import com.android.tools.r8.utils.InternalOptions;
+import com.google.common.base.Suppliers;
 import java.io.IOException;
 import java.nio.file.Path;
 import java.util.Collection;
 import java.util.function.Consumer;
+import java.util.function.Supplier;
 
 public abstract class TestCompilerBuilder<
         C extends BaseCompilerCommand,
         B extends BaseCompilerCommand.Builder<C, B>,
-        T extends TestCompilerBuilder<C, B, T>>
+        R extends TestCompileResult,
+        T extends TestCompilerBuilder<C, B, R, T>>
     extends TestBuilder<T> {
 
   public static final Consumer<InternalOptions> DEFAULT_OPTIONS =
@@ -24,8 +28,8 @@
         public void accept(InternalOptions options) {}
       };
 
-  private final B builder;
-  private final Backend backend;
+  final B builder;
+  final Backend backend;
 
   // Default initialized setup. Can be overwritten if needed.
   private Path defaultLibrary;
@@ -43,7 +47,8 @@
 
   abstract T self();
 
-  abstract void internalCompile(B builder, Consumer<InternalOptions> optionsConsumer)
+  abstract R internalCompile(
+      B builder, Consumer<InternalOptions> optionsConsumer, Supplier<AndroidApp> app)
       throws CompilationFailedException;
 
   public T addOptionsModification(Consumer<InternalOptions> optionsConsumer) {
@@ -51,7 +56,7 @@
     return self();
   }
 
-  public TestCompileResult compile() throws CompilationFailedException {
+  public R compile() throws CompilationFailedException {
     AndroidAppConsumers sink = new AndroidAppConsumers();
     builder.setProgramConsumer(sink.wrapProgramConsumer(programConsumer));
     if (defaultLibrary != null) {
@@ -60,8 +65,7 @@
     if (backend == Backend.DEX && defaultMinApiLevel != null) {
       builder.setMinApiLevel(defaultMinApiLevel.getLevel());
     }
-    internalCompile(builder, optionsConsumer);
-    return new TestCompileResult(getState(), backend, sink.build());
+    return internalCompile(builder, optionsConsumer, Suppliers.memoize(sink::build));
   }
 
   @Override
diff --git a/src/test/java/com/android/tools/r8/cf/BootstrapCurrentEqualityTest.java b/src/test/java/com/android/tools/r8/cf/BootstrapCurrentEqualityTest.java
index 7202914..d2d434a 100644
--- a/src/test/java/com/android/tools/r8/cf/BootstrapCurrentEqualityTest.java
+++ b/src/test/java/com/android/tools/r8/cf/BootstrapCurrentEqualityTest.java
@@ -36,8 +36,8 @@
 import org.junit.rules.TemporaryFolder;
 
 /**
- * This test relies on a freshly built from builds/libs/r8lib_with_deps.jar. If this test fails
- * rebuild r8lib_with_deps by calling test.py or gradle r8libWithdeps.
+ * This test relies on a freshly built builds/libs/r8lib_with_deps.jar. If this test fails
+ * remove build directory and rebuild r8lib_with_deps by calling test.py or gradle r8libWithdeps.
  */
 public class BootstrapCurrentEqualityTest extends TestBase {
 
diff --git a/src/test/java/com/android/tools/r8/classmerging/StaticClassMergerTest.java b/src/test/java/com/android/tools/r8/classmerging/StaticClassMergerTest.java
new file mode 100644
index 0000000..2d2788c
--- /dev/null
+++ b/src/test/java/com/android/tools/r8/classmerging/StaticClassMergerTest.java
@@ -0,0 +1,87 @@
+// Copyright (c) 2018, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+package com.android.tools.r8.classmerging;
+
+import static org.junit.Assert.assertEquals;
+
+import com.android.tools.r8.NeverInline;
+import com.android.tools.r8.TestBase;
+import com.android.tools.r8.utils.StringUtils;
+import com.android.tools.r8.utils.codeinspector.CodeInspector;
+import com.android.tools.r8.utils.codeinspector.FoundClassSubject;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameters;
+
+@RunWith(Parameterized.class)
+public class StaticClassMergerTest extends TestBase {
+
+  static class StaticMergeCandidateA {
+
+    @NeverInline
+    public static String m() {
+      return "StaticMergeCandidateA.m()";
+    }
+  }
+
+  static class StaticMergeCandidateB {
+
+    @NeverInline
+    public static String m() {
+      return "StaticMergeCandidateB.m()";
+    }
+  }
+
+  static class TestClass {
+
+    public static void main(String[] args) {
+      System.out.println(StaticMergeCandidateA.m());
+      System.out.print(StaticMergeCandidateB.m());
+    }
+  }
+
+  private Backend backend;
+
+  public StaticClassMergerTest(Backend backend) {
+    this.backend = backend;
+  }
+
+  @Parameters(name = "Backend: {0}")
+  public static Backend[] data() {
+    return Backend.values();
+  }
+
+  @Test
+  public void testStaticClassIsRemoved() throws Exception {
+    String expected =
+        StringUtils.joinLines("StaticMergeCandidateA.m()", "StaticMergeCandidateB.m()");
+
+    testForJvm().addTestClasspath().run(TestClass.class).assertSuccessWithOutput(expected);
+
+    CodeInspector inspector =
+        testForR8(backend)
+            .addInnerClasses(StaticClassMergerTest.class)
+            .addKeepMainRule(TestClass.class)
+            .addOptionsModification(options -> options.enableMinification = false)
+            .enableInliningAnnotations()
+            .run(TestClass.class)
+            .assertSuccessWithOutput(expected)
+            .inspector();
+
+    // Check that one of the two static merge candidates has been removed
+    List<FoundClassSubject> classes =
+        inspector.allClasses().stream()
+            .filter(clazz -> clazz.getOriginalName().contains("StaticMergeCandidate"))
+            .collect(Collectors.toList());
+    assertEquals(1, classes.size());
+
+    // Check that the remaining static merge candidate has two methods.
+    FoundClassSubject remaining = classes.get(0);
+    assertEquals(2, remaining.allMethods().size());
+  }
+}
diff --git a/src/test/java/com/android/tools/r8/classmerging/ClassMergingTest.java b/src/test/java/com/android/tools/r8/classmerging/VerticalClassMergerTest.java
similarity index 99%
rename from src/test/java/com/android/tools/r8/classmerging/ClassMergingTest.java
rename to src/test/java/com/android/tools/r8/classmerging/VerticalClassMergerTest.java
index 7a1f831..29f4605 100644
--- a/src/test/java/com/android/tools/r8/classmerging/ClassMergingTest.java
+++ b/src/test/java/com/android/tools/r8/classmerging/VerticalClassMergerTest.java
@@ -61,7 +61,7 @@
 // TODO(christofferqa): Add tests to check that statically typed invocations on method handles
 // continue to work after class merging.
 @RunWith(VmTestRunner.class)
-public class ClassMergingTest extends TestBase {
+public class VerticalClassMergerTest extends TestBase {
 
   private static final Path CF_DIR =
       Paths.get(ToolHelper.BUILD_DIR).resolve("test/examples/classes/classmerging");
@@ -1154,7 +1154,7 @@
 
     public void test(AndroidApp app, Path proguardMapPath) throws Throwable {
       Path appPath =
-          File.createTempFile("app", ".zip", ClassMergingTest.this.temp.getRoot()).toPath();
+          File.createTempFile("app", ".zip", VerticalClassMergerTest.this.temp.getRoot()).toPath();
       app.writeToZip(appPath, OutputMode.DexIndexed);
 
       DexDebugTestConfig config = new DexDebugTestConfig(appPath);
diff --git a/src/test/java/com/android/tools/r8/naming/b116840216/ReserveOuterClassNameTest.java b/src/test/java/com/android/tools/r8/naming/b116840216/ReserveOuterClassNameTest.java
index b1881ee..d83c891 100644
--- a/src/test/java/com/android/tools/r8/naming/b116840216/ReserveOuterClassNameTest.java
+++ b/src/test/java/com/android/tools/r8/naming/b116840216/ReserveOuterClassNameTest.java
@@ -10,6 +10,7 @@
 
 import com.android.tools.r8.CompatProguardCommandBuilder;
 import com.android.tools.r8.NeverInline;
+import com.android.tools.r8.NeverMerge;
 import com.android.tools.r8.R8Command;
 import com.android.tools.r8.TestBase;
 import com.android.tools.r8.ToolHelper;
@@ -26,8 +27,10 @@
 import org.junit.runners.Parameterized;
 import org.junit.runners.Parameterized.Parameters;
 
+@NeverMerge
 class Outer {
 
+  @NeverMerge
   static class Inner {
     @NeverInline
     static void foo() {
@@ -79,8 +82,8 @@
             // Note that reproducing b/116840216 relies on the order of following rules that cause
             // the visiting of classes during class minification to be Outer$Inner before Outer.
             "-keepnames class " + Outer.class.getCanonicalName() + "$Inner",
-            keepOuterName ? "-keepnames class " + Outer.class.getCanonicalName() : ""
-        ),
+            keepOuterName ? "-keepnames class " + Outer.class.getCanonicalName() : "",
+            "-nevermerge @com.android.tools.r8.NeverMerge class *"),
         Origin.unknown());
 
     ToolHelper.allowTestProguardOptions(builder);
@@ -109,7 +112,6 @@
     assertThat(foo, isRenamed());
   }
 
-
   @Test
   public void test_keepOuterName() throws Exception {
     runTest(true);
diff --git a/src/test/java/com/android/tools/r8/shaking/testrules/A.java b/src/test/java/com/android/tools/r8/shaking/testrules/A.java
index af05562..cf97eeb 100644
--- a/src/test/java/com/android/tools/r8/shaking/testrules/A.java
+++ b/src/test/java/com/android/tools/r8/shaking/testrules/A.java
@@ -4,6 +4,9 @@
 
 package com.android.tools.r8.shaking.testrules;
 
+import com.android.tools.r8.NeverMerge;
+
+@NeverMerge
 public class A {
 
   public static int m(int a, int b) {
diff --git a/src/test/java/com/android/tools/r8/shaking/testrules/C.java b/src/test/java/com/android/tools/r8/shaking/testrules/C.java
index 5ee4d53..79370a4 100644
--- a/src/test/java/com/android/tools/r8/shaking/testrules/C.java
+++ b/src/test/java/com/android/tools/r8/shaking/testrules/C.java
@@ -4,6 +4,9 @@
 
 package com.android.tools.r8.shaking.testrules;
 
+import com.android.tools.r8.NeverMerge;
+
+@NeverMerge
 public class C {
 
   private static int i;
diff --git a/src/test/java/com/android/tools/r8/shaking/testrules/ForceInlineTest.java b/src/test/java/com/android/tools/r8/shaking/testrules/ForceInlineTest.java
index f37d6c6..96894df 100644
--- a/src/test/java/com/android/tools/r8/shaking/testrules/ForceInlineTest.java
+++ b/src/test/java/com/android/tools/r8/shaking/testrules/ForceInlineTest.java
@@ -66,10 +66,12 @@
 
   @Test
   public void testDefaultInlining() throws Exception {
-    CodeInspector inspector = runTest(ImmutableList.of(
-        "-keep class **.Main { *; }",
-        "-dontobfuscate"
-    ));
+    CodeInspector inspector =
+        runTest(
+            ImmutableList.of(
+                "-keep class **.Main { *; }",
+                "-nevermerge @com.android.tools.r8.NeverMerge class *",
+                "-dontobfuscate"));
 
     ClassSubject classA = inspector.clazz(A.class);
     ClassSubject classB = inspector.clazz(B.class);
@@ -92,12 +94,14 @@
 
   @Test
   public void testNeverInline() throws Exception {
-    CodeInspector inspector = runTest(ImmutableList.of(
-        "-neverinline class **.A { method(); }",
-        "-neverinline class **.B { method(); }",
-        "-keep class **.Main { *; }",
-        "-dontobfuscate"
-    ));
+    CodeInspector inspector =
+        runTest(
+            ImmutableList.of(
+                "-neverinline class **.A { method(); }",
+                "-neverinline class **.B { method(); }",
+                "-keep class **.Main { *; }",
+                "-nevermerge @com.android.tools.r8.NeverMerge class *",
+                "-dontobfuscate"));
 
     ClassSubject classA = inspector.clazz(A.class);
     ClassSubject classB = inspector.clazz(B.class);
@@ -117,12 +121,14 @@
 
   @Test
   public void testForceInline() throws Exception {
-    CodeInspector inspector = runTest(ImmutableList.of(
-        "-forceinline class **.A { int m(int, int); }",
-        "-forceinline class **.B { int m(int, int); }",
-        "-keep class **.Main { *; }",
-        "-dontobfuscate"
-    ));
+    CodeInspector inspector =
+        runTest(
+            ImmutableList.of(
+                "-forceinline class **.A { int m(int, int); }",
+                "-forceinline class **.B { int m(int, int); }",
+                "-keep class **.Main { *; }",
+                "-nevermerge @com.android.tools.r8.NeverMerge class *",
+                "-dontobfuscate"));
 
     ClassSubject classA = inspector.clazz(A.class);
     ClassSubject classB = inspector.clazz(B.class);
@@ -139,11 +145,13 @@
   @Test
   public void testForceInlineFails() throws Exception {
     try {
-      CodeInspector inspector = runTest(ImmutableList.of(
-          "-forceinline class **.A { int x(); }",
-          "-keep class **.Main { *; }",
-          "-dontobfuscate"
-      ));
+      CodeInspector inspector =
+          runTest(
+              ImmutableList.of(
+                  "-forceinline class **.A { int x(); }",
+                  "-keep class **.Main { *; }",
+                  "-nevermerge @com.android.tools.r8.NeverMerge class *",
+                  "-dontobfuscate"));
       fail("Force inline of non-inlinable method succeeded");
     } catch (Throwable t) {
       // Ignore assertion error.
diff --git a/src/test/java/com/android/tools/r8/utils/codeinspector/CodeInspector.java b/src/test/java/com/android/tools/r8/utils/codeinspector/CodeInspector.java
index 28404f0..660156a 100644
--- a/src/test/java/com/android/tools/r8/utils/codeinspector/CodeInspector.java
+++ b/src/test/java/com/android/tools/r8/utils/codeinspector/CodeInspector.java
@@ -26,6 +26,7 @@
 import com.android.tools.r8.naming.MemberNaming.MethodSignature;
 import com.android.tools.r8.naming.signature.GenericSignatureAction;
 import com.android.tools.r8.naming.signature.GenericSignatureParser;
+import com.android.tools.r8.origin.Origin;
 import com.android.tools.r8.utils.AndroidApp;
 import com.android.tools.r8.utils.DescriptorUtils;
 import com.android.tools.r8.utils.InternalOptions;
@@ -111,10 +112,18 @@
     return internalOptions;
   }
 
-  public CodeInspector(AndroidApp app, Path proguardMap) throws IOException, ExecutionException {
+  public CodeInspector(AndroidApp app, Path proguardMapFile)
+      throws IOException, ExecutionException {
     this(
         new ApplicationReader(app, runOptionsConsumer(null), new Timing("CodeInspector"))
-            .read(StringResource.fromFile(proguardMap)));
+            .read(StringResource.fromFile(proguardMapFile)));
+  }
+
+  public CodeInspector(AndroidApp app, String proguardMapContent)
+      throws IOException, ExecutionException {
+    this(
+        new ApplicationReader(app, runOptionsConsumer(null), new Timing("CodeInspector"))
+            .read(StringResource.fromString(proguardMapContent, Origin.unknown())));
   }
 
   public CodeInspector(DexApplication application) {
diff --git a/tools/test.py b/tools/test.py
index cd9b70f..f1543f1 100755
--- a/tools/test.py
+++ b/tools/test.py
@@ -103,6 +103,9 @@
   if 'BUILDBOT_BUILDERNAME' in os.environ:
     gradle.RunGradle(['clean'])
 
+  # Build R8lib with dependencies for bootstrapping tests before adding test sources
+  gradle.RunGradle(['r8libwithdeps'])
+
   gradle_args = ['--stacktrace']
   # Set all necessary Gradle properties and options first.
   if options.verbose:
@@ -156,8 +159,6 @@
     gradle_args.append('-PHEAD_sha1=' + utils.get_HEAD_sha1())
   # Add Gradle tasks
   gradle_args.append('cleanTest')
-  # Build R8lib with dependencies for bootstrapping tests.
-  gradle_args.append('r8libWithDeps')
   gradle_args.append('test')
   # Test filtering. Must always follow the 'test' task.
   for testFilter in args: