Merge pull request #53 from brendandouglas/master

Import of bazel plugin using copybara
diff --git a/README.md b/README.md
index 3d0a727..c8dc1bf 100644
--- a/README.md
+++ b/README.md
@@ -25,4 +25,4 @@
 
 Install Bazel, then run 'bazel build //ijwb:ijwb_bazel --define=ij_product=intellij-latest'
 from the project root. This will create a plugin jar in
-'bazel-genfiles/ijwb/ijwb_bazel.jar'.
+'bazel-genfiles/ijwb/ijwb_bazel.jar'.
\ No newline at end of file
diff --git a/aswb/2.3/src/com/google/idea/blaze/android/project/BlazeBuildSystemService.java b/aswb/2.3/src/com/google/idea/blaze/android/project/BlazeBuildSystemService.java
index 36923d7..ac68a65 100644
--- a/aswb/2.3/src/com/google/idea/blaze/android/project/BlazeBuildSystemService.java
+++ b/aswb/2.3/src/com/google/idea/blaze/android/project/BlazeBuildSystemService.java
@@ -19,19 +19,17 @@
 import com.google.idea.blaze.android.sync.model.AndroidResourceModuleRegistry;
 import com.google.idea.blaze.base.actions.BlazeBuildService;
 import com.google.idea.blaze.base.ideinfo.TargetIdeInfo;
+import com.google.idea.blaze.base.io.VirtualFileSystemProvider;
 import com.google.idea.blaze.base.lang.buildfile.references.BuildReferenceManager;
 import com.google.idea.blaze.base.model.BlazeProjectData;
 import com.google.idea.blaze.base.settings.Blaze;
 import com.google.idea.blaze.base.sync.BlazeSyncManager;
 import com.google.idea.blaze.base.sync.data.BlazeProjectDataManager;
-import com.intellij.openapi.application.ApplicationManager;
 import com.intellij.openapi.fileEditor.FileEditorManager;
 import com.intellij.openapi.fileEditor.OpenFileDescriptor;
 import com.intellij.openapi.module.Module;
 import com.intellij.openapi.project.Project;
-import com.intellij.openapi.vfs.VfsUtil;
 import com.intellij.openapi.vfs.VirtualFile;
-import com.intellij.openapi.vfs.ex.temp.TempFileSystem;
 import com.intellij.psi.PsiElement;
 import java.io.File;
 
@@ -84,16 +82,11 @@
     } else {
       // If not, just the build file is good enough.
       File buildIoFile = blazeProjectData.artifactLocationDecoder.decode(targetIdeInfo.buildFile);
-      VirtualFile buildVirtualFile = findFileByIoFile(buildIoFile);
+      VirtualFile buildVirtualFile =
+          VirtualFileSystemProvider.findFileByIoFileRefreshIfNeeded(buildIoFile);
       if (buildVirtualFile != null) {
         fileEditorManager.openFile(buildVirtualFile, true);
       }
     }
   }
-
-  private static VirtualFile findFileByIoFile(File file) {
-    return ApplicationManager.getApplication().isUnitTestMode()
-        ? TempFileSystem.getInstance().findFileByIoFile(file)
-        : VfsUtil.findFileByIoFile(file, true);
-  }
 }
diff --git a/aswb/2.3/src/com/google/idea/blaze/android/sync/model/idea/BlazeClassJarProvider.java b/aswb/2.3/src/com/google/idea/blaze/android/sync/model/idea/BlazeClassJarProvider.java
index 15c2add..9641ced 100644
--- a/aswb/2.3/src/com/google/idea/blaze/android/sync/model/idea/BlazeClassJarProvider.java
+++ b/aswb/2.3/src/com/google/idea/blaze/android/sync/model/idea/BlazeClassJarProvider.java
@@ -27,6 +27,7 @@
 import com.google.idea.blaze.base.ideinfo.TargetIdeInfo;
 import com.google.idea.blaze.base.ideinfo.TargetKey;
 import com.google.idea.blaze.base.ideinfo.TargetMap;
+import com.google.idea.blaze.base.io.VirtualFileSystemProvider;
 import com.google.idea.blaze.base.model.BlazeProjectData;
 import com.google.idea.blaze.base.sync.data.BlazeProjectDataManager;
 import com.google.idea.blaze.base.sync.workspace.ArtifactLocationDecoder;
@@ -89,7 +90,8 @@
         continue;
       }
       File classJarFile = decoder.decode(jar.classJar);
-      VirtualFile classJarVF = findFileByIoFile(classJarFile);
+      VirtualFile classJarVF =
+          VirtualFileSystemProvider.getInstance().getSystem().findFileByIoFile(classJarFile);
       if (classJarVF == null) {
         if (classJarFile.exists()) {
           missingClassJars.add(classJarFile);
@@ -161,7 +163,8 @@
           // TODO: benchmark to see if optimization is worthwhile.
           if (jar.classJar != null) {
             File classJarFile = decoder.decode(jar.classJar);
-            VirtualFile classJar = findFileByIoFile(classJarFile);
+            VirtualFile classJar =
+                VirtualFileSystemProvider.getInstance().getSystem().findFileByIoFile(classJarFile);
             if (classJar != null) {
               results.add(classJar);
             } else if (classJarFile.exists()) {
@@ -192,12 +195,6 @@
     return results;
   }
 
-  private static VirtualFile findFileByIoFile(File file) {
-    return ApplicationManager.getApplication().isUnitTestMode()
-        ? TempFileSystem.getInstance().findFileByIoFile(file)
-        : LocalFileSystem.getInstance().findFileByIoFile(file);
-  }
-
   private static void maybeRefreshJars(Collection<File> missingJars, AtomicBoolean pendingRefresh) {
     // We probably need to refresh the virtual file system to find these files, but we can't refresh
     // here because we're in a read action. We also can't use the async refreshIoFiles since it
diff --git a/aswb/2.3/tests/unittests/com/google/idea/blaze/android/project/BlazeBuildSystemServiceTest.java b/aswb/2.3/tests/unittests/com/google/idea/blaze/android/project/BlazeBuildSystemServiceTest.java
index 9075d66..fbd398d 100755
--- a/aswb/2.3/tests/unittests/com/google/idea/blaze/android/project/BlazeBuildSystemServiceTest.java
+++ b/aswb/2.3/tests/unittests/com/google/idea/blaze/android/project/BlazeBuildSystemServiceTest.java
@@ -33,6 +33,7 @@
 import com.google.idea.blaze.base.ideinfo.TargetKey;
 import com.google.idea.blaze.base.ideinfo.TargetMap;
 import com.google.idea.blaze.base.ideinfo.TargetMapBuilder;
+import com.google.idea.blaze.base.io.VirtualFileSystemProvider;
 import com.google.idea.blaze.base.lang.buildfile.references.BuildReferenceManager;
 import com.google.idea.blaze.base.model.BlazeProjectData;
 import com.google.idea.blaze.base.model.MockBlazeProjectDataBuilder;
@@ -58,6 +59,7 @@
 import com.intellij.openapi.fileEditor.FileEditorManager;
 import com.intellij.openapi.fileEditor.OpenFileDescriptor;
 import com.intellij.openapi.module.Module;
+import com.intellij.openapi.vfs.LocalFileSystem;
 import com.intellij.openapi.vfs.VirtualFile;
 import com.intellij.openapi.vfs.ex.temp.TempFileSystem;
 import com.intellij.psi.PsiElement;
@@ -124,7 +126,8 @@
     when(buildTargetPsi.getContainingFile()).thenReturn(psiFile);
     when(buildTargetPsi.getTextOffset()).thenReturn(1337);
 
-    VirtualFile buildFile = TempFileSystem.getInstance().findFileByPath("/foo/BUILD");
+    VirtualFile buildFile =
+        VirtualFileSystemProvider.getInstance().getSystem().findFileByPath("/foo/BUILD");
     assertThat(buildFile).isNotNull();
     when(psiFile.getVirtualFile()).thenReturn(buildFile);
 
@@ -149,7 +152,8 @@
     when(BuildReferenceManager.getInstance(project).resolveLabel(new Label("//foo:bar")))
         .thenReturn(null);
 
-    VirtualFile buildFile = TempFileSystem.getInstance().findFileByPath("/foo/BUILD");
+    VirtualFile buildFile =
+        VirtualFileSystemProvider.getInstance().getSystem().findFileByPath("/foo/BUILD");
     assertThat(buildFile).isNotNull();
 
     String dependency = "com.android.foo:bar"; // Doesn't matter.
@@ -185,7 +189,8 @@
     projectServices.register(BuildReferenceManager.class, mock(BuildReferenceManager.class));
     projectServices.register(LazyRangeMarkerFactory.class, mock(LazyRangeMarkerFactoryImpl.class));
 
-    applicationServices.register(TempFileSystem.class, new MockFileSystem("/foo/BUILD"));
+    applicationServices.register(
+        VirtualFileSystemProvider.class, new MockVirtualFileSystemProvider("/foo/BUILD"));
 
     AndroidResourceModuleRegistry moduleRegistry = new AndroidResourceModuleRegistry();
     moduleRegistry.put(
@@ -251,4 +256,18 @@
       return findFileByPath(file.getPath());
     }
   }
+
+  private static class MockVirtualFileSystemProvider implements VirtualFileSystemProvider {
+
+    private final LocalFileSystem fileSystem;
+
+    MockVirtualFileSystemProvider(String... paths) {
+      fileSystem = new MockFileSystem(paths);
+    }
+
+    @Override
+    public LocalFileSystem getSystem() {
+      return fileSystem;
+    }
+  }
 }
diff --git a/aswb/src/META-INF/aswb.xml b/aswb/src/META-INF/aswb.xml
index 48f701d..d59d935 100644
--- a/aswb/src/META-INF/aswb.xml
+++ b/aswb/src/META-INF/aswb.xml
@@ -62,6 +62,7 @@
     <BlazeCommandRunConfigurationHandlerProvider implementation="com.google.idea.blaze.android.run.binary.BlazeAndroidBinaryRunConfigurationHandlerProvider"/>
     <BlazeCommandRunConfigurationHandlerProvider implementation="com.google.idea.blaze.android.run.test.BlazeAndroidTestRunConfigurationHandlerProvider"/>
     <BuildSystemAndroidJdkProvider implementation="com.google.idea.blaze.android.sync.BazelAndroidJdkProvider"/>
+    <BlazeTestEventsHandler implementation="com.google.idea.blaze.android.run.test.smrunner.BlazeAndroidTestEventsHandler"/>
   </extensions>
 
   <extensions defaultExtensionNs="com.android.ide">
diff --git a/aswb/src/com/google/idea/blaze/android/projectview/AndroidMinSdkSection.java b/aswb/src/com/google/idea/blaze/android/projectview/AndroidMinSdkSection.java
new file mode 100644
index 0000000..84b5ece
--- /dev/null
+++ b/aswb/src/com/google/idea/blaze/android/projectview/AndroidMinSdkSection.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.android.projectview;
+
+import com.google.common.primitives.Ints;
+import com.google.idea.blaze.base.projectview.parser.ParseContext;
+import com.google.idea.blaze.base.projectview.parser.ProjectViewParser;
+import com.google.idea.blaze.base.projectview.section.ScalarSection;
+import com.google.idea.blaze.base.projectview.section.ScalarSectionParser;
+import com.google.idea.blaze.base.projectview.section.SectionKey;
+import com.google.idea.blaze.base.projectview.section.SectionParser;
+import org.jetbrains.annotations.Nullable;
+
+/** Allows project wide min sdk. */
+public class AndroidMinSdkSection {
+  public static final SectionKey<Integer, ScalarSection<Integer>> KEY =
+      SectionKey.of("android_min_sdk");
+  public static final SectionParser PARSER = new AndroidMinSdkParser();
+
+  private static class AndroidMinSdkParser extends ScalarSectionParser<Integer> {
+    AndroidMinSdkParser() {
+      super(KEY, ':');
+    }
+
+    @Nullable
+    @Override
+    protected Integer parseItem(ProjectViewParser parser, ParseContext parseContext, String rest) {
+      return Ints.tryParse(rest);
+    }
+
+    @Override
+    protected void printItem(StringBuilder sb, Integer value) {
+      sb.append(value);
+    }
+
+    @Override
+    public ItemType getItemType() {
+      return ItemType.Other;
+    }
+  }
+}
diff --git a/aswb/src/com/google/idea/blaze/android/projectview/AndroidSdkPlatformSection.java b/aswb/src/com/google/idea/blaze/android/projectview/AndroidSdkPlatformSection.java
index 0c14f79..fe75735 100644
--- a/aswb/src/com/google/idea/blaze/android/projectview/AndroidSdkPlatformSection.java
+++ b/aswb/src/com/google/idea/blaze/android/projectview/AndroidSdkPlatformSection.java
@@ -15,6 +15,9 @@
  */
 package com.google.idea.blaze.android.projectview;
 
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableList;
 import com.google.idea.blaze.android.compatibility.Compatibility.AndroidSdkUtils;
 import com.google.idea.blaze.android.sync.sdk.AndroidSdkFromProjectView;
 import com.google.idea.blaze.base.projectview.ProjectView;
@@ -28,7 +31,7 @@
 import com.google.idea.blaze.base.projectview.section.sections.TextBlockSection;
 import com.intellij.openapi.projectRoots.Sdk;
 import com.intellij.openapi.util.text.StringUtil;
-import java.util.Collection;
+import java.util.List;
 import org.jetbrains.annotations.Nullable;
 
 /** Allows manual override of the android sdk. */
@@ -63,19 +66,34 @@
       if (!projectView.getSectionsOfType(KEY).isEmpty()) {
         return projectView;
       }
-      Collection<Sdk> sdks = AndroidSdkUtils.getAllAndroidSdks();
-      return ProjectView.builder(projectView)
-          .add(TextBlockSection.of(TextBlock.newLine()))
-          .add(TextBlockSection.of(TextBlock.of("# Please set to an android SDK platform")))
-          .add(
-              TextBlockSection.of(
-                  TextBlock.of(
-                      sdks.isEmpty()
-                          ? "# You currently have no SDKs. Please use the SDK manager first."
-                          : "# Available SDKs are: "
-                              + AndroidSdkFromProjectView.getAvailableSdkPlatforms(sdks))))
-          .add(ScalarSection.builder(KEY).set("<android sdk platform>"))
-          .build();
+      List<Sdk> sdks = AndroidSdkUtils.getAllAndroidSdks();
+      ProjectView.Builder builder =
+          ProjectView.builder(projectView).add(TextBlockSection.of(TextBlock.newLine()));
+
+      if (sdks.isEmpty()) {
+        builder
+            .add(TextBlockSection.of(TextBlock.of("# Please set to an android SDK platform")))
+            .add(
+                TextBlockSection.of(
+                    TextBlock.of(
+                        "# You currently have no SDKs. Please use the SDK manager first.")))
+            .add(ScalarSection.builder(KEY).set("(android sdk goes here)"));
+      } else if (sdks.size() == 1) {
+        builder.add(
+            ScalarSection.builder(KEY)
+                .set(AndroidSdkFromProjectView.getSdkTargetHash(sdks.get(0))));
+      } else {
+        builder.add(
+            TextBlockSection.of(
+                TextBlock.of("# Please uncomment an android-SDK platform. Available SDKs are:")));
+        List<String> sdkOptions =
+            AndroidSdkFromProjectView.getAvailableSdkTargetHashes(sdks)
+                .stream()
+                .map(androidSdk -> "# android_sdk_platform: " + androidSdk)
+                .collect(toList());
+        builder.add(TextBlockSection.of(new TextBlock(ImmutableList.copyOf(sdkOptions))));
+      }
+      return builder.build();
     }
   }
 }
diff --git a/aswb/src/com/google/idea/blaze/android/run/runner/BlazeAndroidRunConfigurationRunner.java b/aswb/src/com/google/idea/blaze/android/run/runner/BlazeAndroidRunConfigurationRunner.java
index 6d8feb4..bf56acb 100644
--- a/aswb/src/com/google/idea/blaze/android/run/runner/BlazeAndroidRunConfigurationRunner.java
+++ b/aswb/src/com/google/idea/blaze/android/run/runner/BlazeAndroidRunConfigurationRunner.java
@@ -34,7 +34,6 @@
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.idea.blaze.base.experiments.ExperimentScope;
-import com.google.idea.blaze.base.metrics.Action;
 import com.google.idea.blaze.base.run.confighandler.BlazeCommandRunConfigurationRunner;
 import com.google.idea.blaze.base.run.smrunner.SmRunnerUtils;
 import com.google.idea.blaze.base.scope.Scope;
@@ -42,7 +41,6 @@
 import com.google.idea.blaze.base.scope.scopes.BlazeConsoleScope;
 import com.google.idea.blaze.base.scope.scopes.IdeaLogScope;
 import com.google.idea.blaze.base.scope.scopes.IssuesScope;
-import com.google.idea.blaze.base.scope.scopes.LoggedTimingScope;
 import com.google.idea.blaze.base.settings.BlazeUserSettings;
 import com.intellij.execution.DefaultExecutionResult;
 import com.intellij.execution.ExecutionException;
@@ -202,8 +200,7 @@
                   new BlazeConsoleScope.Builder(project)
                       .setSuppressConsole(suppressConsole)
                       .build())
-              .push(new IdeaLogScope())
-              .push(new LoggedTimingScope(project, Action.APK_BUILD_AND_INSTALL));
+              .push(new IdeaLogScope());
 
           BlazeAndroidRunContext runContext = env.getCopyableUserData(RUN_CONTEXT_KEY);
           if (runContext == null) {
diff --git a/aswb/src/com/google/idea/blaze/android/run/runner/BlazeApkBuildStepNormalBuild.java b/aswb/src/com/google/idea/blaze/android/run/runner/BlazeApkBuildStepNormalBuild.java
index 9c18824..835aa1d 100644
--- a/aswb/src/com/google/idea/blaze/android/run/runner/BlazeApkBuildStepNormalBuild.java
+++ b/aswb/src/com/google/idea/blaze/android/run/runner/BlazeApkBuildStepNormalBuild.java
@@ -29,13 +29,11 @@
 import com.google.idea.blaze.base.command.BlazeFlags;
 import com.google.idea.blaze.base.filecache.FileCaches;
 import com.google.idea.blaze.base.issueparser.IssueOutputLineProcessor;
-import com.google.idea.blaze.base.metrics.Action;
 import com.google.idea.blaze.base.model.primitives.Label;
 import com.google.idea.blaze.base.model.primitives.WorkspaceRoot;
 import com.google.idea.blaze.base.scope.BlazeContext;
 import com.google.idea.blaze.base.scope.ScopedTask;
 import com.google.idea.blaze.base.scope.output.IssueOutput;
-import com.google.idea.blaze.base.scope.scopes.LoggedTimingScope;
 import com.google.idea.blaze.base.settings.Blaze;
 import com.google.idea.blaze.base.util.SaveUtil;
 import com.intellij.execution.ExecutionException;
@@ -87,7 +85,7 @@
                             deployInfoHelper.getLineProcessor(),
                             new IssueOutputLineProcessor(project, context, workspaceRoot)))
                     .build()
-                    .run(new LoggedTimingScope(project, Action.BLAZE_BUILD));
+                    .run();
             FileCaches.refresh(project);
 
             if (retVal != 0) {
diff --git a/aswb/src/com/google/idea/blaze/android/run/test/AndroidTestConsoleProvider.java b/aswb/src/com/google/idea/blaze/android/run/test/AndroidTestConsoleProvider.java
index d91419a..c0cbb24 100644
--- a/aswb/src/com/google/idea/blaze/android/run/test/AndroidTestConsoleProvider.java
+++ b/aswb/src/com/google/idea/blaze/android/run/test/AndroidTestConsoleProvider.java
@@ -17,7 +17,7 @@
 
 import com.android.tools.idea.run.ConsoleProvider;
 import com.google.idea.blaze.android.compatibility.Compatibility.AndroidTestConsoleProperties;
-import com.google.idea.blaze.android.run.test.smrunner.BlazeAndroidTestEventsHandler;
+import com.google.idea.blaze.base.run.smrunner.BlazeTestEventsHandler;
 import com.google.idea.blaze.base.run.smrunner.SmRunnerUtils;
 import com.intellij.execution.ExecutionException;
 import com.intellij.execution.Executor;
@@ -37,13 +37,13 @@
   private final Project project;
   private final RunConfiguration runConfiguration;
   private final BlazeAndroidTestRunConfigurationState configState;
-  @Nullable private final BlazeAndroidTestEventsHandler testEventsHandler;
+  @Nullable private final BlazeTestEventsHandler testEventsHandler;
 
   AndroidTestConsoleProvider(
       Project project,
       RunConfiguration runConfiguration,
       BlazeAndroidTestRunConfigurationState configState,
-      @Nullable BlazeAndroidTestEventsHandler testEventsHandler) {
+      @Nullable BlazeTestEventsHandler testEventsHandler) {
     this.project = project;
     this.runConfiguration = runConfiguration;
     this.configState = configState;
diff --git a/aswb/src/com/google/idea/blaze/android/run/test/BlazeAndroidTestClassRunConfigurationProducer.java b/aswb/src/com/google/idea/blaze/android/run/test/BlazeAndroidTestClassRunConfigurationProducer.java
index 8d08ec0..a9b9515 100644
--- a/aswb/src/com/google/idea/blaze/android/run/test/BlazeAndroidTestClassRunConfigurationProducer.java
+++ b/aswb/src/com/google/idea/blaze/android/run/test/BlazeAndroidTestClassRunConfigurationProducer.java
@@ -22,6 +22,7 @@
 import com.google.idea.blaze.base.run.BlazeCommandRunConfiguration;
 import com.google.idea.blaze.base.run.BlazeCommandRunConfigurationType;
 import com.google.idea.blaze.base.run.producers.BlazeRunConfigurationProducer;
+import com.google.idea.blaze.base.run.smrunner.SmRunnerUtils;
 import com.google.idea.blaze.java.run.RunUtil;
 import com.google.idea.blaze.java.run.producers.JUnitConfigurationUtil;
 import com.google.idea.blaze.java.run.producers.ProducerUtils;
@@ -33,7 +34,6 @@
 import com.intellij.psi.PsiClass;
 import com.intellij.psi.PsiElement;
 import com.intellij.psi.PsiMethod;
-import org.jetbrains.annotations.NotNull;
 
 /**
  * Producer for run configurations related to Android test classes in Blaze.
@@ -50,9 +50,9 @@
 
   @Override
   protected boolean doSetupConfigFromContext(
-      @NotNull BlazeCommandRunConfiguration configuration,
-      @NotNull ConfigurationContext context,
-      @NotNull Ref<PsiElement> sourceElement) {
+      BlazeCommandRunConfiguration configuration,
+      ConfigurationContext context,
+      Ref<PsiElement> sourceElement) {
 
     final Location contextLocation = context.getLocation();
     assert contextLocation != null;
@@ -61,6 +61,10 @@
       return false;
     }
 
+    if (!SmRunnerUtils.getSelectedSmRunnerTreeElements(context).isEmpty()) {
+      // handled by a different producer
+      return false;
+    }
     if (JUnitConfigurationUtil.isMultipleElementsSelected(context)) {
       return false;
     }
@@ -93,7 +97,7 @@
 
   @Override
   protected boolean doIsConfigFromContext(
-      @NotNull BlazeCommandRunConfiguration configuration, @NotNull ConfigurationContext context) {
+      BlazeCommandRunConfiguration configuration, ConfigurationContext context) {
 
     final Location contextLocation = context.getLocation();
     assert contextLocation != null;
@@ -102,6 +106,10 @@
       return false;
     }
 
+    if (!SmRunnerUtils.getSelectedSmRunnerTreeElements(context).isEmpty()) {
+      // handled by a different producer
+      return false;
+    }
     if (JUnitConfigurationUtil.isMultipleElementsSelected(context)) {
       return false;
     }
diff --git a/aswb/src/com/google/idea/blaze/android/run/test/BlazeAndroidTestMethodRunConfigurationProducer.java b/aswb/src/com/google/idea/blaze/android/run/test/BlazeAndroidTestMethodRunConfigurationProducer.java
index e37f07f..a0d4878 100644
--- a/aswb/src/com/google/idea/blaze/android/run/test/BlazeAndroidTestMethodRunConfigurationProducer.java
+++ b/aswb/src/com/google/idea/blaze/android/run/test/BlazeAndroidTestMethodRunConfigurationProducer.java
@@ -22,6 +22,7 @@
 import com.google.idea.blaze.base.run.BlazeCommandRunConfiguration;
 import com.google.idea.blaze.base.run.BlazeCommandRunConfigurationType;
 import com.google.idea.blaze.base.run.producers.BlazeRunConfigurationProducer;
+import com.google.idea.blaze.base.run.smrunner.SmRunnerUtils;
 import com.google.idea.blaze.java.run.RunUtil;
 import com.google.idea.blaze.java.run.producers.JUnitConfigurationUtil;
 import com.google.idea.blaze.java.run.producers.ProducerUtils;
@@ -31,7 +32,6 @@
 import com.intellij.psi.PsiClass;
 import com.intellij.psi.PsiElement;
 import com.intellij.psi.PsiMethod;
-import org.jetbrains.annotations.NotNull;
 
 /**
  * Producer for run configurations related to Android test methods in Blaze.
@@ -48,10 +48,13 @@
 
   @Override
   protected boolean doSetupConfigFromContext(
-      @NotNull BlazeCommandRunConfiguration configuration,
-      @NotNull ConfigurationContext context,
-      @NotNull Ref<PsiElement> sourceElement) {
-
+      BlazeCommandRunConfiguration configuration,
+      ConfigurationContext context,
+      Ref<PsiElement> sourceElement) {
+    if (!SmRunnerUtils.getSelectedSmRunnerTreeElements(context).isEmpty()) {
+      // handled by a different producer
+      return false;
+    }
     if (JUnitConfigurationUtil.isMultipleElementsSelected(context)) {
       return false;
     }
@@ -94,8 +97,11 @@
 
   @Override
   protected boolean doIsConfigFromContext(
-      @NotNull BlazeCommandRunConfiguration configuration, @NotNull ConfigurationContext context) {
-
+      BlazeCommandRunConfiguration configuration, ConfigurationContext context) {
+    if (!SmRunnerUtils.getSelectedSmRunnerTreeElements(context).isEmpty()) {
+      // handled by a different producer
+      return false;
+    }
     if (JUnitConfigurationUtil.isMultipleElementsSelected(context)) {
       return false;
     }
diff --git a/aswb/src/com/google/idea/blaze/android/run/test/BlazeAndroidTestRunContext.java b/aswb/src/com/google/idea/blaze/android/run/test/BlazeAndroidTestRunContext.java
index cf418e9..7f5fb95 100644
--- a/aswb/src/com/google/idea/blaze/android/run/test/BlazeAndroidTestRunContext.java
+++ b/aswb/src/com/google/idea/blaze/android/run/test/BlazeAndroidTestRunContext.java
@@ -42,12 +42,12 @@
 import com.google.idea.blaze.android.run.runner.BlazeAndroidRunContext;
 import com.google.idea.blaze.android.run.runner.BlazeApkBuildStep;
 import com.google.idea.blaze.android.run.runner.BlazeApkBuildStepNormalBuild;
-import com.google.idea.blaze.android.run.test.smrunner.BlazeAndroidTestEventsHandler;
 import com.google.idea.blaze.base.model.primitives.Label;
+import com.google.idea.blaze.base.run.BlazeCommandRunConfiguration;
+import com.google.idea.blaze.base.run.smrunner.BlazeTestEventsHandler;
 import com.google.idea.common.experiments.BoolExperiment;
 import com.intellij.execution.ExecutionException;
 import com.intellij.execution.Executor;
-import com.intellij.execution.configurations.RunConfiguration;
 import com.intellij.execution.executors.DefaultDebugExecutor;
 import com.intellij.execution.runners.ExecutionEnvironment;
 import com.intellij.openapi.project.Project;
@@ -61,12 +61,12 @@
 /** Run context for android_test. */
 class BlazeAndroidTestRunContext implements BlazeAndroidRunContext {
 
-  static final BoolExperiment smRunnerUiEnabled =
+  private static final BoolExperiment smRunnerUiEnabled =
       new BoolExperiment("use.smrunner.ui.android", true);
 
   private final Project project;
   private final AndroidFacet facet;
-  private final RunConfiguration runConfiguration;
+  private final BlazeCommandRunConfiguration runConfiguration;
   private final ExecutionEnvironment env;
   private final BlazeAndroidTestRunConfigurationState configState;
   private final Label label;
@@ -80,7 +80,7 @@
   public BlazeAndroidTestRunContext(
       Project project,
       AndroidFacet facet,
-      RunConfiguration runConfiguration,
+      BlazeCommandRunConfiguration runConfiguration,
       ExecutionEnvironment env,
       BlazeAndroidTestRunConfigurationState configState,
       Label label,
@@ -96,12 +96,14 @@
         new BlazeAndroidTestApplicationIdProvider(project, buildStep.getDeployInfo());
     this.apkProvider = new BlazeApkProvider(project, buildStep.getDeployInfo());
 
-    BlazeAndroidTestEventsHandler testEventsHandler = null;
+    BlazeTestEventsHandler testEventsHandler = null;
     if (smRunnerUiEnabled.getValue() && !isDebugging(env.getExecutor())) {
-      testEventsHandler = new BlazeAndroidTestEventsHandler();
+      testEventsHandler =
+          BlazeTestEventsHandler.getHandlerForTarget(project, runConfiguration.getTarget());
+      assert (testEventsHandler != null);
       this.buildFlags =
           ImmutableList.<String>builder()
-              .addAll(testEventsHandler.getBlazeFlags())
+              .addAll(BlazeTestEventsHandler.getBlazeFlags(project))
               .addAll(buildFlags)
               .build();
     } else {
diff --git a/aswb/src/com/google/idea/blaze/android/run/test/smrunner/BlazeAndroidTestEventsHandler.java b/aswb/src/com/google/idea/blaze/android/run/test/smrunner/BlazeAndroidTestEventsHandler.java
index f37359a..947a070 100644
--- a/aswb/src/com/google/idea/blaze/android/run/test/smrunner/BlazeAndroidTestEventsHandler.java
+++ b/aswb/src/com/google/idea/blaze/android/run/test/smrunner/BlazeAndroidTestEventsHandler.java
@@ -16,29 +16,33 @@
 package com.google.idea.blaze.android.run.test.smrunner;
 
 import com.google.idea.blaze.base.command.BlazeFlags;
+import com.google.idea.blaze.base.model.primitives.Kind;
 import com.google.idea.blaze.base.run.smrunner.BlazeTestEventsHandler;
 import com.google.idea.blaze.base.run.smrunner.SmRunnerUtils;
 import com.google.idea.blaze.java.run.producers.BlazeJUnitTestFilterFlags;
 import com.google.idea.blaze.java.run.producers.BlazeJUnitTestFilterFlags.JUnitVersion;
 import com.intellij.execution.Location;
-import com.intellij.execution.testframework.AbstractTestProxy;
 import com.intellij.execution.testframework.sm.runner.SMTestLocator;
 import com.intellij.openapi.project.Project;
 import com.intellij.openapi.util.text.StringUtil;
 import com.intellij.psi.PsiClass;
 import com.intellij.psi.PsiElement;
 import com.intellij.psi.PsiMethod;
-import com.intellij.psi.search.GlobalSearchScope;
-import com.intellij.util.containers.MultiMap;
 import com.intellij.util.io.URLUtil;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import javax.annotation.Nullable;
 
 /** Provides java-specific methods needed by the SM-runner test UI. */
 public class BlazeAndroidTestEventsHandler extends BlazeTestEventsHandler {
 
-  public BlazeAndroidTestEventsHandler() {
-    super("Blaze Android Test");
+  @Override
+  protected EnumSet<Kind> handledKinds() {
+    return EnumSet.of(Kind.ANDROID_TEST);
   }
 
   @Override
@@ -47,12 +51,13 @@
   }
 
   @Override
-  public String suiteLocationUrl(String name) {
+  public String suiteLocationUrl(@Nullable Kind kind, String name) {
     return SmRunnerUtils.GENERIC_SUITE_PROTOCOL + URLUtil.SCHEME_SEPARATOR + name;
   }
 
   @Override
-  public String testLocationUrl(String name, @Nullable String className) {
+  public String testLocationUrl(
+      @Nullable Kind kind, String parentSuite, String name, @Nullable String className) {
     // ignore initial value of className -- it's the test runner class.
     name = StringUtil.trimTrailing(name, '-');
     if (!name.contains("-")) {
@@ -69,7 +74,7 @@
   }
 
   @Override
-  public String testDisplayName(String rawName) {
+  public String testDisplayName(@Nullable Kind kind, String rawName) {
     String name = StringUtil.trimTrailing(rawName, '-');
     if (name.contains("-")) {
       int ix = name.lastIndexOf('-');
@@ -80,31 +85,31 @@
 
   @Nullable
   @Override
-  public String getTestFilter(Project project, List<AbstractTestProxy> failedTests) {
-    GlobalSearchScope projectScope = GlobalSearchScope.allScope(project);
-    MultiMap<PsiClass, PsiMethod> failedMethodsPerClass = new MultiMap<>();
-    for (AbstractTestProxy test : failedTests) {
-      appendTest(failedMethodsPerClass, test.getLocation(project, projectScope));
+  public String getTestFilter(Project project, List<Location<?>> testLocations) {
+    Map<PsiClass, Collection<Location<?>>> failedClassesAndMethods = new HashMap<>();
+    for (Location<?> location : testLocations) {
+      appendTest(failedClassesAndMethods, location);
     }
     // the android test runner always runs with JUnit4
     String filter =
         BlazeJUnitTestFilterFlags.testFilterForClassesAndMethods(
-            failedMethodsPerClass, JUnitVersion.JUNIT_4);
+            failedClassesAndMethods, JUnitVersion.JUNIT_4);
     return filter != null ? BlazeFlags.TEST_FILTER + "=" + filter : null;
   }
 
-  private void appendTest(
-      MultiMap<PsiClass, PsiMethod> testMap, @Nullable Location<?> testLocation) {
-    if (testLocation == null) {
+  private static void appendTest(Map<PsiClass, Collection<Location<?>>> map, Location<?> location) {
+    PsiElement psi = location.getPsiElement();
+    if (psi instanceof PsiClass) {
+      map.computeIfAbsent((PsiClass) psi, k -> new HashSet<>());
       return;
     }
-    PsiElement method = testLocation.getPsiElement();
-    if (!(method instanceof PsiMethod)) {
+    if (!(psi instanceof PsiMethod)) {
       return;
     }
-    PsiClass psiClass = ((PsiMethod) method).getContainingClass();
-    if (psiClass != null) {
-      testMap.putValue(psiClass, (PsiMethod) method);
+    PsiClass psiClass = ((PsiMethod) psi).getContainingClass();
+    if (psiClass == null) {
+      return;
     }
+    map.computeIfAbsent(psiClass, k -> new HashSet<>()).add(location);
   }
 }
diff --git a/aswb/src/com/google/idea/blaze/android/sync/BlazeAndroidSyncPlugin.java b/aswb/src/com/google/idea/blaze/android/sync/BlazeAndroidSyncPlugin.java
index 74d41e7..bee4d9a 100644
--- a/aswb/src/com/google/idea/blaze/android/sync/BlazeAndroidSyncPlugin.java
+++ b/aswb/src/com/google/idea/blaze/android/sync/BlazeAndroidSyncPlugin.java
@@ -20,6 +20,7 @@
 import com.google.idea.blaze.android.compatibility.Compatibility.AndroidSdkUtils;
 import com.google.idea.blaze.android.compatibility.Compatibility.IdeSdks;
 import com.google.idea.blaze.android.cppapi.NdkSupport;
+import com.google.idea.blaze.android.projectview.AndroidMinSdkSection;
 import com.google.idea.blaze.android.projectview.AndroidSdkPlatformSection;
 import com.google.idea.blaze.android.projectview.GeneratedAndroidResourcesSection;
 import com.google.idea.blaze.android.sync.importer.BlazeAndroidWorkspaceImporter;
@@ -298,7 +299,9 @@
   @Override
   public Collection<SectionParser> getSections() {
     return ImmutableList.of(
-        AndroidSdkPlatformSection.PARSER, GeneratedAndroidResourcesSection.PARSER);
+        AndroidMinSdkSection.PARSER,
+        AndroidSdkPlatformSection.PARSER,
+        GeneratedAndroidResourcesSection.PARSER);
   }
 
   @Nullable
diff --git a/aswb/src/com/google/idea/blaze/android/sync/model/AndroidSdkPlatform.java b/aswb/src/com/google/idea/blaze/android/sync/model/AndroidSdkPlatform.java
index 644d749..5aed572 100644
--- a/aswb/src/com/google/idea/blaze/android/sync/model/AndroidSdkPlatform.java
+++ b/aswb/src/com/google/idea/blaze/android/sync/model/AndroidSdkPlatform.java
@@ -22,10 +22,10 @@
 @Immutable
 public class AndroidSdkPlatform implements Serializable {
   public final String androidSdk;
-  public final int androidSdkLevel;
+  public final int androidMinSdkLevel;
 
-  public AndroidSdkPlatform(String androidSdk, int androidSdkLevel) {
+  public AndroidSdkPlatform(String androidSdk, int androidMinSdkLevel) {
     this.androidSdk = androidSdk;
-    this.androidSdkLevel = androidSdkLevel;
+    this.androidMinSdkLevel = androidMinSdkLevel;
   }
 }
diff --git a/aswb/src/com/google/idea/blaze/android/sync/model/idea/BlazeAndroidModel.java b/aswb/src/com/google/idea/blaze/android/sync/model/idea/BlazeAndroidModel.java
index 8e19be7..b89365f 100644
--- a/aswb/src/com/google/idea/blaze/android/sync/model/idea/BlazeAndroidModel.java
+++ b/aswb/src/com/google/idea/blaze/android/sync/model/idea/BlazeAndroidModel.java
@@ -45,7 +45,7 @@
   private final List<SourceProvider> sourceProviders; // Singleton list of sourceProvider
   private final File moduleManifest;
   private final String resourceJavaPackage;
-  private final int androidSdkApiLevel;
+  private final int minSdkVersion;
 
   /** Creates a new {@link BlazeAndroidModel}. */
   public BlazeAndroidModel(
@@ -55,14 +55,14 @@
       SourceProvider sourceProvider,
       File moduleManifest,
       String resourceJavaPackage,
-      int androidSdkApiLevel) {
+      int minSdkVersion) {
     this.project = project;
     this.rootDirPath = rootDirPath;
     this.sourceProvider = sourceProvider;
     this.sourceProviders = ImmutableList.of(sourceProvider);
     this.moduleManifest = moduleManifest;
     this.resourceJavaPackage = resourceJavaPackage;
-    this.androidSdkApiLevel = androidSdkApiLevel;
+    this.minSdkVersion = minSdkVersion;
   }
 
   @Override
@@ -122,7 +122,7 @@
   @Override
   @Nullable
   public AndroidVersion getMinSdkVersion() {
-    return new AndroidVersion(androidSdkApiLevel, null);
+    return new AndroidVersion(minSdkVersion, null);
   }
 
   @Nullable
diff --git a/aswb/src/com/google/idea/blaze/android/sync/projectstructure/BlazeAndroidProjectStructureSyncer.java b/aswb/src/com/google/idea/blaze/android/sync/projectstructure/BlazeAndroidProjectStructureSyncer.java
index 1b55ff1..66b020f 100644
--- a/aswb/src/com/google/idea/blaze/android/sync/projectstructure/BlazeAndroidProjectStructureSyncer.java
+++ b/aswb/src/com/google/idea/blaze/android/sync/projectstructure/BlazeAndroidProjectStructureSyncer.java
@@ -415,7 +415,7 @@
             sourceProvider,
             manifest,
             resourceJavaPackage,
-            androidSdkPlatform.androidSdkLevel);
+            androidSdkPlatform.androidMinSdkLevel);
     AndroidFacet facet = AndroidFacet.getInstance(module);
     if (facet != null) {
       facet.setAndroidModel(androidModel);
diff --git a/aswb/src/com/google/idea/blaze/android/sync/sdk/AndroidSdkFromProjectView.java b/aswb/src/com/google/idea/blaze/android/sync/sdk/AndroidSdkFromProjectView.java
index e4ae1b5..b47e238 100644
--- a/aswb/src/com/google/idea/blaze/android/sync/sdk/AndroidSdkFromProjectView.java
+++ b/aswb/src/com/google/idea/blaze/android/sync/sdk/AndroidSdkFromProjectView.java
@@ -17,16 +17,20 @@
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
 import com.google.idea.blaze.android.compatibility.Compatibility.AndroidSdkUtils;
+import com.google.idea.blaze.android.projectview.AndroidMinSdkSection;
 import com.google.idea.blaze.android.projectview.AndroidSdkPlatformSection;
 import com.google.idea.blaze.android.sync.model.AndroidSdkPlatform;
 import com.google.idea.blaze.base.projectview.ProjectViewSet;
+import com.google.idea.blaze.base.projectview.ProjectViewSet.ProjectViewFile;
 import com.google.idea.blaze.base.scope.BlazeContext;
 import com.google.idea.blaze.base.scope.output.IssueOutput;
 import com.intellij.openapi.projectRoots.Sdk;
 import com.intellij.pom.Navigatable;
 import java.util.Collection;
 import java.util.List;
+import java.util.Set;
 import javax.annotation.Nullable;
 import org.jetbrains.android.sdk.AndroidPlatform;
 import org.jetbrains.android.sdk.AndroidSdkAdditionalData;
@@ -59,52 +63,71 @@
           .submit(context);
       return null;
     }
-    String androidSdk = null;
-    if (projectViewSet != null) {
-      androidSdk = projectViewSet.getScalarValue(AndroidSdkPlatformSection.KEY);
+    if (projectViewSet == null) {
+      return null;
     }
 
+    String androidSdk = projectViewSet.getScalarValue(AndroidSdkPlatformSection.KEY);
+    Integer androidMinSdk = projectViewSet.getScalarValue(AndroidMinSdkSection.KEY);
+
     if (androidSdk == null) {
+      ProjectViewFile projectViewFile = projectViewSet.getTopLevelProjectViewFile();
       IssueOutput.error(
               ("No android_sdk_platform set. Please set to an android platform. "
                   + "Available android_sdk_platforms are: "
-                  + getAvailableSdkPlatforms(sdks)))
-          .inFile(projectViewSet.getTopLevelProjectViewFile().projectViewFile)
+                  + getAvailableTargetHashesAsList(sdks)))
+          .inFile(projectViewFile != null ? projectViewFile.projectViewFile : null)
           .submit(context);
       return null;
     }
 
     Sdk sdk = AndroidSdkUtils.findSuitableAndroidSdk(androidSdk);
     if (sdk == null) {
+      ProjectViewFile projectViewFile = projectViewSet.getTopLevelProjectViewFile();
       IssueOutput.error(
               ("No such android_sdk_platform: '"
                   + androidSdk
                   + "'. "
                   + "Available android_sdk_platforms are: "
-                  + getAvailableSdkPlatforms(sdks)
+                  + getAvailableTargetHashesAsList(sdks)
                   + ". "
                   + "Please change android_sdk_platform or run SDK manager "
                   + "to download missing SDK platforms."))
-          .inFile(projectViewSet.getTopLevelProjectViewFile().projectViewFile)
+          .inFile(projectViewFile != null ? projectViewFile.projectViewFile : null)
           .submit(context);
       return null;
     }
 
-    int androidSdkApiLevel = getAndroidSdkApiLevel(sdk);
-    return new AndroidSdkPlatform(androidSdk, androidSdkApiLevel);
+    if (androidMinSdk == null) {
+      androidMinSdk = getAndroidSdkApiLevel(sdk);
+    }
+    return new AndroidSdkPlatform(androidSdk, androidMinSdk);
   }
 
-  public static String getAvailableSdkPlatforms(Collection<Sdk> sdks) {
-    List<String> names = Lists.newArrayList();
-    for (Sdk sdk : sdks) {
-      AndroidSdkAdditionalData additionalData = AndroidSdkUtils.getAndroidSdkAdditionalData(sdk);
-      if (additionalData == null) {
-        continue;
-      }
-      String targetHash = additionalData.getBuildTargetHashString();
-      names.add(targetHash);
+  @Nullable
+  public static String getSdkTargetHash(Sdk sdk) {
+    AndroidSdkAdditionalData additionalData = AndroidSdkUtils.getAndroidSdkAdditionalData(sdk);
+    if (additionalData == null) {
+      return null;
     }
-    return "{" + Joiner.on(", ").join(names) + "}";
+    return additionalData.getBuildTargetHashString();
+  }
+
+  public static List<String> getAvailableSdkTargetHashes(Collection<Sdk> sdks) {
+    Set<String> names = Sets.newHashSet();
+    for (Sdk sdk : sdks) {
+      String targetHash = getSdkTargetHash(sdk);
+      if (targetHash != null) {
+        names.add(targetHash);
+      }
+    }
+    List<String> result = Lists.newArrayList(names);
+    result.sort(String::compareTo);
+    return result;
+  }
+
+  private static String getAvailableTargetHashesAsList(Collection<Sdk> sdks) {
+    return Joiner.on(", ").join(getAvailableSdkTargetHashes(sdks));
   }
 
   private static int getAndroidSdkApiLevel(Sdk sdk) {
diff --git a/aswb/tests/integrationtests/com/google/idea/blaze/android/run/test/smrunner/BlazeAndroidTestEventsHandlerTest.java b/aswb/tests/integrationtests/com/google/idea/blaze/android/run/test/smrunner/BlazeAndroidTestEventsHandlerTest.java
new file mode 100644
index 0000000..474e1b4
--- /dev/null
+++ b/aswb/tests/integrationtests/com/google/idea/blaze/android/run/test/smrunner/BlazeAndroidTestEventsHandlerTest.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.android.run.test.smrunner;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.Iterables;
+import com.google.idea.blaze.android.BlazeAndroidIntegrationTestCase;
+import com.google.idea.blaze.base.model.primitives.WorkspacePath;
+import com.intellij.execution.Location;
+import com.intellij.openapi.vfs.VirtualFileManager;
+import com.intellij.psi.PsiClass;
+import com.intellij.psi.PsiClassOwner;
+import com.intellij.psi.PsiFile;
+import com.intellij.psi.PsiMethod;
+import com.intellij.psi.search.GlobalSearchScope;
+import javax.annotation.Nullable;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Integration tests for {@link BlazeAndroidTestEventsHandler}. */
+@RunWith(JUnit4.class)
+public class BlazeAndroidTestEventsHandlerTest extends BlazeAndroidIntegrationTestCase {
+
+  private final BlazeAndroidTestEventsHandler handler = new BlazeAndroidTestEventsHandler();
+
+  @Before
+  public final void doSetup() {
+    // BlazeAndroidTestLocator calls JUnitUtil::isTestClass, which requires junit classes.
+    workspace.createPsiFile(
+        new WorkspacePath("org/junit/runner/RunWith.java"),
+        "package org.junit.runner;"
+            + "public @interface RunWith {"
+            + "    Class<? extends Runner> value();"
+            + "}");
+    workspace.createPsiFile(
+        new WorkspacePath("org/junit/Test"), "package org.junit;", "public @interface Test {}");
+    workspace.createPsiFile(
+        new WorkspacePath("org/junit/runners/JUnit4"),
+        "package org.junit.runners;",
+        "public class JUnit4 {}");
+  }
+
+  @Test
+  public void testSuiteLocationResolves() {
+    PsiFile javaFile =
+        workspace.createPsiFile(
+            new WorkspacePath("java/com/google/lib/JavaClass.java"),
+            "package com.google.lib;",
+            "import org.junit.Test;",
+            "import org.junit.runner.RunWith;",
+            "import org.junit.runners.JUnit4;",
+            "@RunWith(JUnit4.class)",
+            "public class JavaClass {",
+            "  @Test",
+            "  public void testMethod() {}",
+            "}");
+    PsiClass javaClass = ((PsiClassOwner) javaFile).getClasses()[0];
+    assertThat(javaClass).isNotNull();
+
+    String url = handler.suiteLocationUrl(null, "JavaClass");
+    Location<?> location = getLocation(url);
+    assertThat(location.getPsiElement()).isEqualTo(javaClass);
+  }
+
+  @Test
+  public void testMethodLocationResolves() {
+    PsiFile javaFile =
+        workspace.createPsiFile(
+            new WorkspacePath("java/com/google/lib/JavaClass.java"),
+            "package com.google.lib;",
+            "import org.junit.Test;",
+            "import org.junit.runner.RunWith;",
+            "import org.junit.runners.JUnit4;",
+            "@RunWith(JUnit4.class)",
+            "public class JavaClass {",
+            "  @Test",
+            "  public void testMethod() {}",
+            "}");
+    PsiClass javaClass = ((PsiClassOwner) javaFile).getClasses()[0];
+    PsiMethod method = javaClass.findMethodsByName("testMethod", false)[0];
+    assertThat(method).isNotNull();
+
+    String url =
+        handler.testLocationUrl(
+            null, null, "JavaClass-testMethod", "com.google.test.AndroidTestBase");
+    Location<?> location = getLocation(url);
+    assertThat(location.getPsiElement()).isEqualTo(method);
+  }
+
+  @Nullable
+  private Location<?> getLocation(String url) {
+    String protocol = VirtualFileManager.extractProtocol(url);
+    String path = VirtualFileManager.extractPath(url);
+    if (protocol == null) {
+      return null;
+    }
+    return Iterables.getFirst(
+        handler
+            .getTestLocator()
+            .getLocation(protocol, path, getProject(), GlobalSearchScope.allScope(getProject())),
+        null);
+  }
+}
diff --git a/base/src/META-INF/blaze-base.xml b/base/src/META-INF/blaze-base.xml
index 34d33f4..913bdb5 100644
--- a/base/src/META-INF/blaze-base.xml
+++ b/base/src/META-INF/blaze-base.xml
@@ -49,8 +49,12 @@
       text="Show Performance Warnings">
     </action>
     <action id="Blaze.EditProjectView"
-      class="com.google.idea.blaze.base.settings.ui.EditProjectViewAction"
-      text="Open Project View Files">
+      class="com.google.idea.blaze.base.settings.ui.OpenAllProjectViewsAction"
+      text="Open All Project View Files">
+    </action>
+    <action id="Blaze.EditLocalProjectView"
+      class="com.google.idea.blaze.base.settings.ui.OpenLocalProjectViewAction"
+      text="Open Local Project View File">
     </action>
     <action class="com.google.idea.blaze.base.buildmap.OpenCorrespondingBuildFile"
       id="Blaze.OpenCorrespondingBuildFile"
@@ -76,7 +80,9 @@
 
     <group id="Blaze.MainMenuActionGroup" class="com.google.idea.blaze.base.actions.BlazeMenuGroup">
       <add-to-group group-id="MainMenu" anchor="before" relative-to-action="HelpMenu"/>
+      <reference id="Blaze.EditLocalProjectView"/>
       <reference id="Blaze.EditProjectView"/>
+      <separator/>
       <group id ="Blaze.SyncMenuGroup" text="Sync" popup="true">
         <reference id="Blaze.IncrementalSyncProject"/>
         <reference id="Blaze.FullSyncProject"/>
@@ -110,15 +116,10 @@
       <separator/>
     </group>
 
-    <group id="Blaze.ProjectViewPopupMenu">
+    <group id="Blaze.PerFileContextMenu">
       <add-to-group anchor="after" group-id="ProjectViewPopupMenu" relative-to-action="EditSource"/>
-      <separator/>
-      <reference ref="Blaze.PartialSync"/>
-      <reference ref="Blaze.OpenCorrespondingBuildFile"/>
-    </group>
-
-    <group id="Blaze.EditorTabPopupMenu">
       <add-to-group anchor="after" group-id="EditorTabPopupMenu" relative-to-action="CopyReference"/>
+      <add-to-group anchor="before" group-id="EditorPopupMenu" relative-to-action="$SearchWeb"/>
       <separator/>
       <reference ref="Blaze.PartialSync"/>
       <reference ref="Blaze.OpenCorrespondingBuildFile"/>
@@ -153,6 +154,8 @@
                         serviceImplementation="com.google.idea.blaze.base.io.InputStreamProviderImpl"/>
     <applicationService serviceInterface="com.google.idea.blaze.base.io.FileAttributeProvider"
                         serviceImplementation="com.google.idea.blaze.base.io.FileAttributeProvider"/>
+    <applicationService serviceInterface="com.google.idea.blaze.base.io.VirtualFileSystemProvider"
+                        serviceImplementation="com.google.idea.blaze.base.io.VirtualFileSystemProviderImpl"/>
     <applicationService serviceInterface="com.google.idea.blaze.base.buildmodifier.BuildFileModifier"
                         serviceImplementation="com.google.idea.blaze.base.lang.buildfile.actions.BuildFileModifierImpl"/>
     <projectService serviceInterface="com.google.idea.blaze.base.buildmodifier.FileSystemModifier"
@@ -193,6 +196,7 @@
                         serviceImplementation="com.google.idea.blaze.base.wizard2.BazelWizardOptionProvider"/>
     <projectService serviceInterface="com.google.idea.blaze.base.sync.workspace.WorkspacePathResolverProvider"
                     serviceImplementation="com.google.idea.blaze.base.sync.workspace.WorkspacePathResolverProviderImpl"/>
+    <projectService serviceImplementation="com.google.idea.blaze.base.sync.SyncCache"/>
     <configurationType implementation="com.google.idea.blaze.base.run.BlazeCommandRunConfigurationType"/>
     <runConfigurationProducer
         implementation="com.google.idea.blaze.base.run.producers.AllInPackageBlazeConfigurationProducer"
@@ -200,6 +204,9 @@
     <runConfigurationProducer
         implementation="com.google.idea.blaze.base.run.producers.BlazeBuildFileRunConfigurationProducer"
         order="first"/>
+    <runConfigurationProducer
+        implementation="com.google.idea.blaze.base.run.producers.BlazeFilterExistingRunConfigurationProducer"
+        order="first"/>
     <stepsBeforeRunProvider implementation="com.google.idea.blaze.base.run.BlazeBeforeRunTaskProvider"/>
     <applicationService serviceInterface="com.google.idea.blaze.base.help.BlazeHelpHandler"
                         serviceImplementation="com.google.idea.blaze.base.help.BlazeHelpHandlerImpl"/>
@@ -223,8 +230,9 @@
   <extensions defaultExtensionNs="com.intellij">
     <fileTypeFactory implementation="com.google.idea.blaze.base.lang.buildfile.language.BuildFileTypeFactory"/>
     <annotator language="BUILD" implementationClass="com.google.idea.blaze.base.lang.buildfile.validation.HighlightingAnnotator"/>
-    <!--<annotator language="BUILD" implementationClass="com.google.idea.blaze.base.lang.buildfile.validation.ErrorAnnotator"/>-->
+    <!--<annotator language="BUILD" implementationClass="com.google.idea.blaze.base.lang.buildfile.validation.LoadErrorAnnotator"/>-->
     <annotator language="BUILD" implementationClass="com.google.idea.blaze.base.lang.buildfile.validation.GlobErrorAnnotator"/>
+    <annotator language="BUILD" implementationClass="com.google.idea.blaze.base.lang.buildfile.validation.BuiltInRuleAnnotator"/>
     <colorSettingsPage implementation="com.google.idea.blaze.base.lang.buildfile.highlighting.BuildColorsPage"/>
     <projectService serviceImplementation="com.google.idea.blaze.base.lang.buildfile.psi.util.BuildElementGenerator"/>
     <projectService serviceImplementation="com.google.idea.blaze.base.lang.buildfile.references.BuildReferenceManager"/>
@@ -248,6 +256,7 @@
     <editor.backspaceModeOverride language="BUILD" implementationClass="com.intellij.codeInsight.editorActions.SmartBackspaceDisabler"/>
     <filetype.stubBuilder filetype="BUILD" implementationClass="com.google.idea.blaze.base.lang.buildfile.stubs.BuildFileStubBuilder"/>
     <editorNotificationProvider implementation="com.google.idea.blaze.base.lang.AdditionalLanguagesHelper"/>
+    <usageTypeProvider implementation="com.google.idea.blaze.base.lang.buildfile.findusages.BuildUsageTypeProvider"/>
   </extensions>
 
   <extensions defaultExtensionNs="com.intellij.lang">
@@ -261,6 +270,7 @@
     <findUsagesProvider language="BUILD" implementationClass="com.google.idea.blaze.base.lang.buildfile.findusages.BuildFindUsagesProvider"/>
     <refactoringSupport language="BUILD" implementationClass="com.google.idea.blaze.base.lang.buildfile.refactor.BuildRefactoringSupportProvider"/>
     <documentationProvider language="BUILD" implementationClass="com.google.idea.blaze.base.lang.buildfile.documentation.BuildDocumentationProvider"/>
+    <elementManipulator forClass="com.google.idea.blaze.base.lang.buildfile.psi.StringLiteral" implementationClass="com.google.idea.blaze.base.lang.buildfile.refactor.StringLiteralElementManipulator"/>
   </extensions>
 
   <extensionPoints>
@@ -304,7 +314,6 @@
     <extensionPoint qualifiedName="com.google.idea.blaze.BuildFlagsProvider" interface="com.google.idea.blaze.base.command.BuildFlagsProvider"/>
     <extensionPoint qualifiedName="com.google.idea.blaze.BuildSystemProvider" interface="com.google.idea.blaze.base.bazel.BuildSystemProvider"/>
     <extensionPoint qualifiedName="com.google.idea.blaze.BuildifierBinaryProvider" interface="com.google.idea.blaze.base.buildmodifier.BuildifierBinaryProvider"/>
-    <extensionPoint qualifiedName="com.google.idea.blaze.LoggingService" interface="com.google.idea.blaze.base.metrics.LoggingService"/>
     <extensionPoint qualifiedName="com.google.idea.blaze.BlazeCommandRunConfigurationHandlerProvider" interface="com.google.idea.blaze.base.run.confighandler.BlazeCommandRunConfigurationHandlerProvider"/>
     <extensionPoint qualifiedName="com.google.idea.blaze.BlazeUserSettingsContributor" interface="com.google.idea.blaze.base.settings.ui.BlazeUserSettingsContributor$Provider"/>
     <extensionPoint qualifiedName="com.google.idea.blaze.BlazePsiDirectoryRootNodeNameModifier" interface="com.google.idea.blaze.base.treeview.BlazePsiDirectoryRootNodeNameModifier"/>
@@ -313,13 +322,16 @@
     <extensionPoint qualifiedName="com.google.idea.blaze.ProjectDataDirectoryValidator" interface="com.google.idea.blaze.base.wizard2.ProjectDataDirectoryValidator"/>
     <extensionPoint qualifiedName="com.google.idea.blaze.AspectStrategyProvider" interface="com.google.idea.blaze.base.sync.aspects.strategy.AspectStrategyProvider"/>
     <extensionPoint qualifiedName="com.google.idea.blaze.DistributedExecutorSupport" interface="com.google.idea.blaze.base.run.DistributedExecutorSupport"/>
+    <extensionPoint qualifiedName="com.google.idea.blaze.FileStringParser" interface="com.google.idea.blaze.base.run.filter.FileResolver"/>
+    <extensionPoint qualifiedName="com.google.idea.blaze.BlazeTestXmlFinderStrategy" interface="com.google.idea.blaze.base.run.testlogs.BlazeTestXmlFinderStrategy"/>
+    <extensionPoint qualifiedName="com.google.idea.blaze.BlazeTestEventsHandler" interface="com.google.idea.blaze.base.run.smrunner.BlazeTestEventsHandler"/>
+    <extensionPoint qualifiedName="com.google.idea.blaze.AttributeSpecificStringLiteralReferenceProvider" interface="com.google.idea.blaze.base.lang.buildfile.references.AttributeSpecificStringLiteralReferenceProvider"/>
   </extensionPoints>
 
   <extensions defaultExtensionNs="com.google.idea.blaze">
+    <SyncListener implementation="com.google.idea.blaze.base.sync.SyncCache$ClearSyncCache"/>
     <SyncListener implementation="com.google.idea.blaze.base.run.BlazeRunConfigurationSyncListener"/>
     <SyncListener implementation="com.google.idea.blaze.base.sync.status.BlazeSyncStatusListener"/>
-    <SyncListener implementation="com.google.idea.blaze.base.run.testmap.TestTargetFilterImpl$ClearTestMap"/>
-    <SyncListener implementation="com.google.idea.blaze.base.targetmaps.SourceToTargetMapImpl$ClearSourceToTargetMap"/>
     <SyncPlugin implementation="com.google.idea.blaze.base.lang.buildfile.sync.BuildLangSyncPlugin"/>
     <BuildFlagsProvider implementation="com.google.idea.blaze.base.command.BuildFlagsProviderImpl"/>
     <VcsHandler implementation="com.google.idea.blaze.base.vcs.git.GitBlazeVcsHandler"/>
@@ -331,6 +343,9 @@
     <TestTargetHeuristic implementation="com.google.idea.blaze.base.run.TestSizeHeuristic" order="last" id="TestSizeHeuristic"/>
     <RunConfigurationFactory implementation="com.google.idea.blaze.base.run.BlazeBuildTargetRunConfigurationFactory" order="last"/>
     <AspectStrategyProvider implementation="com.google.idea.blaze.base.sync.aspects.strategy.AspectStrategyProviderBazel" order="last"/>
+    <FileStringParser implementation="com.google.idea.blaze.base.run.filter.StandardFileResolver" order="last"/>
+    <BlazeTestXmlFinderStrategy implementation="com.google.idea.blaze.base.run.testlogs.TargetPathTestXmlFinderStrategy"/>
+    <BlazeTestEventsHandler implementation="com.google.idea.blaze.base.run.smrunner.BlazeCompositeTestEventsHandler" order="last"/>
   </extensions>
 
 </idea-plugin>
diff --git a/base/src/com/google/idea/blaze/base/actions/BlazeBuildService.java b/base/src/com/google/idea/blaze/base/actions/BlazeBuildService.java
index 4da44f1..6b9e095 100644
--- a/base/src/com/google/idea/blaze/base/actions/BlazeBuildService.java
+++ b/base/src/com/google/idea/blaze/base/actions/BlazeBuildService.java
@@ -21,7 +21,6 @@
 import com.google.idea.blaze.base.async.executor.BlazeExecutor;
 import com.google.idea.blaze.base.experiments.ExperimentScope;
 import com.google.idea.blaze.base.filecache.FileCaches;
-import com.google.idea.blaze.base.metrics.Action;
 import com.google.idea.blaze.base.model.BlazeProjectData;
 import com.google.idea.blaze.base.model.primitives.Label;
 import com.google.idea.blaze.base.model.primitives.TargetExpression;
@@ -34,7 +33,6 @@
 import com.google.idea.blaze.base.scope.scopes.BlazeConsoleScope;
 import com.google.idea.blaze.base.scope.scopes.IdeaLogScope;
 import com.google.idea.blaze.base.scope.scopes.IssuesScope;
-import com.google.idea.blaze.base.scope.scopes.LoggedTimingScope;
 import com.google.idea.blaze.base.scope.scopes.NotificationScope;
 import com.google.idea.blaze.base.scope.scopes.TimingScope;
 import com.google.idea.blaze.base.settings.Blaze;
@@ -59,7 +57,6 @@
         project,
         Lists.newArrayList(targets),
         ProjectViewManager.getInstance(project).getProjectViewSet(),
-        new LoggedTimingScope(project, Action.MAKE_MODULE_TOTAL_TIME),
         new NotificationScope(
             project,
             "Make",
@@ -80,7 +77,6 @@
         project,
         projectViewSet.listItems(TargetSection.KEY),
         projectViewSet,
-        new LoggedTimingScope(project, Action.MAKE_PROJECT_TOTAL_TIME),
         new NotificationScope(
             project,
             "Make",
@@ -94,7 +90,6 @@
       Project project,
       List<TargetExpression> targets,
       ProjectViewSet projectViewSet,
-      LoggedTimingScope loggedTimingScope,
       NotificationScope notificationScope) {
     if (targets.isEmpty() || projectViewSet == null) {
       return;
@@ -115,7 +110,6 @@
                 .push(new IssuesScope(project))
                 .push(new IdeaLogScope())
                 .push(new TimingScope("Make"))
-                .push(loggedTimingScope)
                 .push(notificationScope);
 
             WorkspaceRoot workspaceRoot = WorkspaceRoot.fromProject(project);
diff --git a/base/src/com/google/idea/blaze/base/async/FutureUtil.java b/base/src/com/google/idea/blaze/base/async/FutureUtil.java
index 07f3e4e..e3be151 100644
--- a/base/src/com/google/idea/blaze/base/async/FutureUtil.java
+++ b/base/src/com/google/idea/blaze/base/async/FutureUtil.java
@@ -52,7 +52,7 @@
 
   /** Builder for the future */
   public static class Builder<T> {
-    private static final Logger LOG = Logger.getInstance(FutureUtil.class);
+    private static final Logger logger = Logger.getInstance(FutureUtil.class);
     private final BlazeContext context;
     private final ListenableFuture<T> future;
     private String timingCategory;
@@ -95,7 +95,7 @@
               Thread.currentThread().interrupt();
               context.setCancelled();
             } catch (ExecutionException e) {
-              LOG.error(e);
+              logger.error(e);
               if (errorMessage != null) {
                 IssueOutput.error(errorMessage).submit(childContext);
               }
diff --git a/base/src/com/google/idea/blaze/base/async/process/ExternalTask.java b/base/src/com/google/idea/blaze/base/async/process/ExternalTask.java
index 86b7d50..67fe83b 100644
--- a/base/src/com/google/idea/blaze/base/async/process/ExternalTask.java
+++ b/base/src/com/google/idea/blaze/base/async/process/ExternalTask.java
@@ -41,7 +41,7 @@
 
 /** Invokes an external process */
 public class ExternalTask {
-  private static final Logger LOG = Logger.getInstance(ExternalTask.class);
+  private static final Logger logger = Logger.getInstance(ExternalTask.class);
 
   static final OutputStream NULL_STREAM = ByteStreams.nullOutputStream();
 
@@ -222,6 +222,8 @@
         Thread shutdownHook = new Thread(process::destroy);
         try {
           Runtime.getRuntime().addShutdownHook(shutdownHook);
+          // These tasks are non-interactive, so close the stream connected to the process's input.
+          process.getOutputStream().close();
           Thread stdoutThread = ProcessUtil.forwardAsync(process.getInputStream(), stdout);
           Thread stderrThread = null;
           if (!redirectErrorStream) {
@@ -248,7 +250,7 @@
           }
         }
       } catch (IOException e) {
-        LOG.warn(e);
+        logger.warn(e);
         IssueOutput.error(e.getMessage()).submit(context);
       }
     } finally {
diff --git a/base/src/com/google/idea/blaze/base/async/process/ProcessUtil.java b/base/src/com/google/idea/blaze/base/async/process/ProcessUtil.java
index 01b02b7..733fca7 100644
--- a/base/src/com/google/idea/blaze/base/async/process/ProcessUtil.java
+++ b/base/src/com/google/idea/blaze/base/async/process/ProcessUtil.java
@@ -21,7 +21,7 @@
 import java.io.OutputStream;
 
 class ProcessUtil {
-  private static final Logger LOG = Logger.getInstance(ProcessUtil.class);
+  private static final Logger logger = Logger.getInstance(ProcessUtil.class);
 
   public static Thread forwardAsync(final InputStream input, final OutputStream output) {
     Thread thread =
@@ -40,7 +40,7 @@
                     read = input.read(buffer);
                   }
                 } catch (IOException e) {
-                  LOG.warn("Error redirecting output", e);
+                  logger.warn("Error redirecting output", e);
                 }
               }
             });
diff --git a/base/src/com/google/idea/blaze/base/bazel/BuildSystemProvider.java b/base/src/com/google/idea/blaze/base/bazel/BuildSystemProvider.java
index 6ce5aca..61c7257 100644
--- a/base/src/com/google/idea/blaze/base/bazel/BuildSystemProvider.java
+++ b/base/src/com/google/idea/blaze/base/bazel/BuildSystemProvider.java
@@ -21,7 +21,6 @@
 import com.google.idea.blaze.base.model.BlazeVersionData;
 import com.google.idea.blaze.base.model.primitives.WorkspaceRoot;
 import com.google.idea.blaze.base.settings.Blaze.BuildSystem;
-import com.intellij.openapi.components.ServiceManager;
 import com.intellij.openapi.extensions.ExtensionPointName;
 import com.intellij.openapi.fileTypes.FileNameMatcher;
 import com.intellij.openapi.vfs.VirtualFile;
@@ -65,10 +64,6 @@
     return provider.getWorkspaceRootProvider();
   }
 
-  static BuildSystemProvider getInstance() {
-    return ServiceManager.getService(BuildSystemProvider.class);
-  }
-
   /**
    * Returns the default build system for this application. This should only be called in situations
    * where it doesn't make sense to use the current project.<br>
diff --git a/base/src/com/google/idea/blaze/base/buildmap/OpenCorrespondingBuildFile.java b/base/src/com/google/idea/blaze/base/buildmap/OpenCorrespondingBuildFile.java
index 592b7b4..712022f 100644
--- a/base/src/com/google/idea/blaze/base/buildmap/OpenCorrespondingBuildFile.java
+++ b/base/src/com/google/idea/blaze/base/buildmap/OpenCorrespondingBuildFile.java
@@ -17,8 +17,11 @@
 
 import com.google.common.collect.Iterables;
 import com.google.idea.blaze.base.actions.BlazeProjectAction;
-import com.google.idea.blaze.base.metrics.Action;
-import com.google.idea.blaze.base.metrics.LoggingService;
+import com.google.idea.blaze.base.lang.buildfile.references.BuildReferenceManager;
+import com.google.idea.blaze.base.model.BlazeProjectData;
+import com.google.idea.blaze.base.model.primitives.Label;
+import com.google.idea.blaze.base.sync.data.BlazeProjectDataManager;
+import com.google.idea.blaze.base.targetmaps.SourceToTargetMap;
 import com.intellij.ide.actions.OpenFileAction;
 import com.intellij.openapi.actionSystem.ActionPlaces;
 import com.intellij.openapi.actionSystem.AnActionEvent;
@@ -27,6 +30,8 @@
 import com.intellij.openapi.actionSystem.Presentation;
 import com.intellij.openapi.project.Project;
 import com.intellij.openapi.vfs.VirtualFile;
+import com.intellij.psi.NavigatablePsiElement;
+import com.intellij.psi.PsiElement;
 import java.io.File;
 import java.util.Collection;
 import javax.annotation.Nullable;
@@ -36,16 +41,29 @@
   @Override
   protected void actionPerformedInBlazeProject(Project project, AnActionEvent e) {
     VirtualFile virtualFile = e.getData(CommonDataKeys.VIRTUAL_FILE);
+    if (virtualFile == null) {
+      return;
+    }
+    navigateToTargetOrFile(project, virtualFile);
+  }
+
+  /** Returns true if a target or BUILD file could be found and navigated to. */
+  private static void navigateToTargetOrFile(Project project, VirtualFile virtualFile) {
+    // first, look for a specific target which includes this source file
+    PsiElement target = findBuildTarget(project, new File(virtualFile.getPath()));
+    if (target instanceof NavigatablePsiElement) {
+      ((NavigatablePsiElement) target).navigate(true);
+      return;
+    }
     File file = getBuildFile(project, virtualFile);
     if (file == null) {
       return;
     }
     OpenFileAction.openFile(file.getPath(), project);
-    LoggingService.reportEvent(project, Action.OPEN_CORRESPONDING_BUILD_FILE);
   }
 
   @Nullable
-  private File getBuildFile(Project project, @Nullable VirtualFile virtualFile) {
+  private static File getBuildFile(Project project, @Nullable VirtualFile virtualFile) {
     if (virtualFile == null) {
       return null;
     }
@@ -54,6 +72,25 @@
     return Iterables.getFirst(fileInfoList, null);
   }
 
+  @Nullable
+  private static PsiElement findBuildTarget(Project project, File file) {
+    BlazeProjectData blazeProjectData =
+        BlazeProjectDataManager.getInstance(project).getBlazeProjectData();
+    if (blazeProjectData == null) {
+      return null;
+    }
+    Label label =
+        SourceToTargetMap.getInstance(project)
+            .getTargetsToBuildForSourceFile(file)
+            .stream()
+            .findFirst()
+            .orElse(null);
+    if (label == null) {
+      return null;
+    }
+    return BuildReferenceManager.getInstance(project).resolveLabel(label);
+  }
+
   @Override
   protected void updateForBlazeProject(Project project, AnActionEvent e) {
     Presentation presentation = e.getPresentation();
diff --git a/base/src/com/google/idea/blaze/base/command/info/BlazeInfo.java b/base/src/com/google/idea/blaze/base/command/info/BlazeInfo.java
index 6099fc2..1d329aa 100644
--- a/base/src/com/google/idea/blaze/base/command/info/BlazeInfo.java
+++ b/base/src/com/google/idea/blaze/base/command/info/BlazeInfo.java
@@ -31,6 +31,7 @@
   public static final String BUILD_LANGUAGE = "build-language";
   public static final String OUTPUT_BASE_KEY = "output_base";
   public static final String MASTER_LOG = "master-log";
+  public static final String COMMAND_LOG = "command_log";
   public static final String RELEASE = "release";
 
   public static String blazeBinKey(BuildSystem buildSystem) {
@@ -55,6 +56,17 @@
     }
   }
 
+  public static String blazeTestlogsKey(BuildSystem buildSystem) {
+    switch (buildSystem) {
+      case Blaze:
+        return "blaze-testlogs";
+      case Bazel:
+        return "bazel-testlogs";
+      default:
+        throw new IllegalArgumentException("Unrecognized build system: " + buildSystem);
+    }
+  }
+
   public static BlazeInfo getInstance() {
     return ServiceManager.getService(BlazeInfo.class);
   }
diff --git a/base/src/com/google/idea/blaze/base/command/info/BlazeInfoImpl.java b/base/src/com/google/idea/blaze/base/command/info/BlazeInfoImpl.java
index cf82fdd..4b9fe6c 100644
--- a/base/src/com/google/idea/blaze/base/command/info/BlazeInfoImpl.java
+++ b/base/src/com/google/idea/blaze/base/command/info/BlazeInfoImpl.java
@@ -30,7 +30,7 @@
 import javax.annotation.Nullable;
 
 class BlazeInfoImpl extends BlazeInfo {
-  private static final Logger LOG = Logger.getInstance(BlazeInfoImpl.class);
+  private static final Logger logger = Logger.getInstance(BlazeInfoImpl.class);
 
   @Override
   public ListenableFuture<String> runBlazeInfo(
@@ -110,7 +110,7 @@
     for (String blazeInfoLine : blazeInfoLines) {
       // Just split on the first ":".
       String[] keyValue = blazeInfoLine.split(":", 2);
-      LOG.assertTrue(keyValue.length == 2, blazeInfoLine);
+      logger.assertTrue(keyValue.length == 2, blazeInfoLine);
       String key = keyValue[0].trim();
       String value = keyValue[1].trim();
       blazeInfoMapBuilder.put(key, value);
diff --git a/base/src/com/google/idea/blaze/base/console/ColoredConsoleStream.java b/base/src/com/google/idea/blaze/base/console/ColoredConsoleStream.java
new file mode 100644
index 0000000..1ee034e
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/console/ColoredConsoleStream.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.base.console;
+
+import com.intellij.execution.process.AnsiEscapeDecoder;
+import com.intellij.execution.process.AnsiEscapeDecoder.ColoredTextAcceptor;
+import com.intellij.execution.process.ProcessOutputTypes;
+import com.intellij.execution.ui.ConsoleViewContentType;
+import com.intellij.openapi.util.Key;
+
+/** ConsoleStream that decodes color codes before forwarding data to the next console stream. */
+public class ColoredConsoleStream implements ColoredTextAcceptor, ConsoleStream {
+
+  private final ConsoleStream consoleStream;
+  private final AnsiEscapeDecoder ansiEscapeDecoder = new AnsiEscapeDecoder();
+
+  public ColoredConsoleStream(ConsoleStream consoleStream) {
+    this.consoleStream = consoleStream;
+  }
+
+  @Override
+  public void print(String text, ConsoleViewContentType contentType) {
+    Key<?> key =
+        contentType == ConsoleViewContentType.ERROR_OUTPUT
+            ? ProcessOutputTypes.STDERR
+            : ProcessOutputTypes.STDOUT;
+    ansiEscapeDecoder.escapeText(text, key, this);
+  }
+
+  @Override
+  public void coloredTextAvailable(String escapedText, @SuppressWarnings("rawtypes") Key key) {
+    ConsoleViewContentType contentType = ConsoleViewContentType.getConsoleViewType(key);
+    consoleStream.print(escapedText, contentType);
+  }
+}
diff --git a/base/src/com/google/idea/blaze/base/run/smrunner/BlazeTestEventsHandlerProvider.java b/base/src/com/google/idea/blaze/base/console/ConsoleStream.java
similarity index 62%
rename from base/src/com/google/idea/blaze/base/run/smrunner/BlazeTestEventsHandlerProvider.java
rename to base/src/com/google/idea/blaze/base/console/ConsoleStream.java
index c02a0cb..15dab3c 100644
--- a/base/src/com/google/idea/blaze/base/run/smrunner/BlazeTestEventsHandlerProvider.java
+++ b/base/src/com/google/idea/blaze/base/console/ConsoleStream.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2016 The Bazel Authors. All rights reserved.
+ * Copyright 2017 The Bazel Authors. 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.
@@ -13,10 +13,12 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.google.idea.blaze.base.run.smrunner;
+package com.google.idea.blaze.base.console;
 
-/** Provides a {@link BlazeTestEventsHandler}. */
-public interface BlazeTestEventsHandlerProvider {
+import com.intellij.execution.ui.ConsoleViewContentType;
 
-  BlazeTestEventsHandler getHandler();
+/** Stream that outputs strings with a {@link ConsoleViewContentType}. */
+public interface ConsoleStream {
+
+  void print(String text, ConsoleViewContentType contentType);
 }
diff --git a/base/src/com/google/idea/blaze/base/filecache/FileDiffer.java b/base/src/com/google/idea/blaze/base/filecache/FileDiffer.java
index a76fa8d..0142f06 100644
--- a/base/src/com/google/idea/blaze/base/filecache/FileDiffer.java
+++ b/base/src/com/google/idea/blaze/base/filecache/FileDiffer.java
@@ -27,7 +27,7 @@
 
 /** Provides a diffing service for a collection of files. */
 public final class FileDiffer {
-  private static Logger LOG = Logger.getInstance(FileDiffer.class);
+  private static Logger logger = Logger.getInstance(FileDiffer.class);
 
   private FileDiffer() {}
 
@@ -50,7 +50,7 @@
     try {
       return ModifiedTimeScanner.readTimestamps(files);
     } catch (Exception e) {
-      LOG.error(e);
+      logger.error(e);
       return null;
     }
   }
diff --git a/base/src/com/google/idea/blaze/base/ide/NewBlazePackageAction.java b/base/src/com/google/idea/blaze/base/ide/NewBlazePackageAction.java
index 2d0f519..19c2b10 100644
--- a/base/src/com/google/idea/blaze/base/ide/NewBlazePackageAction.java
+++ b/base/src/com/google/idea/blaze/base/ide/NewBlazePackageAction.java
@@ -21,7 +21,6 @@
 import com.google.idea.blaze.base.actions.BlazeProjectAction;
 import com.google.idea.blaze.base.buildmodifier.BuildFileModifier;
 import com.google.idea.blaze.base.buildmodifier.FileSystemModifier;
-import com.google.idea.blaze.base.metrics.Action;
 import com.google.idea.blaze.base.model.primitives.Kind;
 import com.google.idea.blaze.base.model.primitives.Label;
 import com.google.idea.blaze.base.model.primitives.WorkspacePath;
@@ -33,9 +32,9 @@
 import com.google.idea.blaze.base.scope.ScopedOperation;
 import com.google.idea.blaze.base.scope.output.PrintOutput;
 import com.google.idea.blaze.base.scope.output.StatusOutput;
-import com.google.idea.blaze.base.scope.scopes.LoggedTimingScope;
 import com.google.idea.blaze.base.settings.Blaze;
 import com.google.idea.blaze.base.sync.projectview.ImportRoots;
+import com.google.idea.blaze.base.util.WorkspacePathUtil;
 import com.intellij.history.LocalHistory;
 import com.intellij.history.LocalHistoryAction;
 import com.intellij.ide.IdeView;
@@ -46,7 +45,6 @@
 import com.intellij.openapi.diagnostic.Logger;
 import com.intellij.openapi.project.DumbAware;
 import com.intellij.openapi.project.Project;
-import com.intellij.openapi.util.io.FileUtil;
 import com.intellij.openapi.vfs.VfsUtil;
 import com.intellij.openapi.vfs.VirtualFile;
 import com.intellij.psi.PsiDirectory;
@@ -60,7 +58,7 @@
 import org.jetbrains.annotations.NotNull;
 
 class NewBlazePackageAction extends BlazeProjectAction implements DumbAware {
-  private static final Logger LOG = Logger.getInstance(NewBlazePackageAction.class);
+  private static final Logger logger = Logger.getInstance(NewBlazePackageAction.class);
 
   private static final String BUILD_FILE_NAME = "BUILD";
 
@@ -75,8 +73,6 @@
         new ScopedOperation() {
           @Override
           public void execute(@NotNull final BlazeContext context) {
-            context.push(new LoggedTimingScope(project, Action.CREATE_BLAZE_PACKAGE));
-
             if (view == null || project == null) {
               return;
             }
@@ -96,8 +92,8 @@
             final Label newRule = newBlazePackageDialog.getNewRule();
             final Kind newRuleKind = newBlazePackageDialog.getNewRuleKind();
             // If we returned OK, we should have a non null result
-            LOG.assertTrue(newRule != null);
-            LOG.assertTrue(newRuleKind != null);
+            logger.assertTrue(newRule != null);
+            logger.assertTrue(newRuleKind != null);
 
             context.output(
                 new StatusOutput(
@@ -113,7 +109,7 @@
                 WorkspaceRoot.fromProject(project).fileForPath(newRule.blazePackage());
             VirtualFile virtualFile = VfsUtil.findFileByIoFile(newDirectory, true);
             // We just created this file, it should exist
-            LOG.assertTrue(virtualFile != null);
+            logger.assertTrue(virtualFile != null);
             PsiFile psiFile = PsiManager.getInstance(project).findFile(virtualFile);
             view.selectElement(psiFile);
           }
@@ -217,13 +213,7 @@
       return false;
     }
     WorkspacePath workspacePath = workspaceRoot.workspacePathFor(virtualFile);
-    return importRoots
-        .rootDirectories()
-        .stream()
-        .anyMatch(
-            importRoot ->
-                FileUtil.isAncestor(
-                    importRoot.relativePath(), workspacePath.relativePath(), false));
+    return WorkspacePathUtil.isUnderAnyWorkspacePath(importRoots.rootDirectories(), workspacePath);
   }
 
   @Nullable
diff --git a/base/src/com/google/idea/blaze/base/ide/NewBlazePackageDialog.java b/base/src/com/google/idea/blaze/base/ide/NewBlazePackageDialog.java
index f51ccd9..1f5d7c5 100644
--- a/base/src/com/google/idea/blaze/base/ide/NewBlazePackageDialog.java
+++ b/base/src/com/google/idea/blaze/base/ide/NewBlazePackageDialog.java
@@ -45,7 +45,7 @@
 import org.jetbrains.annotations.Nullable;
 
 class NewBlazePackageDialog extends DialogWrapper {
-  private static final Logger LOG = Logger.getInstance(NewBlazePackageDialog.class);
+  private static final Logger logger = Logger.getInstance(NewBlazePackageDialog.class);
 
   @NotNull private final Project project;
   @NotNull private final PsiDirectory parentDirectory;
@@ -105,7 +105,7 @@
   @Override
   protected void doOKAction() {
     WorkspaceRoot workspaceRoot = WorkspaceRoot.fromProject(project);
-    LOG.assertTrue(parentDirectory.getVirtualFile().isInLocalFileSystem());
+    logger.assertTrue(parentDirectory.getVirtualFile().isInLocalFileSystem());
     File parentDirectoryFile = new File(parentDirectory.getVirtualFile().getPath());
     String newPackageName = packageNameField.getText();
     File newPackageDirectory = new File(parentDirectoryFile, newPackageName);
diff --git a/base/src/com/google/idea/blaze/base/ide/NewBlazeRuleAction.java b/base/src/com/google/idea/blaze/base/ide/NewBlazeRuleAction.java
index 422dada..1e072af 100644
--- a/base/src/com/google/idea/blaze/base/ide/NewBlazeRuleAction.java
+++ b/base/src/com/google/idea/blaze/base/ide/NewBlazeRuleAction.java
@@ -18,12 +18,10 @@
 
 import com.google.idea.blaze.base.actions.BlazeProjectAction;
 import com.google.idea.blaze.base.experiments.ExperimentScope;
-import com.google.idea.blaze.base.metrics.Action;
 import com.google.idea.blaze.base.scope.Scope;
 import com.google.idea.blaze.base.scope.scopes.BlazeConsoleScope;
 import com.google.idea.blaze.base.scope.scopes.IdeaLogScope;
 import com.google.idea.blaze.base.scope.scopes.IssuesScope;
-import com.google.idea.blaze.base.scope.scopes.LoggedTimingScope;
 import com.google.idea.blaze.base.settings.Blaze;
 import com.intellij.openapi.actionSystem.ActionPlaces;
 import com.intellij.openapi.actionSystem.AnActionEvent;
@@ -54,8 +52,7 @@
               .push(new ExperimentScope())
               .push(new BlazeConsoleScope.Builder(project).build())
               .push(new IssuesScope(project))
-              .push(new IdeaLogScope())
-              .push(new LoggedTimingScope(project, Action.CREATE_BLAZE_RULE));
+              .push(new IdeaLogScope());
           NewBlazeRuleDialog newBlazeRuleDialog =
               new NewBlazeRuleDialog(context, project, virtualFile);
           newBlazeRuleDialog.show();
diff --git a/base/src/com/google/idea/blaze/base/ide/NewBlazeRuleDialog.java b/base/src/com/google/idea/blaze/base/ide/NewBlazeRuleDialog.java
index ef114da..d77e25c 100644
--- a/base/src/com/google/idea/blaze/base/ide/NewBlazeRuleDialog.java
+++ b/base/src/com/google/idea/blaze/base/ide/NewBlazeRuleDialog.java
@@ -37,7 +37,7 @@
 import javax.swing.JPanel;
 
 class NewBlazeRuleDialog extends DialogWrapper {
-  private static final Logger LOG = Logger.getInstance(NewBlazeRuleDialog.class);
+  private static final Logger logger = Logger.getInstance(NewBlazeRuleDialog.class);
 
   private static final int UI_INDENT = 0;
   private static final int TEXT_BOX_WIDTH = 40;
diff --git a/base/src/com/google/idea/blaze/base/ideinfo/IntellijPluginDeployInfo.java b/base/src/com/google/idea/blaze/base/ideinfo/IntellijPluginDeployInfo.java
new file mode 100644
index 0000000..426fa6b
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/ideinfo/IntellijPluginDeployInfo.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.base.ideinfo;
+
+import com.google.common.collect.ImmutableList;
+import java.io.Serializable;
+import javax.annotation.concurrent.Immutable;
+
+/** A special rule representing the files that need to be deployed for an IntelliJ plugin */
+@Immutable
+public class IntellijPluginDeployInfo implements Serializable {
+  private static final long serialVersionUID = 1L;
+
+  /** A single file for deployment */
+  @Immutable
+  public static class IntellijPluginDeployFile implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /** The source file to deploy. */
+    public final ArtifactLocation src;
+    /** A plugins-directory relative location to deploy to. */
+    public final String deployLocation;
+
+    public IntellijPluginDeployFile(ArtifactLocation src, String deployLocation) {
+      this.src = src;
+      this.deployLocation = deployLocation;
+    }
+  }
+
+  public final ImmutableList<IntellijPluginDeployFile> deployFiles;
+
+  public IntellijPluginDeployInfo(ImmutableList<IntellijPluginDeployFile> deployFiles) {
+    this.deployFiles = deployFiles;
+  }
+}
diff --git a/base/src/com/google/idea/blaze/base/ideinfo/JavaIdeInfo.java b/base/src/com/google/idea/blaze/base/ideinfo/JavaIdeInfo.java
index bf9e527..174db51 100644
--- a/base/src/com/google/idea/blaze/base/ideinfo/JavaIdeInfo.java
+++ b/base/src/com/google/idea/blaze/base/ideinfo/JavaIdeInfo.java
@@ -46,17 +46,22 @@
   /** File containing dependencies. */
   @Nullable public final ArtifactLocation jdepsFile;
 
+  /** main_class attribute value for java_binary targets */
+  @Nullable public String javaBinaryMainClass;
+
   public JavaIdeInfo(
       Collection<LibraryArtifact> jars,
       Collection<LibraryArtifact> generatedJars,
       @Nullable LibraryArtifact filteredGenJar,
       @Nullable ArtifactLocation packageManifest,
-      @Nullable ArtifactLocation jdepsFile) {
+      @Nullable ArtifactLocation jdepsFile,
+      @Nullable String javaBinaryMainClass) {
     this.jars = jars;
     this.generatedJars = generatedJars;
     this.packageManifest = packageManifest;
     this.jdepsFile = jdepsFile;
     this.filteredGenJar = filteredGenJar;
+    this.javaBinaryMainClass = javaBinaryMainClass;
   }
 
   public static Builder builder() {
@@ -67,7 +72,8 @@
   public static class Builder {
     ImmutableList.Builder<LibraryArtifact> jars = ImmutableList.builder();
     ImmutableList.Builder<LibraryArtifact> generatedJars = ImmutableList.builder();
-    LibraryArtifact filteredGenJar;
+    @Nullable LibraryArtifact filteredGenJar;
+    @Nullable String mainClass;
 
     public Builder addJar(LibraryArtifact.Builder jar) {
       jars.add(jar.build());
@@ -84,8 +90,14 @@
       return this;
     }
 
+    public Builder setMainClass(@Nullable String mainClass) {
+      this.mainClass = mainClass;
+      return this;
+    }
+
     public JavaIdeInfo build() {
-      return new JavaIdeInfo(jars.build(), generatedJars.build(), filteredGenJar, null, null);
+      return new JavaIdeInfo(
+          jars.build(), generatedJars.build(), filteredGenJar, null, null, mainClass);
     }
   }
 }
diff --git a/base/src/com/google/idea/blaze/base/ideinfo/TargetIdeInfo.java b/base/src/com/google/idea/blaze/base/ideinfo/TargetIdeInfo.java
index 6ea3685..34f2bc2 100644
--- a/base/src/com/google/idea/blaze/base/ideinfo/TargetIdeInfo.java
+++ b/base/src/com/google/idea/blaze/base/ideinfo/TargetIdeInfo.java
@@ -27,7 +27,7 @@
 
 /** Simple implementation of TargetIdeInfo. */
 public final class TargetIdeInfo implements Serializable {
-  private static final long serialVersionUID = 14L;
+  private static final long serialVersionUID = 15L;
 
   public final TargetKey key;
   public final Kind kind;
@@ -43,6 +43,7 @@
   @Nullable public final TestIdeInfo testIdeInfo;
   @Nullable public final ProtoLibraryLegacyInfo protoLibraryLegacyInfo;
   @Nullable public final JavaToolchainIdeInfo javaToolchainIdeInfo;
+  @Nullable public final IntellijPluginDeployInfo intellijPluginDeployInfo;
 
   public TargetIdeInfo(
       TargetKey key,
@@ -58,7 +59,8 @@
       @Nullable PyIdeInfo pyIdeInfo,
       @Nullable TestIdeInfo testIdeInfo,
       @Nullable ProtoLibraryLegacyInfo protoLibraryLegacyInfo,
-      @Nullable JavaToolchainIdeInfo javaToolchainIdeInfo) {
+      @Nullable JavaToolchainIdeInfo javaToolchainIdeInfo,
+      @Nullable IntellijPluginDeployInfo intellijPluginDeployInfo) {
     this.key = key;
     this.kind = kind;
     this.buildFile = buildFile;
@@ -73,6 +75,7 @@
     this.testIdeInfo = testIdeInfo;
     this.protoLibraryLegacyInfo = protoLibraryLegacyInfo;
     this.javaToolchainIdeInfo = javaToolchainIdeInfo;
+    this.intellijPluginDeployInfo = intellijPluginDeployInfo;
   }
 
   @Override
@@ -243,7 +246,8 @@
           pyIdeInfo,
           testIdeInfo,
           protoLibraryLegacyInfo,
-          javaToolchainIdeInfo);
+          javaToolchainIdeInfo,
+          null);
     }
   }
 }
diff --git a/base/src/com/google/idea/blaze/base/io/FileAttributeProvider.java b/base/src/com/google/idea/blaze/base/io/FileAttributeProvider.java
index aeb0f24..64c83fb 100644
--- a/base/src/com/google/idea/blaze/base/io/FileAttributeProvider.java
+++ b/base/src/com/google/idea/blaze/base/io/FileAttributeProvider.java
@@ -17,6 +17,7 @@
 
 import com.intellij.openapi.components.ServiceManager;
 import java.io.File;
+import javax.annotation.Nullable;
 
 /** Simple file system checks (existence, isDirectory) */
 public class FileAttributeProvider {
@@ -45,6 +46,7 @@
     return file.length();
   }
 
+  @Nullable
   public File[] listFiles(File file) {
     return file.listFiles();
   }
diff --git a/base/src/com/google/idea/blaze/base/io/VirtualFileSystemProvider.java b/base/src/com/google/idea/blaze/base/io/VirtualFileSystemProvider.java
new file mode 100644
index 0000000..182204f
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/io/VirtualFileSystemProvider.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.base.io;
+
+import com.intellij.openapi.components.ServiceManager;
+import com.intellij.openapi.vfs.LocalFileSystem;
+import com.intellij.openapi.vfs.VirtualFile;
+import java.io.File;
+import javax.annotation.Nullable;
+
+/** Provides indirection for virtual file systems. */
+public interface VirtualFileSystemProvider {
+
+  static VirtualFileSystemProvider getInstance() {
+    return ServiceManager.getService(VirtualFileSystemProvider.class);
+  }
+
+  LocalFileSystem getSystem();
+
+  /**
+   * Like {@link com.intellij.openapi.vfs.VfsUtil#findFileByIoFile} with refresh set to true.
+   *
+   * <p>Note: there are restrictions on the calling context. See comments for
+   * refreshAndFindFileByIoFile.
+   */
+  @Nullable
+  static VirtualFile findFileByIoFileRefreshIfNeeded(File file) {
+    LocalFileSystem fileSystem = getInstance().getSystem();
+    VirtualFile virtualFile = fileSystem.findFileByIoFile(file);
+    if (virtualFile == null || !virtualFile.isValid()) {
+      virtualFile = fileSystem.refreshAndFindFileByIoFile(file);
+    }
+    return virtualFile;
+  }
+}
diff --git a/base/src/com/google/idea/blaze/base/io/VirtualFileSystemProviderImpl.java b/base/src/com/google/idea/blaze/base/io/VirtualFileSystemProviderImpl.java
new file mode 100644
index 0000000..86a3495
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/io/VirtualFileSystemProviderImpl.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.base.io;
+
+import com.intellij.openapi.vfs.LocalFileSystem;
+
+/** Default implementation of {@link VirtualFileSystemProvider}. */
+public class VirtualFileSystemProviderImpl implements VirtualFileSystemProvider {
+
+  @Override
+  public LocalFileSystem getSystem() {
+    return LocalFileSystem.getInstance();
+  }
+}
diff --git a/base/src/com/google/idea/blaze/base/lang/buildfile/actions/BuildFileModifierImpl.java b/base/src/com/google/idea/blaze/base/lang/buildfile/actions/BuildFileModifierImpl.java
index a4fe33a..dd9178c 100644
--- a/base/src/com/google/idea/blaze/base/lang/buildfile/actions/BuildFileModifierImpl.java
+++ b/base/src/com/google/idea/blaze/base/lang/buildfile/actions/BuildFileModifierImpl.java
@@ -37,7 +37,7 @@
 /** Implementation of BuildFileModifier. Modifies the PSI tree directly. */
 public class BuildFileModifierImpl implements BuildFileModifier {
 
-  private static final Logger LOG = Logger.getInstance(BuildFileModifierImpl.class);
+  private static final Logger logger = Logger.getInstance(BuildFileModifierImpl.class);
 
   @Override
   public boolean addRule(Project project, BlazeContext context, Label newRule, Kind ruleKind) {
@@ -53,7 +53,7 @@
               LocalFileSystem.getInstance().refreshIoFiles(ImmutableList.of(file));
               BuildFile buildFile = manager.resolveBlazePackage(newRule.blazePackage());
               if (buildFile == null) {
-                LOG.error("No BUILD file found at location: " + newRule.blazePackage());
+                logger.error("No BUILD file found at location: " + newRule.blazePackage());
                 return false;
               }
               buildFile.add(createRule(project, ruleKind, newRule.targetName().toString()));
diff --git a/base/src/com/google/idea/blaze/base/lang/buildfile/findusages/BuildUsageTypeProvider.java b/base/src/com/google/idea/blaze/base/lang/buildfile/findusages/BuildUsageTypeProvider.java
new file mode 100644
index 0000000..6f86a21
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/lang/buildfile/findusages/BuildUsageTypeProvider.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.base.lang.buildfile.findusages;
+
+import com.google.idea.blaze.base.lang.buildfile.psi.BuildElement;
+import com.google.idea.blaze.base.lang.buildfile.psi.GlobExpression;
+import com.google.idea.blaze.base.lang.buildfile.psi.LoadStatement;
+import com.intellij.psi.PsiElement;
+import com.intellij.psi.util.PsiTreeUtil;
+import com.intellij.usages.UsageTarget;
+import com.intellij.usages.impl.rules.UsageType;
+import com.intellij.usages.impl.rules.UsageTypeProviderEx;
+import javax.annotation.Nullable;
+import org.jetbrains.annotations.NotNull;
+
+/** Used to provide user-visible string when grouping 'find usages' results by type. */
+public class BuildUsageTypeProvider implements UsageTypeProviderEx {
+  private static final UsageType IN_LOAD = new UsageType("Usage in load statement");
+  private static final UsageType IN_GLOB = new UsageType("Usage in BUILD glob");
+  private static final UsageType GENERIC = new UsageType("Usage in BUILD/Skylark file");
+
+  @Override
+  @Nullable
+  public UsageType getUsageType(PsiElement element) {
+    return getUsageType(element, UsageTarget.EMPTY_ARRAY);
+  }
+
+  @Override
+  @Nullable
+  public UsageType getUsageType(PsiElement element, @NotNull UsageTarget[] targets) {
+    if (!(element instanceof BuildElement)) {
+      return null;
+    }
+    if (PsiTreeUtil.getParentOfType(element, LoadStatement.class) != null) {
+      return IN_LOAD;
+    }
+    if (PsiTreeUtil.getParentOfType(element, GlobExpression.class, false) != null) {
+      return IN_GLOB;
+    }
+    return GENERIC;
+  }
+}
diff --git a/base/src/com/google/idea/blaze/base/lang/buildfile/globbing/UnixGlob.java b/base/src/com/google/idea/blaze/base/lang/buildfile/globbing/UnixGlob.java
index 321908b..b7a44c4 100644
--- a/base/src/com/google/idea/blaze/base/lang/buildfile/globbing/UnixGlob.java
+++ b/base/src/com/google/idea/blaze/base/lang/buildfile/globbing/UnixGlob.java
@@ -33,9 +33,7 @@
 import com.google.idea.blaze.base.lang.buildfile.validation.GlobPatternValidator;
 import com.intellij.openapi.application.ApplicationManager;
 import com.intellij.openapi.progress.ProgressManager;
-import com.intellij.openapi.vfs.LocalFileSystem;
 import com.intellij.openapi.vfs.VirtualFile;
-import com.intellij.openapi.vfs.VirtualFileSystem;
 import com.intellij.openapi.vfs.ex.temp.TempFileSystem;
 import java.io.File;
 import java.io.IOException;
@@ -738,13 +736,6 @@
       }
     }
 
-    private static VirtualFileSystem getFileSystem() {
-      if (ApplicationManager.getApplication().isUnitTestMode()) {
-        return TempFileSystem.getInstance();
-      }
-      return LocalFileSystem.getInstance();
-    }
-
     @Nullable
     private File[] getChildren(File file) {
       if (!ApplicationManager.getApplication().isUnitTestMode()) {
diff --git a/base/src/com/google/idea/blaze/base/lang/buildfile/language/semantics/BuiltInNamesProvider.java b/base/src/com/google/idea/blaze/base/lang/buildfile/language/semantics/BuiltInNamesProvider.java
index 7b90bc2..b2b063a 100644
--- a/base/src/com/google/idea/blaze/base/lang/buildfile/language/semantics/BuiltInNamesProvider.java
+++ b/base/src/com/google/idea/blaze/base/lang/buildfile/language/semantics/BuiltInNamesProvider.java
@@ -71,7 +71,7 @@
       ImmutableSet.of(
           "load",
           "package",
-          "pacakge_group",
+          "package_group",
           "licenses",
           "exports_files",
           "glob",
diff --git a/base/src/com/google/idea/blaze/base/lang/buildfile/language/semantics/RuleDefinition.java b/base/src/com/google/idea/blaze/base/lang/buildfile/language/semantics/RuleDefinition.java
index aa85f8d..085fdf8 100644
--- a/base/src/com/google/idea/blaze/base/lang/buildfile/language/semantics/RuleDefinition.java
+++ b/base/src/com/google/idea/blaze/base/lang/buildfile/language/semantics/RuleDefinition.java
@@ -24,6 +24,8 @@
 /** Simple implementation of RuleDefinition, from build.proto */
 public class RuleDefinition implements Serializable {
 
+  private static final long serialVersionUID = 2L;
+
   /**
    * In previous versions of blaze/bazel, this wasn't included in the proto. All other documented
    * attributes seem to be.
@@ -48,6 +50,7 @@
   public final String name;
   /** This map is not exhaustive; it only contains documented attributes. */
   public final ImmutableMap<String, AttributeDefinition> attributes;
+  public final ImmutableMap<String, AttributeDefinition> mandatoryAttributes;
 
   @Nullable public final String documentation;
 
@@ -58,6 +61,14 @@
     this.name = name;
     this.attributes = attributes;
     this.documentation = documentation;
+
+    ImmutableMap.Builder<String, AttributeDefinition> builder = ImmutableMap.builder();
+    for (AttributeDefinition attr : attributes.values()) {
+      if (attr.mandatory) {
+        builder.put(attr.name, attr);
+      }
+    }
+    mandatoryAttributes = builder.build();
   }
 
   public ImmutableSet<String> getKnownAttributeNames() {
diff --git a/base/src/com/google/idea/blaze/base/lang/buildfile/parser/ExpressionParsing.java b/base/src/com/google/idea/blaze/base/lang/buildfile/parser/ExpressionParsing.java
index 11688ef..f87d429 100644
--- a/base/src/com/google/idea/blaze/base/lang/buildfile/parser/ExpressionParsing.java
+++ b/base/src/com/google/idea/blaze/base/lang/buildfile/parser/ExpressionParsing.java
@@ -86,23 +86,27 @@
   }
 
   public void parseExpression(boolean insideParens) {
-    // handle lists without parens (e.g. 'a,b,c = 1')
-    PsiBuilder.Marker marker = insideParens ? null : builder.mark();
+    PsiBuilder.Marker tupleMarker = builder.mark();
     parseNonTupleExpression();
     if (currentToken() == TokenKind.COMMA) {
-      parseExpressionList();
-      if (marker != null) {
-        marker.done(BuildElementTypes.LIST_LITERAL);
-      }
-    } else if (marker != null) {
-      marker.drop();
+      parseExpressionList(insideParens);
+      tupleMarker.done(BuildElementTypes.TUPLE_EXPRESSION);
+    } else {
+      tupleMarker.drop();
     }
   }
 
-  // expr_list ::= ( ',' expr )* ','?
-  private void parseExpressionList() {
+  /**
+   * Parses a comma-separated list of expressions. It assumes that the first expression was already
+   * parsed, so it starts with a comma. It is used to parse tuples and list elements.<br>
+   * expr_list ::= ( ',' expr )* ','?
+   */
+  private void parseExpressionList(boolean trailingColonAllowed) {
     while (matches(TokenKind.COMMA)) {
       if (atAnyOfTokens(EXPR_LIST_TERMINATOR_SET)) {
+        if (!trailingColonAllowed) {
+          builder.error("Trailing commas are allowed only in parenthesized tuples.");
+        }
         break;
       }
       parseNonTupleExpression();
@@ -270,12 +274,12 @@
         marker = builder.mark();
         builder.advanceLexer();
         if (matches(TokenKind.RPAREN)) {
-          marker.done(BuildElementTypes.LIST_LITERAL);
+          marker.done(BuildElementTypes.TUPLE_EXPRESSION);
           return;
         }
         parseExpression(true);
         expect(TokenKind.RPAREN, true);
-        marker.done(BuildElementTypes.LIST_LITERAL);
+        marker.done(BuildElementTypes.PARENTHESIZED_EXPRESSION);
         return;
       case MINUS:
         marker = builder.mark();
@@ -405,7 +409,7 @@
         marker.done(BuildElementTypes.LIST_COMPREHENSION_EXPR);
         return;
       case COMMA:
-        parseExpressionList();
+        parseExpressionList(true);
         if (!matches(TokenKind.RBRACKET)) {
           builder.error("expected 'for' or ']'");
           syncPast(LIST_TERMINATOR_SET);
@@ -475,7 +479,7 @@
         expect(TokenKind.IN);
         parseNonTupleExpression(0);
       } else if (matches(TokenKind.IF)) {
-        parseExpression(true);
+        parseExpression(false);
       } else if (matches(closingBracket)) {
         return;
       } else {
diff --git a/base/src/com/google/idea/blaze/base/lang/buildfile/psi/BuildElementTypes.java b/base/src/com/google/idea/blaze/base/lang/buildfile/psi/BuildElementTypes.java
index 5f33a6d..7c4d142 100644
--- a/base/src/com/google/idea/blaze/base/lang/buildfile/psi/BuildElementTypes.java
+++ b/base/src/com/google/idea/blaze/base/lang/buildfile/psi/BuildElementTypes.java
@@ -79,6 +79,9 @@
   BuildElementType LIST_COMPREHENSION_EXPR =
       new BuildElementType("list_comp", ListComprehensionExpression.class);
   BuildElementType LOADED_SYMBOL = new BuildElementType("loaded_symbol", LoadedSymbol.class);
+  BuildElementType PARENTHESIZED_EXPRESSION =
+      new BuildElementType("parens", ParenthesizedExpression.class);
+  BuildElementType TUPLE_EXPRESSION = new BuildElementType("tuple", TupleExpression.class);
 
   TokenSet EXPRESSIONS =
       TokenSet.create(
@@ -90,6 +93,8 @@
           STRING_LITERAL,
           INTEGER_LITERAL,
           LIST_LITERAL,
+          PARENTHESIZED_EXPRESSION,
+          TUPLE_EXPRESSION,
           REFERENCE_EXPRESSION,
           TARGET_EXPRESSION,
           LIST_COMPREHENSION_EXPR,
diff --git a/base/src/com/google/idea/blaze/base/lang/buildfile/psi/BuildElementVisitor.java b/base/src/com/google/idea/blaze/base/lang/buildfile/psi/BuildElementVisitor.java
index 68e9455..804a5dc 100644
--- a/base/src/com/google/idea/blaze/base/lang/buildfile/psi/BuildElementVisitor.java
+++ b/base/src/com/google/idea/blaze/base/lang/buildfile/psi/BuildElementVisitor.java
@@ -143,4 +143,12 @@
   public void visitLoadedSymbol(LoadedSymbol node) {
     visitElement(node);
   }
+
+  public void visitParenthesizedExpression(ParenthesizedExpression node) {
+    visitElement(node);
+  }
+
+  public void visitTupleExpression(TupleExpression node) {
+    visitElement(node);
+  }
 }
diff --git a/base/src/com/google/idea/blaze/base/lang/buildfile/psi/ListLiteral.java b/base/src/com/google/idea/blaze/base/lang/buildfile/psi/ListLiteral.java
index 65fe842..971c824 100644
--- a/base/src/com/google/idea/blaze/base/lang/buildfile/psi/ListLiteral.java
+++ b/base/src/com/google/idea/blaze/base/lang/buildfile/psi/ListLiteral.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.ImmutableList;
 import com.intellij.lang.ASTNode;
 
-/** PSI element for list and tuple literals */
+/** PSI element for list literals */
 public class ListLiteral extends BuildListType<Expression> implements LiteralExpression {
 
   public ListLiteral(ASTNode astNode) {
@@ -51,6 +51,6 @@
 
   @Override
   public ImmutableList<Character> getEndChars() {
-    return ImmutableList.of(')', ']');
+    return ImmutableList.of(']');
   }
 }
diff --git a/base/src/com/google/idea/blaze/base/lang/buildfile/psi/ParenthesizedExpression.java b/base/src/com/google/idea/blaze/base/lang/buildfile/psi/ParenthesizedExpression.java
new file mode 100644
index 0000000..0852db9
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/lang/buildfile/psi/ParenthesizedExpression.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.base.lang.buildfile.psi;
+
+import com.intellij.lang.ASTNode;
+import com.intellij.psi.util.PsiTreeUtil;
+import javax.annotation.Nullable;
+
+/** PSI element for a parenthesized expression. */
+public class ParenthesizedExpression extends BuildElementImpl implements Expression {
+
+  public ParenthesizedExpression(ASTNode astNode) {
+    super(astNode);
+  }
+
+  @Override
+  protected void acceptVisitor(BuildElementVisitor visitor) {
+    visitor.visitParenthesizedExpression(this);
+  }
+
+  @Nullable
+  public Expression getContainedExpression() {
+    Expression expr = this;
+    while (expr instanceof ParenthesizedExpression) {
+      expr = PsiTreeUtil.getChildOfType(this, Expression.class);
+    }
+    return expr;
+  }
+}
diff --git a/base/src/com/google/idea/blaze/base/lang/buildfile/psi/StringLiteral.java b/base/src/com/google/idea/blaze/base/lang/buildfile/psi/StringLiteral.java
index 009dda6..027107e 100644
--- a/base/src/com/google/idea/blaze/base/lang/buildfile/psi/StringLiteral.java
+++ b/base/src/com/google/idea/blaze/base/lang/buildfile/psi/StringLiteral.java
@@ -15,11 +15,15 @@
  */
 package com.google.idea.blaze.base.lang.buildfile.psi;
 
+import com.google.idea.blaze.base.lang.buildfile.psi.Argument.Keyword;
+import com.google.idea.blaze.base.lang.buildfile.psi.util.PsiUtils;
+import com.google.idea.blaze.base.lang.buildfile.references.AttributeSpecificStringLiteralReferenceProvider;
 import com.google.idea.blaze.base.lang.buildfile.references.LabelReference;
 import com.google.idea.blaze.base.lang.buildfile.references.LoadedSymbolReference;
 import com.google.idea.blaze.base.lang.buildfile.references.PackageReferenceFragment;
 import com.google.idea.blaze.base.lang.buildfile.references.QuoteType;
 import com.intellij.lang.ASTNode;
+import com.intellij.openapi.util.TextRange;
 import com.intellij.psi.PsiElement;
 import com.intellij.psi.PsiReference;
 import javax.annotation.Nullable;
@@ -32,21 +36,30 @@
    * (in which case escaped characters, raw strings, etc. are unlikely).
    */
   public static String stripQuotes(String string) {
+    return textRangeInElement(string).substring(string);
+  }
+
+  /** The range of text characters, excluding leading and trailing quotes. */
+  public static TextRange textRangeInElement(String string) {
     // TODO: Handle escaped characters, etc. here?
     // (extract logic from BuildLexerBase.addStringLiteral)
     if (string.startsWith("\"\"\"")) {
-      return string.length() <= 3 ? "" : string.substring(3, endTrimIndex(string, '"', 3));
+      return string.length() <= 3
+          ? TextRange.EMPTY_RANGE
+          : TextRange.create(3, endTrimIndex(string, '"', 3));
     }
     if (string.startsWith("'''")) {
-      return string.length() <= 3 ? "" : string.substring(3, endTrimIndex(string, '\'', 3));
+      return string.length() <= 3
+          ? TextRange.EMPTY_RANGE
+          : TextRange.create(3, endTrimIndex(string, '\'', 3));
     }
     if (string.startsWith("\"")) {
-      return string.substring(1, endTrimIndex(string, '"', 1));
+      return TextRange.create(1, endTrimIndex(string, '"', 1));
     }
     if (string.startsWith("'")) {
-      return string.substring(1, endTrimIndex(string, '\'', 1));
+      return TextRange.create(1, endTrimIndex(string, '\'', 1));
     }
-    return string;
+    return TextRange.allOf(string);
   }
 
   private static int endTrimIndex(String string, char quoteChar, int numberQuoteChars) {
@@ -104,6 +117,15 @@
    */
   @Override
   public PsiReference[] getReferences() {
+    // first look for attribute-specific references
+    String attributeName = getParentAttributeName();
+    if (attributeName != null) {
+      PsiReference[] refs =
+          AttributeSpecificStringLiteralReferenceProvider.findReferences(attributeName, this);
+      if (refs.length != 0) {
+        return refs;
+      }
+    }
     PsiReference primaryReference = getReference();
     if (primaryReference instanceof LabelReference) {
       return new PsiReference[] {
@@ -134,6 +156,13 @@
     return new LabelReference(this, true);
   }
 
+  /** If this string is an attribute value within a BUILD rule, return the attribute type. */
+  @Nullable
+  private String getParentAttributeName() {
+    Keyword parentKeyword = PsiUtils.getParentOfType(this, Keyword.class);
+    return parentKeyword != null ? parentKeyword.getName() : null;
+  }
+
   @Nullable
   public LoadStatement getLoadStatementParent() {
     PsiElement parent = getParent();
diff --git a/base/src/com/google/idea/blaze/base/lang/buildfile/psi/TupleExpression.java b/base/src/com/google/idea/blaze/base/lang/buildfile/psi/TupleExpression.java
new file mode 100644
index 0000000..762bb8a
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/lang/buildfile/psi/TupleExpression.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.base.lang.buildfile.psi;
+
+import com.google.common.collect.ImmutableList;
+import com.intellij.lang.ASTNode;
+
+/**
+ * PSI element for tuples inside a parenthesized expression. Also used for tuples without enclosing
+ * parentheses, not supported in Skylark (accompanied by an appropriate error annotation).
+ */
+public class TupleExpression extends BuildListType<Expression> implements Expression {
+
+  public TupleExpression(ASTNode astNode) {
+    super(astNode, Expression.class);
+  }
+
+  @Override
+  protected void acceptVisitor(BuildElementVisitor visitor) {
+    visitor.visitTupleExpression(this);
+  }
+
+  @Override
+  public String getPresentableText() {
+    return "tuple";
+  }
+
+  public Expression[] getChildExpressions() {
+    return findChildrenByClass(Expression.class);
+  }
+
+  @Override
+  public Expression[] getElements() {
+    return getChildExpressions();
+  }
+
+  @Override
+  public boolean isEmpty() {
+    return findChildByClass(Expression.class) != null;
+  }
+
+  @Override
+  public ImmutableList<Character> getEndChars() {
+    return ImmutableList.of(')');
+  }
+}
diff --git a/base/src/com/google/idea/blaze/base/lang/buildfile/psi/util/PsiUtils.java b/base/src/com/google/idea/blaze/base/lang/buildfile/psi/util/PsiUtils.java
index 229220b..30d8c5e 100644
--- a/base/src/com/google/idea/blaze/base/lang/buildfile/psi/util/PsiUtils.java
+++ b/base/src/com/google/idea/blaze/base/lang/buildfile/psi/util/PsiUtils.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.Lists;
 import com.google.idea.blaze.base.lang.buildfile.psi.AssignmentStatement;
 import com.google.idea.blaze.base.lang.buildfile.psi.Expression;
+import com.google.idea.blaze.base.lang.buildfile.psi.ParenthesizedExpression;
 import com.google.idea.blaze.base.lang.buildfile.psi.ReferenceExpression;
 import com.google.idea.blaze.base.lang.buildfile.psi.TargetExpression;
 import com.intellij.lang.ASTNode;
@@ -154,18 +155,29 @@
 
   /**
    * For ReferenceExpressions, follows the chain of references until it hits a
-   * non-ReferenceExpression. For other types, returns the input expression.
+   * non-ReferenceExpression.<br>
+   * Unwraps ParenthesizedExpression.<br>
+   * For other types, returns the input expression.
    */
   public static PsiElement getReferencedTarget(Expression expr) {
     PsiElement element = expr;
-    while (element instanceof ReferenceExpression) {
-      PsiElement referencedElement = ((ReferenceExpression) element).getReferencedElement();
-      if (referencedElement == null) {
+    while (true) {
+      PsiElement unwrapped = unwrap(element);
+      if (unwrapped == null || unwrapped == element) {
         return element;
       }
-      element = referencedElement;
+      element = unwrapped;
     }
-    return element;
+  }
+
+  private static PsiElement unwrap(PsiElement expr) {
+    if (expr instanceof ParenthesizedExpression) {
+      return ((ParenthesizedExpression) expr).getContainedExpression();
+    }
+    if (expr instanceof ReferenceExpression) {
+      return ((ReferenceExpression) expr).getReferencedElement();
+    }
+    return expr;
   }
 
   /**
diff --git a/base/src/com/google/idea/blaze/base/lang/buildfile/refactor/StringLiteralElementManipulator.java b/base/src/com/google/idea/blaze/base/lang/buildfile/refactor/StringLiteralElementManipulator.java
new file mode 100644
index 0000000..502d5dd
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/lang/buildfile/refactor/StringLiteralElementManipulator.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.base.lang.buildfile.refactor;
+
+import com.google.idea.blaze.base.lang.buildfile.psi.StringLiteral;
+import com.google.idea.blaze.base.lang.buildfile.psi.util.PsiUtils;
+import com.intellij.lang.ASTNode;
+import com.intellij.openapi.util.TextRange;
+import com.intellij.psi.AbstractElementManipulator;
+import com.intellij.util.IncorrectOperationException;
+import org.jetbrains.annotations.NotNull;
+
+/** Allows direct text substitution in {@link StringLiteral}'s by IntelliJ's refactoring tools. */
+public class StringLiteralElementManipulator extends AbstractElementManipulator<StringLiteral> {
+
+  @Override
+  public StringLiteral handleContentChange(
+      StringLiteral element, TextRange range, String newContent)
+      throws IncorrectOperationException {
+    ASTNode node = element.getNode();
+    node.replaceChild(
+        node.getFirstChildNode(), PsiUtils.createNewLabel(element.getProject(), newContent));
+    return element;
+  }
+
+  @NotNull
+  @Override
+  public TextRange getRangeInElement(@NotNull StringLiteral element) {
+    return StringLiteral.textRangeInElement(element.getText());
+  }
+}
diff --git a/base/src/com/google/idea/blaze/base/lang/buildfile/references/AttributeSpecificStringLiteralReferenceProvider.java b/base/src/com/google/idea/blaze/base/lang/buildfile/references/AttributeSpecificStringLiteralReferenceProvider.java
new file mode 100644
index 0000000..a34c12d
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/lang/buildfile/references/AttributeSpecificStringLiteralReferenceProvider.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.base.lang.buildfile.references;
+
+import com.google.idea.blaze.base.lang.buildfile.psi.StringLiteral;
+import com.intellij.openapi.extensions.ExtensionPointName;
+import com.intellij.psi.PsiReference;
+
+/** Non-default reference provider for {@link StringLiteral} values for a given attribute type. */
+public interface AttributeSpecificStringLiteralReferenceProvider {
+
+  ExtensionPointName<AttributeSpecificStringLiteralReferenceProvider> EP_NAME =
+      ExtensionPointName.create(
+          "com.google.idea.blaze.AttributeSpecificStringLiteralReferenceProvider");
+
+  /** Find a reference type specific to values of this attribute. */
+  static PsiReference[] findReferences(String attributeName, StringLiteral literal) {
+    for (AttributeSpecificStringLiteralReferenceProvider provider : EP_NAME.getExtensions()) {
+      PsiReference[] refs = provider.getReferences(attributeName, literal);
+      if (refs.length != 0) {
+        return refs;
+      }
+    }
+    return PsiReference.EMPTY_ARRAY;
+  }
+
+  /** Find references specific to this attribute. */
+  PsiReference[] getReferences(String attributeName, StringLiteral literal);
+}
diff --git a/base/src/com/google/idea/blaze/base/lang/buildfile/references/BuildReferenceManager.java b/base/src/com/google/idea/blaze/base/lang/buildfile/references/BuildReferenceManager.java
index 6635ade..9b72b1d 100644
--- a/base/src/com/google/idea/blaze/base/lang/buildfile/references/BuildReferenceManager.java
+++ b/base/src/com/google/idea/blaze/base/lang/buildfile/references/BuildReferenceManager.java
@@ -17,6 +17,7 @@
 
 import com.google.common.collect.Lists;
 import com.google.idea.blaze.base.io.FileAttributeProvider;
+import com.google.idea.blaze.base.io.VirtualFileSystemProvider;
 import com.google.idea.blaze.base.lang.buildfile.completion.BuildLookupElement;
 import com.google.idea.blaze.base.lang.buildfile.psi.BuildFile;
 import com.google.idea.blaze.base.lang.buildfile.psi.FuncallExpression;
@@ -26,16 +27,12 @@
 import com.google.idea.blaze.base.settings.Blaze;
 import com.google.idea.blaze.base.sync.workspace.WorkspacePathResolver;
 import com.google.idea.blaze.base.sync.workspace.WorkspacePathResolverProvider;
-import com.intellij.openapi.application.ApplicationManager;
 import com.intellij.openapi.components.ServiceManager;
 import com.intellij.openapi.progress.ProgressManager;
 import com.intellij.openapi.project.Project;
 import com.intellij.openapi.util.io.FileUtil;
 import com.intellij.openapi.util.text.StringUtil;
-import com.intellij.openapi.vfs.LocalFileSystem;
 import com.intellij.openapi.vfs.VirtualFile;
-import com.intellij.openapi.vfs.VirtualFileSystem;
-import com.intellij.openapi.vfs.ex.temp.TempFileSystem;
 import com.intellij.psi.PsiElement;
 import com.intellij.psi.PsiFile;
 import com.intellij.psi.PsiFileSystemItem;
@@ -100,7 +97,8 @@
 
   @Nullable
   public PsiFileSystemItem resolveFile(File file) {
-    VirtualFile vf = getFileSystem().findFileByPath(file.getPath());
+    VirtualFile vf =
+        VirtualFileSystemProvider.getInstance().getSystem().findFileByPath(file.getPath());
     if (vf == null) {
       return null;
     }
@@ -152,7 +150,8 @@
     if (file == null || !provider.isDirectory(file)) {
       return BuildLookupElement.EMPTY_ARRAY;
     }
-    VirtualFile vf = getFileSystem().findFileByPath(file.getPath());
+    VirtualFile vf =
+        VirtualFileSystemProvider.getInstance().getSystem().findFileByPath(file.getPath());
     if (vf == null || !vf.isDirectory()) {
       return BuildLookupElement.EMPTY_ARRAY;
     }
@@ -212,7 +211,10 @@
     if (packageDirectory == null || !provider.isDirectory(packageDirectory)) {
       return null;
     }
-    VirtualFile vf = getFileSystem().findFileByPath(packageDirectory.getPath());
+    VirtualFile vf =
+        VirtualFileSystemProvider.getInstance()
+            .getSystem()
+            .findFileByPath(packageDirectory.getPath());
     if (vf == null) {
       return null;
     }
@@ -248,11 +250,4 @@
     WorkspacePathResolver pathResolver = getWorkspacePathResolver();
     return pathResolver != null ? pathResolver.getWorkspacePath(new File(absolutePath)) : null;
   }
-
-  private static VirtualFileSystem getFileSystem() {
-    if (ApplicationManager.getApplication().isUnitTestMode()) {
-      return TempFileSystem.getInstance();
-    }
-    return LocalFileSystem.getInstance();
-  }
 }
diff --git a/base/src/com/google/idea/blaze/base/lang/buildfile/references/GlobReference.java b/base/src/com/google/idea/blaze/base/lang/buildfile/references/GlobReference.java
index c880fb8..c3ddb46 100644
--- a/base/src/com/google/idea/blaze/base/lang/buildfile/references/GlobReference.java
+++ b/base/src/com/google/idea/blaze/base/lang/buildfile/references/GlobReference.java
@@ -41,7 +41,7 @@
 /** References from a glob to a list of files contained in the same blaze package. */
 public class GlobReference extends PsiPolyVariantCachingReference {
 
-  private static final Logger LOG = Logger.getInstance(GlobReference.class);
+  private static final Logger logger = Logger.getInstance(GlobReference.class);
 
   private final GlobExpression element;
 
@@ -203,7 +203,7 @@
     try {
       return range.substring(text);
     } catch (StringIndexOutOfBoundsException e) {
-      LOG.error(
+      logger.error(
           "Wrong range in reference " + this + ": " + range + ". Reference text: '" + text + "'",
           e);
       return text;
diff --git a/base/src/com/google/idea/blaze/base/lang/buildfile/references/LabelUtils.java b/base/src/com/google/idea/blaze/base/lang/buildfile/references/LabelUtils.java
index 3ac8a5f..db58991 100644
--- a/base/src/com/google/idea/blaze/base/lang/buildfile/references/LabelUtils.java
+++ b/base/src/com/google/idea/blaze/base/lang/buildfile/references/LabelUtils.java
@@ -15,7 +15,9 @@
  */
 package com.google.idea.blaze.base.lang.buildfile.references;
 
+import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Multimap;
 import com.google.idea.blaze.base.lang.buildfile.psi.BuildFile;
 import com.google.idea.blaze.base.lang.buildfile.search.BlazePackage;
 import com.google.idea.blaze.base.model.primitives.Label;
@@ -23,6 +25,7 @@
 import com.google.idea.blaze.base.model.primitives.WorkspacePath;
 import com.intellij.codeInsight.completion.CompletionUtilCore;
 import com.intellij.util.PathUtil;
+import java.util.ArrayList;
 import java.util.List;
 import javax.annotation.Nullable;
 
@@ -52,6 +55,14 @@
       return null;
     }
     WorkspacePath packagePath = blazePackage.buildFile.getPackageWorkspacePath();
+    return createLabelFromRuleName(packagePath, ruleName);
+  }
+
+  public static Label createLabelFromRuleName(
+      @Nullable WorkspacePath packagePath, @Nullable String ruleName) {
+    if (ruleName == null) {
+      return null;
+    }
     TargetName name = TargetName.createIfValid(ruleName);
     if (packagePath == null || name == null) {
       return null;
@@ -160,6 +171,36 @@
   }
 
   /**
+   * Return a map from a base label string -> variants of the label string that share the common
+   * base.
+   */
+  public static Multimap<String, String> getAllValidLabelStringsPartitioned(
+      Label label, boolean includePackageLocalLabels) {
+    Multimap<String, String> stringToVariant = ArrayListMultimap.create();
+    String fullLabelString = label.toString();
+    List<String> fullVariants = new ArrayList<>();
+    fullVariants.add(fullLabelString);
+    String packagePath = label.blazePackage().relativePath();
+    String ruleName = label.targetName().toString();
+    if (!packagePath.isEmpty()) {
+      if (PathUtil.getFileName(packagePath).equals(ruleName)) {
+        String implicitRuleName = "//" + packagePath;
+        fullVariants.add(implicitRuleName);
+        fullLabelString = implicitRuleName;
+      }
+    }
+    stringToVariant.putAll(fullLabelString, fullVariants);
+    if (!includePackageLocalLabels) {
+      return stringToVariant;
+    }
+    List<String> localVariants = new ArrayList<>();
+    localVariants.add(":" + ruleName);
+    localVariants.add(ruleName);
+    stringToVariant.putAll(ruleName, localVariants);
+    return stringToVariant;
+  }
+
+  /**
    * IntelliJ inserts an identifier string at the caret position during code completion.<br>
    * We're only interested in the portion of the string before the caret, so trim the rest.
    */
diff --git a/base/src/com/google/idea/blaze/base/lang/buildfile/search/ExcludeBuildFilesScope.java b/base/src/com/google/idea/blaze/base/lang/buildfile/search/ExcludeBuildFilesScope.java
index 3fa294b..66fb03c 100644
--- a/base/src/com/google/idea/blaze/base/lang/buildfile/search/ExcludeBuildFilesScope.java
+++ b/base/src/com/google/idea/blaze/base/lang/buildfile/search/ExcludeBuildFilesScope.java
@@ -28,11 +28,12 @@
  * <br>
  * BUILD file / BUILD package references are handled by a separate reference searcher.
  *
- * <p>This is a hack, but greatly improves efficiency. The reasoning behind this: - BUILD files have
- * very strict file reference patterns, and very narrow direct reference scopes (a package can't
- * directly reference files in another package). - IJ *constantly* performs global searches on
- * strings when manipulating files (e.g. searching for file uses for highlighting, rename, move
- * operations). This causes us to re-parse every BUILD file in the project, multiple times.
+ * <p>This is a hack, but greatly improves efficiency. The reasoning behind this:
+ * <li>BUILD files have very strict file reference patterns, and very narrow direct reference scopes
+ *     (a package can't directly reference files in another package).
+ * <li>IJ *constantly* performs global searches on strings when manipulating files (e.g. searching
+ *     for file uses for highlighting, rename, move operations). This causes us to re-parse every
+ *     BUILD file in the project, multiple times.
  */
 public class ExcludeBuildFilesScope extends UseScopeOptimizer {
 
diff --git a/base/src/com/google/idea/blaze/base/lang/buildfile/sync/BuildLangSyncPlugin.java b/base/src/com/google/idea/blaze/base/lang/buildfile/sync/BuildLangSyncPlugin.java
index 561cd1a..0279a2f 100644
--- a/base/src/com/google/idea/blaze/base/lang/buildfile/sync/BuildLangSyncPlugin.java
+++ b/base/src/com/google/idea/blaze/base/lang/buildfile/sync/BuildLangSyncPlugin.java
@@ -44,7 +44,7 @@
 /** Updates the language specification during the blaze sync process */
 public class BuildLangSyncPlugin extends BlazeSyncPlugin.Adapter {
 
-  private static final Logger LOG = Logger.getInstance(BuildLangSyncPlugin.class);
+  private static final Logger logger = Logger.getInstance(BuildLangSyncPlugin.class);
 
   @Override
   public void updateSyncState(
@@ -115,11 +115,11 @@
       return null;
     } catch (ExecutionException | InvalidProtocolBufferException | NullPointerException e) {
       if (!ApplicationManager.getApplication().isUnitTestMode()) {
-        LOG.error(e);
+        logger.error(e);
       }
       return null;
     } catch (Throwable e) {
-      LOG.error(e);
+      logger.error(e);
       return null;
     }
   }
diff --git a/base/src/com/google/idea/blaze/base/lang/buildfile/validation/BuildElementValidation.java b/base/src/com/google/idea/blaze/base/lang/buildfile/validation/BuildElementValidation.java
new file mode 100644
index 0000000..138908c
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/lang/buildfile/validation/BuildElementValidation.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.base.lang.buildfile.validation;
+
+import com.google.idea.blaze.base.lang.buildfile.psi.DictionaryLiteral;
+import com.google.idea.blaze.base.lang.buildfile.psi.FunctionStatement;
+import com.google.idea.blaze.base.lang.buildfile.psi.GlobExpression;
+import com.google.idea.blaze.base.lang.buildfile.psi.IntegerLiteral;
+import com.google.idea.blaze.base.lang.buildfile.psi.ListLiteral;
+import com.google.idea.blaze.base.lang.buildfile.psi.LiteralExpression;
+import com.google.idea.blaze.base.lang.buildfile.psi.LoadStatement;
+import com.google.idea.blaze.base.lang.buildfile.psi.StringLiteral;
+import com.google.repackaged.devtools.build.lib.query2.proto.proto2api.Build;
+import com.google.repackaged.devtools.build.lib.query2.proto.proto2api.Build.Attribute.Discriminator;
+import com.intellij.psi.PsiElement;
+import java.util.EnumSet;
+
+/**
+ * Provides simple validation of BUILD psi element types (e.g. is the type known to not resolve to a
+ * string).<br>
+ * We err on the side of avoiding spurious errors.
+ */
+class BuildElementValidation {
+
+  private static final EnumSet<Build.Attribute.Discriminator> LIST_TYPES =
+      EnumSet.of(
+          Discriminator.STRING_LIST,
+          Discriminator.LABEL_LIST,
+          Discriminator.OUTPUT_LIST,
+          Discriminator.FILESET_ENTRY_LIST,
+          Discriminator.INTEGER_LIST,
+          Discriminator.LICENSE);
+
+  private static final EnumSet<Build.Attribute.Discriminator> DICT_TYPES =
+      EnumSet.of(Discriminator.LABEL_LIST_DICT, Discriminator.STRING_LIST_DICT);
+
+  private static final EnumSet<Build.Attribute.Discriminator> STRING_TYPES =
+      EnumSet.of(
+          Discriminator.STRING,
+          Discriminator.LABEL,
+          Discriminator.OUTPUT,
+          Discriminator.BOOLEAN,
+          Discriminator.TRISTATE);
+
+  private static final EnumSet<Build.Attribute.Discriminator> INTEGER_TYPES =
+      EnumSet.of(Discriminator.INTEGER, Discriminator.BOOLEAN, Discriminator.TRISTATE);
+
+  /** Returns false iff we know with certainty that the element cannot resolve to the given type. */
+  public static boolean possiblyValidType(PsiElement element, Build.Attribute.Discriminator type) {
+    if (type == Discriminator.UNKNOWN) {
+      return true;
+    }
+    if (element instanceof ListLiteral || element instanceof GlobExpression) {
+      return LIST_TYPES.contains(type);
+    }
+    if (element instanceof StringLiteral) {
+      return STRING_TYPES.contains(type);
+    }
+    if (element instanceof DictionaryLiteral) {
+      return DICT_TYPES.contains(type);
+    }
+    if (element instanceof IntegerLiteral) {
+      return INTEGER_TYPES.contains(type);
+    }
+    return true;
+  }
+
+  /** Returns false iff we know with certainty that the element cannot resolve to a list literal. */
+  public static boolean possiblyValidListLiteral(PsiElement element) {
+    if (element instanceof ListLiteral || element instanceof GlobExpression) {
+      return true; // these evaluate directly to list literals
+    }
+    if (element instanceof LiteralExpression) {
+      return false; // all other literals cannot evaluate to a ListLiteral
+    }
+    if (element instanceof LoadStatement || element instanceof FunctionStatement) {
+      return false;
+    }
+    // everything else treated as possibly evaluating to a list
+    return true;
+  }
+
+  /**
+   * Returns false iff we know with certainty that the element cannot resolve to a string literal.
+   */
+  public static boolean possiblyValidStringLiteral(PsiElement element) {
+    if (element instanceof StringLiteral) {
+      return true;
+    }
+    if (element instanceof LiteralExpression) {
+      return false; // all other literals cannot evaluate to a StringLiteral
+    }
+    if (element instanceof LoadStatement
+        || element instanceof FunctionStatement
+        || element instanceof GlobExpression) {
+      return false;
+    }
+    // everything else treated as possibly evaluating to a string
+    return true;
+  }
+}
diff --git a/base/src/com/google/idea/blaze/base/lang/buildfile/validation/BuiltInRuleAnnotator.java b/base/src/com/google/idea/blaze/base/lang/buildfile/validation/BuiltInRuleAnnotator.java
new file mode 100644
index 0000000..48f8136
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/lang/buildfile/validation/BuiltInRuleAnnotator.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.base.lang.buildfile.validation;
+
+import com.google.common.base.Joiner;
+import com.google.idea.blaze.base.lang.buildfile.language.semantics.AttributeDefinition;
+import com.google.idea.blaze.base.lang.buildfile.language.semantics.BuildLanguageSpec;
+import com.google.idea.blaze.base.lang.buildfile.language.semantics.BuildLanguageSpecProvider;
+import com.google.idea.blaze.base.lang.buildfile.language.semantics.RuleDefinition;
+import com.google.idea.blaze.base.lang.buildfile.psi.Argument;
+import com.google.idea.blaze.base.lang.buildfile.psi.Expression;
+import com.google.idea.blaze.base.lang.buildfile.psi.FuncallExpression;
+import com.google.idea.blaze.base.lang.buildfile.psi.util.PsiUtils;
+import com.intellij.psi.PsiElement;
+import java.util.Set;
+import java.util.TreeSet;
+
+/** Validation of known rule types. */
+public class BuiltInRuleAnnotator extends BuildAnnotator {
+
+  @Override
+  public void visitFuncallExpression(FuncallExpression node) {
+    BuildLanguageSpec spec =
+        BuildLanguageSpecProvider.getInstance().getLanguageSpec(node.getProject());
+    if (spec == null) {
+      return;
+    }
+    String ruleName = node.getFunctionName();
+    RuleDefinition rule = spec.getRule(ruleName);
+    if (rule == null) {
+      return;
+    }
+    Set<String> missingAttributes = new TreeSet<>(rule.mandatoryAttributes.keySet());
+    for (Argument arg : node.getArguments()) {
+      String name = arg.getName();
+      if (name == null) {
+        continue;
+      }
+      AttributeDefinition attribute = rule.getAttribute(name);
+      if (attribute == null) {
+        markError(
+            arg, String.format("Unrecognized attribute '%s' for rule type '%s'", name, ruleName));
+        continue;
+      }
+      missingAttributes.remove(name);
+      Expression argValue = arg.getValue();
+      if (argValue == null) {
+        continue;
+      }
+      PsiElement rootElement = PsiUtils.getReferencedTargetValue(argValue);
+      if (!BuildElementValidation.possiblyValidType(rootElement, attribute.type)) {
+        markError(
+            arg,
+            String.format(
+                "Invalid value for attribute '%s'. Expected a value of type '%s'",
+                name, attribute.type));
+      }
+    }
+    if (!missingAttributes.isEmpty()) {
+      markError(
+          node,
+          String.format(
+              "Target missing required attribute(s): %s", Joiner.on(',').join(missingAttributes)));
+    }
+  }
+}
diff --git a/base/src/com/google/idea/blaze/base/lang/buildfile/validation/GlobErrorAnnotator.java b/base/src/com/google/idea/blaze/base/lang/buildfile/validation/GlobErrorAnnotator.java
index 9cf5e7b..7c2c1f3 100644
--- a/base/src/com/google/idea/blaze/base/lang/buildfile/validation/GlobErrorAnnotator.java
+++ b/base/src/com/google/idea/blaze/base/lang/buildfile/validation/GlobErrorAnnotator.java
@@ -17,11 +17,8 @@
 
 import com.google.idea.blaze.base.lang.buildfile.psi.Argument;
 import com.google.idea.blaze.base.lang.buildfile.psi.Expression;
-import com.google.idea.blaze.base.lang.buildfile.psi.FunctionStatement;
 import com.google.idea.blaze.base.lang.buildfile.psi.GlobExpression;
 import com.google.idea.blaze.base.lang.buildfile.psi.ListLiteral;
-import com.google.idea.blaze.base.lang.buildfile.psi.LiteralExpression;
-import com.google.idea.blaze.base.lang.buildfile.psi.LoadStatement;
 import com.google.idea.blaze.base.lang.buildfile.psi.ReferenceExpression;
 import com.google.idea.blaze.base.lang.buildfile.psi.StringLiteral;
 import com.google.idea.blaze.base.lang.buildfile.psi.util.PsiUtils;
@@ -76,7 +73,8 @@
     if (rootElement instanceof ListLiteral) {
       return validatePatternList(keyword, ((ListLiteral) rootElement).getChildExpressions());
     }
-    if (rootElement instanceof ReferenceExpression || !possiblyValidListLiteral(rootElement)) {
+    if (rootElement instanceof ReferenceExpression
+        || !BuildElementValidation.possiblyValidListLiteral(rootElement)) {
       markError(expr, "Glob parameter '" + keyword + "' must be a list of strings");
       return false;
     }
@@ -89,7 +87,8 @@
     boolean possiblyHasString = false;
     for (Expression expr : expressions) {
       PsiElement rootElement = PsiUtils.getReferencedTargetValue(expr);
-      if (rootElement instanceof ReferenceExpression || !possiblyValidStringLiteral(rootElement)) {
+      if (rootElement instanceof ReferenceExpression
+          || !BuildElementValidation.possiblyValidStringLiteral(rootElement)) {
         markError(expr, "Glob parameter '" + keyword + "' must be a list of strings");
       } else {
         possiblyHasString = true;
@@ -107,38 +106,4 @@
       markError(pattern, error);
     }
   }
-
-  /** Returns false iff we know with certainty that the element cannot resolve to a list literal. */
-  private static boolean possiblyValidListLiteral(PsiElement element) {
-    if (element instanceof ListLiteral || element instanceof GlobExpression) {
-      return true; // these evaluate directly to list literals
-    }
-    if (element instanceof LiteralExpression) {
-      return false; // all other literals cannot evaluate to a ListLiteral
-    }
-    if (element instanceof LoadStatement || element instanceof FunctionStatement) {
-      return false;
-    }
-    // everything else treated as possibly evaluating to a list
-    return true;
-  }
-
-  /**
-   * Returns false iff we know with certainty that the element cannot resolve to a string literal.
-   */
-  private static boolean possiblyValidStringLiteral(PsiElement element) {
-    if (element instanceof StringLiteral) {
-      return true;
-    }
-    if (element instanceof LiteralExpression) {
-      return false; // all other literals cannot evaluate to a StringLiteral
-    }
-    if (element instanceof LoadStatement
-        || element instanceof FunctionStatement
-        || element instanceof GlobExpression) {
-      return false;
-    }
-    // everything else treated as possibly evaluating to a string
-    return true;
-  }
 }
diff --git a/base/src/com/google/idea/blaze/base/lang/buildfile/validation/ErrorAnnotator.java b/base/src/com/google/idea/blaze/base/lang/buildfile/validation/LoadErrorAnnotator.java
similarity index 96%
rename from base/src/com/google/idea/blaze/base/lang/buildfile/validation/ErrorAnnotator.java
rename to base/src/com/google/idea/blaze/base/lang/buildfile/validation/LoadErrorAnnotator.java
index 40e89b2..c8ea27a 100644
--- a/base/src/com/google/idea/blaze/base/lang/buildfile/validation/ErrorAnnotator.java
+++ b/base/src/com/google/idea/blaze/base/lang/buildfile/validation/LoadErrorAnnotator.java
@@ -30,14 +30,14 @@
 import javax.annotation.Nullable;
 
 /**
- * Additional error annotations, post parsing.
+ * Error annotations for load statements, post parsing.
  *
  * <p>This has been turned off because it's unusable. BuildFile is re-parsed *every* time it's
  * touched, and is never cached. Until this is fixed, we can't run any annotators touching the file.
  *
  * <p>One option: try moving all expensive checks to 'visitFile', so they're not run in parallel
  */
-public class ErrorAnnotator extends BuildAnnotator {
+public class LoadErrorAnnotator extends BuildAnnotator {
 
   @Override
   public void visitLoadStatement(LoadStatement node) {
@@ -91,7 +91,7 @@
   public void visitFuncallExpression(FuncallExpression node) {
     FunctionStatement function = (FunctionStatement) node.getReferencedElement();
     if (function == null) {
-      // likely a built-in rule. We don't yet recognize these.
+      // likely a built-in rule.
       return;
     }
     // check keyword args match function parameters
diff --git a/base/src/com/google/idea/blaze/base/lang/buildfile/views/BuildStructureViewElement.java b/base/src/com/google/idea/blaze/base/lang/buildfile/views/BuildStructureViewElement.java
index 1f71b8c..75768dd 100644
--- a/base/src/com/google/idea/blaze/base/lang/buildfile/views/BuildStructureViewElement.java
+++ b/base/src/com/google/idea/blaze/base/lang/buildfile/views/BuildStructureViewElement.java
@@ -18,7 +18,6 @@
 import com.google.common.collect.ImmutableList;
 import com.google.idea.blaze.base.lang.buildfile.psi.BuildElement;
 import com.google.idea.blaze.base.lang.buildfile.psi.BuildFile;
-import com.google.idea.blaze.base.lang.buildfile.psi.ListLiteral;
 import com.intellij.ide.structureView.StructureViewTreeElement;
 import com.intellij.ide.structureView.impl.common.PsiTreeElementBase;
 import java.util.Collection;
@@ -38,8 +37,6 @@
   @NotNull
   @Override
   public Collection<StructureViewTreeElement> getChildrenBase() {
-    if (element instanceof ListLiteral) {}
-
     if (!(element instanceof BuildFile)) {
       // TODO: show inner build rules in Skylark .bzl extensions
       return ImmutableList.of();
diff --git a/base/src/com/google/idea/blaze/base/lang/projectview/references/ProjectViewLabelReference.java b/base/src/com/google/idea/blaze/base/lang/projectview/references/ProjectViewLabelReference.java
index d1748ec..f2f3634 100644
--- a/base/src/com/google/idea/blaze/base/lang/projectview/references/ProjectViewLabelReference.java
+++ b/base/src/com/google/idea/blaze/base/lang/projectview/references/ProjectViewLabelReference.java
@@ -15,6 +15,7 @@
  */
 package com.google.idea.blaze.base.lang.projectview.references;
 
+import com.google.idea.blaze.base.io.VirtualFileSystemProvider;
 import com.google.idea.blaze.base.lang.buildfile.completion.BuildLookupElement;
 import com.google.idea.blaze.base.lang.buildfile.completion.LabelRuleLookupElement;
 import com.google.idea.blaze.base.lang.buildfile.psi.BuildFile;
@@ -28,14 +29,10 @@
 import com.google.idea.blaze.base.model.primitives.Label;
 import com.google.idea.blaze.base.model.primitives.WorkspacePath;
 import com.intellij.lang.ASTNode;
-import com.intellij.openapi.application.ApplicationManager;
 import com.intellij.openapi.project.Project;
 import com.intellij.openapi.util.TextRange;
 import com.intellij.openapi.util.text.StringUtil;
-import com.intellij.openapi.vfs.LocalFileSystem;
 import com.intellij.openapi.vfs.VirtualFile;
-import com.intellij.openapi.vfs.VirtualFileSystem;
-import com.intellij.openapi.vfs.ex.temp.TempFileSystem;
 import com.intellij.psi.PsiElement;
 import com.intellij.psi.PsiFileSystemItem;
 import com.intellij.psi.PsiManager;
@@ -79,7 +76,8 @@
     if (file == null) {
       return null;
     }
-    VirtualFile vf = getFileSystem().findFileByPath(file.getPath());
+    VirtualFile vf =
+        VirtualFileSystemProvider.getInstance().getSystem().findFileByPath(file.getPath());
     if (vf == null) {
       return null;
     }
@@ -170,11 +168,4 @@
   private Project getProject() {
     return myElement.getProject();
   }
-
-  private static VirtualFileSystem getFileSystem() {
-    if (ApplicationManager.getApplication().isUnitTestMode()) {
-      return TempFileSystem.getInstance();
-    }
-    return LocalFileSystem.getInstance();
-  }
 }
diff --git a/base/src/com/google/idea/blaze/base/metrics/Action.java b/base/src/com/google/idea/blaze/base/metrics/Action.java
deleted file mode 100644
index b7f1ccf..0000000
--- a/base/src/com/google/idea/blaze/base/metrics/Action.java
+++ /dev/null
@@ -1,75 +0,0 @@
-/*
- * Copyright 2016 The Bazel Authors. 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.idea.blaze.base.metrics;
-
-import org.jetbrains.annotations.NonNls;
-import org.jetbrains.annotations.NotNull;
-
-/**
- * An item that can be logged. All actions contain a name that is used as a primary key. The name
- * should be immutable forever to keep the logs sane.
- *
- * <p>The name used by each {@link Action} should be [a-zA-Z0-9]* to keep things robust since
- * various log back ends may have different rules about what may or may not be in a key.
- *
- * <p>Do not use any of the following retired values for enums: INDEX_TOTAL_TIME("index")
- * REBUILD_TOTAL_TIME("rtt") SYNC_SAVE_FILES("ssf") SYNC_COMPUTE_MODULE_DIFF("scmd")
- * RUN_TOTAL_TIME("ttrp") DEBUG_TOTAL_TIME("ttsbp") RUN_TOTAL_TIME_FOR_ANDROID_TEST("ttrpat")
- * DEBUG_TOTAL_TIME_FOR_ANDROID_TEST("ttsbpat") IMPORT_TOTAL_TIME("tip")
- * IDE_BUILD_INFO_RESPONSE("ibi") RULES_EXTRACTION("re") BLAZE_MODULES_CREATION("mvc")
- * INTELLIJ_MODULE_CREATION("imc") SYNC_RESET_PROJECT("srp")
- *
- * <p>
- */
-public enum Action {
-  MAKE_PROJECT_TOTAL_TIME("mtt"),
-  MAKE_MODULE_TOTAL_TIME("mmtt"),
-
-  SYNC_TOTAL_TIME("stt"),
-  SYNC_IMPORT_DATA_TIME("sidt"),
-  BLAZE_BUILD_DURING_SYNC("bb"),
-  BLAZE_BUILD("bld"),
-
-  APK_BUILD_AND_INSTALL("apkbi"),
-
-  BLAZE_COMMAND_USAGE("ttrpbc"),
-
-  OPEN_IN_CODESEARCH("oics"),
-  COPY_DEPOT_PATH("cg3p"),
-  OPEN_CORRESPONDING_BUILD_FILE("ocbf"),
-
-  CREATE_BLAZE_RULE("cbr"),
-  CREATE_BLAZE_PACKAGE("cbp"),
-
-  SYNC_SDK("ssdk"),
-
-  C_RESOLVE_FILE("crf"),
-  BLAZE_CLION_TEST_RUN("ctr"),
-  BLAZE_CLION_TEST_DEBUG("ctd"),
-
-  PYTHON_ACTIVE("pysync");
-
-  @NotNull @NonNls private final String name;
-
-  Action(@NotNull String name) {
-    this.name = name;
-  }
-
-  @NotNull
-  public String getName() {
-    return name;
-  }
-}
diff --git a/base/src/com/google/idea/blaze/base/metrics/LoggingService.java b/base/src/com/google/idea/blaze/base/metrics/LoggingService.java
deleted file mode 100644
index 47a03df..0000000
--- a/base/src/com/google/idea/blaze/base/metrics/LoggingService.java
+++ /dev/null
@@ -1,65 +0,0 @@
-/*
- * Copyright 2016 The Bazel Authors. 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.idea.blaze.base.metrics;
-
-import com.intellij.openapi.extensions.ExtensionPointName;
-import com.intellij.openapi.project.Project;
-import javax.annotation.Nullable;
-
-/**
- * Logging service that handles logging timing, hit, and other events to an external sink for later
- * analysis.
- */
-public interface LoggingService {
-
-  ExtensionPointName<LoggingService> EP_NAME =
-      ExtensionPointName.create("com.google.idea.blaze.LoggingService");
-
-  /**
-   * Report a value for an event to the available logging services.
-   *
-   * @param variable The variable to report to. Once a value is selected for a logical measurement,
-   *     the variable's name should never change, even if the colloquial name for the variable
-   *     changes.
-   */
-  static void reportEvent(Project project, Action variable) {
-    reportEvent(project, variable, 0);
-  }
-
-  /**
-   * Report a value for an event to the available logging services.
-   *
-   * @param variable The variable to report to. Once a value is selected for a logical measurement,
-   *     the variable's name should never change, even if the colloquial name for the variable
-   *     changes.
-   * @param value should be >= 0, set the value to 0 if the value is meaningless
-   */
-  static void reportEvent(Project project, Action variable, long value) {
-    for (LoggingService service : EP_NAME.getExtensions()) {
-      service.doReportEvent(project, variable, value);
-    }
-  }
-
-  /**
-   * Report a value for an event to the logging service
-   *
-   * @param variable The variable to report to. Once a value is selected for a logical measurement,
-   *     the variable's name should never change, even if the colloquial name for the variable
-   *     changes.
-   * @param value should be >= 0, set the value to 0 if the value is meaningless
-   */
-  void doReportEvent(@Nullable Project project, Action variable, long value);
-}
diff --git a/base/src/com/google/idea/blaze/base/model/BlazeLibrary.java b/base/src/com/google/idea/blaze/base/model/BlazeLibrary.java
index d0cbe97..4676411 100644
--- a/base/src/com/google/idea/blaze/base/model/BlazeLibrary.java
+++ b/base/src/com/google/idea/blaze/base/model/BlazeLibrary.java
@@ -16,17 +16,14 @@
 package com.google.idea.blaze.base.model;
 
 import com.google.common.base.Objects;
+import com.google.idea.blaze.base.io.VirtualFileSystemProvider;
 import com.google.idea.blaze.base.sync.workspace.ArtifactLocationDecoder;
-import com.intellij.openapi.application.ApplicationManager;
 import com.intellij.openapi.project.Project;
 import com.intellij.openapi.roots.libraries.Library;
 import com.intellij.openapi.util.io.FileUtil;
 import com.intellij.openapi.util.io.FileUtilRt;
-import com.intellij.openapi.vfs.LocalFileSystem;
 import com.intellij.openapi.vfs.StandardFileSystems;
 import com.intellij.openapi.vfs.VirtualFileManager;
-import com.intellij.openapi.vfs.VirtualFileSystem;
-import com.intellij.openapi.vfs.ex.temp.TempFileSystem;
 import com.intellij.util.io.URLUtil;
 import java.io.File;
 import java.io.Serializable;
@@ -77,7 +74,9 @@
         FileUtilRt.extensionEquals(name, "jar") || FileUtilRt.extensionEquals(name, "zip");
     // .jar files require an URL with "jar" protocol.
     String protocol =
-        isJarFile ? StandardFileSystems.JAR_PROTOCOL : defaultFileSystem().getProtocol();
+        isJarFile
+            ? StandardFileSystems.JAR_PROTOCOL
+            : VirtualFileSystemProvider.getInstance().getSystem().getProtocol();
     String filePath = FileUtil.toSystemIndependentName(path.getPath());
     String url = VirtualFileManager.constructUrl(protocol, filePath);
     if (isJarFile) {
@@ -85,11 +84,4 @@
     }
     return url;
   }
-
-  private static VirtualFileSystem defaultFileSystem() {
-    if (ApplicationManager.getApplication().isUnitTestMode()) {
-      return TempFileSystem.getInstance();
-    }
-    return LocalFileSystem.getInstance();
-  }
 }
diff --git a/base/src/com/google/idea/blaze/base/model/primitives/ExecutionRootPath.java b/base/src/com/google/idea/blaze/base/model/primitives/ExecutionRootPath.java
index 1a7f550..4c8d3f0 100644
--- a/base/src/com/google/idea/blaze/base/model/primitives/ExecutionRootPath.java
+++ b/base/src/com/google/idea/blaze/base/model/primitives/ExecutionRootPath.java
@@ -42,6 +42,10 @@
     return path;
   }
 
+  public boolean isAbsolute() {
+    return path.isAbsolute();
+  }
+
   public File getFileRootedAt(File absoluteRoot) {
     if (path.isAbsolute()) {
       return path;
diff --git a/base/src/com/google/idea/blaze/base/model/primitives/Kind.java b/base/src/com/google/idea/blaze/base/model/primitives/Kind.java
index 206a73e..eba71f9 100644
--- a/base/src/com/google/idea/blaze/base/model/primitives/Kind.java
+++ b/base/src/com/google/idea/blaze/base/model/primitives/Kind.java
@@ -56,6 +56,7 @@
   GO_LIBRARY("go_library", LanguageClass.GO),
   GO_APPENGINE_LIBRARY("go_appengine_library", LanguageClass.GO),
   GO_WRAP_CC("go_wrap_cc", LanguageClass.GO),
+  INTELLIJ_PLUGIN_DEBUG_TARGET("intellij_plugin_debug_target", LanguageClass.JAVA),
   ;
 
   static final ImmutableMap<String, Kind> STRING_TO_KIND = makeStringToKindMap();
diff --git a/base/src/com/google/idea/blaze/base/model/primitives/Label.java b/base/src/com/google/idea/blaze/base/model/primitives/Label.java
index c5f31ab..c06f6a2 100644
--- a/base/src/com/google/idea/blaze/base/model/primitives/Label.java
+++ b/base/src/com/google/idea/blaze/base/model/primitives/Label.java
@@ -26,7 +26,7 @@
 /** Wrapper around a string for a blaze label (//package:rule). */
 @Immutable
 public final class Label extends TargetExpression {
-  private static final Logger LOG = Logger.getInstance(Label.class);
+  private static final Logger logger = Logger.getInstance(Label.class);
 
   public static final long serialVersionUID = 2L;
 
@@ -102,7 +102,7 @@
     String labelStr = toString();
     int startIndex = labelStr.indexOf("//") + "//".length();
     int colonIndex = labelStr.lastIndexOf(':');
-    LOG.assertTrue(colonIndex >= 0);
+    logger.assertTrue(colonIndex >= 0);
     return new WorkspacePath(labelStr.substring(startIndex, colonIndex));
   }
 
diff --git a/base/src/com/google/idea/blaze/base/projectview/ProjectViewEdit.java b/base/src/com/google/idea/blaze/base/projectview/ProjectViewEdit.java
index 7087681..4740038 100644
--- a/base/src/com/google/idea/blaze/base/projectview/ProjectViewEdit.java
+++ b/base/src/com/google/idea/blaze/base/projectview/ProjectViewEdit.java
@@ -32,7 +32,7 @@
 /** Represents a modification to one or more project view files. */
 public class ProjectViewEdit {
 
-  private static final Logger LOG = Logger.getInstance(ProjectViewEdit.class);
+  private static final Logger logger = Logger.getInstance(ProjectViewEdit.class);
   private final Project project;
   private final List<Modification> modifications;
 
@@ -100,7 +100,7 @@
         ProjectViewStorageManager.getInstance()
             .writeProjectView(projectViewText, modification.projectViewFile);
       } catch (IOException e) {
-        LOG.error(e);
+        logger.error(e);
         Messages.showErrorDialog(
             project,
             "Could not write updated project view. Is the file write protected?",
diff --git a/base/src/com/google/idea/blaze/base/projectview/ProjectViewManagerImpl.java b/base/src/com/google/idea/blaze/base/projectview/ProjectViewManagerImpl.java
index 1e48489..bab57dc 100644
--- a/base/src/com/google/idea/blaze/base/projectview/ProjectViewManagerImpl.java
+++ b/base/src/com/google/idea/blaze/base/projectview/ProjectViewManagerImpl.java
@@ -35,7 +35,7 @@
 /** Stores mutable per-project user settings. */
 final class ProjectViewManagerImpl extends ProjectViewManager {
 
-  private static final Logger LOG = Logger.getInstance(ProjectViewManagerImpl.class);
+  private static final Logger logger = Logger.getInstance(ProjectViewManagerImpl.class);
   private static final String CACHE_FILE_NAME = "project.view.dat";
 
   private final Project project;
@@ -64,7 +64,7 @@
         classLoaders.add(Thread.currentThread().getContextClassLoader());
         loadedProjectViewSet = (ProjectViewSet) SerializationUtil.loadFromDisk(file, classLoaders);
       } catch (IOException e) {
-        LOG.info(e);
+        logger.info(e);
       }
       this.projectViewSet = loadedProjectViewSet;
       this.projectViewSetLoaded = true;
@@ -90,7 +90,7 @@
       try {
         SerializationUtil.saveToDisk(file, projectViewSet);
       } catch (IOException e) {
-        LOG.error(e);
+        logger.error(e);
       }
       this.projectViewSet = projectViewSet;
     }
diff --git a/base/src/com/google/idea/blaze/base/projectview/ProjectViewStorageManagerImpl.java b/base/src/com/google/idea/blaze/base/projectview/ProjectViewStorageManagerImpl.java
index de784d7..729b1d8 100644
--- a/base/src/com/google/idea/blaze/base/projectview/ProjectViewStorageManagerImpl.java
+++ b/base/src/com/google/idea/blaze/base/projectview/ProjectViewStorageManagerImpl.java
@@ -28,7 +28,7 @@
 
 /** Project view storage implementation. */
 final class ProjectViewStorageManagerImpl extends ProjectViewStorageManager {
-  private static final Logger LOG = Logger.getInstance(ProjectViewManagerImpl.class);
+  private static final Logger logger = Logger.getInstance(ProjectViewManagerImpl.class);
 
   @Nullable
   @Override
diff --git a/base/src/com/google/idea/blaze/base/projectview/ProjectViewVerifier.java b/base/src/com/google/idea/blaze/base/projectview/ProjectViewVerifier.java
index 0953d07..9b4b465 100644
--- a/base/src/com/google/idea/blaze/base/projectview/ProjectViewVerifier.java
+++ b/base/src/com/google/idea/blaze/base/projectview/ProjectViewVerifier.java
@@ -15,13 +15,17 @@
  */
 package com.google.idea.blaze.base.projectview;
 
+import static java.util.stream.Collectors.toList;
+
 import com.google.common.collect.Lists;
 import com.google.idea.blaze.base.io.FileAttributeProvider;
 import com.google.idea.blaze.base.model.primitives.WorkspacePath;
+import com.google.idea.blaze.base.projectview.ProjectViewSet.ProjectViewFile;
 import com.google.idea.blaze.base.projectview.section.ListSection;
+import com.google.idea.blaze.base.projectview.section.SectionParser;
 import com.google.idea.blaze.base.projectview.section.sections.DirectoryEntry;
 import com.google.idea.blaze.base.projectview.section.sections.DirectorySection;
-import com.google.idea.blaze.base.projectview.section.sections.ExcludedSourceSection;
+import com.google.idea.blaze.base.projectview.section.sections.Sections;
 import com.google.idea.blaze.base.scope.BlazeContext;
 import com.google.idea.blaze.base.scope.output.IssueOutput;
 import com.google.idea.blaze.base.sync.BlazeSyncPlugin;
@@ -56,22 +60,47 @@
         return false;
       }
     }
-    if (!projectViewSet.listItems(ExcludedSourceSection.KEY).isEmpty()) {
-      IssueOutput.warn("excluded_sources is deprecated and has no effect.")
-          .inFile(projectViewSet.getTopLevelProjectViewFile().projectViewFile)
-          .submit(context);
-    }
+    warnAboutDeprecatedSections(context, projectViewSet);
     if (!verifyIncludedPackagesExistOnDisk(context, workspacePathResolver, projectViewSet)) {
       return false;
     }
     return true;
   }
 
+  private static void warnAboutDeprecatedSections(
+      BlazeContext context, ProjectViewSet projectViewSet) {
+    List<SectionParser> deprecatedParsers =
+        Sections.getParsers().stream().filter(SectionParser::isDeprecated).collect(toList());
+    for (SectionParser sectionParser : deprecatedParsers) {
+      for (ProjectViewFile projectViewFile : projectViewSet.getProjectViewFiles()) {
+        ProjectView projectView = projectViewFile.projectView;
+        if (projectView
+            .getSections()
+            .stream()
+            .anyMatch(section -> section.isSectionType(sectionParser.getSectionKey()))) {
+          String deprecationMessage = sectionParser.getDeprecationMessage();
+          if (deprecationMessage == null) {
+            deprecationMessage = String.format("%s is deprecated", sectionParser.getName());
+          }
+          IssueOutput.warn(deprecationMessage)
+              .inFile(projectViewFile.projectViewFile)
+              .submit(context);
+        }
+      }
+    }
+  }
+
   private static boolean verifyIncludedPackagesAreNotExcluded(
       BlazeContext context, ProjectViewSet projectViewSet) {
     boolean ok = true;
 
-    List<WorkspacePath> includedDirectories = getIncludedDirectories(projectViewSet);
+    List<WorkspacePath> includedDirectories =
+        projectViewSet
+            .listItems(DirectorySection.KEY)
+            .stream()
+            .filter(entry -> entry.included)
+            .map(entry -> entry.directory)
+            .collect(toList());
 
     for (WorkspacePath includedDirectory : includedDirectories) {
       for (ProjectViewSet.ProjectViewFile projectViewFile : projectViewSet.getProjectViewFiles()) {
@@ -103,16 +132,6 @@
     return ok;
   }
 
-  private static List<WorkspacePath> getIncludedDirectories(ProjectViewSet projectViewSet) {
-    List<WorkspacePath> includedDirectories = Lists.newArrayList();
-    for (DirectoryEntry entry : projectViewSet.listItems(DirectorySection.KEY)) {
-      if (entry.included) {
-        includedDirectories.add(entry.directory);
-      }
-    }
-    return includedDirectories;
-  }
-
   private static boolean verifyIncludedPackagesExistOnDisk(
       BlazeContext context,
       WorkspacePathResolver workspacePathResolver,
diff --git a/base/src/com/google/idea/blaze/base/projectview/section/SectionParser.java b/base/src/com/google/idea/blaze/base/projectview/section/SectionParser.java
index ddfa57e..1e0f043 100644
--- a/base/src/com/google/idea/blaze/base/projectview/section/SectionParser.java
+++ b/base/src/com/google/idea/blaze/base/projectview/section/SectionParser.java
@@ -47,6 +47,11 @@
     return false;
   }
 
+  @Nullable
+  public String getDeprecationMessage() {
+    return null;
+  }
+
   /** Allows the section to add a default value. Used during the wizard. */
   public ProjectView addProjectViewDefaultValue(ProjectView projectView) {
     return projectView;
diff --git a/base/src/com/google/idea/blaze/base/projectview/section/sections/ExcludedSourceSection.java b/base/src/com/google/idea/blaze/base/projectview/section/sections/ExcludedSourceSection.java
index de6081f..8fceb2a 100644
--- a/base/src/com/google/idea/blaze/base/projectview/section/sections/ExcludedSourceSection.java
+++ b/base/src/com/google/idea/blaze/base/projectview/section/sections/ExcludedSourceSection.java
@@ -20,6 +20,7 @@
 import com.google.idea.blaze.base.projectview.section.ListSection;
 import com.google.idea.blaze.base.projectview.section.SectionKey;
 import com.google.idea.blaze.base.projectview.section.SectionParser;
+import javax.annotation.Nullable;
 
 /** Section for excluding source files. */
 @Deprecated
@@ -31,5 +32,11 @@
         public boolean isDeprecated() {
           return true;
         }
+
+        @Nullable
+        @Override
+        public String getDeprecationMessage() {
+          return "excluded_sources is deprecated and has no effect.";
+        }
       };
 }
diff --git a/base/src/com/google/idea/blaze/base/projectview/section/sections/MetricsProjectSection.java b/base/src/com/google/idea/blaze/base/projectview/section/sections/MetricsProjectSection.java
deleted file mode 100644
index 3a0d6c8..0000000
--- a/base/src/com/google/idea/blaze/base/projectview/section/sections/MetricsProjectSection.java
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * Copyright 2016 The Bazel Authors. 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.idea.blaze.base.projectview.section.sections;
-
-import com.google.idea.blaze.base.projectview.parser.ParseContext;
-import com.google.idea.blaze.base.projectview.parser.ProjectViewParser;
-import com.google.idea.blaze.base.projectview.section.ScalarSection;
-import com.google.idea.blaze.base.projectview.section.ScalarSectionParser;
-import com.google.idea.blaze.base.projectview.section.SectionKey;
-import com.google.idea.blaze.base.projectview.section.SectionParser;
-import com.intellij.openapi.util.text.StringUtil;
-import javax.annotation.Nullable;
-
-/** Sets the metrics project to allow monitoring of individual projects */
-public class MetricsProjectSection {
-  public static final SectionKey<String, ScalarSection<String>> KEY =
-      SectionKey.of("metrics_project");
-  public static final SectionParser PARSER = new MetricsProjectSectionParser();
-
-  private static class MetricsProjectSectionParser extends ScalarSectionParser<String> {
-    public MetricsProjectSectionParser() {
-      super(KEY, ':');
-    }
-
-    @Nullable
-    @Override
-    protected String parseItem(ProjectViewParser parser, ParseContext parseContext, String rest) {
-      return StringUtil.unquoteString(rest);
-    }
-
-    @Override
-    protected void printItem(StringBuilder sb, String value) {
-      sb.append(value);
-    }
-
-    @Override
-    public ItemType getItemType() {
-      return ItemType.Other;
-    }
-  }
-}
diff --git a/base/src/com/google/idea/blaze/base/projectview/section/sections/Sections.java b/base/src/com/google/idea/blaze/base/projectview/section/sections/Sections.java
index ad0c35e..4d071a3 100644
--- a/base/src/com/google/idea/blaze/base/projectview/section/sections/Sections.java
+++ b/base/src/com/google/idea/blaze/base/projectview/section/sections/Sections.java
@@ -36,7 +36,6 @@
           ImportTargetOutputSection.PARSER,
           ExcludeTargetSection.PARSER,
           ExcludedSourceSection.PARSER,
-          MetricsProjectSection.PARSER,
           RunConfigurationsSection.PARSER);
 
   public static List<SectionParser> getParsers() {
diff --git a/base/src/com/google/idea/blaze/base/run/BlazeCommandRunConfiguration.java b/base/src/com/google/idea/blaze/base/run/BlazeCommandRunConfiguration.java
index 07fd7e3..9448702 100644
--- a/base/src/com/google/idea/blaze/base/run/BlazeCommandRunConfiguration.java
+++ b/base/src/com/google/idea/blaze/base/run/BlazeCommandRunConfiguration.java
@@ -38,6 +38,7 @@
 import com.intellij.execution.configurations.LocatableConfigurationBase;
 import com.intellij.execution.configurations.ModuleRunProfile;
 import com.intellij.execution.configurations.RunConfiguration;
+import com.intellij.execution.configurations.RunConfigurationWithSuppressedDefaultDebugAction;
 import com.intellij.execution.configurations.RunProfileState;
 import com.intellij.execution.configurations.RuntimeConfigurationError;
 import com.intellij.execution.configurations.RuntimeConfigurationException;
@@ -66,9 +67,12 @@
 
 /** A run configuration which executes Blaze commands. */
 public class BlazeCommandRunConfiguration extends LocatableConfigurationBase
-    implements BlazeRunConfiguration, RunnerIconProvider, ModuleRunProfile {
+    implements BlazeRunConfiguration,
+        RunnerIconProvider,
+        ModuleRunProfile,
+        RunConfigurationWithSuppressedDefaultDebugAction {
 
-  private static final Logger LOG = Logger.getInstance(BlazeCommandRunConfiguration.class);
+  private static final Logger logger = Logger.getInstance(BlazeCommandRunConfiguration.class);
 
   private static final String HANDLER_ATTR = "handler-id";
   private static final String TARGET_TAG = "blaze-target";
@@ -103,7 +107,7 @@
     try {
       handler.getState().readExternal(elementState);
     } catch (InvalidDataException e) {
-      LOG.error(e);
+      logger.error(e);
     }
   }
 
@@ -164,14 +168,14 @@
     try {
       handler.getState().writeExternal(elementState);
     } catch (WriteExternalException e) {
-      LOG.error(e);
+      logger.error(e);
     }
     handlerProvider = newProvider;
     handler = newProvider.createHandler(this);
     try {
       handler.getState().readExternal(elementState);
     } catch (InvalidDataException e) {
-      LOG.error(e);
+      logger.error(e);
     }
   }
 
@@ -321,7 +325,7 @@
     try {
       configuration.handler.getState().readExternal(configuration.elementState);
     } catch (InvalidDataException e) {
-      LOG.error(e);
+      logger.error(e);
     }
 
     return configuration;
@@ -425,7 +429,7 @@
       try {
         handler.getState().readExternal(config.elementState);
       } catch (InvalidDataException e) {
-        LOG.error(e);
+        logger.error(e);
       }
       handlerStateEditor = handler.getState().getEditor(config.getProject());
 
@@ -459,7 +463,7 @@
       try {
         handler.getState().writeExternal(elementState);
       } catch (WriteExternalException e) {
-        LOG.error(e);
+        logger.error(e);
       }
       config.keepInSync = keepInSyncCheckBox.isVisible() ? keepInSyncCheckBox.isSelected() : null;
 
@@ -469,7 +473,7 @@
       try {
         config.handler.getState().readExternal(config.elementState);
       } catch (InvalidDataException e) {
-        LOG.error(e);
+        logger.error(e);
       }
 
       // finally, update the handler
diff --git a/base/src/com/google/idea/blaze/base/run/DistributedExecutorSupport.java b/base/src/com/google/idea/blaze/base/run/DistributedExecutorSupport.java
index 851eab6..c6991ca 100644
--- a/base/src/com/google/idea/blaze/base/run/DistributedExecutorSupport.java
+++ b/base/src/com/google/idea/blaze/base/run/DistributedExecutorSupport.java
@@ -44,21 +44,18 @@
   }
 
   /** Returns the blaze/bazel flags required to specify whether to run on a distributed executor. */
-  static List<String> getBlazeFlags(Project project, @Nullable Boolean runDistributed) {
-    if (runDistributed == null) {
-      return ImmutableList.of();
-    }
+  static List<String> getBlazeFlags(Project project, boolean runDistributed) {
     DistributedExecutorSupport executorInfo = getAvailableExecutor(Blaze.getBuildSystem(project));
     if (executorInfo == null) {
       return ImmutableList.of();
     }
-    return ImmutableList.of(executorInfo.getBlazeFlag(runDistributed));
+    return executorInfo.getBlazeFlags(runDistributed);
   }
 
   String executorName();
 
   boolean isAvailable(BuildSystem buildSystem);
 
-  /** Get blaze/bazel flag specifying whether to run on this distributed executor */
-  String getBlazeFlag(boolean runDistributed);
+  /** Get blaze/bazel flags specifying whether to run on this distributed executor */
+  ImmutableList<String> getBlazeFlags(boolean runDistributed);
 }
diff --git a/base/src/com/google/idea/blaze/base/run/confighandler/BlazeCommandGenericRunConfigurationRunner.java b/base/src/com/google/idea/blaze/base/run/confighandler/BlazeCommandGenericRunConfigurationRunner.java
index d2c6512..7c508e2 100644
--- a/base/src/com/google/idea/blaze/base/run/confighandler/BlazeCommandGenericRunConfigurationRunner.java
+++ b/base/src/com/google/idea/blaze/base/run/confighandler/BlazeCommandGenericRunConfigurationRunner.java
@@ -21,25 +21,24 @@
 import com.google.idea.blaze.base.command.BlazeCommandName;
 import com.google.idea.blaze.base.command.BlazeFlags;
 import com.google.idea.blaze.base.issueparser.IssueOutputLineProcessor;
-import com.google.idea.blaze.base.metrics.Action;
 import com.google.idea.blaze.base.model.primitives.WorkspaceRoot;
 import com.google.idea.blaze.base.projectview.ProjectViewManager;
 import com.google.idea.blaze.base.projectview.ProjectViewSet;
 import com.google.idea.blaze.base.run.BlazeCommandRunConfiguration;
 import com.google.idea.blaze.base.run.DistributedExecutorSupport;
+import com.google.idea.blaze.base.run.filter.BlazeTargetFilter;
 import com.google.idea.blaze.base.run.processhandler.LineProcessingProcessAdapter;
 import com.google.idea.blaze.base.run.processhandler.ScopedBlazeProcessHandler;
 import com.google.idea.blaze.base.run.smrunner.BlazeTestEventsHandler;
-import com.google.idea.blaze.base.run.smrunner.BlazeTestEventsHandlerProvider;
 import com.google.idea.blaze.base.run.smrunner.SmRunnerUtils;
 import com.google.idea.blaze.base.run.state.BlazeCommandRunConfigurationCommonState;
 import com.google.idea.blaze.base.scope.BlazeContext;
 import com.google.idea.blaze.base.scope.scopes.IdeaLogScope;
 import com.google.idea.blaze.base.scope.scopes.IssuesScope;
-import com.google.idea.blaze.base.scope.scopes.LoggedTimingScope;
 import com.google.idea.blaze.base.settings.Blaze;
 import com.google.idea.blaze.base.settings.BlazeImportSettings;
 import com.google.idea.blaze.base.settings.BlazeImportSettingsManager;
+import com.google.idea.common.experiments.BoolExperiment;
 import com.intellij.execution.DefaultExecutionResult;
 import com.intellij.execution.ExecutionException;
 import com.intellij.execution.ExecutionResult;
@@ -48,6 +47,7 @@
 import com.intellij.execution.configurations.RunProfile;
 import com.intellij.execution.configurations.RunProfileState;
 import com.intellij.execution.configurations.WrappingRunConfiguration;
+import com.intellij.execution.filters.Filter;
 import com.intellij.execution.filters.TextConsoleBuilderImpl;
 import com.intellij.execution.process.ProcessHandler;
 import com.intellij.execution.process.ProcessListener;
@@ -55,7 +55,7 @@
 import com.intellij.execution.runners.ProgramRunner;
 import com.intellij.execution.ui.ConsoleView;
 import com.intellij.openapi.project.Project;
-import javax.annotation.Nullable;
+import java.util.Collection;
 import org.jetbrains.annotations.NotNull;
 
 /**
@@ -65,9 +65,12 @@
 public final class BlazeCommandGenericRunConfigurationRunner
     implements BlazeCommandRunConfigurationRunner {
 
+  private static final BoolExperiment smRunnerUiEnabled =
+      new BoolExperiment("use.smrunner.ui.general", true);
+
   @Override
   public RunProfileState getRunProfileState(Executor executor, ExecutionEnvironment environment) {
-    return new BlazeCommandRunProfileState(environment, null);
+    return new BlazeCommandRunProfileState(environment, ImmutableList.of());
   }
 
   @Override
@@ -80,16 +83,19 @@
   public static class BlazeCommandRunProfileState extends CommandLineState {
     private final BlazeCommandRunConfiguration configuration;
     private final BlazeCommandRunConfigurationCommonState handlerState;
-    @Nullable private final BlazeTestEventsHandlerProvider testEventsHandlerProvider;
+    private final ImmutableList<Filter> consoleFilters;
 
     public BlazeCommandRunProfileState(
-        ExecutionEnvironment environment,
-        @Nullable BlazeTestEventsHandlerProvider testEventsHandlerProvider) {
+        ExecutionEnvironment environment, Collection<Filter> consoleFilters) {
       super(environment);
       this.configuration = getConfiguration(environment);
       this.handlerState =
           (BlazeCommandRunConfigurationCommonState) configuration.getHandler().getState();
-      this.testEventsHandlerProvider = testEventsHandlerProvider;
+      this.consoleFilters =
+          ImmutableList.<Filter>builder()
+              .addAll(consoleFilters)
+              .add(new BlazeTargetFilter(environment.getProject()))
+              .build();
     }
 
     private static BlazeCommandRunConfiguration getConfiguration(ExecutionEnvironment environment) {
@@ -120,11 +126,11 @@
 
       ImmutableList<String> testHandlerFlags = ImmutableList.of();
       BlazeTestEventsHandler testEventsHandler =
-          canUseTestUi() && testEventsHandlerProvider != null
-              ? testEventsHandlerProvider.getHandler()
+          canUseTestUi()
+              ? BlazeTestEventsHandler.getHandlerForTarget(project, configuration.getTarget())
               : null;
       if (testEventsHandler != null) {
-        testHandlerFlags = testEventsHandler.getBlazeFlags();
+        testHandlerFlags = BlazeTestEventsHandler.getBlazeFlags(project);
         setConsoleBuilder(
             new TextConsoleBuilderImpl(project) {
               @Override
@@ -134,6 +140,7 @@
               }
             });
       }
+      addConsoleFilters(consoleFilters.toArray(new Filter[0]));
 
       BlazeCommand blazeCommand = getBlazeCommand(project, testHandlerFlags);
 
@@ -146,7 +153,6 @@
             @Override
             public void onBlazeContextStart(BlazeContext context) {
               context
-                  .push(new LoggedTimingScope(project, Action.BLAZE_COMMAND_USAGE))
                   .push(new IssuesScope(project))
                   .push(new IdeaLogScope());
             }
@@ -165,23 +171,29 @@
       ProjectViewSet projectViewSet = ProjectViewManager.getInstance(project).getProjectViewSet();
       assert projectViewSet != null;
 
-      return BlazeCommand.builder(Blaze.getBuildSystem(project), handlerState.getCommand())
-          .setBlazeBinary(handlerState.getBlazeBinary())
-          .addTargets(configuration.getTarget())
-          .addBlazeFlags(BlazeFlags.buildFlags(project, projectViewSet))
-          .addBlazeFlags(testHandlerFlags)
-          .addBlazeFlags(handlerState.getBlazeFlags())
-          .addBlazeFlags(
-              DistributedExecutorSupport.getBlazeFlags(
-                  project, handlerState.getRunOnDistributedExecutor()))
-          .addExeFlags(handlerState.getExeFlags())
-          .build();
+      BlazeCommand.Builder command =
+          BlazeCommand.builder(Blaze.getBuildSystem(project), handlerState.getCommand())
+              .setBlazeBinary(handlerState.getBlazeBinary())
+              .addTargets(configuration.getTarget())
+              .addBlazeFlags(BlazeFlags.buildFlags(project, projectViewSet))
+              .addBlazeFlags(testHandlerFlags)
+              .addBlazeFlags(handlerState.getBlazeFlags())
+              .addExeFlags(handlerState.getExeFlags());
+
+      boolean runDistributed = handlerState.getRunOnDistributedExecutor();
+      command.addBlazeFlags(
+          DistributedExecutorSupport.getBlazeFlags(
+              project, handlerState.getRunOnDistributedExecutor()));
+      if (!runDistributed) {
+        command.addBlazeFlags(BlazeFlags.TEST_OUTPUT_STREAMED);
+      }
+      return command.build();
     }
 
     private boolean canUseTestUi() {
-      return testEventsHandlerProvider != null
+      return smRunnerUiEnabled.getValue()
           && BlazeCommandName.TEST.equals(handlerState.getCommand())
-          && !Boolean.TRUE.equals(handlerState.getRunOnDistributedExecutor());
+          && !handlerState.getRunOnDistributedExecutor();
     }
   }
 }
diff --git a/base/src/com/google/idea/blaze/base/run/filter/BlazeTargetFilter.java b/base/src/com/google/idea/blaze/base/run/filter/BlazeTargetFilter.java
new file mode 100644
index 0000000..1cf0e0c
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/run/filter/BlazeTargetFilter.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2016 The Bazel Authors. 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.idea.blaze.base.run.filter;
+
+import com.google.idea.blaze.base.lang.buildfile.references.BuildReferenceManager;
+import com.google.idea.blaze.base.lang.buildfile.references.LabelUtils;
+import com.google.idea.blaze.base.model.primitives.Label;
+import com.intellij.execution.filters.Filter;
+import com.intellij.execution.filters.HyperlinkInfo;
+import com.intellij.openapi.project.Project;
+import com.intellij.psi.NavigatablePsiElement;
+import com.intellij.psi.PsiElement;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.jetbrains.annotations.Nullable;
+
+/** Parse blaze targets in streamed output. */
+public class BlazeTargetFilter implements Filter {
+
+  private static final Pattern TARGET_PATTERN = Pattern.compile("//([^\\s:]*):(\\S*)");
+
+  private final Project project;
+
+  public BlazeTargetFilter(Project project) {
+    this.project = project;
+  }
+
+  @Nullable
+  @Override
+  public Result applyFilter(String line, int entireLength) {
+    Matcher matcher = TARGET_PATTERN.matcher(line);
+    if (!matcher.find()) {
+      return null;
+    }
+    String labelString = matcher.group();
+    Label label = LabelUtils.createLabelFromString(null, labelString);
+    if (label == null) {
+      return null;
+    }
+    PsiElement psi = BuildReferenceManager.getInstance(project).resolveLabel(label);
+    if (!(psi instanceof NavigatablePsiElement)) {
+      return null;
+    }
+    HyperlinkInfo link = project -> ((NavigatablePsiElement) psi).navigate(true);
+    int offset = entireLength - line.length();
+    return new Result(matcher.start() + offset, matcher.end() + offset, link);
+  }
+}
diff --git a/base/src/com/google/idea/blaze/base/run/filter/FileResolver.java b/base/src/com/google/idea/blaze/base/run/filter/FileResolver.java
new file mode 100644
index 0000000..5ada31d
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/run/filter/FileResolver.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2016 The Bazel Authors. 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.idea.blaze.base.run.filter;
+
+import com.intellij.openapi.extensions.ExtensionPointName;
+import com.intellij.openapi.project.Project;
+import com.intellij.openapi.vfs.VirtualFile;
+import javax.annotation.Nullable;
+
+/** Parses file strings in blaze/bazel output. */
+public interface FileResolver {
+
+  ExtensionPointName<FileResolver> EP_NAME =
+      ExtensionPointName.create("com.google.idea.blaze.FileStringParser");
+
+  /**
+   * Iterates through all available {@link FileResolver}s, returning the first successful result.
+   */
+  static VirtualFile resolve(Project project, String fileString) {
+    for (FileResolver parser : EP_NAME.getExtensions()) {
+      VirtualFile result = parser.resolveToFile(project, fileString);
+      if (result != null) {
+        return result;
+      }
+    }
+    return null;
+  }
+
+  @Nullable
+  VirtualFile resolveToFile(Project project, String fileString);
+}
diff --git a/base/src/com/google/idea/blaze/base/run/filter/StandardFileResolver.java b/base/src/com/google/idea/blaze/base/run/filter/StandardFileResolver.java
new file mode 100644
index 0000000..15a3d09
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/run/filter/StandardFileResolver.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2016 The Bazel Authors. 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.idea.blaze.base.run.filter;
+
+import com.google.idea.blaze.base.io.VirtualFileSystemProvider;
+import com.google.idea.blaze.base.model.BlazeProjectData;
+import com.google.idea.blaze.base.sync.data.BlazeProjectDataManager;
+import com.intellij.openapi.project.Project;
+import com.intellij.openapi.vfs.VirtualFile;
+import java.io.File;
+import javax.annotation.Nullable;
+
+/** Parses absolute and workspace-relative paths. */
+public class StandardFileResolver implements FileResolver {
+
+  @Nullable
+  @Override
+  public VirtualFile resolveToFile(Project project, String fileString) {
+    File file = new File(fileString);
+    if (file.isAbsolute()) {
+      return VirtualFileSystemProvider.getInstance().getSystem().findFileByPath(file.getPath());
+    }
+    BlazeProjectData projectData =
+        BlazeProjectDataManager.getInstance(project).getBlazeProjectData();
+    if (projectData == null) {
+      return null;
+    }
+    file = projectData.workspacePathResolver.resolveToFile(fileString);
+    return VirtualFileSystemProvider.getInstance().getSystem().findFileByPath(file.getPath());
+  }
+}
diff --git a/base/src/com/google/idea/blaze/base/run/producers/BlazeBuildFileRunConfigurationProducer.java b/base/src/com/google/idea/blaze/base/run/producers/BlazeBuildFileRunConfigurationProducer.java
index c8d0ba3..60faff7 100644
--- a/base/src/com/google/idea/blaze/base/run/producers/BlazeBuildFileRunConfigurationProducer.java
+++ b/base/src/com/google/idea/blaze/base/run/producers/BlazeBuildFileRunConfigurationProducer.java
@@ -40,7 +40,7 @@
 public class BlazeBuildFileRunConfigurationProducer
     extends BlazeRunConfigurationProducer<BlazeCommandRunConfiguration> {
 
-  private static final Logger LOG =
+  private static final Logger logger =
       Logger.getInstance(BlazeBuildFileRunConfigurationProducer.class);
 
   private static class BuildTarget {
@@ -162,7 +162,7 @@
   public static void setupConfiguration(RunConfiguration configuration, Label label) {
     BuildTarget target = buildTargetFromLabel(configuration.getProject(), label);
     if (target == null || !(configuration instanceof BlazeCommandRunConfiguration)) {
-      LOG.error("Configuration not handled by BUILD file config producer: " + configuration);
+      logger.error("Configuration not handled by BUILD file config producer: " + configuration);
       return;
     }
     setupBuildFileConfiguration((BlazeCommandRunConfiguration) configuration, target);
diff --git a/base/src/com/google/idea/blaze/base/run/producers/BlazeFilterExistingRunConfigurationProducer.java b/base/src/com/google/idea/blaze/base/run/producers/BlazeFilterExistingRunConfigurationProducer.java
new file mode 100644
index 0000000..feae47b
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/run/producers/BlazeFilterExistingRunConfigurationProducer.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.base.run.producers;
+
+import com.google.idea.blaze.base.command.BlazeCommandName;
+import com.google.idea.blaze.base.command.BlazeFlags;
+import com.google.idea.blaze.base.model.primitives.TargetExpression;
+import com.google.idea.blaze.base.run.BlazeCommandRunConfiguration;
+import com.google.idea.blaze.base.run.BlazeCommandRunConfigurationType;
+import com.google.idea.blaze.base.run.smrunner.BlazeTestEventsHandler;
+import com.google.idea.blaze.base.run.smrunner.SmRunnerUtils;
+import com.google.idea.blaze.base.run.state.BlazeCommandRunConfigurationCommonState;
+import com.intellij.execution.Location;
+import com.intellij.execution.actions.ConfigurationContext;
+import com.intellij.execution.configurations.RunConfiguration;
+import com.intellij.openapi.util.Ref;
+import com.intellij.psi.PsiElement;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import javax.annotation.Nullable;
+
+/**
+ * Handles the specific case where the user creates a run configuration by selecting test suites /
+ * classes / methods from the test UI tree.
+ *
+ * <p>In this special case we already know the blaze target string, and only need to apply a filter
+ * to the existing configuration. Delegates language-specific filter calculation to {@link
+ * BlazeTestEventsHandler}.
+ */
+public class BlazeFilterExistingRunConfigurationProducer
+    extends BlazeRunConfigurationProducer<BlazeCommandRunConfiguration> {
+
+  public BlazeFilterExistingRunConfigurationProducer() {
+    super(BlazeCommandRunConfigurationType.getInstance());
+  }
+
+  @Override
+  protected boolean doSetupConfigFromContext(
+      BlazeCommandRunConfiguration configuration,
+      ConfigurationContext context,
+      Ref<PsiElement> sourceElement) {
+    String testFilter = getTestFilter(context);
+    if (testFilter == null) {
+      return false;
+    }
+    BlazeCommandRunConfigurationCommonState handlerState =
+        configuration.getHandlerStateIfType(BlazeCommandRunConfigurationCommonState.class);
+    if (handlerState == null || !BlazeCommandName.TEST.equals(handlerState.getCommand())) {
+      return false;
+    }
+    // replace old test filter flag if present
+    List<String> flags = new ArrayList<>(handlerState.getBlazeFlags());
+    flags.removeIf((flag) -> flag.startsWith(BlazeFlags.TEST_FILTER));
+    flags.add(testFilter);
+    handlerState.setBlazeFlags(flags);
+    configuration.setName(configuration.getName() + " (filtered)");
+    configuration.setNameChangedByUser(true);
+    return true;
+  }
+
+  @Override
+  protected boolean doIsConfigFromContext(
+      BlazeCommandRunConfiguration configuration, ConfigurationContext context) {
+    String testFilter = getTestFilter(context);
+    if (testFilter == null) {
+      return false;
+    }
+    BlazeCommandRunConfigurationCommonState handlerState =
+        configuration.getHandlerStateIfType(BlazeCommandRunConfigurationCommonState.class);
+
+    return handlerState != null
+        && Objects.equals(handlerState.getCommand(), BlazeCommandName.TEST)
+        && Objects.equals(testFilter, handlerState.getTestFilterFlag());
+  }
+
+  @Nullable
+  private static String getTestFilter(ConfigurationContext context) {
+    RunConfiguration base = context.getOriginalConfiguration(null);
+    if (!(base instanceof BlazeCommandRunConfiguration)) {
+      return null;
+    }
+    TargetExpression target = ((BlazeCommandRunConfiguration) base).getTarget();
+    if (target == null) {
+      return null;
+    }
+    BlazeTestEventsHandler testEventsHandler =
+        BlazeTestEventsHandler.getHandlerForTarget(context.getProject(), target);
+    if (testEventsHandler == null) {
+      return null;
+    }
+    List<Location<?>> selectedElements = SmRunnerUtils.getSelectedSmRunnerTreeElements(context);
+    if (selectedElements.isEmpty()) {
+      return null;
+    }
+    return testEventsHandler.getTestFilter(context.getProject(), selectedElements);
+  }
+}
diff --git a/base/src/com/google/idea/blaze/base/run/smrunner/BlazeCompositeTestEventsHandler.java b/base/src/com/google/idea/blaze/base/run/smrunner/BlazeCompositeTestEventsHandler.java
new file mode 100644
index 0000000..5d86bdf
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/run/smrunner/BlazeCompositeTestEventsHandler.java
@@ -0,0 +1,132 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.base.run.smrunner;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Maps;
+import com.google.idea.blaze.base.model.primitives.Kind;
+import com.intellij.execution.Location;
+import com.intellij.execution.testframework.actions.AbstractRerunFailedTestsAction;
+import com.intellij.execution.testframework.sm.runner.SMTestLocator;
+import com.intellij.execution.ui.ConsoleView;
+import com.intellij.openapi.project.Project;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+
+/** Combines multiple language-specific handlers (e.g. to handle test_suite targets). */
+public class BlazeCompositeTestEventsHandler extends BlazeTestEventsHandler {
+
+  private static ImmutableMap<Kind, BlazeTestEventsHandler> collectHandlers() {
+    Map<Kind, BlazeTestEventsHandler> map = new HashMap<>();
+    for (BlazeTestEventsHandler handler : BlazeTestEventsHandler.EP_NAME.getExtensions()) {
+      if (handler instanceof BlazeCompositeTestEventsHandler) {
+        continue;
+      }
+      for (Kind kind : handler.handledKinds()) {
+        // earlier handlers get priority.
+        map.putIfAbsent(kind, handler);
+      }
+    }
+    return Maps.immutableEnumMap(map);
+  }
+
+  private static ImmutableMap<Kind, BlazeTestEventsHandler> handlers;
+
+  private static ImmutableMap<Kind, BlazeTestEventsHandler> getHandlers() {
+    if (handlers == null) {
+      handlers = collectHandlers();
+    }
+    return handlers;
+  }
+
+  @Override
+  public boolean handlesTargetKind(@Nullable Kind kind) {
+    // composite handler specifically exists to handle test-suites and multi-target blaze
+    // invocations, so must handle targets without a kind.
+    return kind == null || kind == Kind.TEST_SUITE || handledKinds().contains(kind);
+  }
+
+  @Override
+  protected EnumSet<Kind> handledKinds() {
+    ImmutableSet<Kind> handledKinds = getHandlers().keySet();
+    return !handledKinds.isEmpty() ? EnumSet.copyOf(handledKinds) : EnumSet.noneOf(Kind.class);
+  }
+
+  @Override
+  public SMTestLocator getTestLocator() {
+    return new CompositeSMTestLocator(
+        ImmutableList.copyOf(
+            getHandlers()
+                .values()
+                .stream()
+                .map(BlazeTestEventsHandler::getTestLocator)
+                .collect(Collectors.toList())));
+  }
+
+  @Nullable
+  @Override
+  public String getTestFilter(Project project, List<Location<?>> testLocations) {
+    // We make no attempt to support re-running a subset of tests for test_suites or target patterns
+    return null;
+  }
+
+  @Nullable
+  @Override
+  public AbstractRerunFailedTestsAction createRerunFailedTestsAction(ConsoleView consoleView) {
+    return null;
+  }
+
+  /** Converts the testsuite name in the blaze test XML to a user-friendly format */
+  @Override
+  public String suiteDisplayName(@Nullable Kind kind, String rawName) {
+    BlazeTestEventsHandler handler = kind != null ? getHandlers().get(kind) : null;
+    return handler != null
+        ? handler.suiteDisplayName(kind, rawName)
+        : super.suiteDisplayName(kind, rawName);
+  }
+
+  /** Converts the testcase name in the blaze test XML to a user-friendly format */
+  @Override
+  public String testDisplayName(@Nullable Kind kind, String rawName) {
+    BlazeTestEventsHandler handler = kind != null ? getHandlers().get(kind) : null;
+    return handler != null
+        ? handler.testDisplayName(kind, rawName)
+        : super.testDisplayName(kind, rawName);
+  }
+
+  @Override
+  public String suiteLocationUrl(@Nullable Kind kind, String name) {
+    BlazeTestEventsHandler handler = kind != null ? getHandlers().get(kind) : null;
+    return handler != null
+        ? handler.suiteLocationUrl(kind, name)
+        : super.suiteLocationUrl(kind, name);
+  }
+
+  @Override
+  public String testLocationUrl(
+      @Nullable Kind kind, String parentSuite, String name, @Nullable String className) {
+    BlazeTestEventsHandler handler = getHandlers().get(kind);
+    return handler != null
+        ? handler.testLocationUrl(kind, parentSuite, name, className)
+        : super.testLocationUrl(kind, parentSuite, name, className);
+  }
+}
diff --git a/base/src/com/google/idea/blaze/base/run/smrunner/BlazeRerunFailedTestsAction.java b/base/src/com/google/idea/blaze/base/run/smrunner/BlazeRerunFailedTestsAction.java
index bc90989..2522bab 100644
--- a/base/src/com/google/idea/blaze/base/run/smrunner/BlazeRerunFailedTestsAction.java
+++ b/base/src/com/google/idea/blaze/base/run/smrunner/BlazeRerunFailedTestsAction.java
@@ -21,14 +21,20 @@
 import com.google.idea.blaze.base.run.state.BlazeCommandRunConfigurationCommonState;
 import com.intellij.execution.ExecutionException;
 import com.intellij.execution.Executor;
+import com.intellij.execution.Location;
 import com.intellij.execution.configurations.RunProfileState;
 import com.intellij.execution.runners.ExecutionEnvironment;
+import com.intellij.execution.testframework.AbstractTestProxy;
 import com.intellij.execution.testframework.TestFrameworkRunningModel;
 import com.intellij.execution.testframework.actions.AbstractRerunFailedTestsAction;
 import com.intellij.openapi.module.Module;
+import com.intellij.openapi.project.Project;
 import com.intellij.openapi.ui.ComponentContainer;
+import com.intellij.psi.search.GlobalSearchScope;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
 import javax.annotation.Nullable;
 
 /** Re-run failed tests. */
@@ -77,12 +83,27 @@
       if (handlerState == null || !BlazeCommandName.TEST.equals(handlerState.getCommand())) {
         return null;
       }
-      String testFilter = eventsHandler.getTestFilter(getProject(), getFailedTests(getProject()));
+      Project project = getProject();
+      List<Location<?>> locations =
+          getFailedTests(project)
+              .stream()
+              .map((test) -> toLocation(project, test))
+              .filter(Objects::nonNull)
+              .collect(Collectors.toList());
+      String testFilter = eventsHandler.getTestFilter(getProject(), locations);
+      if (testFilter == null) {
+        return null;
+      }
       List<String> blazeFlags = setTestFilter(handlerState.getBlazeFlags(), testFilter);
       handlerState.setBlazeFlags(blazeFlags);
       return configuration.getState(executor, environment);
     }
 
+    @Nullable
+    private Location<?> toLocation(Project project, AbstractTestProxy test) {
+      return test.getLocation(project, GlobalSearchScope.allScope(project));
+    }
+
     /** Replaces existing test_filter flag, or appends if none exists. */
     private List<String> setTestFilter(List<String> flags, String testFilter) {
       List<String> copy = new ArrayList<>(flags);
diff --git a/base/src/com/google/idea/blaze/base/run/smrunner/BlazeTestConsoleProperties.java b/base/src/com/google/idea/blaze/base/run/smrunner/BlazeTestConsoleProperties.java
index 6582c23..f4c95b9 100644
--- a/base/src/com/google/idea/blaze/base/run/smrunner/BlazeTestConsoleProperties.java
+++ b/base/src/com/google/idea/blaze/base/run/smrunner/BlazeTestConsoleProperties.java
@@ -34,7 +34,7 @@
 
   public BlazeTestConsoleProperties(
       RunConfiguration runConfiguration, Executor executor, BlazeTestEventsHandler eventsHandler) {
-    super(runConfiguration, eventsHandler.frameworkName, executor);
+    super(runConfiguration, SmRunnerUtils.BLAZE_FRAMEWORK, executor);
     this.eventsHandler = eventsHandler;
   }
 
diff --git a/base/src/com/google/idea/blaze/base/run/smrunner/BlazeTestEventsHandler.java b/base/src/com/google/idea/blaze/base/run/smrunner/BlazeTestEventsHandler.java
index 87e1b7f..2f3286f 100644
--- a/base/src/com/google/idea/blaze/base/run/smrunner/BlazeTestEventsHandler.java
+++ b/base/src/com/google/idea/blaze/base/run/smrunner/BlazeTestEventsHandler.java
@@ -17,42 +17,75 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
-import com.intellij.execution.testframework.AbstractTestProxy;
+import com.google.idea.blaze.base.ideinfo.TargetIdeInfo;
+import com.google.idea.blaze.base.model.primitives.Kind;
+import com.google.idea.blaze.base.model.primitives.Label;
+import com.google.idea.blaze.base.model.primitives.TargetExpression;
+import com.google.idea.blaze.base.run.smrunner.BlazeXmlSchema.TestSuite;
+import com.google.idea.blaze.base.run.targetfinder.TargetFinder;
+import com.google.idea.blaze.base.settings.Blaze;
+import com.google.idea.blaze.base.settings.Blaze.BuildSystem;
+import com.intellij.execution.Location;
 import com.intellij.execution.testframework.actions.AbstractRerunFailedTestsAction;
 import com.intellij.execution.testframework.sm.runner.SMTestLocator;
 import com.intellij.execution.ui.ConsoleView;
+import com.intellij.openapi.extensions.ExtensionPointName;
 import com.intellij.openapi.project.Project;
 import com.intellij.util.io.URLUtil;
-import java.io.File;
-import java.io.IOException;
-import java.nio.file.Files;
+import java.util.EnumSet;
 import java.util.List;
 import javax.annotation.Nullable;
 
 /** Language-specific handling of SM runner test protocol */
 public abstract class BlazeTestEventsHandler {
 
-  public final String frameworkName;
-  public final File testOutputXml;
-
-  protected BlazeTestEventsHandler(String frameworkName) {
-    this.frameworkName = frameworkName;
-    this.testOutputXml = generateTempTestXmlFile();
-  }
+  static final ExtensionPointName<BlazeTestEventsHandler> EP_NAME =
+      ExtensionPointName.create("com.google.idea.blaze.BlazeTestEventsHandler");
 
   /**
    * Blaze/Bazel flags required for test UI.<br>
-   * Forces local test execution, without sharding, and sets the output test xml path.
+   * Forces local test execution, without sharding.
    */
-  public ImmutableList<String> getBlazeFlags() {
-    return ImmutableList.of(
-        "--test_env=XML_OUTPUT_FILE=" + testOutputXml,
-        "--test_sharding_strategy=disabled",
-        "--runs_per_test=1",
-        "--flaky_test_attempts=1",
-        "--test_strategy=local");
+  public static ImmutableList<String> getBlazeFlags(Project project) {
+    ImmutableList.Builder<String> flags =
+        ImmutableList.<String>builder()
+            .add(
+                "--test_sharding_strategy=disabled",
+                "--runs_per_test=1",
+                "--flaky_test_attempts=1");
+    if (Blaze.getBuildSystem(project) == BuildSystem.Blaze) {
+      flags.add("--test_strategy=local");
+    }
+    return flags.build();
   }
 
+  @Nullable
+  public static BlazeTestEventsHandler getHandlerForTarget(
+      Project project, TargetExpression target) {
+    Kind kind = getKindForTarget(project, target);
+    for (BlazeTestEventsHandler handler : EP_NAME.getExtensions()) {
+      if (handler.handlesTargetKind(kind)) {
+        return handler;
+      }
+    }
+    return null;
+  }
+
+  @Nullable
+  private static Kind getKindForTarget(Project project, TargetExpression target) {
+    if (!(target instanceof Label)) {
+      return null;
+    }
+    TargetIdeInfo targetInfo = TargetFinder.getInstance().targetForLabel(project, (Label) target);
+    return targetInfo != null ? targetInfo.kind : null;
+  }
+
+  public boolean handlesTargetKind(@Nullable Kind kind) {
+    return handledKinds().contains(kind);
+  }
+
+  protected abstract EnumSet<Kind> handledKinds();
+
   public abstract SMTestLocator getTestLocator();
 
   /**
@@ -61,7 +94,7 @@
    * @return null if no filter can be constructed for these tests.
    */
   @Nullable
-  public abstract String getTestFilter(Project project, List<AbstractTestProxy> failedTests);
+  public abstract String getTestFilter(Project project, List<Location<?>> testLocations);
 
   @Nullable
   public AbstractRerunFailedTestsAction createRerunFailedTestsAction(ConsoleView consoleView) {
@@ -69,20 +102,21 @@
   }
 
   /** Converts the testsuite name in the blaze test XML to a user-friendly format */
-  public String suiteDisplayName(String rawName) {
+  public String suiteDisplayName(@Nullable Kind kind, String rawName) {
     return rawName;
   }
 
   /** Converts the testcase name in the blaze test XML to a user-friendly format */
-  public String testDisplayName(String rawName) {
+  public String testDisplayName(@Nullable Kind kind, String rawName) {
     return rawName;
   }
 
-  public String suiteLocationUrl(String name) {
+  public String suiteLocationUrl(@Nullable Kind kind, String name) {
     return SmRunnerUtils.GENERIC_SUITE_PROTOCOL + URLUtil.SCHEME_SEPARATOR + name;
   }
 
-  public String testLocationUrl(String name, @Nullable String className) {
+  public String testLocationUrl(
+      @Nullable Kind kind, String parentSuite, String name, @Nullable String className) {
     String base = SmRunnerUtils.GENERIC_TEST_PROTOCOL + URLUtil.SCHEME_SEPARATOR + name;
     if (Strings.isNullOrEmpty(className)) {
       return base;
@@ -90,14 +124,9 @@
     return base + SmRunnerUtils.TEST_NAME_PARTS_SPLITTER + className;
   }
 
-  private static File generateTempTestXmlFile() {
-    try {
-      File file = Files.createTempFile("blazeTest", ".xml").toFile();
-      file.deleteOnExit();
-      return file;
-
-    } catch (IOException e) {
-      throw new RuntimeException(e);
-    }
+  /** Whether to skip logging a {@link TestSuite}. */
+  public boolean ignoreSuite(TestSuite suite) {
+    // by default only include innermost 'testsuite' elements
+    return suite.testSuites.isEmpty();
   }
 }
diff --git a/base/src/com/google/idea/blaze/base/run/smrunner/BlazeXmlSchema.java b/base/src/com/google/idea/blaze/base/run/smrunner/BlazeXmlSchema.java
index 8f6d1f0..4126bc8 100644
--- a/base/src/com/google/idea/blaze/base/run/smrunner/BlazeXmlSchema.java
+++ b/base/src/com/google/idea/blaze/base/run/smrunner/BlazeXmlSchema.java
@@ -32,7 +32,7 @@
 
   static {
     try {
-      CONTEXT = JAXBContext.newInstance(TestSuite.class);
+      CONTEXT = JAXBContext.newInstance(TestSuite.class, TestSuites.class);
     } catch (JAXBException e) {
       throw new RuntimeException(e);
     }
@@ -40,15 +40,33 @@
 
   static TestSuite parse(InputStream input) {
     try {
-      return (TestSuite) CONTEXT.createUnmarshaller().unmarshal(input);
+      Object parsed = CONTEXT.createUnmarshaller().unmarshal(input);
+      return parsed instanceof TestSuites
+          ? ((TestSuites) parsed).convertToTestSuite()
+          : (TestSuite) parsed;
+
     } catch (JAXBException e) {
       throw new RuntimeException("Failed to parse test XML", e);
     }
   }
 
+  // optional wrapping XML element. Some test runners don't include it.
   @XmlRootElement(name = "testsuites")
-  static class TestSuite {
-    @XmlAttribute String name;
+  static class TestSuites {
+    @XmlElement(name = "testsuite")
+    List<TestSuite> testSuites = Lists.newArrayList();
+
+    TestSuite convertToTestSuite() {
+      TestSuite suite = new TestSuite();
+      suite.testSuites.addAll(testSuites);
+      return suite;
+    }
+  }
+
+  /** XML output by blaze test runners. */
+  @XmlRootElement(name = "testsuite")
+  public static class TestSuite {
+    public @XmlAttribute String name;
     @XmlAttribute String classname;
     @XmlAttribute int tests;
     @XmlAttribute int failures;
@@ -70,7 +88,7 @@
     ErrorOrFailureOrSkipped failure;
 
     @XmlElement(name = "testsuite")
-    List<TestSuite> testSuites = Lists.newArrayList();
+    public List<TestSuite> testSuites = Lists.newArrayList();
 
     @XmlElement(name = "testdecorator")
     List<TestSuite> testDecorators = Lists.newArrayList();
diff --git a/base/src/com/google/idea/blaze/base/run/smrunner/BlazeXmlToTestEventsConverter.java b/base/src/com/google/idea/blaze/base/run/smrunner/BlazeXmlToTestEventsConverter.java
index 82dcae8..548557a 100644
--- a/base/src/com/google/idea/blaze/base/run/smrunner/BlazeXmlToTestEventsConverter.java
+++ b/base/src/com/google/idea/blaze/base/run/smrunner/BlazeXmlToTestEventsConverter.java
@@ -15,9 +15,15 @@
  */
 package com.google.idea.blaze.base.run.smrunner;
 
+import com.google.idea.blaze.base.ideinfo.TargetIdeInfo;
+import com.google.idea.blaze.base.model.primitives.Kind;
+import com.google.idea.blaze.base.model.primitives.Label;
 import com.google.idea.blaze.base.run.smrunner.BlazeXmlSchema.ErrorOrFailureOrSkipped;
 import com.google.idea.blaze.base.run.smrunner.BlazeXmlSchema.TestCase;
 import com.google.idea.blaze.base.run.smrunner.BlazeXmlSchema.TestSuite;
+import com.google.idea.blaze.base.run.targetfinder.TargetFinder;
+import com.google.idea.blaze.base.run.testlogs.BlazeTestXmlFinderStrategy;
+import com.google.idea.blaze.base.run.testlogs.CompletedTestTarget;
 import com.google.idea.sdkcompat.smrunner.SmRunnerCompatUtils;
 import com.intellij.execution.process.ProcessOutputTypes;
 import com.intellij.execution.testframework.TestConsoleProperties;
@@ -29,6 +35,7 @@
 import com.intellij.execution.testframework.sm.runner.events.TestStartedEvent;
 import com.intellij.execution.testframework.sm.runner.events.TestSuiteFinishedEvent;
 import com.intellij.execution.testframework.sm.runner.events.TestSuiteStartedEvent;
+import com.intellij.openapi.project.Project;
 import com.intellij.openapi.util.Key;
 import java.io.FileInputStream;
 import java.io.InputStream;
@@ -42,6 +49,11 @@
 
   private static final ErrorOrFailureOrSkipped NO_ERROR = new ErrorOrFailureOrSkipped();
 
+  {
+    NO_ERROR.message = "No message"; // cannot be null
+  }
+
+  private final Project project;
   private final BlazeTestEventsHandler eventsHandler;
 
   public BlazeXmlToTestEventsConverter(
@@ -49,6 +61,7 @@
       TestConsoleProperties testConsoleProperties,
       BlazeTestEventsHandler eventsHandler) {
     super(testFrameworkName, testConsoleProperties);
+    this.project = testConsoleProperties.getProject();
     this.eventsHandler = eventsHandler;
   }
 
@@ -72,41 +85,52 @@
   public void flushBufferBeforeTerminating() {
     super.flushBufferBeforeTerminating();
     onStartTesting();
-    try (InputStream input = new FileInputStream(eventsHandler.testOutputXml)) {
-      parseXmlInput(getProcessor(), input);
-    } catch (Exception e) {
-      // ignore parsing errors -- most common cause is user cancellation, which we can't easily
-      // recognize.
+    getProcessor().onTestsReporterAttached();
+
+    for (CompletedTestTarget testTarget : BlazeTestXmlFinderStrategy.locateTestXmlFiles(project)) {
+      try (InputStream input = new FileInputStream(testTarget.testResultXml)) {
+        parseXmlInput(getProcessor(), getKind(project, testTarget.label), input);
+      } catch (Exception e) {
+        // ignore parsing errors -- most common cause is user cancellation, which we can't easily
+        // recognize.
+      }
     }
   }
 
-  private void parseXmlInput(GeneralTestEventsProcessor processor, InputStream input) {
-    TestSuite testResult = BlazeXmlSchema.parse(input);
-    processor.onTestsReporterAttached();
-    processTestSuite(processor, testResult);
+  @Nullable
+  private static Kind getKind(Project project, Label label) {
+    TargetIdeInfo target = TargetFinder.getInstance().targetForLabel(project, label);
+    return target != null ? target.kind : null;
   }
 
-  private void processTestSuite(GeneralTestEventsProcessor processor, TestSuite suite) {
+  private void parseXmlInput(
+      GeneralTestEventsProcessor processor, @Nullable Kind kind, InputStream input) {
+    TestSuite testResult = BlazeXmlSchema.parse(input);
+    processTestSuite(processor, kind, testResult);
+  }
+
+  private void processTestSuite(
+      GeneralTestEventsProcessor processor, @Nullable Kind kind, TestSuite suite) {
     if (!hasRunChild(suite)) {
       return;
     }
-    // don't include the outermost 'testsuites' element.
-    boolean logSuite = suite.testSuites.isEmpty();
+    // only include the innermost 'testsuite' element
+    boolean logSuite = !eventsHandler.ignoreSuite(suite);
     if (suite.name != null && logSuite) {
       TestSuiteStarted suiteStarted =
-          new TestSuiteStarted(eventsHandler.suiteDisplayName(suite.name));
-      String locationUrl = eventsHandler.suiteLocationUrl(suite.name);
+          new TestSuiteStarted(eventsHandler.suiteDisplayName(kind, suite.name));
+      String locationUrl = eventsHandler.suiteLocationUrl(kind, suite.name);
       processor.onSuiteStarted(new TestSuiteStartedEvent(suiteStarted, locationUrl));
     }
 
     for (TestSuite child : suite.testSuites) {
-      processTestSuite(processor, child);
+      processTestSuite(processor, kind, child);
     }
     for (TestSuite decorator : suite.testDecorators) {
-      processTestSuite(processor, decorator);
+      processTestSuite(processor, kind, decorator);
     }
     for (TestCase test : suite.testCases) {
-      processTestCase(processor, test);
+      processTestCase(processor, kind, suite, test);
     }
 
     if (suite.sysOut != null) {
@@ -118,7 +142,7 @@
 
     if (suite.name != null && logSuite) {
       processor.onSuiteFinished(
-          new TestSuiteFinishedEvent(eventsHandler.suiteDisplayName(suite.name)));
+          new TestSuiteFinishedEvent(eventsHandler.suiteDisplayName(kind, suite.name)));
     }
   }
 
@@ -138,7 +162,7 @@
       }
     }
     for (TestCase test : suite.testCases) {
-      if ("run".equals(test.status)) {
+      if (wasRun(test) && !isIgnored(test)) {
         return true;
       }
     }
@@ -149,6 +173,14 @@
     return "interrupted".equalsIgnoreCase(test.result) || "cancelled".equalsIgnoreCase(test.result);
   }
 
+  private static boolean wasRun(TestCase test) {
+    // 'status' is not always set. In cases where it's not, tests which aren't run have a 0 runtime.
+    if (test.status != null) {
+      return test.status.equals("run");
+    }
+    return parseTimeMillis(test.time) != 0;
+  }
+
   private static boolean isIgnored(TestCase test) {
     if (test.skipped != null) {
       return true;
@@ -162,12 +194,14 @@
     return test.failure != null || test.error != null;
   }
 
-  private void processTestCase(GeneralTestEventsProcessor processor, TestCase test) {
-    if (test.name == null || "notrun".equals(test.status) || isCancelled(test)) {
+  private void processTestCase(
+      GeneralTestEventsProcessor processor, @Nullable Kind kind, TestSuite parent, TestCase test) {
+    if (test.name == null || !wasRun(test) || isCancelled(test)) {
       return;
     }
-    String displayName = eventsHandler.testDisplayName(test.name);
-    String locationUrl = eventsHandler.testLocationUrl(test.name, test.classname);
+    String displayName = eventsHandler.testDisplayName(kind, test.name);
+    String locationUrl =
+        eventsHandler.testLocationUrl(kind, parent.name, test.name, test.classname);
     processor.onTestStarted(new TestStartedEvent(displayName, locationUrl));
 
     if (test.sysOut != null) {
diff --git a/base/src/com/google/idea/blaze/base/run/smrunner/CompositeSMTestLocator.java b/base/src/com/google/idea/blaze/base/run/smrunner/CompositeSMTestLocator.java
new file mode 100644
index 0000000..22588d7
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/run/smrunner/CompositeSMTestLocator.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.base.run.smrunner;
+
+import com.google.common.collect.ImmutableList;
+import com.intellij.execution.Location;
+import com.intellij.execution.testframework.sm.runner.SMTestLocator;
+import com.intellij.openapi.project.Project;
+import com.intellij.psi.search.GlobalSearchScope;
+import java.util.List;
+
+/** Combines multiple language-specific {@link SMTestLocator}s. */
+public class CompositeSMTestLocator implements SMTestLocator {
+
+  private final ImmutableList<SMTestLocator> locators;
+
+  protected CompositeSMTestLocator(ImmutableList<SMTestLocator> locators) {
+    this.locators = locators;
+  }
+
+  @Override
+  public List<Location> getLocation(
+      String protocol, String path, Project project, GlobalSearchScope scope) {
+    for (SMTestLocator locator : locators) {
+      List<Location> result = locator.getLocation(protocol, path, project, scope);
+      if (!result.isEmpty()) {
+        return result;
+      }
+    }
+    return ImmutableList.of();
+  }
+}
diff --git a/base/src/com/google/idea/blaze/base/run/smrunner/SmRunnerUtils.java b/base/src/com/google/idea/blaze/base/run/smrunner/SmRunnerUtils.java
index 04715a7..b9e80ba 100644
--- a/base/src/com/google/idea/blaze/base/run/smrunner/SmRunnerUtils.java
+++ b/base/src/com/google/idea/blaze/base/run/smrunner/SmRunnerUtils.java
@@ -15,17 +15,29 @@
  */
 package com.google.idea.blaze.base.run.smrunner;
 
+import com.google.common.collect.ImmutableList;
 import com.intellij.execution.DefaultExecutionResult;
 import com.intellij.execution.Executor;
+import com.intellij.execution.Location;
+import com.intellij.execution.actions.ConfigurationContext;
 import com.intellij.execution.configurations.RunConfiguration;
 import com.intellij.execution.testframework.TestConsoleProperties;
 import com.intellij.execution.testframework.actions.AbstractRerunFailedTestsAction;
 import com.intellij.execution.testframework.sm.SMTestRunnerConnectionUtil;
 import com.intellij.execution.testframework.sm.runner.SMTRunnerConsoleProperties;
+import com.intellij.execution.testframework.sm.runner.SMTestProxy;
 import com.intellij.execution.testframework.sm.runner.ui.SMTRunnerConsoleView;
+import com.intellij.execution.testframework.sm.runner.ui.SMTRunnerTestTreeView;
 import com.intellij.execution.ui.ExecutionConsole;
 import com.intellij.openapi.project.Project;
 import com.intellij.openapi.util.Disposer;
+import com.intellij.psi.search.GlobalSearchScope;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+import javax.swing.tree.TreePath;
 import javax.swing.tree.TreeSelectionModel;
 
 /** Utility methods for setting up the SM runner test UI. */
@@ -34,6 +46,7 @@
   public static final String GENERIC_SUITE_PROTOCOL = "blaze:suite";
   public static final String GENERIC_TEST_PROTOCOL = "blaze:test";
   public static final String TEST_NAME_PARTS_SPLITTER = "::";
+  public static final String BLAZE_FRAMEWORK = "blaze-test";
 
   public static SMTRunnerConsoleView getConsoleView(
       Project project,
@@ -44,7 +57,7 @@
         new BlazeTestConsoleProperties(configuration, executor, eventsHandler);
     SMTRunnerConsoleView console =
         (SMTRunnerConsoleView)
-            SMTestRunnerConnectionUtil.createConsole(eventsHandler.frameworkName, properties);
+            SMTestRunnerConnectionUtil.createConsole(BLAZE_FRAMEWORK, properties);
     Disposer.register(project, console);
     console
         .getResultsViewer()
@@ -73,4 +86,30 @@
     }
     return result;
   }
+
+  public static List<Location<?>> getSelectedSmRunnerTreeElements(ConfigurationContext context) {
+    SMTRunnerTestTreeView treeView =
+        SMTRunnerTestTreeView.SM_TEST_RUNNER_VIEW.getData(context.getDataContext());
+    if (treeView == null) {
+      return ImmutableList.of();
+    }
+    TreePath[] paths = treeView.getSelectionPaths();
+    if (paths == null || paths.length == 0) {
+      return ImmutableList.of();
+    }
+    return Arrays.stream(paths)
+        .map((path) -> toLocation(context.getProject(), treeView, path))
+        .filter(Objects::nonNull)
+        .collect(Collectors.toList());
+  }
+
+  @Nullable
+  private static Location<?> toLocation(
+      Project project, SMTRunnerTestTreeView treeView, TreePath path) {
+    if (treeView.isPathSelected(path.getParentPath())) {
+      return null;
+    }
+    SMTestProxy test = treeView.getSelectedTest(path);
+    return test != null ? test.getLocation(project, GlobalSearchScope.allScope(project)) : null;
+  }
 }
diff --git a/base/src/com/google/idea/blaze/base/run/state/BlazeCommandRunConfigurationCommonState.java b/base/src/com/google/idea/blaze/base/run/state/BlazeCommandRunConfigurationCommonState.java
index 1991ae6..ef2ec8e 100644
--- a/base/src/com/google/idea/blaze/base/run/state/BlazeCommandRunConfigurationCommonState.java
+++ b/base/src/com/google/idea/blaze/base/run/state/BlazeCommandRunConfigurationCommonState.java
@@ -96,11 +96,11 @@
     return null;
   }
 
-  public Boolean getRunOnDistributedExecutor() {
+  public boolean getRunOnDistributedExecutor() {
     return runOnDistributedExecutor.runOnDistributedExecutor;
   }
 
-  public void setRunOnDistributedExecutor(Boolean runOnDistributedExecutor) {
+  public void setRunOnDistributedExecutor(boolean runOnDistributedExecutor) {
     this.runOnDistributedExecutor.runOnDistributedExecutor = runOnDistributedExecutor;
   }
 
diff --git a/base/src/com/google/idea/blaze/base/run/state/BlazeRunOnDistributedExecutorState.java b/base/src/com/google/idea/blaze/base/run/state/BlazeRunOnDistributedExecutorState.java
index b251d3f..556b3f3 100644
--- a/base/src/com/google/idea/blaze/base/run/state/BlazeRunOnDistributedExecutorState.java
+++ b/base/src/com/google/idea/blaze/base/run/state/BlazeRunOnDistributedExecutorState.java
@@ -29,8 +29,8 @@
 import org.jdom.Element;
 
 /**
- * Provides an option to run blaze/bazel on a distributed executor, if available, rather than
- * locally.
+ * Provides an option to run blaze/bazel on a distributed executor, if available. If unchecked, we
+ * fall back to whatever the default is.
  */
 public class BlazeRunOnDistributedExecutorState implements RunConfigurationState {
 
@@ -39,10 +39,10 @@
 
   @Nullable private final DistributedExecutorSupport executorInfo;
 
-  public Boolean runOnDistributedExecutor = null;
+  public boolean runOnDistributedExecutor;
 
   BlazeRunOnDistributedExecutorState(BuildSystem buildSystem) {
-    this.executorInfo = DistributedExecutorSupport.getAvailableExecutor(buildSystem);
+    executorInfo = DistributedExecutorSupport.getAvailableExecutor(buildSystem);
   }
 
   @Override
@@ -55,7 +55,7 @@
 
   @Override
   public void writeExternal(Element element) throws WriteExternalException {
-    if (executorInfo != null && runOnDistributedExecutor != null) {
+    if (executorInfo != null && runOnDistributedExecutor) {
       element.setAttribute(
           RUN_ON_DISTRIBUTED_EXECUTOR_ATTR, Boolean.toString(runOnDistributedExecutor));
     } else {
@@ -71,12 +71,11 @@
   /** Editor for {@link BlazeRunOnDistributedExecutorState} */
   class RunOnExecutorStateEditor implements RunConfigurationStateEditor {
 
-    private final JBCheckBox checkBox =
-        new JBCheckBox("Run on " + (executorInfo != null ? executorInfo.executorName() : null));
+    private final String executorName =
+        executorInfo != null ? executorInfo.executorName() : "distributed executor";
+    private final JBCheckBox checkBox = new JBCheckBox("Run on " + executorName);
     private final JLabel warning =
-        new JLabel(
-            "Warning: test UI integration is not available when running on distributed "
-                + "executor");
+        new JLabel("Warning: test UI integration is not available when running on " + executorName);
 
     private boolean componentVisible = executorInfo != null;
     private boolean isTest = false;
@@ -90,9 +89,7 @@
     @Override
     public void resetEditorFrom(RunConfigurationState genericState) {
       BlazeRunOnDistributedExecutorState state = (BlazeRunOnDistributedExecutorState) genericState;
-      if (state.runOnDistributedExecutor != null) {
-        checkBox.setSelected(state.runOnDistributedExecutor);
-      }
+      checkBox.setSelected(state.runOnDistributedExecutor);
     }
 
     @Override
diff --git a/base/src/com/google/idea/blaze/base/run/testlogs/BlazeCommandLogParser.java b/base/src/com/google/idea/blaze/base/run/testlogs/BlazeCommandLogParser.java
new file mode 100644
index 0000000..9993c83
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/run/testlogs/BlazeCommandLogParser.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.base.run.testlogs;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableSet;
+import com.google.idea.blaze.base.model.primitives.Label;
+import com.intellij.openapi.diagnostic.Logger;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import javax.annotation.Nullable;
+
+/** Parses the list of test targets run from the bazel command log */
+public class BlazeCommandLogParser {
+
+  private static final Logger logger = Logger.getInstance(BlazeCommandLogParser.class);
+
+  private static final Pattern TEST_LOG = Pattern.compile("^(//[^\\s]*) .*? (PASSED|FAILED)");
+
+  /** Finds log location and target label for all tests listed in the master log. */
+  public static ImmutableSet<Label> parseTestTargets(File commandLog) {
+    try (Stream<String> stream = Files.lines(Paths.get(commandLog.getPath()))) {
+      return parseTestTargets(stream);
+    } catch (IOException e) {
+      logger.warn("Error parsing master log", e);
+      return ImmutableSet.of();
+    }
+  }
+
+  @VisibleForTesting
+  static ImmutableSet<Label> parseTestTargets(Stream<String> lines) {
+    return ImmutableSet.copyOf(
+        lines
+            .map(BlazeCommandLogParser::parseTestTarget)
+            .filter(Objects::nonNull)
+            .collect(Collectors.toSet()));
+  }
+
+  @Nullable
+  @VisibleForTesting
+  static Label parseTestTarget(String line) {
+    Matcher match = TEST_LOG.matcher(line);
+    if (!match.find()) {
+      return null;
+    }
+    return Label.createIfValid(match.group(1));
+  }
+}
diff --git a/base/src/com/google/idea/blaze/base/run/testlogs/BlazeTestLogParser.java b/base/src/com/google/idea/blaze/base/run/testlogs/BlazeTestLogParser.java
new file mode 100644
index 0000000..a262398
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/run/testlogs/BlazeTestLogParser.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.base.run.testlogs;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.intellij.openapi.diagnostic.Logger;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+import javax.annotation.Nullable;
+
+/** Parses an individual test target's log file produced by blaze. */
+public class BlazeTestLogParser {
+
+  private static final Logger logger = Logger.getInstance(BlazeTestLogParser.class);
+
+  private static final Pattern XML_LOCATION = Pattern.compile("XML_OUTPUT_FILE=([^\\s]*)");
+
+  /** Finds log location and target label for all tests listed in the master log. */
+  @Nullable
+  public static File findTestXmlFile(File testLog) {
+    try (Stream<String> stream = Files.lines(Paths.get(testLog.getPath()))) {
+      return parseTestXmlFile(stream);
+    } catch (IOException e) {
+      logger.warn("Error parsing test log", e);
+      return null;
+    }
+  }
+
+  @Nullable
+  @VisibleForTesting
+  static File parseTestXmlFile(Stream<String> stream) {
+    return stream
+        .map(BlazeTestLogParser::parseXmlLocation)
+        .filter(Objects::nonNull)
+        .findFirst()
+        .orElse(null);
+  }
+
+  @Nullable
+  @VisibleForTesting
+  static File parseXmlLocation(String line) {
+    Matcher match = XML_LOCATION.matcher(line);
+    if (!match.find()) {
+      return null;
+    }
+    return new File(match.group(1));
+  }
+}
diff --git a/base/src/com/google/idea/blaze/base/run/testlogs/BlazeTestXmlFinderStrategy.java b/base/src/com/google/idea/blaze/base/run/testlogs/BlazeTestXmlFinderStrategy.java
new file mode 100644
index 0000000..174d512
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/run/testlogs/BlazeTestXmlFinderStrategy.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.base.run.testlogs;
+
+import com.google.common.collect.ImmutableList;
+import com.google.idea.blaze.base.settings.Blaze;
+import com.google.idea.blaze.base.settings.Blaze.BuildSystem;
+import com.intellij.openapi.extensions.ExtensionPointName;
+import com.intellij.openapi.project.Project;
+
+/** A strategy for locating output test XML files. */
+public interface BlazeTestXmlFinderStrategy {
+
+  ExtensionPointName<BlazeTestXmlFinderStrategy> EP_NAME =
+      ExtensionPointName.create("com.google.idea.blaze.BlazeTestXmlFinderStrategy");
+
+  /**
+   * Attempt to find all output test XML files associated with the given run configuration. Called
+   * after the 'blaze test' process completes.
+   */
+  static ImmutableList<CompletedTestTarget> locateTestXmlFiles(Project project) {
+    BuildSystem buildSystem = Blaze.getBuildSystem(project);
+    ImmutableList.Builder<CompletedTestTarget> output = ImmutableList.builder();
+    for (BlazeTestXmlFinderStrategy strategy : EP_NAME.getExtensions()) {
+      if (strategy.handlesBuildSystem(buildSystem)) {
+        output.addAll(strategy.findTestXmlFiles(project));
+      }
+    }
+    return output.build();
+  }
+
+  /**
+   * Attempt to find all output test XML files associated with the given run configuration using a
+   * particular strategy. Called after the 'blaze test' process completes.
+   */
+  ImmutableList<CompletedTestTarget> findTestXmlFiles(Project project);
+
+  boolean handlesBuildSystem(BuildSystem buildSystem);
+}
diff --git a/base/src/com/google/idea/blaze/base/run/testlogs/CompletedTestTarget.java b/base/src/com/google/idea/blaze/base/run/testlogs/CompletedTestTarget.java
new file mode 100644
index 0000000..bf0b33b
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/run/testlogs/CompletedTestTarget.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.base.run.testlogs;
+
+import com.google.idea.blaze.base.model.primitives.Label;
+import java.io.File;
+
+/** Information relating to a completed test target. */
+public class CompletedTestTarget {
+
+  public final File testResultXml;
+  public final Label label;
+
+  public CompletedTestTarget(File testResultXml, Label label) {
+    this.testResultXml = testResultXml;
+    this.label = label;
+  }
+}
diff --git a/base/src/com/google/idea/blaze/base/run/testlogs/TargetPathTestXmlFinderStrategy.java b/base/src/com/google/idea/blaze/base/run/testlogs/TargetPathTestXmlFinderStrategy.java
new file mode 100644
index 0000000..ce5ec40
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/run/testlogs/TargetPathTestXmlFinderStrategy.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.base.run.testlogs;
+
+import com.google.common.collect.ImmutableList;
+import com.google.idea.blaze.base.command.info.BlazeInfo;
+import com.google.idea.blaze.base.model.BlazeProjectData;
+import com.google.idea.blaze.base.model.primitives.Label;
+import com.google.idea.blaze.base.settings.Blaze;
+import com.google.idea.blaze.base.settings.Blaze.BuildSystem;
+import com.google.idea.blaze.base.sync.data.BlazeProjectDataManager;
+import com.intellij.openapi.project.Project;
+import java.io.File;
+import java.util.Objects;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+
+/**
+ * Attempts to parse the list of test targets from the command log, then searches the corresponding
+ * path in the bazel-testlogs output tree.
+ */
+public class TargetPathTestXmlFinderStrategy implements BlazeTestXmlFinderStrategy {
+
+  @Override
+  public boolean handlesBuildSystem(BuildSystem buildSystem) {
+    return buildSystem == BuildSystem.Bazel;
+  }
+
+  @Override
+  public ImmutableList<CompletedTestTarget> findTestXmlFiles(Project project) {
+    File testLogsDir = getTestLogsTree(project);
+    if (testLogsDir == null) {
+      return ImmutableList.of();
+    }
+    File commandLog = getCommandLog(project);
+    if (commandLog == null) {
+      return ImmutableList.of();
+    }
+    return ImmutableList.copyOf(
+        BlazeCommandLogParser.parseTestTargets(commandLog)
+            .stream()
+            .map((label) -> toKindAndTestXml(testLogsDir, label))
+            .filter(Objects::nonNull)
+            .collect(Collectors.toList()));
+  }
+
+  @Nullable
+  private static CompletedTestTarget toKindAndTestXml(File testLogsDir, Label label) {
+    String labelPath = label.blazePackage() + File.separator + label.targetName();
+    File testXml = new File(testLogsDir, labelPath + File.separator + "test.xml");
+    return new CompletedTestTarget(testXml, label);
+  }
+
+  @Nullable
+  private static File getTestLogsTree(Project project) {
+    BlazeProjectData projectData =
+        BlazeProjectDataManager.getInstance(project).getBlazeProjectData();
+    if (projectData == null) {
+      return null;
+    }
+    String testLogsLocation =
+        projectData.blazeInfo.get(BlazeInfo.blazeTestlogsKey(Blaze.getBuildSystem(project)));
+    return testLogsLocation != null ? new File(testLogsLocation) : null;
+  }
+
+  @Nullable
+  private static File getCommandLog(Project project) {
+    BlazeProjectData projectData =
+        BlazeProjectDataManager.getInstance(project).getBlazeProjectData();
+    if (projectData == null) {
+      return null;
+    }
+    String commandLogLocation = projectData.blazeInfo.get(BlazeInfo.COMMAND_LOG);
+    return commandLogLocation != null ? new File(commandLogLocation) : null;
+  }
+}
diff --git a/base/src/com/google/idea/blaze/base/run/testmap/FilteredTargetMap.java b/base/src/com/google/idea/blaze/base/run/testmap/FilteredTargetMap.java
new file mode 100644
index 0000000..34080d4
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/run/testmap/FilteredTargetMap.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.base.run.testmap;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Queues;
+import com.google.common.collect.Sets;
+import com.google.idea.blaze.base.ideinfo.ArtifactLocation;
+import com.google.idea.blaze.base.ideinfo.TargetIdeInfo;
+import com.google.idea.blaze.base.ideinfo.TargetKey;
+import com.google.idea.blaze.base.ideinfo.TargetMap;
+import com.google.idea.blaze.base.model.BlazeProjectData;
+import com.google.idea.blaze.base.model.primitives.Label;
+import com.google.idea.blaze.base.sync.data.BlazeProjectDataManager;
+import com.google.idea.blaze.base.sync.workspace.ArtifactLocationDecoder;
+import com.intellij.openapi.project.Project;
+import java.io.File;
+import java.util.Collection;
+import java.util.List;
+import java.util.Queue;
+import java.util.Set;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+
+/** Filters a {@link TargetMap} according to a given filter. */
+public class FilteredTargetMap {
+
+  private final Project project;
+  private final Multimap<File, TargetKey> rootsMap;
+  private final TargetMap targetMap;
+  private final Predicate<TargetIdeInfo> filter;
+
+  public FilteredTargetMap(
+      Project project,
+      ArtifactLocationDecoder artifactLocationDecoder,
+      TargetMap targetMap,
+      Predicate<TargetIdeInfo> filter) {
+    this.project = project;
+    this.rootsMap = createRootsMap(artifactLocationDecoder, targetMap.targets());
+    this.targetMap = targetMap;
+    this.filter = filter;
+  }
+
+  public Collection<TargetIdeInfo> targetsForSourceFile(File sourceFile) {
+    BlazeProjectData blazeProjectData =
+        BlazeProjectDataManager.getInstance(project).getBlazeProjectData();
+    if (blazeProjectData != null) {
+      return targetsForSourceFileImpl(blazeProjectData.reverseDependencies, sourceFile);
+    }
+    return ImmutableList.of();
+  }
+
+  @VisibleForTesting
+  Collection<Label> targetsForSourceFile(
+      ImmutableMultimap<TargetKey, TargetKey> rdepsMap, File sourceFile) {
+    return targetsForSourceFileImpl(rdepsMap, sourceFile)
+        .stream()
+        .filter(TargetIdeInfo::isPlainTarget)
+        .map(target -> target.key.label)
+        .collect(Collectors.toList());
+  }
+
+  private Collection<TargetIdeInfo> targetsForSourceFileImpl(
+      ImmutableMultimap<TargetKey, TargetKey> rdepsMap, File sourceFile) {
+    List<TargetIdeInfo> result = Lists.newArrayList();
+    Collection<TargetKey> roots = rootsMap.get(sourceFile);
+
+    Queue<TargetKey> todo = Queues.newArrayDeque();
+    for (TargetKey label : roots) {
+      todo.add(label);
+    }
+    Set<TargetKey> seen = Sets.newHashSet();
+    while (!todo.isEmpty()) {
+      TargetKey targetKey = todo.remove();
+      if (!seen.add(targetKey)) {
+        continue;
+      }
+
+      TargetIdeInfo target = targetMap.get(targetKey);
+      if (filter.test(target)) {
+        result.add(target);
+      }
+      for (TargetKey rdep : rdepsMap.get(targetKey)) {
+        todo.add(rdep);
+      }
+    }
+    return result;
+  }
+
+  private static Multimap<File, TargetKey> createRootsMap(
+      ArtifactLocationDecoder artifactLocationDecoder, Collection<TargetIdeInfo> targets) {
+    Multimap<File, TargetKey> result = ArrayListMultimap.create();
+    for (TargetIdeInfo target : targets) {
+      for (ArtifactLocation source : target.sources) {
+        result.put(artifactLocationDecoder.decode(source), target.key);
+      }
+    }
+    return result;
+  }
+}
diff --git a/base/src/com/google/idea/blaze/base/run/testmap/TestTargetFilterImpl.java b/base/src/com/google/idea/blaze/base/run/testmap/TestTargetFilterImpl.java
index 99d490f..3a74582 100644
--- a/base/src/com/google/idea/blaze/base/run/testmap/TestTargetFilterImpl.java
+++ b/base/src/com/google/idea/blaze/base/run/testmap/TestTargetFilterImpl.java
@@ -16,35 +16,17 @@
 package com.google.idea.blaze.base.run.testmap;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMultimap;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Multimap;
-import com.google.common.collect.Queues;
-import com.google.common.collect.Sets;
-import com.google.idea.blaze.base.ideinfo.ArtifactLocation;
 import com.google.idea.blaze.base.ideinfo.TargetIdeInfo;
-import com.google.idea.blaze.base.ideinfo.TargetKey;
 import com.google.idea.blaze.base.ideinfo.TargetMap;
 import com.google.idea.blaze.base.model.BlazeProjectData;
 import com.google.idea.blaze.base.model.primitives.Kind;
-import com.google.idea.blaze.base.model.primitives.Label;
-import com.google.idea.blaze.base.projectview.ProjectViewSet;
 import com.google.idea.blaze.base.run.TestTargetFinder;
-import com.google.idea.blaze.base.scope.BlazeContext;
-import com.google.idea.blaze.base.settings.BlazeImportSettings;
-import com.google.idea.blaze.base.sync.BlazeSyncParams.SyncMode;
-import com.google.idea.blaze.base.sync.SyncListener;
-import com.google.idea.blaze.base.sync.data.BlazeProjectDataManager;
+import com.google.idea.blaze.base.sync.SyncCache;
 import com.google.idea.blaze.base.sync.workspace.ArtifactLocationDecoder;
 import com.intellij.openapi.project.Project;
 import java.io.File;
 import java.util.Collection;
-import java.util.List;
-import java.util.Queue;
-import java.util.Set;
-import java.util.stream.Collectors;
 import javax.annotation.Nullable;
 
 /**
@@ -55,89 +37,6 @@
 public class TestTargetFilterImpl implements TestTargetFinder {
 
   private final Project project;
-  @Nullable private TestMap testMap;
-
-  static class TestMap {
-    private final Project project;
-    private final Multimap<File, TargetKey> rootsMap;
-    private final TargetMap targetMap;
-
-    TestMap(Project project, ArtifactLocationDecoder artifactLocationDecoder, TargetMap targetMap) {
-      this.project = project;
-      this.rootsMap = createRootsMap(artifactLocationDecoder, targetMap.targets());
-      this.targetMap = targetMap;
-    }
-
-    private Collection<TargetIdeInfo> testTargetsForSourceFile(File sourceFile) {
-      BlazeProjectData blazeProjectData =
-          BlazeProjectDataManager.getInstance(project).getBlazeProjectData();
-      if (blazeProjectData != null) {
-        return testTargetsForSourceFileImpl(blazeProjectData.reverseDependencies, sourceFile);
-      }
-      return ImmutableList.of();
-    }
-
-    @VisibleForTesting
-    Collection<Label> testTargetsForSourceFile(
-        ImmutableMultimap<TargetKey, TargetKey> rdepsMap, File sourceFile) {
-      return testTargetsForSourceFileImpl(rdepsMap, sourceFile)
-          .stream()
-          .filter(TargetIdeInfo::isPlainTarget)
-          .map(target -> target.key.label)
-          .collect(Collectors.toList());
-    }
-
-    Collection<TargetIdeInfo> testTargetsForSourceFileImpl(
-        ImmutableMultimap<TargetKey, TargetKey> rdepsMap, File sourceFile) {
-      List<TargetIdeInfo> result = Lists.newArrayList();
-      Collection<TargetKey> roots = rootsMap.get(sourceFile);
-
-      Queue<TargetKey> todo = Queues.newArrayDeque();
-      for (TargetKey label : roots) {
-        todo.add(label);
-      }
-      Set<TargetKey> seen = Sets.newHashSet();
-      while (!todo.isEmpty()) {
-        TargetKey targetKey = todo.remove();
-        if (!seen.add(targetKey)) {
-          continue;
-        }
-
-        TargetIdeInfo target = targetMap.get(targetKey);
-        if (isTestTarget(target)) {
-          result.add(target);
-        }
-        for (TargetKey rdep : rdepsMap.get(targetKey)) {
-          todo.add(rdep);
-        }
-      }
-      return result;
-    }
-
-    static Multimap<File, TargetKey> createRootsMap(
-        ArtifactLocationDecoder artifactLocationDecoder, Collection<TargetIdeInfo> targets) {
-      Multimap<File, TargetKey> result = ArrayListMultimap.create();
-      for (TargetIdeInfo target : targets) {
-        for (ArtifactLocation source : target.sources) {
-          result.put(artifactLocationDecoder.decode(source), target.key);
-        }
-      }
-      return result;
-    }
-
-    private static boolean isTestTarget(@Nullable TargetIdeInfo target) {
-      return target != null
-          && target.kind != null
-          && target.kind.isOneOf(
-              Kind.ANDROID_ROBOLECTRIC_TEST,
-              Kind.ANDROID_TEST,
-              Kind.JAVA_TEST,
-              Kind.GWT_TEST,
-              Kind.CC_TEST,
-              Kind.PY_TEST,
-              Kind.GO_TEST);
-    }
-  }
 
   public TestTargetFilterImpl(Project project) {
     this.project = project;
@@ -145,47 +44,35 @@
 
   @Override
   public Collection<TargetIdeInfo> testTargetsForSourceFile(File sourceFile) {
-    TestMap testMap = getTestMap();
+    FilteredTargetMap testMap =
+        SyncCache.getInstance(project)
+            .get(TestTargetFilterImpl.class, TestTargetFilterImpl::computeTestMap);
     if (testMap == null) {
       return ImmutableList.of();
     }
-    return testMap.testTargetsForSourceFile(sourceFile);
+    return testMap.targetsForSourceFile(sourceFile);
   }
 
-  private synchronized TestMap getTestMap() {
-    if (testMap == null) {
-      testMap = initTestMap();
-    }
-    return testMap;
+  private static FilteredTargetMap computeTestMap(Project project, BlazeProjectData projectData) {
+    return computeTestMap(project, projectData.artifactLocationDecoder, projectData.targetMap);
   }
 
-  @Nullable
-  private TestMap initTestMap() {
-    BlazeProjectData blazeProjectData =
-        BlazeProjectDataManager.getInstance(project).getBlazeProjectData();
-    if (blazeProjectData == null) {
-      return null;
-    }
-    return new TestMap(
-        project, blazeProjectData.artifactLocationDecoder, blazeProjectData.targetMap);
+  @VisibleForTesting
+  static FilteredTargetMap computeTestMap(
+      Project project, ArtifactLocationDecoder decoder, TargetMap targetMap) {
+    return new FilteredTargetMap(project, decoder, targetMap, TestTargetFilterImpl::isTestTarget);
   }
 
-  private synchronized void clearMapData() {
-    this.testMap = null;
-  }
-
-  static class ClearTestMap extends SyncListener.Adapter {
-    @Override
-    public void onSyncComplete(
-        Project project,
-        BlazeContext context,
-        BlazeImportSettings importSettings,
-        ProjectViewSet projectViewSet,
-        BlazeProjectData blazeProjectData,
-        SyncMode syncMode,
-        SyncResult syncResult) {
-      TestTargetFinder testTargetFinder = TestTargetFinder.getInstance(project);
-      ((TestTargetFilterImpl) testTargetFinder).clearMapData();
-    }
+  private static boolean isTestTarget(@Nullable TargetIdeInfo target) {
+    return target != null
+        && target.kind != null
+        && target.kind.isOneOf(
+            Kind.ANDROID_ROBOLECTRIC_TEST,
+            Kind.ANDROID_TEST,
+            Kind.JAVA_TEST,
+            Kind.GWT_TEST,
+            Kind.CC_TEST,
+            Kind.PY_TEST,
+            Kind.GO_TEST);
   }
 }
diff --git a/base/src/com/google/idea/blaze/base/scope/Scope.java b/base/src/com/google/idea/blaze/base/scope/Scope.java
index bb0c98d..4c0f12a 100644
--- a/base/src/com/google/idea/blaze/base/scope/Scope.java
+++ b/base/src/com/google/idea/blaze/base/scope/Scope.java
@@ -21,7 +21,7 @@
 
 /** Helper methods to run scoped functions and operations in a scoped context. */
 public final class Scope {
-  private static final Logger LOG = Logger.getInstance(Scope.class);
+  private static final Logger logger = Logger.getInstance(Scope.class);
 
   /** Runs a scoped function in a new root scope. */
   public static <T> T root(@NotNull ScopedFunction<T> scopedFunction) {
@@ -36,7 +36,7 @@
       return scopedFunction.execute(context);
     } catch (RuntimeException e) {
       context.setHasError();
-      LOG.error(e);
+      logger.error(e);
       throw e;
     } finally {
       context.endScope();
@@ -56,7 +56,7 @@
       scopedOperation.execute(context);
     } catch (RuntimeException e) {
       context.setHasError();
-      LOG.error(e);
+      logger.error(e);
       throw e;
     } finally {
       context.endScope();
diff --git a/base/src/com/google/idea/blaze/base/scope/scopes/BlazeConsoleScope.java b/base/src/com/google/idea/blaze/base/scope/scopes/BlazeConsoleScope.java
index 19205cf..1875f20 100644
--- a/base/src/com/google/idea/blaze/base/scope/scopes/BlazeConsoleScope.java
+++ b/base/src/com/google/idea/blaze/base/scope/scopes/BlazeConsoleScope.java
@@ -16,6 +16,8 @@
 package com.google.idea.blaze.base.scope.scopes;
 
 import com.google.idea.blaze.base.console.BlazeConsoleService;
+import com.google.idea.blaze.base.console.ColoredConsoleStream;
+import com.google.idea.blaze.base.console.ConsoleStream;
 import com.google.idea.blaze.base.scope.BlazeContext;
 import com.google.idea.blaze.base.scope.BlazeScope;
 import com.google.idea.blaze.base.scope.OutputSink;
@@ -37,6 +39,7 @@
     private Project project;
     private ProgressIndicator progressIndicator;
     private boolean suppressConsole = false;
+    private boolean escapeAnsiColorCodes = false;
 
     public Builder(@NotNull Project project) {
       this(project, null);
@@ -52,13 +55,17 @@
       return this;
     }
 
+    public Builder escapeAnsiColorcodes(boolean escapeAnsiColorCodes) {
+      this.escapeAnsiColorCodes = escapeAnsiColorCodes;
+      return this;
+    }
+
     public BlazeConsoleScope build() {
-      return new BlazeConsoleScope(project, progressIndicator, suppressConsole);
+      return new BlazeConsoleScope(
+          project, progressIndicator, suppressConsole, escapeAnsiColorCodes);
     }
   }
 
-  @NotNull private final Project project;
-
   @NotNull private final BlazeConsoleService blazeConsoleService;
 
   @Nullable private final ProgressIndicator progressIndicator;
@@ -66,6 +73,8 @@
   private final boolean showDialogOnChange;
   private boolean activated;
 
+  private final ConsoleStream consoleStream;
+
   private OutputSink<PrintOutput> printSink =
       (output) -> {
         @NotNull String text = output.getText();
@@ -89,20 +98,23 @@
   private BlazeConsoleScope(
       @NotNull Project project,
       @Nullable ProgressIndicator progressIndicator,
-      boolean suppressConsole) {
-    this.project = project;
+      boolean suppressConsole,
+      boolean escapeAnsiColorCodes) {
     this.blazeConsoleService = BlazeConsoleService.getInstance(project);
     this.progressIndicator = progressIndicator;
     this.showDialogOnChange = !suppressConsole;
+    ConsoleStream sinkConsoleStream = blazeConsoleService::print;
+    this.consoleStream =
+        escapeAnsiColorCodes ? new ColoredConsoleStream(sinkConsoleStream) : sinkConsoleStream;
   }
 
   private void print(String text, ConsoleViewContentType contentType) {
-    blazeConsoleService.print(text + "\n", contentType);
+    consoleStream.print(text, contentType);
+    consoleStream.print("\n", contentType);
 
     if (showDialogOnChange && !activated) {
       activated = true;
-      ApplicationManager.getApplication()
-          .invokeLater(() -> blazeConsoleService.activateConsoleWindow());
+      ApplicationManager.getApplication().invokeLater(blazeConsoleService::activateConsoleWindow);
     }
   }
 
diff --git a/base/src/com/google/idea/blaze/base/scope/scopes/IdeaLogScope.java b/base/src/com/google/idea/blaze/base/scope/scopes/IdeaLogScope.java
index 4a1130c..dc86561 100644
--- a/base/src/com/google/idea/blaze/base/scope/scopes/IdeaLogScope.java
+++ b/base/src/com/google/idea/blaze/base/scope/scopes/IdeaLogScope.java
@@ -27,11 +27,11 @@
 /** Scope that captures relevant output to the IntelliJ log file. */
 public class IdeaLogScope implements BlazeScope {
 
-  private static final Logger LOG = Logger.getInstance(IdeaLogScope.class);
+  private static final Logger logger = Logger.getInstance(IdeaLogScope.class);
 
   private static final OutputSink<IssueOutput> issueSink =
       (output) -> {
-        LOG.warn(output.toString());
+        logger.warn(output.toString());
         return OutputSink.Propagation.Continue;
       };
 
@@ -41,10 +41,10 @@
           case NORMAL:
             break;
           case LOGGED:
-            LOG.info(output.getText());
+            logger.info(output.getText());
             break;
           case ERROR:
-            LOG.warn(output.getText());
+            logger.warn(output.getText());
             break;
         }
         return OutputSink.Propagation.Continue;
@@ -52,7 +52,7 @@
 
   private static final OutputSink<StatusOutput> statusSink =
       (output) -> {
-        LOG.info(output.getStatus());
+        logger.info(output.getStatus());
         return OutputSink.Propagation.Continue;
       };
 
diff --git a/base/src/com/google/idea/blaze/base/scope/scopes/LoggedTimingScope.java b/base/src/com/google/idea/blaze/base/scope/scopes/LoggedTimingScope.java
deleted file mode 100644
index 3b34acd..0000000
--- a/base/src/com/google/idea/blaze/base/scope/scopes/LoggedTimingScope.java
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * Copyright 2016 The Bazel Authors. 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.idea.blaze.base.scope.scopes;
-
-import com.google.common.base.Stopwatch;
-import com.google.idea.blaze.base.metrics.Action;
-import com.google.idea.blaze.base.metrics.LoggingService;
-import com.google.idea.blaze.base.scope.BlazeContext;
-import com.google.idea.blaze.base.scope.BlazeScope;
-import com.intellij.openapi.project.Project;
-import java.util.concurrent.TimeUnit;
-
-/** Timing scope where the results are sent to a logging service */
-public class LoggedTimingScope implements BlazeScope {
-  // It is not guaranteed that the threading model will be sane during the entirety of this scope,
-  // so we use wall clock time and not ThreadMXBean where we could get user/system time.
-
-  Project project;
-  private final Action action;
-  private Stopwatch timer;
-
-  /** @param action The action we will be reporting a time for to the logging service */
-  public LoggedTimingScope(Project project, Action action) {
-    this.project = project;
-    this.action = action;
-    this.timer = Stopwatch.createUnstarted();
-  }
-
-  @Override
-  public void onScopeBegin(BlazeContext context) {
-    timer.start();
-  }
-
-  @Override
-  public void onScopeEnd(BlazeContext context) {
-    if (!context.isCancelled()) {
-      long totalMS = timer.elapsed(TimeUnit.MILLISECONDS);
-      LoggingService.reportEvent(project, action, totalMS);
-    }
-  }
-}
diff --git a/base/src/com/google/idea/blaze/base/settings/ui/EditProjectViewAction.java b/base/src/com/google/idea/blaze/base/settings/ui/OpenAllProjectViewsAction.java
similarity index 62%
rename from base/src/com/google/idea/blaze/base/settings/ui/EditProjectViewAction.java
rename to base/src/com/google/idea/blaze/base/settings/ui/OpenAllProjectViewsAction.java
index 926483b..d1e572e 100644
--- a/base/src/com/google/idea/blaze/base/settings/ui/EditProjectViewAction.java
+++ b/base/src/com/google/idea/blaze/base/settings/ui/OpenAllProjectViewsAction.java
@@ -19,15 +19,10 @@
 import com.google.idea.blaze.base.projectview.ProjectViewManager;
 import com.google.idea.blaze.base.projectview.ProjectViewSet;
 import com.intellij.openapi.actionSystem.AnActionEvent;
-import com.intellij.openapi.fileEditor.FileEditorManager;
-import com.intellij.openapi.fileEditor.OpenFileDescriptor;
 import com.intellij.openapi.project.Project;
-import com.intellij.openapi.vfs.VfsUtil;
-import com.intellij.openapi.vfs.VirtualFile;
-import java.io.File;
 
 /** Opens all the user's project views. */
-public class EditProjectViewAction extends BlazeProjectAction {
+public class OpenAllProjectViewsAction extends BlazeProjectAction {
 
   @Override
   protected void actionPerformedInBlazeProject(Project project, AnActionEvent e) {
@@ -35,15 +30,8 @@
     if (projectViewSet == null) {
       return;
     }
-    for (ProjectViewSet.ProjectViewFile projectViewFile : projectViewSet.getProjectViewFiles()) {
-      File file = projectViewFile.projectViewFile;
-      if (file != null) {
-        VirtualFile virtualFile = VfsUtil.findFileByIoFile(file, true);
-        if (virtualFile != null) {
-          OpenFileDescriptor descriptor = new OpenFileDescriptor(project, virtualFile);
-          FileEditorManager.getInstance(project).openTextEditor(descriptor, true);
-        }
-      }
-    }
+    projectViewSet
+        .getProjectViewFiles()
+        .forEach(f -> ProjectViewHelper.openProjectViewFile(project, f));
   }
 }
diff --git a/base/src/com/google/idea/blaze/base/settings/ui/EditProjectViewAction.java b/base/src/com/google/idea/blaze/base/settings/ui/OpenLocalProjectViewAction.java
similarity index 60%
copy from base/src/com/google/idea/blaze/base/settings/ui/EditProjectViewAction.java
copy to base/src/com/google/idea/blaze/base/settings/ui/OpenLocalProjectViewAction.java
index 926483b..0b40fb8 100644
--- a/base/src/com/google/idea/blaze/base/settings/ui/EditProjectViewAction.java
+++ b/base/src/com/google/idea/blaze/base/settings/ui/OpenLocalProjectViewAction.java
@@ -19,15 +19,10 @@
 import com.google.idea.blaze.base.projectview.ProjectViewManager;
 import com.google.idea.blaze.base.projectview.ProjectViewSet;
 import com.intellij.openapi.actionSystem.AnActionEvent;
-import com.intellij.openapi.fileEditor.FileEditorManager;
-import com.intellij.openapi.fileEditor.OpenFileDescriptor;
 import com.intellij.openapi.project.Project;
-import com.intellij.openapi.vfs.VfsUtil;
-import com.intellij.openapi.vfs.VirtualFile;
-import java.io.File;
 
-/** Opens all the user's project views. */
-public class EditProjectViewAction extends BlazeProjectAction {
+/** Opens the user's local project view file. */
+public class OpenLocalProjectViewAction extends BlazeProjectAction {
 
   @Override
   protected void actionPerformedInBlazeProject(Project project, AnActionEvent e) {
@@ -35,15 +30,6 @@
     if (projectViewSet == null) {
       return;
     }
-    for (ProjectViewSet.ProjectViewFile projectViewFile : projectViewSet.getProjectViewFiles()) {
-      File file = projectViewFile.projectViewFile;
-      if (file != null) {
-        VirtualFile virtualFile = VfsUtil.findFileByIoFile(file, true);
-        if (virtualFile != null) {
-          OpenFileDescriptor descriptor = new OpenFileDescriptor(project, virtualFile);
-          FileEditorManager.getInstance(project).openTextEditor(descriptor, true);
-        }
-      }
-    }
+    ProjectViewHelper.openProjectViewFile(project, projectViewSet.getTopLevelProjectViewFile());
   }
 }
diff --git a/base/src/com/google/idea/blaze/base/settings/ui/ProjectViewHelper.java b/base/src/com/google/idea/blaze/base/settings/ui/ProjectViewHelper.java
new file mode 100644
index 0000000..7c95d50
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/settings/ui/ProjectViewHelper.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2016 The Bazel Authors. 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.idea.blaze.base.settings.ui;
+
+import com.google.idea.blaze.base.projectview.ProjectViewSet.ProjectViewFile;
+import com.intellij.openapi.fileEditor.FileEditorManager;
+import com.intellij.openapi.fileEditor.OpenFileDescriptor;
+import com.intellij.openapi.project.Project;
+import com.intellij.openapi.vfs.VfsUtil;
+import com.intellij.openapi.vfs.VirtualFile;
+import java.io.File;
+
+/** Helper methods for manipulating project views. */
+final class ProjectViewHelper {
+  private ProjectViewHelper() {}
+
+  static void openProjectViewFile(Project project, ProjectViewFile projectViewFile) {
+    File file = projectViewFile.projectViewFile;
+    if (file != null) {
+      VirtualFile virtualFile = VfsUtil.findFileByIoFile(file, true);
+      if (virtualFile != null) {
+        OpenFileDescriptor descriptor = new OpenFileDescriptor(project, virtualFile);
+        FileEditorManager.getInstance(project).openTextEditor(descriptor, true);
+      }
+    }
+  }
+}
diff --git a/base/src/com/google/idea/blaze/base/sync/BlazeSyncPlugin.java b/base/src/com/google/idea/blaze/base/sync/BlazeSyncPlugin.java
index aa429f3..6675f13 100644
--- a/base/src/com/google/idea/blaze/base/sync/BlazeSyncPlugin.java
+++ b/base/src/com/google/idea/blaze/base/sync/BlazeSyncPlugin.java
@@ -65,13 +65,6 @@
      */
     ModifiableRootModel editModule(Module module);
 
-    /**
-     * Registers a module. This prevents garbage collection of the module upon commit.
-     *
-     * @return True if the module exists and was registered.
-     */
-    boolean registerModule(String moduleName);
-
     /** Finds a module by name. This doesn't register the module. */
     @Nullable
     Module findModule(String moduleName);
@@ -115,6 +108,13 @@
       SyncState.Builder syncStateBuilder,
       @Nullable SyncState previousSyncState);
 
+  /**
+   * Refresh any VFS files which may have changed during sync, and aren't covered by file watchers.
+   *
+   * <p>Called prior to updateProjectSdk and updateProjectStructure, from inside a write action.
+   */
+  void refreshVirtualFileSystem(BlazeProjectData blazeProjectData);
+
   /** Updates the sdk for the project. */
   void updateProjectSdk(
       Project project,
@@ -240,6 +240,9 @@
         ModifiableRootModel workspaceModifiableModel) {}
 
     @Override
+    public void refreshVirtualFileSystem(BlazeProjectData blazeProjectData) {}
+
+    @Override
     public void updateInMemoryState(
         Project project,
         BlazeContext context,
diff --git a/base/src/com/google/idea/blaze/base/sync/BlazeSyncTask.java b/base/src/com/google/idea/blaze/base/sync/BlazeSyncTask.java
index db199e3..9b6849f 100644
--- a/base/src/com/google/idea/blaze/base/sync/BlazeSyncTask.java
+++ b/base/src/com/google/idea/blaze/base/sync/BlazeSyncTask.java
@@ -32,7 +32,7 @@
 import com.google.idea.blaze.base.ideinfo.TargetKey;
 import com.google.idea.blaze.base.ideinfo.TargetMap;
 import com.google.idea.blaze.base.io.FileAttributeProvider;
-import com.google.idea.blaze.base.metrics.Action;
+import com.google.idea.blaze.base.io.VirtualFileSystemProvider;
 import com.google.idea.blaze.base.model.BlazeLibrary;
 import com.google.idea.blaze.base.model.BlazeProjectData;
 import com.google.idea.blaze.base.model.BlazeVersionData;
@@ -54,7 +54,6 @@
 import com.google.idea.blaze.base.scope.scopes.BlazeConsoleScope;
 import com.google.idea.blaze.base.scope.scopes.IdeaLogScope;
 import com.google.idea.blaze.base.scope.scopes.IssuesScope;
-import com.google.idea.blaze.base.scope.scopes.LoggedTimingScope;
 import com.google.idea.blaze.base.scope.scopes.NotificationScope;
 import com.google.idea.blaze.base.scope.scopes.PerformanceWarningScope;
 import com.google.idea.blaze.base.scope.scopes.ProgressIndicatorScope;
@@ -100,10 +99,7 @@
 import com.intellij.openapi.roots.ModifiableRootModel;
 import com.intellij.openapi.roots.ex.ProjectRootManagerEx;
 import com.intellij.openapi.util.io.FileUtil;
-import com.intellij.openapi.vfs.LocalFileSystem;
 import com.intellij.openapi.vfs.VirtualFileManager;
-import com.intellij.openapi.vfs.VirtualFileSystem;
-import com.intellij.openapi.vfs.ex.temp.TempFileSystem;
 import java.io.File;
 import java.util.Collection;
 import java.util.Collections;
@@ -115,14 +111,13 @@
 /** Syncs the project with blaze. */
 final class BlazeSyncTask implements Progressive {
 
-  private static final Logger LOG = Logger.getInstance(BlazeSyncTask.class);
+  private static final Logger logger = Logger.getInstance(BlazeSyncTask.class);
 
   private final Project project;
   private final BlazeImportSettings importSettings;
   private final WorkspaceRoot workspaceRoot;
   private final BlazeSyncParams syncParams;
   private final boolean showPerformanceWarnings;
-  private long syncStartTime;
 
   BlazeSyncTask(
       Project project, BlazeImportSettings importSettings, final BlazeSyncParams syncParams) {
@@ -141,10 +136,7 @@
           if (showPerformanceWarnings) {
             context.push(new PerformanceWarningScope());
           }
-          context
-              .push(new ProgressIndicatorScope(indicator))
-              .push(new TimingScope("Sync"))
-              .push(new LoggedTimingScope(project, Action.SYNC_TOTAL_TIME));
+          context.push(new ProgressIndicatorScope(indicator)).push(new TimingScope("Sync"));
 
           if (!syncParams.backgroundSync) {
             context
@@ -191,7 +183,7 @@
         onSyncComplete(project, context, projectViewSet, blazeProjectData, syncMode, syncResult);
       }
     } catch (AssertionError | Exception e) {
-      LOG.error(e);
+      logger.error(e);
       IssueOutput.error("Internal error: " + e.getMessage()).submit(context);
     } finally {
       afterSync(project, context, syncMode, syncResult);
@@ -202,7 +194,7 @@
   /** @return true if sync successfully completed */
   private SyncResult doSyncProject(
       BlazeContext context, SyncMode syncMode, @Nullable BlazeProjectData oldBlazeProjectData) {
-    this.syncStartTime = System.currentTimeMillis();
+    long syncStartTime = System.currentTimeMillis();
 
     if (!FileAttributeProvider.getInstance().exists(workspaceRoot.directory())) {
       IssueOutput.error(String.format("Workspace '%s' doesn't exist.", workspaceRoot.directory()))
@@ -404,14 +396,11 @@
         .onError("Prefetch failed")
         .run();
 
+    refreshVirtualFileSystem(context, newBlazeProjectData);
+
     boolean success =
         updateProject(
-            project,
-            context,
-            projectViewSet,
-            blazeVersionData,
-            oldBlazeProjectData,
-            newBlazeProjectData);
+            context, projectViewSet, blazeVersionData, oldBlazeProjectData, newBlazeProjectData);
     if (!success) {
       return SyncResult.FAILURE;
     }
@@ -437,6 +426,25 @@
     return syncResult;
   }
 
+  private static void refreshVirtualFileSystem(
+      BlazeContext context, BlazeProjectData blazeProjectData) {
+    Transactions.submitTransactionAndWait(
+        () ->
+            ApplicationManager.getApplication()
+                .runWriteAction(
+                    (Runnable)
+                        () ->
+                            Scope.push(
+                                context,
+                                (childContext) -> {
+                                  childContext.push(new TimingScope("RefreshVirtualFileSystem"));
+                                  for (BlazeSyncPlugin syncPlugin :
+                                      BlazeSyncPlugin.EP_NAME.getExtensions()) {
+                                    syncPlugin.refreshVirtualFileSystem(blazeProjectData);
+                                  }
+                                })));
+  }
+
   static class WorkspacePathResolverAndProjectView {
     final WorkspacePathResolver workspacePathResolver;
     final ProjectViewSet projectViewSet;
@@ -465,7 +473,7 @@
                 context,
                 (childContext) -> {
                   childContext.push(new TimingScope("UpdateVcs"));
-                  return vcsSyncHandler.update(context, blazeRoots, executor);
+                  return vcsSyncHandler.update(context, executor);
                 });
         if (!ok) {
           return null;
@@ -612,9 +620,7 @@
     return Scope.push(
         parentContext,
         context -> {
-          context
-              .push(new LoggedTimingScope(project, Action.BLAZE_BUILD_DURING_SYNC))
-              .push(new TimingScope(Blaze.buildSystemName(project) + "Build"));
+          context.push(new TimingScope(Blaze.buildSystemName(project) + "Build"));
           context.output(new StatusOutput("Building IDE resolve files..."));
 
           // We don't want IDE resolve errors to fail the whole sync
@@ -630,7 +636,6 @@
   }
 
   private boolean updateProject(
-      Project project,
       BlazeContext parentContext,
       ProjectViewSet projectViewSet,
       BlazeVersionData blazeVersionData,
@@ -639,42 +644,38 @@
     return Scope.push(
         parentContext,
         context -> {
-          context
-              .push(new LoggedTimingScope(project, Action.SYNC_IMPORT_DATA_TIME))
-              .push(new TimingScope("UpdateProjectStructure"));
+          context.push(new TimingScope("UpdateProjectStructure"));
           context.output(new StatusOutput("Committing project structure..."));
 
           try {
             Transactions.submitTransactionAndWait(
-                () -> {
-                  ApplicationManager.getApplication()
-                      .runWriteAction(
-                          (Runnable)
-                              () -> {
-                                ProjectRootManagerEx.getInstanceEx(this.project)
-                                    .mergeRootsChangesDuring(
-                                        () -> {
-                                          updateProjectSdk(
-                                              context,
-                                              projectViewSet,
-                                              blazeVersionData,
-                                              newBlazeProjectData);
-                                          updateProjectStructure(
-                                              context,
-                                              importSettings,
-                                              projectViewSet,
-                                              newBlazeProjectData,
-                                              oldBlazeProjectData);
-                                        });
-                              });
-                });
-          } catch (Throwable t) {
-            IssueOutput.error("Internal error. Error: " + t).submit(context);
-            LOG.error(t);
+                () ->
+                    ApplicationManager.getApplication()
+                        .runWriteAction(
+                            (Runnable)
+                                () ->
+                                    ProjectRootManagerEx.getInstanceEx(this.project)
+                                        .mergeRootsChangesDuring(
+                                            () -> {
+                                              updateProjectSdk(
+                                                  context,
+                                                  projectViewSet,
+                                                  blazeVersionData,
+                                                  newBlazeProjectData);
+                                              updateProjectStructure(
+                                                  context,
+                                                  importSettings,
+                                                  projectViewSet,
+                                                  newBlazeProjectData,
+                                                  oldBlazeProjectData);
+                                            })));
+          } catch (Throwable e) {
+            IssueOutput.error("Internal error. Error: " + e).submit(context);
+            logger.error(e);
             return false;
           }
 
-          BlazeProjectDataManagerImpl.getImpl(this.project)
+          BlazeProjectDataManagerImpl.getImpl(project)
               .saveProject(importSettings, newBlazeProjectData);
           return true;
         });
@@ -720,12 +721,7 @@
     ModifiableRootModel workspaceModifiableModel = moduleEditor.editModule(workspaceModule);
 
     ContentEntryEditor.createContentEntries(
-        project,
-        context,
-        workspaceRoot,
-        projectViewSet,
-        newBlazeProjectData,
-        workspaceModifiableModel);
+        project, workspaceRoot, projectViewSet, newBlazeProjectData, workspaceModifiableModel);
 
     List<BlazeLibrary> libraries = BlazeLibraryCollector.getLibraries(newBlazeProjectData);
     LibraryEditor.updateProjectLibraries(project, context, newBlazeProjectData, libraries);
@@ -799,14 +795,8 @@
 
   private static String pathToUrl(File path) {
     String filePath = FileUtil.toSystemIndependentName(path.getPath());
-    return VirtualFileManager.constructUrl(defaultFileSystem().getProtocol(), filePath);
-  }
-
-  private static VirtualFileSystem defaultFileSystem() {
-    if (ApplicationManager.getApplication().isUnitTestMode()) {
-      return TempFileSystem.getInstance();
-    }
-    return LocalFileSystem.getInstance();
+    return VirtualFileManager.constructUrl(
+        VirtualFileSystemProvider.getInstance().getSystem().getProtocol(), filePath);
   }
 
   private static void onSyncStart(Project project, BlazeContext context, SyncMode syncMode) {
diff --git a/base/src/com/google/idea/blaze/base/sync/BuildTargetFinder.java b/base/src/com/google/idea/blaze/base/sync/BuildTargetFinder.java
index 8525a20..8d8ca16 100644
--- a/base/src/com/google/idea/blaze/base/sync/BuildTargetFinder.java
+++ b/base/src/com/google/idea/blaze/base/sync/BuildTargetFinder.java
@@ -41,7 +41,7 @@
   }
 
   @Nullable
-  public TargetExpression findTargetForFile(File file) {
+  public File findBuildFileForFile(File file) {
     if (fileAttributeProvider.isFile(file)) {
       file = file.getParentFile();
       if (file == null) {
@@ -66,12 +66,21 @@
     do {
       File buildFile = buildSystemProvider.findBuildFileInDirectory(currentDirectory);
       if (buildFile != null) {
-        return TargetExpression.allFromPackageNonRecursive(
-            workspaceRoot.workspacePathFor(currentDirectory));
+        return buildFile;
       }
       currentDirectory = currentDirectory.getParentFile();
     } while (currentDirectory != null && FileUtil.isAncestor(root, currentDirectory, false));
 
     return null;
   }
+
+  @Nullable
+  public TargetExpression findTargetForFile(File file) {
+    File buildFile = findBuildFileForFile(file);
+    if (buildFile != null) {
+      return TargetExpression.allFromPackageNonRecursive(
+          workspaceRoot.workspacePathFor(buildFile.getParentFile()));
+    }
+    return null;
+  }
 }
diff --git a/base/src/com/google/idea/blaze/base/sync/GenericSourceFolderProvider.java b/base/src/com/google/idea/blaze/base/sync/GenericSourceFolderProvider.java
index 1810e9c..a2d1d8c 100644
--- a/base/src/com/google/idea/blaze/base/sync/GenericSourceFolderProvider.java
+++ b/base/src/com/google/idea/blaze/base/sync/GenericSourceFolderProvider.java
@@ -16,9 +16,10 @@
 package com.google.idea.blaze.base.sync;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.idea.blaze.base.util.UrlUtil;
 import com.intellij.openapi.roots.ContentEntry;
 import com.intellij.openapi.roots.SourceFolder;
-import com.intellij.openapi.vfs.VirtualFile;
+import java.io.File;
 
 /** An implementation of {@link SourceFolderProvider} with no language-specific settings. */
 public class GenericSourceFolderProvider implements SourceFolderProvider {
@@ -28,22 +29,14 @@
   private GenericSourceFolderProvider() {}
 
   @Override
-  public ImmutableMap<VirtualFile, SourceFolder> initializeSourceFolders(
-      ContentEntry contentEntry) {
-    ImmutableMap.Builder<VirtualFile, SourceFolder> output = ImmutableMap.builder();
-    VirtualFile file = contentEntry.getFile();
-    if (file != null) {
-      output.put(file, contentEntry.addSourceFolder(file, false));
-    }
-    return output.build();
+  public ImmutableMap<File, SourceFolder> initializeSourceFolders(ContentEntry contentEntry) {
+    String url = contentEntry.getUrl();
+    return ImmutableMap.of(UrlUtil.urlToFile(url), contentEntry.addSourceFolder(url, false));
   }
 
   @Override
   public SourceFolder setSourceFolderForLocation(
-      ContentEntry contentEntry,
-      SourceFolder parentFolder,
-      VirtualFile file,
-      boolean isTestSource) {
-    return contentEntry.addSourceFolder(file, isTestSource);
+      ContentEntry contentEntry, SourceFolder parentFolder, File file, boolean isTestSource) {
+    return contentEntry.addSourceFolder(UrlUtil.fileToIdeaUrl(file), isTestSource);
   }
 }
diff --git a/base/src/com/google/idea/blaze/base/sync/SourceFolderProvider.java b/base/src/com/google/idea/blaze/base/sync/SourceFolderProvider.java
index 6514a82..91f3630 100644
--- a/base/src/com/google/idea/blaze/base/sync/SourceFolderProvider.java
+++ b/base/src/com/google/idea/blaze/base/sync/SourceFolderProvider.java
@@ -19,7 +19,7 @@
 import com.google.idea.blaze.base.model.BlazeProjectData;
 import com.intellij.openapi.roots.ContentEntry;
 import com.intellij.openapi.roots.SourceFolder;
-import com.intellij.openapi.vfs.VirtualFile;
+import java.io.File;
 
 /** Provides source folders for each content entry during sync. */
 public interface SourceFolderProvider {
@@ -42,11 +42,11 @@
    * 'initial' because the 'is test' property (and potentially additional test source folders) are
    * added later.
    */
-  ImmutableMap<VirtualFile, SourceFolder> initializeSourceFolders(ContentEntry contentEntry);
+  ImmutableMap<File, SourceFolder> initializeSourceFolders(ContentEntry contentEntry);
 
   /**
    * Sets the source folder for the given file, incorporating the test information as appropriate.
    */
   SourceFolder setSourceFolderForLocation(
-      ContentEntry contentEntry, SourceFolder parentFolder, VirtualFile file, boolean isTestSource);
+      ContentEntry contentEntry, SourceFolder parentFolder, File file, boolean isTestSource);
 }
diff --git a/base/src/com/google/idea/blaze/base/sync/SyncCache.java b/base/src/com/google/idea/blaze/base/sync/SyncCache.java
new file mode 100644
index 0000000..24cdd46
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/sync/SyncCache.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2016 The Bazel Authors. 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.idea.blaze.base.sync;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.Maps;
+import com.google.idea.blaze.base.model.BlazeProjectData;
+import com.google.idea.blaze.base.projectview.ProjectViewSet;
+import com.google.idea.blaze.base.scope.BlazeContext;
+import com.google.idea.blaze.base.settings.BlazeImportSettings;
+import com.google.idea.blaze.base.sync.BlazeSyncParams.SyncMode;
+import com.google.idea.blaze.base.sync.data.BlazeProjectDataManager;
+import com.intellij.openapi.components.ServiceManager;
+import com.intellij.openapi.project.Project;
+import java.util.Map;
+import javax.annotation.Nullable;
+
+/** Computes a cache on the project data. */
+public class SyncCache {
+  /** Computes a value based on the sync project data. */
+  public interface SyncCacheComputable<T> {
+    T compute(Project project, BlazeProjectData projectData);
+  }
+
+  private final Project project;
+  private final Map<Object, Object> cache = Maps.newHashMap();
+
+  public SyncCache(Project project) {
+    this.project = project;
+  }
+
+  public static SyncCache getInstance(Project project) {
+    return ServiceManager.getService(project, SyncCache.class);
+  }
+
+  /** Computes a value derived from the sync project data and caches it until the next sync. */
+  @Nullable
+  @SuppressWarnings("unchecked")
+  public synchronized <T> T get(Object key, SyncCacheComputable<T> computable) {
+    T value = (T) cache.get(key);
+    if (value == null) {
+      BlazeProjectData blazeProjectData =
+          BlazeProjectDataManager.getInstance(project).getBlazeProjectData();
+      if (blazeProjectData != null) {
+        value = computable.compute(project, blazeProjectData);
+        cache.put(key, value);
+      }
+    }
+    return value;
+  }
+
+  @VisibleForTesting
+  public synchronized void clear() {
+    cache.clear();
+  }
+
+  static class ClearSyncCache extends SyncListener.Adapter {
+    @Override
+    public void onSyncComplete(
+        Project project,
+        BlazeContext context,
+        BlazeImportSettings importSettings,
+        ProjectViewSet projectViewSet,
+        BlazeProjectData blazeProjectData,
+        SyncMode syncMode,
+        SyncResult syncResult) {
+      SyncCache syncCache = getInstance(project);
+      syncCache.clear();
+    }
+  }
+}
diff --git a/base/src/com/google/idea/blaze/base/sync/aspects/BlazeIdeInterfaceAspectsImpl.java b/base/src/com/google/idea/blaze/base/sync/aspects/BlazeIdeInterfaceAspectsImpl.java
index 36e29af..12a09c5 100644
--- a/base/src/com/google/idea/blaze/base/sync/aspects/BlazeIdeInterfaceAspectsImpl.java
+++ b/base/src/com/google/idea/blaze/base/sync/aspects/BlazeIdeInterfaceAspectsImpl.java
@@ -35,7 +35,6 @@
 import com.google.idea.blaze.base.ideinfo.TargetKey;
 import com.google.idea.blaze.base.ideinfo.TargetMap;
 import com.google.idea.blaze.base.issueparser.IssueOutputLineProcessor;
-import com.google.idea.blaze.base.metrics.Action;
 import com.google.idea.blaze.base.model.BlazeVersionData;
 import com.google.idea.blaze.base.model.SyncState;
 import com.google.idea.blaze.base.model.primitives.TargetExpression;
@@ -48,7 +47,6 @@
 import com.google.idea.blaze.base.scope.ScopedFunction;
 import com.google.idea.blaze.base.scope.output.PerformanceWarning;
 import com.google.idea.blaze.base.scope.output.PrintOutput;
-import com.google.idea.blaze.base.scope.scopes.LoggedTimingScope;
 import com.google.idea.blaze.base.scope.scopes.TimingScope;
 import com.google.idea.blaze.base.settings.Blaze;
 import com.google.idea.blaze.base.settings.Blaze.BuildSystem;
@@ -75,7 +73,7 @@
 /** Implementation of BlazeIdeInterface based on aspects. */
 public class BlazeIdeInterfaceAspectsImpl implements BlazeIdeInterface {
 
-  private static final Logger LOG = Logger.getInstance(BlazeIdeInterfaceAspectsImpl.class);
+  private static final Logger logger = Logger.getInstance(BlazeIdeInterfaceAspectsImpl.class);
 
   static class State implements Serializable {
     private static final long serialVersionUID = 14L;
@@ -219,7 +217,7 @@
                           new ExperimentalShowArtifactsLineProcessor(result, fileFilter),
                           new IssueOutputLineProcessor(project, context, workspaceRoot)))
                   .build()
-                  .run(new LoggedTimingScope(project, Action.BLAZE_BUILD));
+                  .run();
 
           BuildResult buildResult = BuildResult.fromExitCode(retVal);
           return new IdeInfoResult(result, buildResult);
@@ -357,7 +355,7 @@
                 });
 
     if (result.error != null) {
-      LOG.error(result.error);
+      logger.error(result.error);
       return null;
     }
     return result.result;
@@ -397,7 +395,7 @@
   }
 
   private static boolean hasIdeCompileOutputGroup(BlazeVersionData blazeVersionData) {
-    return blazeVersionData.bazelIsAtLeastVersion(0, 4, 3);
+    return blazeVersionData.bazelIsAtLeastVersion(0, 4, 4);
   }
 
   private static BuildResult resolveIdeArtifacts(
@@ -433,7 +431,7 @@
                 LineProcessingOutputStream.of(
                     new IssueOutputLineProcessor(project, context, workspaceRoot)))
             .build()
-            .run(new LoggedTimingScope(project, Action.BLAZE_BUILD));
+            .run();
 
     return BuildResult.fromExitCode(retVal);
   }
diff --git a/base/src/com/google/idea/blaze/base/sync/aspects/IdeInfoFromProtobuf.java b/base/src/com/google/idea/blaze/base/sync/aspects/IdeInfoFromProtobuf.java
index c140587..8bf9585 100644
--- a/base/src/com/google/idea/blaze/base/sync/aspects/IdeInfoFromProtobuf.java
+++ b/base/src/com/google/idea/blaze/base/sync/aspects/IdeInfoFromProtobuf.java
@@ -27,6 +27,8 @@
 import com.google.idea.blaze.base.ideinfo.CToolchainIdeInfo;
 import com.google.idea.blaze.base.ideinfo.Dependency;
 import com.google.idea.blaze.base.ideinfo.Dependency.DependencyType;
+import com.google.idea.blaze.base.ideinfo.IntellijPluginDeployInfo;
+import com.google.idea.blaze.base.ideinfo.IntellijPluginDeployInfo.IntellijPluginDeployFile;
 import com.google.idea.blaze.base.ideinfo.JavaIdeInfo;
 import com.google.idea.blaze.base.ideinfo.JavaToolchainIdeInfo;
 import com.google.idea.blaze.base.ideinfo.LibraryArtifact;
@@ -123,6 +125,11 @@
     if (message.hasJavaToolchainIdeInfo()) {
       javaToolchainIdeInfo = makeJavaToolchainIdeInfo(message.getJavaToolchainIdeInfo());
     }
+    IntellijPluginDeployInfo intellijPluginDeployInfo = null;
+    if (message.hasIntellijPluginDeployInfo()) {
+      intellijPluginDeployInfo =
+          makeIntellijPluginDeployInfo(message.getIntellijPluginDeployInfo());
+    }
 
     return new TargetIdeInfo(
         key,
@@ -138,7 +145,8 @@
         pyIdeInfo,
         testIdeInfo,
         protoLibraryLegacyInfo,
-        javaToolchainIdeInfo);
+        javaToolchainIdeInfo,
+        intellijPluginDeployInfo);
   }
 
   private static Collection<Dependency> makeDependencyListFromLabelList(
@@ -244,7 +252,8 @@
         javaIdeInfo.hasPackageManifest()
             ? makeArtifactLocation(javaIdeInfo.getPackageManifest())
             : null,
-        javaIdeInfo.hasJdeps() ? makeArtifactLocation(javaIdeInfo.getJdeps()) : null);
+        javaIdeInfo.hasJdeps() ? makeArtifactLocation(javaIdeInfo.getJdeps()) : null,
+        Strings.emptyToNull(javaIdeInfo.getMainClass()));
   }
 
   private static AndroidIdeInfo makeAndroidIdeInfo(IntellijIdeInfo.AndroidIdeInfo androidIdeInfo) {
@@ -325,6 +334,21 @@
         javaToolchainIdeInfo.getSourceVersion(), javaToolchainIdeInfo.getTargetVersion());
   }
 
+  private static IntellijPluginDeployInfo makeIntellijPluginDeployInfo(
+      IntellijIdeInfo.IntellijPluginDeployInfo intellijPluginDeployInfo) {
+    return new IntellijPluginDeployInfo(
+        ImmutableList.copyOf(
+            intellijPluginDeployInfo
+                .getDeployFilesList()
+                .stream()
+                .map(
+                    deployFile ->
+                        new IntellijPluginDeployFile(
+                            makeArtifactLocation(deployFile.getSrc()),
+                            deployFile.getDeployLocation()))
+                .collect(toList())));
+  }
+
   private static Collection<LibraryArtifact> makeLibraryArtifactList(
       List<IntellijIdeInfo.LibraryArtifact> jarsList) {
     ImmutableList.Builder<LibraryArtifact> builder = ImmutableList.builder();
diff --git a/base/src/com/google/idea/blaze/base/sync/aspects/strategy/AspectStrategyProviderBazel.java b/base/src/com/google/idea/blaze/base/sync/aspects/strategy/AspectStrategyProviderBazel.java
index f3b1029..180ae9e 100644
--- a/base/src/com/google/idea/blaze/base/sync/aspects/strategy/AspectStrategyProviderBazel.java
+++ b/base/src/com/google/idea/blaze/base/sync/aspects/strategy/AspectStrategyProviderBazel.java
@@ -21,12 +21,12 @@
 
 class AspectStrategyProviderBazel implements AspectStrategyProvider {
   private static final BoolExperiment useSkylarkAspect =
-      new BoolExperiment("use.skylark.aspect.bazel", true);
+      new BoolExperiment("use.skylark.aspect.bazel.2", true);
 
   @Override
   public AspectStrategy getAspectStrategy(Project project, BlazeVersionData blazeVersionData) {
     boolean canUseSkylark =
-        useSkylarkAspect.getValue() && blazeVersionData.bazelIsAtLeastVersion(0, 4, 3);
+        useSkylarkAspect.getValue() && blazeVersionData.bazelIsAtLeastVersion(0, 4, 4);
 
     return canUseSkylark ? new AspectStrategySkylark() : new AspectStrategyNative();
   }
diff --git a/base/src/com/google/idea/blaze/base/sync/data/BlazeProjectDataManagerImpl.java b/base/src/com/google/idea/blaze/base/sync/data/BlazeProjectDataManagerImpl.java
index 754e60f..bd4494f 100644
--- a/base/src/com/google/idea/blaze/base/sync/data/BlazeProjectDataManagerImpl.java
+++ b/base/src/com/google/idea/blaze/base/sync/data/BlazeProjectDataManagerImpl.java
@@ -34,7 +34,8 @@
 /** Stores a cache of blaze project data and issues any side effects when that data is updated. */
 public class BlazeProjectDataManagerImpl implements BlazeProjectDataManager {
 
-  private static final Logger LOG = Logger.getInstance(BlazeProjectDataManagerImpl.class.getName());
+  private static final Logger logger =
+      Logger.getInstance(BlazeProjectDataManagerImpl.class.getName());
 
   private final Project project;
 
@@ -89,7 +90,7 @@
       context.output(
           new StatusOutput(
               String.format("Stale %s project cache, sync will be needed", buildSystemName)));
-      LOG.info(e);
+      logger.info(e);
     }
 
     this.blazeProjectData = blazeProjectData;
@@ -110,7 +111,7 @@
               File file = getCacheFile(project, importSettings);
               SerializationUtil.saveToDisk(file, blazeProjectData);
             } catch (IOException e) {
-              LOG.error(
+              logger.error(
                   "Could not save cache data file to disk. Please resync project. Error: "
                       + e.getMessage());
             }
diff --git a/base/src/com/google/idea/blaze/base/sync/libraries/LibraryEditor.java b/base/src/com/google/idea/blaze/base/sync/libraries/LibraryEditor.java
index 8452d19..44969c3 100644
--- a/base/src/com/google/idea/blaze/base/sync/libraries/LibraryEditor.java
+++ b/base/src/com/google/idea/blaze/base/sync/libraries/LibraryEditor.java
@@ -40,7 +40,7 @@
 
 /** Edits IntelliJ libraries */
 public class LibraryEditor {
-  private static final Logger LOG = Logger.getInstance(LibraryEditor.class);
+  private static final Logger logger = Logger.getInstance(LibraryEditor.class);
 
   public static void updateProjectLibraries(
       Project project,
@@ -142,7 +142,7 @@
     LibraryTable libraryTable = ProjectLibraryTable.getInstance(model.getProject());
     Library library = libraryTable.getLibraryByName(libraryKey.getIntelliJLibraryName());
     if (library == null) {
-      LOG.error(
+      logger.error(
           "Library missing: "
               + libraryKey.getIntelliJLibraryName()
               + ". Please resync project to resolve.");
diff --git a/base/src/com/google/idea/blaze/base/sync/projectstructure/ContentEntryEditor.java b/base/src/com/google/idea/blaze/base/sync/projectstructure/ContentEntryEditor.java
index d8e72f6..5b76a0c 100644
--- a/base/src/com/google/idea/blaze/base/sync/projectstructure/ContentEntryEditor.java
+++ b/base/src/com/google/idea/blaze/base/sync/projectstructure/ContentEntryEditor.java
@@ -19,40 +19,29 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Multimap;
+import com.google.idea.blaze.base.io.FileAttributeProvider;
 import com.google.idea.blaze.base.model.BlazeProjectData;
 import com.google.idea.blaze.base.model.primitives.WorkspacePath;
 import com.google.idea.blaze.base.model.primitives.WorkspaceRoot;
 import com.google.idea.blaze.base.projectview.ProjectViewSet;
-import com.google.idea.blaze.base.scope.BlazeContext;
-import com.google.idea.blaze.base.scope.output.IssueOutput;
 import com.google.idea.blaze.base.settings.Blaze;
 import com.google.idea.blaze.base.sync.SourceFolderProvider;
 import com.google.idea.blaze.base.sync.projectview.ImportRoots;
 import com.google.idea.blaze.base.sync.projectview.SourceTestConfig;
-import com.intellij.openapi.application.ApplicationManager;
+import com.google.idea.blaze.base.util.UrlUtil;
 import com.intellij.openapi.project.Project;
 import com.intellij.openapi.roots.ContentEntry;
 import com.intellij.openapi.roots.ModifiableRootModel;
 import com.intellij.openapi.roots.SourceFolder;
-import com.intellij.openapi.util.io.FileUtil;
-import com.intellij.openapi.util.io.FileUtilRt;
-import com.intellij.openapi.vfs.LocalFileSystem;
-import com.intellij.openapi.vfs.VirtualFile;
-import com.intellij.openapi.vfs.VirtualFileManager;
-import com.intellij.openapi.vfs.VirtualFileSystem;
-import com.intellij.openapi.vfs.ex.temp.TempFileSystem;
-import com.intellij.util.io.URLUtil;
 import java.io.File;
 import java.util.Collection;
 import java.util.List;
-import javax.annotation.Nullable;
 
 /** Modifies content entries based on project data. */
 public class ContentEntryEditor {
 
   public static void createContentEntries(
       Project project,
-      BlazeContext context,
       WorkspaceRoot workspaceRoot,
       ProjectViewSet projectViewSet,
       BlazeProjectData blazeProjectData,
@@ -72,27 +61,18 @@
     List<ContentEntry> contentEntries = Lists.newArrayList();
     for (WorkspacePath rootDirectory : rootDirectories) {
       File root = workspaceRoot.fileForPath(rootDirectory);
-      ContentEntry contentEntry = modifiableRootModel.addContentEntry(pathToUrl(root.getPath()));
+      ContentEntry contentEntry =
+          modifiableRootModel.addContentEntry(UrlUtil.pathToUrl(root.getPath()));
       contentEntries.add(contentEntry);
 
       for (WorkspacePath exclude : excludesByRootDirectory.get(rootDirectory)) {
         File excludeFolder = workspaceRoot.fileForPath(exclude);
-        contentEntry.addExcludeFolder(pathToIdeaUrl(excludeFolder));
+        contentEntry.addExcludeFolder(UrlUtil.fileToIdeaUrl(excludeFolder));
       }
 
-      ImmutableMap<VirtualFile, SourceFolder> sourceFolders =
+      ImmutableMap<File, SourceFolder> sourceFolders =
           provider.initializeSourceFolders(contentEntry);
-      VirtualFile rootFile = getVirtualFile(root);
-      if (rootFile == null) {
-        IssueOutput.warn(
-                String.format(
-                    "Could not find directory %s. Your 'test_sources' project view "
-                        + "attribute will not have any effect. Please resync.",
-                    workspaceRoot))
-            .submit(context);
-        continue;
-      }
-      SourceFolder rootSource = sourceFolders.get(rootFile);
+      SourceFolder rootSource = sourceFolders.get(root);
       walkFileSystem(
           workspaceRoot,
           testConfig,
@@ -101,7 +81,7 @@
           provider,
           sourceFolders,
           rootSource,
-          rootFile);
+          root);
     }
   }
 
@@ -111,10 +91,10 @@
       Collection<WorkspacePath> excludedDirectories,
       ContentEntry contentEntry,
       SourceFolderProvider provider,
-      ImmutableMap<VirtualFile, SourceFolder> sourceFolders,
+      ImmutableMap<File, SourceFolder> sourceFolders,
       SourceFolder parent,
-      VirtualFile file) {
-    if (!file.isDirectory()) {
+      File file) {
+    if (!FileAttributeProvider.getInstance().isDirectory(file)) {
       return;
     }
     WorkspacePath workspacePath;
@@ -128,7 +108,7 @@
       return;
     }
     boolean isTest = testConfig.isTestSource(workspacePath.relativePath());
-    SourceFolder current = sourceFolders.get(file);
+    SourceFolder current = sourceFolders.get(new File(file.getPath()));
     SourceFolder currentOrParent = current != null ? current : parent;
     if (isTest != currentOrParent.isTestSource()) {
       currentOrParent =
@@ -137,7 +117,11 @@
         contentEntry.removeSourceFolder(current);
       }
     }
-    for (VirtualFile child : file.getChildren()) {
+    File[] children = FileAttributeProvider.getInstance().listFiles(file);
+    if (children == null) {
+      return;
+    }
+    for (File child : children) {
       walkFileSystem(
           workspaceRoot,
           testConfig,
@@ -150,18 +134,6 @@
     }
   }
 
-  @Nullable
-  private static VirtualFile getVirtualFile(File file) {
-    return getFileSystem().findFileByPath(file.getPath());
-  }
-
-  private static VirtualFileSystem getFileSystem() {
-    if (ApplicationManager.getApplication().isUnitTestMode()) {
-      return TempFileSystem.getInstance();
-    }
-    return LocalFileSystem.getInstance();
-  }
-
   private static Multimap<WorkspacePath, WorkspacePath> sortExcludesByRootDirectory(
       Collection<WorkspacePath> rootDirectories, Collection<WorkspacePath> excludedDirectories) {
 
@@ -189,30 +161,4 @@
         && (relativePath.length() == rootDirectoryString.length()
             || (relativePath.charAt(rootDirectoryString.length()) == '/'));
   }
-
-  private static String pathToUrl(String filePath) {
-    filePath = FileUtil.toSystemIndependentName(filePath);
-    if (filePath.endsWith(".srcjar") || filePath.endsWith(".jar")) {
-      return URLUtil.JAR_PROTOCOL + URLUtil.SCHEME_SEPARATOR + filePath + URLUtil.JAR_SEPARATOR;
-    } else if (filePath.contains("src.jar!")) {
-      return URLUtil.JAR_PROTOCOL + URLUtil.SCHEME_SEPARATOR + filePath;
-    } else {
-      return VirtualFileManager.constructUrl(defaultFileSystem().getProtocol(), filePath);
-    }
-  }
-
-  private static VirtualFileSystem defaultFileSystem() {
-    if (ApplicationManager.getApplication().isUnitTestMode()) {
-      return TempFileSystem.getInstance();
-    }
-    return LocalFileSystem.getInstance();
-  }
-
-  private static String pathToIdeaUrl(File path) {
-    return pathToUrl(toSystemIndependentName(path.getPath()));
-  }
-
-  private static String toSystemIndependentName(String aFileName) {
-    return FileUtilRt.toSystemIndependentName(aFileName);
-  }
 }
diff --git a/base/src/com/google/idea/blaze/base/sync/projectstructure/ModuleEditorImpl.java b/base/src/com/google/idea/blaze/base/sync/projectstructure/ModuleEditorImpl.java
index 2f05f68..7a9fde2 100644
--- a/base/src/com/google/idea/blaze/base/sync/projectstructure/ModuleEditorImpl.java
+++ b/base/src/com/google/idea/blaze/base/sync/projectstructure/ModuleEditorImpl.java
@@ -17,7 +17,7 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Sets;
+import com.google.common.collect.Maps;
 import com.google.idea.blaze.base.io.FileAttributeProvider;
 import com.google.idea.blaze.base.scope.BlazeContext;
 import com.google.idea.blaze.base.scope.output.PrintOutput;
@@ -40,22 +40,20 @@
 import com.intellij.openapi.vfs.VirtualFile;
 import java.io.File;
 import java.io.IOException;
-import java.util.Collection;
 import java.util.List;
-import java.util.Set;
+import java.util.Map;
 import javax.annotation.Nullable;
 
 /** Module editor implementation. */
 public class ModuleEditorImpl implements BlazeSyncPlugin.ModuleEditor {
-  private static final Logger LOG = Logger.getInstance(ModuleEditorImpl.class.getName());
+  private static final Logger logger = Logger.getInstance(ModuleEditorImpl.class.getName());
   private static final String EXTERNAL_SYSTEM_ID_KEY = "external.system.id";
   private static final String EXTERNAL_SYSTEM_ID_VALUE = "Blaze";
 
   private final Project project;
   private final ModifiableModuleModel moduleModel;
   private final File imlDirectory;
-  private final Set<String> moduleNames = Sets.newHashSet();
-  @VisibleForTesting public Collection<ModifiableRootModel> modifiableModels = Lists.newArrayList();
+  @VisibleForTesting public Map<String, ModifiableRootModel> modules = Maps.newHashMap();
 
   public ModuleEditorImpl(Project project, BlazeImportSettings importSettings) {
     this.project = project;
@@ -64,21 +62,12 @@
     this.imlDirectory = getImlDirectory(importSettings);
     if (!FileAttributeProvider.getInstance().exists(imlDirectory)) {
       if (!imlDirectory.mkdirs()) {
-        LOG.error("Could not make directory: " + imlDirectory.getPath());
+        logger.error("Could not make directory: " + imlDirectory.getPath());
       }
     }
   }
 
   @Override
-  public boolean registerModule(String moduleName) {
-    boolean hasModule = moduleModel.findModuleByName(moduleName) != null;
-    if (hasModule) {
-      moduleNames.add(moduleName);
-    }
-    return hasModule;
-  }
-
-  @Override
   public Module createModule(String moduleName, ModuleType moduleType) {
     Module module = moduleModel.findModuleByName(moduleName);
     if (module == null) {
@@ -88,16 +77,10 @@
       module.setOption(EXTERNAL_SYSTEM_ID_KEY, EXTERNAL_SYSTEM_ID_VALUE);
     }
     module.setOption(Module.ELEMENT_TYPE, moduleType.getId());
-    moduleNames.add(moduleName);
-    return module;
-  }
 
-  @Override
-  public ModifiableRootModel editModule(Module module) {
     ModifiableRootModel modifiableModel =
         ModuleRootManager.getInstance(module).getModifiableModel();
-    modifiableModels.add(modifiableModel);
-
+    modules.put(module.getName(), modifiableModel);
     modifiableModel.clear();
     modifiableModel.inheritSdk();
     CompilerModuleExtension compilerSettings =
@@ -106,7 +89,12 @@
       compilerSettings.inheritCompilerOutputPath(false);
     }
 
-    return modifiableModel;
+    return module;
+  }
+
+  @Override
+  public ModifiableRootModel editModule(Module module) {
+    return modules.get(module.getName());
   }
 
   @Override
@@ -118,7 +106,7 @@
   public void commitWithGc(BlazeContext context) {
     List<Module> orphanModules = Lists.newArrayList();
     for (Module module : ModuleManager.getInstance(project).getModules()) {
-      if (!moduleNames.contains(module.getName())) {
+      if (!modules.containsKey(module.getName())) {
         orphanModules.add(module);
       }
     }
@@ -135,15 +123,14 @@
       }
     }
 
-    context.output(
-        PrintOutput.log(String.format("Workspace has %s modules", modifiableModels.size())));
+    context.output(PrintOutput.log(String.format("Workspace has %s modules", modules.size())));
 
     commit();
   }
 
   @Override
   public void commit() {
-    ModifiableModelCommitter.multiCommit(modifiableModels, moduleModel);
+    ModifiableModelCommitter.multiCommit(modules.values(), moduleModel);
   }
 
   private File getImlDirectory(BlazeImportSettings importSettings) {
@@ -165,7 +152,7 @@
                   try {
                     imlVirtualFile.delete(this);
                   } catch (IOException e) {
-                    LOG.warn(
+                    logger.warn(
                         String.format(
                             "Could not delete file: %s, will try to continue anyway.",
                             imlVirtualFile.getPath()),
diff --git a/base/src/com/google/idea/blaze/base/sync/projectview/ImportRoots.java b/base/src/com/google/idea/blaze/base/sync/projectview/ImportRoots.java
index 5a73ef4..c484df1 100644
--- a/base/src/com/google/idea/blaze/base/sync/projectview/ImportRoots.java
+++ b/base/src/com/google/idea/blaze/base/sync/projectview/ImportRoots.java
@@ -27,7 +27,7 @@
 import com.google.idea.blaze.base.projectview.section.sections.DirectoryEntry;
 import com.google.idea.blaze.base.projectview.section.sections.DirectorySection;
 import com.google.idea.blaze.base.settings.Blaze.BuildSystem;
-import com.intellij.openapi.util.io.FileUtil;
+import com.google.idea.blaze.base.util.WorkspacePathUtil;
 import java.util.Collection;
 import java.util.Set;
 
@@ -66,30 +66,20 @@
     }
 
     public ImportRoots build() {
-      ImmutableCollection<WorkspacePath> rootDirectories = rootDirectoriesBuilder.build();
-
       // Remove any duplicates and any overlapping directories
-      ImmutableSet.Builder<WorkspacePath> minimalRootDirectories = ImmutableSet.builder();
-      for (WorkspacePath directory : rootDirectories) {
-        boolean ok = true;
-        for (WorkspacePath otherDirectory : rootDirectories) {
-          if (directory == otherDirectory) {
-            continue;
-          }
-          ok = ok && !isAncestor(otherDirectory.relativePath(), directory.relativePath());
-        }
-        if (ok) {
-          minimalRootDirectories.add(directory);
-        }
-      }
+      ImmutableSet<WorkspacePath> minimalRootDirectories =
+          WorkspacePathUtil.calculateMinimalWorkspacePaths(rootDirectoriesBuilder.build());
 
       // for bazel projects, if we're including the workspace root,
       // we force-exclude the bazel artifact directories
       // (e.g. bazel-bin, bazel-genfiles).
-      if (buildSystem == BuildSystem.Bazel && hasWorkspaceRoot(rootDirectories)) {
+      if (buildSystem == BuildSystem.Bazel && hasWorkspaceRoot(minimalRootDirectories)) {
         excludeBuildSystemArtifacts();
       }
-      return new ImportRoots(minimalRootDirectories.build(), excludeDirectoriesBuilder.build());
+      ImmutableSet<WorkspacePath> minimalExcludes =
+          WorkspacePathUtil.calculateMinimalWorkspacePaths(excludeDirectoriesBuilder.build());
+
+      return new ImportRoots(minimalRootDirectories, minimalExcludes);
     }
 
     private void excludeBuildSystemArtifacts() {
@@ -158,14 +148,4 @@
     }
     return false;
   }
-
-  /** Returns true if 'path' is a strict child of 'ancestorPath'. */
-  private static boolean isAncestor(String ancestorPath, String path) {
-    // FileUtil.isAncestor has a bug in its handling of equal,
-    // empty paths (it ignores the 'strict' flag in this case).
-    if (ancestorPath.equals(path)) {
-      return false;
-    }
-    return FileUtil.isAncestor(ancestorPath, path, true);
-  }
 }
diff --git a/base/src/com/google/idea/blaze/base/sync/projectview/LanguageSupport.java b/base/src/com/google/idea/blaze/base/sync/projectview/LanguageSupport.java
index b01fbdc..da9ae7f 100644
--- a/base/src/com/google/idea/blaze/base/sync/projectview/LanguageSupport.java
+++ b/base/src/com/google/idea/blaze/base/sync/projectview/LanguageSupport.java
@@ -31,7 +31,7 @@
 /** Reads the user's language preferences from the project view. */
 public class LanguageSupport {
 
-  private static final Logger LOG = Logger.getInstance(LanguageSupport.class);
+  private static final Logger logger = Logger.getInstance(LanguageSupport.class);
 
   public static WorkspaceLanguageSettings createWorkspaceLanguageSettings(
       BlazeContext context, ProjectViewSet projectViewSet) {
@@ -56,7 +56,7 @@
     }
 
     if (workspaceType == null) {
-      LOG.error("Could not find workspace type."); // Should never happen
+      logger.error("Could not find workspace type."); // Should never happen
       return null;
     }
 
diff --git a/base/src/com/google/idea/blaze/base/sync/workspace/BlazeRoots.java b/base/src/com/google/idea/blaze/base/sync/workspace/BlazeRoots.java
index aa63765..c66d024 100644
--- a/base/src/com/google/idea/blaze/base/sync/workspace/BlazeRoots.java
+++ b/base/src/com/google/idea/blaze/base/sync/workspace/BlazeRoots.java
@@ -31,7 +31,7 @@
 /** The data output by BlazeInfo. */
 public class BlazeRoots implements Serializable {
   public static final long serialVersionUID = 3L;
-  private static final Logger LOG = Logger.getInstance(BlazeRoots.class);
+  private static final Logger logger = Logger.getInstance(BlazeRoots.class);
 
   public static BlazeRoots build(
       BuildSystem buildSystem,
@@ -60,8 +60,8 @@
     ExecutionRootPath blazeGenfilesExecutionRootPath =
         ExecutionRootPath.createAncestorRelativePath(executionRoot, new File(blazeGenfilesRoot));
     File externalSourceRootFile = new File(externalSourceRoot.trim());
-    LOG.assertTrue(blazeBinExecutionRootPath != null);
-    LOG.assertTrue(blazeGenfilesExecutionRootPath != null);
+    logger.assertTrue(blazeBinExecutionRootPath != null);
+    logger.assertTrue(blazeGenfilesExecutionRootPath != null);
     return new BlazeRoots(
         executionRoot,
         packagePaths,
diff --git a/base/src/com/google/idea/blaze/base/sync/workspace/ExecutionRootPathResolver.java b/base/src/com/google/idea/blaze/base/sync/workspace/ExecutionRootPathResolver.java
new file mode 100644
index 0000000..c6b7eef
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/sync/workspace/ExecutionRootPathResolver.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2016 The Bazel Authors. 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.idea.blaze.base.sync.workspace;
+
+import com.google.common.collect.ImmutableList;
+import com.google.idea.blaze.base.model.primitives.ExecutionRootPath;
+import com.google.idea.blaze.base.model.primitives.WorkspacePath;
+import com.intellij.openapi.util.io.FileUtil;
+import java.io.File;
+import java.util.List;
+
+/**
+ * Converts execution-root-relative paths to absolute files with a minimum of file system calls
+ * (typically none).
+ *
+ * <p>Files which exist both underneath the execution root and within the workspace will be resolved
+ * to workspace paths.
+ */
+public class ExecutionRootPathResolver {
+
+  private final BlazeRoots blazeRoots;
+  private final WorkspacePathResolver workspacePathResolver;
+
+  public ExecutionRootPathResolver(
+      BlazeRoots blazeRoots, WorkspacePathResolver workspacePathResolver) {
+    this.blazeRoots = blazeRoots;
+    this.workspacePathResolver = workspacePathResolver;
+  }
+
+  /**
+   * This method should be used for directories. Returns all workspace files corresponding to the
+   * given execution-root-relative path. If the file does not exist inside the workspace (e.g. for
+   * blaze output files or external workspace files), returns the path rooted in the execution root.
+   */
+  public ImmutableList<File> resolveToIncludeDirectories(ExecutionRootPath path) {
+    if (path.isAbsolute()) {
+      return ImmutableList.of(path.getAbsoluteOrRelativeFile());
+    }
+    if (isInWorkspace(path)) {
+      WorkspacePath workspacePath = new WorkspacePath(path.getAbsoluteOrRelativeFile().getPath());
+      return workspacePathResolver.resolveToIncludeDirectories(workspacePath);
+    }
+    return ImmutableList.of(path.getFileRootedAt(blazeRoots.executionRoot));
+  }
+
+  private boolean isInWorkspace(ExecutionRootPath path) {
+    boolean inOutputDir =
+        ExecutionRootPath.isAncestor(blazeRoots.blazeBinExecutionRootPath, path, false)
+            || ExecutionRootPath.isAncestor(blazeRoots.blazeGenfilesExecutionRootPath, path, false)
+            || isExternalWorkspacePath(path);
+    return !inOutputDir;
+  }
+
+  private static boolean isExternalWorkspacePath(ExecutionRootPath path) {
+    List<String> pathComponents = FileUtil.splitPath(path.getAbsoluteOrRelativeFile().getPath());
+    return pathComponents.size() > 1 && "external".equals(pathComponents.get(0));
+  }
+}
diff --git a/base/src/com/google/idea/blaze/base/sync/workspace/WorkspacePathResolver.java b/base/src/com/google/idea/blaze/base/sync/workspace/WorkspacePathResolver.java
index f7f8df3..128ca91 100644
--- a/base/src/com/google/idea/blaze/base/sync/workspace/WorkspacePathResolver.java
+++ b/base/src/com/google/idea/blaze/base/sync/workspace/WorkspacePathResolver.java
@@ -16,7 +16,6 @@
 package com.google.idea.blaze.base.sync.workspace;
 
 import com.google.common.collect.ImmutableList;
-import com.google.idea.blaze.base.model.primitives.ExecutionRootPath;
 import com.google.idea.blaze.base.model.primitives.WorkspacePath;
 import java.io.File;
 import java.io.Serializable;
@@ -40,9 +39,9 @@
 
   /**
    * This method should be used for directories. Returns all workspace files corresponding to the
-   * given execution-root-relative path.
+   * given workspace path.
    */
-  ImmutableList<File> resolveToIncludeDirectories(ExecutionRootPath executionRootPath);
+  ImmutableList<File> resolveToIncludeDirectories(WorkspacePath relativePath);
 
   /** Finds the package root directory that a workspace relative path is in. */
   File findPackageRoot(String relativePath);
diff --git a/base/src/com/google/idea/blaze/base/sync/workspace/WorkspacePathResolverImpl.java b/base/src/com/google/idea/blaze/base/sync/workspace/WorkspacePathResolverImpl.java
index 0dc1c69..ac131c1 100644
--- a/base/src/com/google/idea/blaze/base/sync/workspace/WorkspacePathResolverImpl.java
+++ b/base/src/com/google/idea/blaze/base/sync/workspace/WorkspacePathResolverImpl.java
@@ -17,7 +17,6 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.idea.blaze.base.io.FileAttributeProvider;
-import com.google.idea.blaze.base.model.primitives.ExecutionRootPath;
 import com.google.idea.blaze.base.model.primitives.WorkspacePath;
 import com.google.idea.blaze.base.model.primitives.WorkspaceRoot;
 import java.io.File;
@@ -45,9 +44,8 @@
   }
 
   @Override
-  public ImmutableList<File> resolveToIncludeDirectories(ExecutionRootPath executionRootPath) {
-    File trackedLocation = executionRootPath.getFileRootedAt(workspaceRoot.directory());
-    return ImmutableList.of(trackedLocation);
+  public ImmutableList<File> resolveToIncludeDirectories(WorkspacePath relativePath) {
+    return ImmutableList.of(workspaceRoot.fileForPath(relativePath));
   }
 
   @Override
diff --git a/base/src/com/google/idea/blaze/base/targetmaps/SourceToTargetMapImpl.java b/base/src/com/google/idea/blaze/base/targetmaps/SourceToTargetMapImpl.java
index 5cf22c6..4d4a28f 100644
--- a/base/src/com/google/idea/blaze/base/targetmaps/SourceToTargetMapImpl.java
+++ b/base/src/com/google/idea/blaze/base/targetmaps/SourceToTargetMapImpl.java
@@ -23,14 +23,9 @@
 import com.google.idea.blaze.base.ideinfo.TargetKey;
 import com.google.idea.blaze.base.model.BlazeProjectData;
 import com.google.idea.blaze.base.model.primitives.Label;
-import com.google.idea.blaze.base.projectview.ProjectViewSet;
-import com.google.idea.blaze.base.scope.BlazeContext;
-import com.google.idea.blaze.base.settings.BlazeImportSettings;
-import com.google.idea.blaze.base.sync.BlazeSyncParams.SyncMode;
-import com.google.idea.blaze.base.sync.SyncListener;
+import com.google.idea.blaze.base.sync.SyncCache;
 import com.google.idea.blaze.base.sync.data.BlazeProjectDataManager;
 import com.google.idea.blaze.base.sync.workspace.ArtifactLocationDecoder;
-import com.intellij.openapi.components.ServiceManager;
 import com.intellij.openapi.project.Project;
 import java.io.File;
 import java.util.Objects;
@@ -40,11 +35,6 @@
 /** Maps source files to their respective targets */
 public class SourceToTargetMapImpl implements SourceToTargetMap {
   private final Project project;
-  private ImmutableMultimap<File, TargetKey> sourceToTargetMap;
-
-  public static SourceToTargetMapImpl getImpl(Project project) {
-    return (SourceToTargetMapImpl) ServiceManager.getService(project, SourceToTargetMap.class);
-  }
 
   public SourceToTargetMapImpl(Project project) {
     this.project = project;
@@ -80,23 +70,13 @@
 
   @Nullable
   private synchronized ImmutableMultimap<File, TargetKey> getSourceToTargetMap() {
-    if (this.sourceToTargetMap == null) {
-      this.sourceToTargetMap = initSourceToTargetMap();
-    }
-    return this.sourceToTargetMap;
-  }
-
-  private synchronized void clearSourceToTargetMap() {
-    this.sourceToTargetMap = null;
+    return SyncCache.getInstance(project)
+        .get(SourceToTargetMapImpl.class, SourceToTargetMapImpl::computeSourceToTargetMap);
   }
 
   @Nullable
-  private ImmutableMultimap<File, TargetKey> initSourceToTargetMap() {
-    BlazeProjectData blazeProjectData =
-        BlazeProjectDataManager.getInstance(project).getBlazeProjectData();
-    if (blazeProjectData == null) {
-      return null;
-    }
+  private static ImmutableMultimap<File, TargetKey> computeSourceToTargetMap(
+      Project project, BlazeProjectData blazeProjectData) {
     ArtifactLocationDecoder artifactLocationDecoder = blazeProjectData.artifactLocationDecoder;
     ImmutableMultimap.Builder<File, TargetKey> sourceToTargetMap = ImmutableMultimap.builder();
     for (TargetIdeInfo target : blazeProjectData.targetMap.targets()) {
@@ -107,18 +87,4 @@
     }
     return sourceToTargetMap.build();
   }
-
-  static class ClearSourceToTargetMap extends SyncListener.Adapter {
-    @Override
-    public void onSyncComplete(
-        Project project,
-        BlazeContext context,
-        BlazeImportSettings importSettings,
-        ProjectViewSet projectViewSet,
-        BlazeProjectData blazeProjectData,
-        SyncMode syncMode,
-        SyncResult syncResult) {
-      getImpl(project).clearSourceToTargetMap();
-    }
-  }
 }
diff --git a/base/src/com/google/idea/blaze/base/util/SerializationUtil.java b/base/src/com/google/idea/blaze/base/util/SerializationUtil.java
index 51f46d4..f098673 100644
--- a/base/src/com/google/idea/blaze/base/util/SerializationUtil.java
+++ b/base/src/com/google/idea/blaze/base/util/SerializationUtil.java
@@ -31,7 +31,7 @@
 
 /** Utils for serialization. */
 public class SerializationUtil {
-  private static final Logger LOG = Logger.getInstance(SerializationUtil.class.getName());
+  private static final Logger logger = Logger.getInstance(SerializationUtil.class.getName());
 
   public static void saveToDisk(@NotNull File file, @NotNull Serializable serializable)
       throws IOException {
diff --git a/base/src/com/google/idea/blaze/base/util/UrlUtil.java b/base/src/com/google/idea/blaze/base/util/UrlUtil.java
new file mode 100644
index 0000000..a2cc7b2
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/util/UrlUtil.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.base.util;
+
+import com.google.idea.blaze.base.io.VirtualFileSystemProvider;
+import com.intellij.openapi.util.io.FileUtil;
+import com.intellij.openapi.util.io.FileUtilRt;
+import com.intellij.openapi.vfs.VirtualFileManager;
+import com.intellij.util.io.URLUtil;
+import java.io.File;
+
+/** Utility methods for converting between URLs and file paths. */
+public class UrlUtil {
+
+  public static File urlToFile(String url) {
+    return new File(VirtualFileManager.extractPath(url));
+  }
+
+  public static String fileToIdeaUrl(File path) {
+    return pathToUrl(toSystemIndependentName(path.getPath()));
+  }
+
+  public static String pathToUrl(String filePath) {
+    filePath = FileUtil.toSystemIndependentName(filePath);
+    if (filePath.endsWith(".srcjar") || filePath.endsWith(".jar")) {
+      return URLUtil.JAR_PROTOCOL + URLUtil.SCHEME_SEPARATOR + filePath + URLUtil.JAR_SEPARATOR;
+    } else if (filePath.contains("src.jar!")) {
+      return URLUtil.JAR_PROTOCOL + URLUtil.SCHEME_SEPARATOR + filePath;
+    } else {
+      return VirtualFileManager.constructUrl(
+          VirtualFileSystemProvider.getInstance().getSystem().getProtocol(), filePath);
+    }
+  }
+
+  private static String toSystemIndependentName(String aFileName) {
+    return FileUtilRt.toSystemIndependentName(aFileName);
+  }
+}
diff --git a/base/src/com/google/idea/blaze/base/util/WorkspacePathUtil.java b/base/src/com/google/idea/blaze/base/util/WorkspacePathUtil.java
new file mode 100644
index 0000000..e77df9e
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/util/WorkspacePathUtil.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2016 The Bazel Authors. 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.idea.blaze.base.util;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.idea.blaze.base.model.primitives.WorkspacePath;
+import com.intellij.openapi.util.io.FileUtil;
+import java.util.Collection;
+
+/** Removes any duplicates or overlapping directories */
+public class WorkspacePathUtil {
+
+  /** Returns whether the given workspace path is a child of any workspace path. */
+  public static boolean isUnderAnyWorkspacePath(
+      Collection<WorkspacePath> ancestors, WorkspacePath child) {
+    return ancestors
+        .stream()
+        .anyMatch(
+            importRoot ->
+                FileUtil.isAncestor(importRoot.relativePath(), child.relativePath(), false));
+  }
+
+  /** Removes any duplicates or overlapping directories */
+  public static ImmutableSet<WorkspacePath> calculateMinimalWorkspacePaths(
+      Collection<WorkspacePath> workspacePaths) {
+    ImmutableSet.Builder<WorkspacePath> minimalWorkspacePaths = ImmutableSet.builder();
+    for (WorkspacePath directory : workspacePaths) {
+      boolean ok = true;
+      for (WorkspacePath otherDirectory : workspacePaths) {
+        if (directory.equals(otherDirectory)) {
+          continue;
+        }
+        if (FileUtil.isAncestor(otherDirectory.relativePath(), directory.relativePath(), true)) {
+          ok = false;
+          break;
+        }
+      }
+      if (ok) {
+        minimalWorkspacePaths.add(directory);
+      }
+    }
+    return minimalWorkspacePaths.build();
+  }
+}
diff --git a/base/src/com/google/idea/blaze/base/vcs/BlazeVcsHandler.java b/base/src/com/google/idea/blaze/base/vcs/BlazeVcsHandler.java
index b05e69a..c5a855f 100644
--- a/base/src/com/google/idea/blaze/base/vcs/BlazeVcsHandler.java
+++ b/base/src/com/google/idea/blaze/base/vcs/BlazeVcsHandler.java
@@ -17,12 +17,12 @@
 
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.idea.blaze.base.model.primitives.WorkspacePath;
 import com.google.idea.blaze.base.model.primitives.WorkspaceRoot;
 import com.google.idea.blaze.base.projectview.ProjectViewSet;
 import com.google.idea.blaze.base.scope.BlazeContext;
 import com.google.idea.blaze.base.settings.Blaze;
 import com.google.idea.blaze.base.settings.Blaze.BuildSystem;
-import com.google.idea.blaze.base.sync.workspace.BlazeRoots;
 import com.google.idea.blaze.base.sync.workspace.WorkingSet;
 import com.google.idea.blaze.base.sync.workspace.WorkspacePathResolver;
 import com.intellij.openapi.extensions.ExtensionPointName;
@@ -59,6 +59,14 @@
       WorkspaceRoot workspaceRoot,
       ListeningExecutorService executor);
 
+  /** Returns the original file content of a file path from "upstream". */
+  ListenableFuture<String> getUpstreamContent(
+      Project project,
+      BlazeContext context,
+      WorkspaceRoot workspaceRoot,
+      WorkspacePath path,
+      ListeningExecutorService executor);
+
   /** Optionally creates a sync handler to perform vcs-specific computation during sync. */
   @Nullable
   BlazeVcsSyncHandler createSyncHandler(Project project, WorkspaceRoot workspaceRoot);
@@ -76,7 +84,7 @@
      *
      * @return True for OK, false to abort the sync process.
      */
-    boolean update(BlazeContext context, BlazeRoots blazeRoots, ListeningExecutorService executor);
+    boolean update(BlazeContext context, ListeningExecutorService executor);
 
     /** Returns a custom workspace path resolver for this vcs. */
     @Nullable
diff --git a/base/src/com/google/idea/blaze/base/vcs/FallbackBlazeVcsHandler.java b/base/src/com/google/idea/blaze/base/vcs/FallbackBlazeVcsHandler.java
index a975203..63cfcf4 100644
--- a/base/src/com/google/idea/blaze/base/vcs/FallbackBlazeVcsHandler.java
+++ b/base/src/com/google/idea/blaze/base/vcs/FallbackBlazeVcsHandler.java
@@ -18,6 +18,7 @@
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.idea.blaze.base.model.primitives.WorkspacePath;
 import com.google.idea.blaze.base.model.primitives.WorkspaceRoot;
 import com.google.idea.blaze.base.scope.BlazeContext;
 import com.google.idea.blaze.base.settings.Blaze.BuildSystem;
@@ -50,6 +51,16 @@
     return Futures.immediateFuture(null);
   }
 
+  @Override
+  public ListenableFuture<String> getUpstreamContent(
+      Project project,
+      BlazeContext context,
+      WorkspaceRoot workspaceRoot,
+      WorkspacePath path,
+      ListeningExecutorService executor) {
+    return Futures.immediateFuture("");
+  }
+
   @Nullable
   @Override
   public BlazeVcsSyncHandler createSyncHandler(Project project, WorkspaceRoot workspaceRoot) {
diff --git a/base/src/com/google/idea/blaze/base/vcs/git/GitBlazeVcsHandler.java b/base/src/com/google/idea/blaze/base/vcs/git/GitBlazeVcsHandler.java
index e1ffb78..4e58d0d 100644
--- a/base/src/com/google/idea/blaze/base/vcs/git/GitBlazeVcsHandler.java
+++ b/base/src/com/google/idea/blaze/base/vcs/git/GitBlazeVcsHandler.java
@@ -19,6 +19,7 @@
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.idea.blaze.base.async.process.ExternalTask;
 import com.google.idea.blaze.base.io.FileAttributeProvider;
+import com.google.idea.blaze.base.model.primitives.WorkspacePath;
 import com.google.idea.blaze.base.model.primitives.WorkspaceRoot;
 import com.google.idea.blaze.base.scope.BlazeContext;
 import com.google.idea.blaze.base.settings.Blaze.BuildSystem;
@@ -34,7 +35,7 @@
 /** Vcs diff provider for git */
 public class GitBlazeVcsHandler implements BlazeVcsHandler {
 
-  private static final Logger LOG = Logger.getInstance(GitBlazeVcsHandler.class);
+  private static final Logger logger = Logger.getInstance(GitBlazeVcsHandler.class);
 
   @Override
   public String getVcsName() {
@@ -70,6 +71,36 @@
     return null;
   }
 
+  @Override
+  public ListenableFuture<String> getUpstreamContent(
+      Project project,
+      BlazeContext context,
+      WorkspaceRoot workspaceRoot,
+      WorkspacePath path,
+      ListeningExecutorService executor) {
+    return executor.submit(() -> getGitUpstreamContent(workspaceRoot, path));
+  }
+
+  private static String getGitUpstreamContent(WorkspaceRoot workspaceRoot, WorkspacePath path) {
+    String upstreamSha = getUpstreamSha(workspaceRoot, false);
+    ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+    ExternalTask.builder(workspaceRoot)
+        .args(
+            "git",
+            "show",
+            // Normally "For plain blobs, it shows the plain contents.", but let's add some
+            // options to be a bit more paranoid.
+            "--no-color",
+            "--no-expand-tabs",
+            "--no-notes",
+            "--no-textconv",
+            String.format("%s:./%s", upstreamSha, path.relativePath()))
+        .stdout(outputStream)
+        .build()
+        .run();
+    return outputStream.toString();
+  }
+
   private static boolean isGitRepository(WorkspaceRoot workspaceRoot) {
     // TODO: What if the git repo root is a parent directory of the workspace root?
     // Just call 'git rev-parse --is-inside-work-tree' or similar instead?
@@ -102,7 +133,7 @@
             .run();
     if (retVal != 0) {
       if (!suppressErrors) {
-        LOG.error(stderr);
+        logger.error(stderr);
       }
       return null;
     }
diff --git a/base/src/com/google/idea/blaze/base/vcs/git/GitWorkingSetProvider.java b/base/src/com/google/idea/blaze/base/vcs/git/GitWorkingSetProvider.java
index cfef8c0..465c6ac 100644
--- a/base/src/com/google/idea/blaze/base/vcs/git/GitWorkingSetProvider.java
+++ b/base/src/com/google/idea/blaze/base/vcs/git/GitWorkingSetProvider.java
@@ -33,7 +33,7 @@
 /** Vcs diff provider for git. */
 public class GitWorkingSetProvider {
 
-  private static final Logger LOG = Logger.getInstance(GitWorkingSetProvider.class);
+  private static final Logger logger = Logger.getInstance(GitWorkingSetProvider.class);
 
   /**
    * Finds all changes between HEAD and the git commit specified by the provided SHA.<br>
@@ -58,7 +58,7 @@
             .build()
             .run();
     if (retVal != 0) {
-      LOG.error(stderr);
+      logger.error(stderr);
       return null;
     }
 
@@ -100,7 +100,7 @@
             .build()
             .run();
     if (retVal != 0) {
-      LOG.error(stderr);
+      logger.error(stderr);
       return null;
     }
     return StringUtil.trimEnd(stdout.toString(), "\n");
diff --git a/base/src/com/google/idea/blaze/base/wizard2/BlazeNewProjectBuilder.java b/base/src/com/google/idea/blaze/base/wizard2/BlazeNewProjectBuilder.java
index 11b4fbb..16648e6 100644
--- a/base/src/com/google/idea/blaze/base/wizard2/BlazeNewProjectBuilder.java
+++ b/base/src/com/google/idea/blaze/base/wizard2/BlazeNewProjectBuilder.java
@@ -40,7 +40,7 @@
 
 /** Contains the state to build a new project throughout the new project wizard process. */
 public final class BlazeNewProjectBuilder {
-  private static final Logger LOG = Logger.getInstance(BlazeNewProjectBuilder.class);
+  private static final Logger logger = Logger.getInstance(BlazeNewProjectBuilder.class);
 
   // The import wizard should keep this many items around for fields that care about history
   public static final int HISTORY_SIZE = 8;
@@ -198,7 +198,7 @@
     }
 
     try {
-      LOG.assertTrue(projectViewFile != null);
+      logger.assertTrue(projectViewFile != null);
       ProjectViewStorageManager.getInstance()
           .writeProjectView(ProjectViewParser.projectViewToString(projectView), projectViewFile);
     } catch (IOException e) {
diff --git a/base/src/com/google/idea/blaze/base/wizard2/BlazeSelectProjectViewOption.java b/base/src/com/google/idea/blaze/base/wizard2/BlazeSelectProjectViewOption.java
index 2f7a6b5..353ecb4 100644
--- a/base/src/com/google/idea/blaze/base/wizard2/BlazeSelectProjectViewOption.java
+++ b/base/src/com/google/idea/blaze/base/wizard2/BlazeSelectProjectViewOption.java
@@ -37,5 +37,10 @@
     return false;
   }
 
+  /** Returns the default project name */
+  default String getDefaultProjectName(String workspaceName) {
+    return workspaceName;
+  }
+
   void commit();
 }
diff --git a/base/src/com/google/idea/blaze/base/wizard2/GenerateFromBuildFileSelectProjectViewOption.java b/base/src/com/google/idea/blaze/base/wizard2/GenerateFromBuildFileSelectProjectViewOption.java
index b2edeff..341d023 100644
--- a/base/src/com/google/idea/blaze/base/wizard2/GenerateFromBuildFileSelectProjectViewOption.java
+++ b/base/src/com/google/idea/blaze/base/wizard2/GenerateFromBuildFileSelectProjectViewOption.java
@@ -33,6 +33,7 @@
 import com.intellij.openapi.fileChooser.FileChooserDescriptor;
 import com.intellij.openapi.fileChooser.FileChooserDialog;
 import com.intellij.openapi.fileChooser.FileChooserFactory;
+import com.intellij.openapi.ui.Messages;
 import com.intellij.openapi.util.io.FileUtil;
 import com.intellij.openapi.util.text.StringUtil;
 import com.intellij.openapi.vfs.LocalFileSystem;
@@ -119,6 +120,12 @@
   }
 
   @Override
+  public String getDefaultProjectName(String workspaceName) {
+    File buildFileParent = new File(getBuildFilePath()).getParentFile();
+    return buildFileParent != null ? buildFileParent.getName() : workspaceName;
+  }
+
+  @Override
   public void commit() {
     userSettings.put(LAST_WORKSPACE_PATH, getBuildFilePath());
     buildFilePathField.addCurrentTextToHistory();
@@ -182,6 +189,7 @@
   private void chooseWorkspacePath() {
     BuildSystemProvider buildSystem =
         BuildSystemProvider.getBuildSystemProvider(builder.getBuildSystem());
+    assert buildSystem != null;
     FileChooserDescriptor descriptor =
         new FileChooserDescriptor(true, false, false, false, false, false)
             .withShowHiddenFiles(true) // Show root project view file
@@ -216,6 +224,14 @@
       return;
     }
     VirtualFile file = files[0];
+
+    if (!FileUtil.isAncestor(fileBrowserRoot.getPath(), file.getPath(), true)) {
+      Messages.showErrorDialog(
+          String.format("You must choose a BUILD file under %s.", fileBrowserRoot.getPath()),
+          "Cannot Use BUILD File");
+      return;
+    }
+
     String newWorkspacePath = FileUtil.getRelativePath(fileBrowserRoot, new File(file.getPath()));
     buildFilePathField.setText(newWorkspacePath);
   }
diff --git a/base/src/com/google/idea/blaze/base/wizard2/ImportFromWorkspaceProjectViewOption.java b/base/src/com/google/idea/blaze/base/wizard2/ImportFromWorkspaceProjectViewOption.java
index bdec2eb..fe43d1c 100644
--- a/base/src/com/google/idea/blaze/base/wizard2/ImportFromWorkspaceProjectViewOption.java
+++ b/base/src/com/google/idea/blaze/base/wizard2/ImportFromWorkspaceProjectViewOption.java
@@ -109,6 +109,16 @@
   }
 
   @Override
+  public String getDefaultProjectName(String workspaceName) {
+    File projectViewFile = new File(getProjectViewPath());
+    File projectViewDirectory = projectViewFile.getParentFile();
+    if (projectViewDirectory == null) {
+      return workspaceName;
+    }
+    return projectViewDirectory.getName();
+  }
+
+  @Override
   public void commit() {
     userSettings.put(LAST_WORKSPACE_PATH, getProjectViewPath());
     projectViewPathField.addCurrentTextToHistory();
diff --git a/base/src/com/google/idea/blaze/base/wizard2/ui/BlazeEditProjectViewControl.java b/base/src/com/google/idea/blaze/base/wizard2/ui/BlazeEditProjectViewControl.java
index 4f3be05..e936db3 100644
--- a/base/src/com/google/idea/blaze/base/wizard2/ui/BlazeEditProjectViewControl.java
+++ b/base/src/com/google/idea/blaze/base/wizard2/ui/BlazeEditProjectViewControl.java
@@ -79,7 +79,7 @@
 
   private static final FileChooserDescriptor PROJECT_FOLDER_DESCRIPTOR =
       new FileChooserDescriptor(false, true, false, false, false, false);
-  private static final Logger LOG = Logger.getInstance(BlazeEditProjectViewControl.class);
+  private static final Logger logger = Logger.getInstance(BlazeEditProjectViewControl.class);
 
   private static final BoolExperiment allowAddprojectViewDefaultValues =
       new BoolExperiment("allow.add.project.view.default.values", true);
@@ -148,7 +148,8 @@
   public void update(BlazeNewProjectBuilder builder) {
     BlazeSelectWorkspaceOption workspaceOption = builder.getWorkspaceOption();
     BlazeSelectProjectViewOption projectViewOption = builder.getProjectViewOption();
-    String workspaceName = workspaceOption.getWorkspaceName();
+    String defaultProjectName =
+        projectViewOption.getDefaultProjectName(workspaceOption.getWorkspaceName());
     WorkspaceRoot workspaceRoot = workspaceOption.getWorkspaceRoot();
     WorkspacePath workspacePath = projectViewOption.getSharedProjectView();
     String initialProjectViewText = projectViewOption.getInitialProjectViewText();
@@ -160,7 +161,7 @@
     HashCode hashCode =
         Hashing.md5()
             .newHasher()
-            .putUnencodedChars(workspaceName)
+            .putUnencodedChars(defaultProjectName)
             .putUnencodedChars(workspaceRoot.toString())
             .putUnencodedChars(workspacePath != null ? workspacePath.toString() : "")
             .putUnencodedChars(initialProjectViewText != null ? initialProjectViewText : "")
@@ -171,7 +172,7 @@
     if (!hashCode.equals(paramsHash)) {
       this.paramsHash = hashCode;
       init(
-          workspaceName,
+          defaultProjectName,
           workspaceRoot,
           workspacePathResolver,
           workspacePath,
@@ -198,7 +199,7 @@
   }
 
   private void init(
-      String workspaceName,
+      String defaultProjectName,
       WorkspaceRoot workspaceRoot,
       WorkspacePathResolver workspacePathResolver,
       @Nullable WorkspacePath sharedProjectView,
@@ -211,8 +212,8 @@
 
     this.workspaceRoot = workspaceRoot;
     this.workspacePathResolver = workspacePathResolver;
-    projectNameField.setText(workspaceName);
-    String defaultDataDir = getDefaultProjectDataDirectory(workspaceName);
+    projectNameField.setText(defaultProjectName);
+    String defaultDataDir = getDefaultProjectDataDirectory(defaultProjectName);
     projectDataDirField.setText(defaultDataDir);
 
     String projectViewText = "";
@@ -225,15 +226,15 @@
         projectViewText =
             ProjectViewStorageManager.getInstance().loadProjectView(sharedProjectViewFile);
         if (projectViewText == null) {
-          LOG.error("Could not load project view: " + sharedProjectViewFile);
+          logger.error("Could not load project view: " + sharedProjectViewFile);
           projectViewText = "";
         }
       } catch (IOException e) {
-        LOG.error(e);
+        logger.error(e);
       }
     } else {
       projectViewText = initialProjectViewText;
-      LOG.assertTrue(projectViewText != null);
+      logger.assertTrue(projectViewText != null);
     }
 
     projectViewUi.init(
diff --git a/base/src/com/google/idea/blaze/base/wizard2/ui/BlazeSelectOptionControl.java b/base/src/com/google/idea/blaze/base/wizard2/ui/BlazeSelectOptionControl.java
index a79644d..69a36e4 100644
--- a/base/src/com/google/idea/blaze/base/wizard2/ui/BlazeSelectOptionControl.java
+++ b/base/src/com/google/idea/blaze/base/wizard2/ui/BlazeSelectOptionControl.java
@@ -37,7 +37,7 @@
 
 /** UI for selecting a client during the import process. */
 public abstract class BlazeSelectOptionControl<T extends BlazeWizardOption> {
-  private static final Logger LOG = Logger.getInstance(BlazeSelectOptionControl.class);
+  private static final Logger logger = Logger.getInstance(BlazeSelectOptionControl.class);
 
   private final BlazeWizardUserSettings userSettings;
   private final JPanel canvas;
@@ -56,7 +56,7 @@
 
   BlazeSelectOptionControl(BlazeNewProjectBuilder builder, Collection<T> options) {
     if (options == null) {
-      LOG.error("No options on select screen '" + getTitle() + "'");
+      logger.error("No options on select screen '" + getTitle() + "'");
     }
 
     this.userSettings = builder.getUserSettings();
diff --git a/base/tests/integrationtests/com/google/idea/blaze/base/lang/buildfile/parser/BuildParserTest.java b/base/tests/integrationtests/com/google/idea/blaze/base/lang/buildfile/parser/BuildParserTest.java
index 4d1a295..3823ea8 100644
--- a/base/tests/integrationtests/com/google/idea/blaze/base/lang/buildfile/parser/BuildParserTest.java
+++ b/base/tests/integrationtests/com/google/idea/blaze/base/lang/buildfile/parser/BuildParserTest.java
@@ -63,7 +63,7 @@
 
   @Test
   public void testAssign() throws Exception {
-    assertThat(parse("a, b = 5\n")).isEqualTo("assignment(list(reference, target), int)");
+    assertThat(parse("a, b = 5\n")).isEqualTo("assignment(tuple(reference, target), int)");
     assertNoErrors();
   }
 
@@ -244,24 +244,43 @@
 
   @Test
   public void testEmptyTuple() throws Exception {
-    assertThat(parse("()")).isEqualTo("list");
-    assertNoErrors();
-  }
-
-  @Test
-  public void testTupleTrailingComma() throws Exception {
-    assertThat(parse("(42,)")).isEqualTo("list(int)");
+    assertThat(parse("()")).isEqualTo("tuple");
     assertNoErrors();
   }
 
   @Test
   public void testSingleton() throws Exception {
-    assertThat(parse("(42)")) // not a tuple!
-        .isEqualTo("list(int)");
+    assertThat(parse("(42)")).isEqualTo("parens(int)");
     assertNoErrors();
   }
 
   @Test
+  public void testTupleWithoutParens() throws Exception {
+    assertThat(parse("a,b = 1")).isEqualTo("assignment(tuple(reference, target), int)");
+    assertNoErrors();
+  }
+
+  @Test
+  public void testTupleWithParens() throws Exception {
+    assertThat(parse("(a,b) = 1"))
+        .isEqualTo("assignment(parens(tuple(reference, reference)), int)");
+    assertNoErrors();
+  }
+
+  @Test
+  public void testTupleTrailingComma() throws Exception {
+    assertThat(parse("(42,)")).isEqualTo("parens(tuple(int))");
+    assertNoErrors();
+  }
+
+  @Test
+  public void testTupleTrailingCommaWithoutParens() throws Exception {
+    // valid python, but not valid skylark
+    assertThat(parse("42,1,")).isEqualTo("tuple(int, int)");
+    assertContainsError("Trailing commas are allowed only in parenthesized tuples.");
+  }
+
+  @Test
   public void testDictionaryLiterals() throws Exception {
     assertThat(parse("{1:42}")).isEqualTo("dict(dict_entry(int, int))");
     assertNoErrors();
@@ -370,21 +389,21 @@
   @Test
   public void testPrecedence2() {
     assertThat(parse("('%sx' + 'foo') * 'bar'"))
-        .isEqualTo("binary_op(list(binary_op(string, string)), string)");
+        .isEqualTo("binary_op(parens(binary_op(string, string)), string)");
     assertNoErrors();
   }
 
   @Test
   public void testPrecedence3() {
     assertThat(parse("'%sx' % ('foo' + 'bar')"))
-        .isEqualTo("binary_op(string, list(binary_op(string, string)))");
+        .isEqualTo("binary_op(string, parens(binary_op(string, string)))");
     assertNoErrors();
   }
 
   @Test
   public void testPrecedence4() throws Exception {
     assertThat(parse("1 + - (2 - 3)"))
-        .isEqualTo("binary_op(int, positional(list(binary_op(int, int))))");
+        .isEqualTo("binary_op(int, positional(parens(binary_op(int, int))))");
     assertNoErrors();
   }
 
diff --git a/base/tests/integrationtests/com/google/idea/blaze/base/lang/buildfile/validation/BuiltInRuleAnnotatorTest.java b/base/tests/integrationtests/com/google/idea/blaze/base/lang/buildfile/validation/BuiltInRuleAnnotatorTest.java
new file mode 100644
index 0000000..28eece6
--- /dev/null
+++ b/base/tests/integrationtests/com/google/idea/blaze/base/lang/buildfile/validation/BuiltInRuleAnnotatorTest.java
@@ -0,0 +1,298 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.base.lang.buildfile.validation;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.idea.blaze.base.lang.buildfile.BuildFileIntegrationTestCase;
+import com.google.idea.blaze.base.lang.buildfile.language.semantics.AttributeDefinition;
+import com.google.idea.blaze.base.lang.buildfile.language.semantics.BuildLanguageSpec;
+import com.google.idea.blaze.base.lang.buildfile.language.semantics.BuildLanguageSpecProvider;
+import com.google.idea.blaze.base.lang.buildfile.language.semantics.RuleDefinition;
+import com.google.idea.blaze.base.lang.buildfile.psi.BuildFile;
+import com.google.idea.blaze.base.lang.buildfile.psi.FuncallExpression;
+import com.google.idea.blaze.base.lang.buildfile.psi.util.PsiUtils;
+import com.google.idea.blaze.base.model.primitives.WorkspacePath;
+import com.google.repackaged.devtools.build.lib.query2.proto.proto2api.Build.Attribute.Discriminator;
+import com.intellij.codeInsight.daemon.impl.AnnotationHolderImpl;
+import com.intellij.lang.annotation.Annotation;
+import com.intellij.lang.annotation.AnnotationHolder;
+import com.intellij.lang.annotation.AnnotationSession;
+import com.intellij.openapi.project.Project;
+import com.intellij.psi.PsiFile;
+import java.util.List;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Integration tests for {@link BuiltInRuleAnnotator}. */
+@RunWith(JUnit4.class)
+public class BuiltInRuleAnnotatorTest extends BuildFileIntegrationTestCase {
+
+  private static final AttributeDefinition NAME_ATTRIBUTE =
+      new AttributeDefinition("name", Discriminator.STRING, true, null, null);
+
+  private static final AttributeDefinition SRCS_ATTRIBUTE =
+      new AttributeDefinition("srcs", Discriminator.LABEL_LIST, false, null, null);
+
+  private static final AttributeDefinition NEVERLINK_ATTRIBUTE =
+      new AttributeDefinition("neverlink", Discriminator.BOOLEAN, false, null, null);
+
+  private static final RuleDefinition JAVA_TEST =
+      new RuleDefinition(
+          "java_test",
+          ImmutableMap.of(
+              "name", NAME_ATTRIBUTE, "srcs", SRCS_ATTRIBUTE, "neverlink", NEVERLINK_ATTRIBUTE),
+          null);
+
+  private MockBuildLanguageSpecProvider specProvider;
+
+  @Before
+  public final void before() {
+    specProvider = new MockBuildLanguageSpecProvider();
+    registerApplicationService(BuildLanguageSpecProvider.class, specProvider);
+  }
+
+  @Test
+  public void testUnrecognizedRuleTypeIgnored() {
+    setRules("java_library", "java_binary");
+    BuildFile file =
+        createBuildFile(
+            new WorkspacePath("java/com/google/BUILD"), "java_import(name = 'import',)");
+
+    assertNoErrors(file);
+  }
+
+  @Test
+  public void testNoErrorsForValidStandardRule() {
+    specProvider.setRules(ImmutableMap.of(JAVA_TEST.name, JAVA_TEST));
+    BuildFile file =
+        createBuildFile(
+            new WorkspacePath("java/com/google/BUILD"),
+            "java_test(",
+            "    name = 'import',",
+            "    srcs = ['src'],",
+            "    neverlink = 0,",
+            ")");
+    assertNoErrors(file);
+  }
+
+  @Test
+  public void testGlobTreatedAsList() {
+    specProvider.setRules(ImmutableMap.of(JAVA_TEST.name, JAVA_TEST));
+    BuildFile file =
+        createBuildFile(
+            new WorkspacePath("java/com/google/BUILD"),
+            "java_test(",
+            "    name = 'import',",
+            "    srcs = glob(['src']),",
+            "    neverlink = 0,",
+            ")");
+    assertNoErrors(file);
+  }
+
+  @Test
+  public void testMissingMandatoryAttributeError() {
+    specProvider.setRules(ImmutableMap.of(JAVA_TEST.name, JAVA_TEST));
+    BuildFile file =
+        createBuildFile(
+            new WorkspacePath("java/com/google/BUILD"),
+            "java_test(",
+            "    srcs = ['src'],",
+            "    neverlink = 0,",
+            ")");
+    assertHasError(file, "Target missing required attribute(s): name");
+  }
+
+  @Test
+  public void testInvalidAttributeTypeError() {
+    specProvider.setRules(ImmutableMap.of(JAVA_TEST.name, JAVA_TEST));
+    BuildFile file =
+        createBuildFile(
+            new WorkspacePath("java/com/google/BUILD"),
+            "java_test(",
+            "    name = 'import',",
+            "    srcs = 'src',",
+            "    neverlink = 0,",
+            ")");
+    assertHasError(
+        file,
+        String.format(
+            "Invalid value for attribute 'srcs'. Expected a value of type '%s'",
+            Discriminator.LABEL_LIST));
+  }
+
+  @Test
+  public void testInvalidAttributeTypeError2() {
+    specProvider.setRules(ImmutableMap.of(JAVA_TEST.name, JAVA_TEST));
+    BuildFile file =
+        createBuildFile(
+            new WorkspacePath("java/com/google/BUILD"),
+            "java_test(",
+            "    name = ['import'],",
+            "    srcs = ['src'],",
+            "    neverlink = 0,",
+            ")");
+    assertHasError(
+        file,
+        String.format(
+            "Invalid value for attribute 'name'. Expected a value of type '%s'",
+            Discriminator.STRING));
+  }
+
+  @Test
+  public void testNoErrorForIfStatement() {
+    specProvider.setRules(ImmutableMap.of(JAVA_TEST.name, JAVA_TEST));
+    BuildFile file =
+        createBuildFile(
+            new WorkspacePath("java/com/google/BUILD"),
+            "java_test(",
+            "    name = 'import',",
+            "    srcs = ['src'],",
+            "    neverlink = if test : a else b",
+            ")");
+    // we don't know what the if statement evaluates to
+    assertNoErrors(file);
+  }
+
+  @Test
+  public void testUnknownReferenceIgnored() {
+    specProvider.setRules(ImmutableMap.of(JAVA_TEST.name, JAVA_TEST));
+    BuildFile file =
+        createBuildFile(
+            new WorkspacePath("java/com/google/BUILD"),
+            "java_test(",
+            "    name = 'import',",
+            "    srcs = ['src'],",
+            "    neverlink = ref,",
+            ")");
+    assertNoErrors(file);
+  }
+
+  @Test
+  public void testKnownIncorrectReference() {
+    specProvider.setRules(ImmutableMap.of(JAVA_TEST.name, JAVA_TEST));
+    BuildFile file =
+        createBuildFile(
+            new WorkspacePath("java/com/google/BUILD"),
+            "ref = []",
+            "java_test(",
+            "    name = 'import',",
+            "    srcs = ['src'],",
+            "    neverlink = ref,",
+            ")");
+    assertHasError(
+        file,
+        String.format(
+            "Invalid value for attribute 'neverlink'. Expected a value of type '%s'",
+            Discriminator.BOOLEAN));
+  }
+
+  @Test
+  public void testValidValueInsideParenthesizedExpression() {
+    specProvider.setRules(ImmutableMap.of(JAVA_TEST.name, JAVA_TEST));
+    BuildFile file =
+        createBuildFile(
+            new WorkspacePath("java/com/google/BUILD"),
+            "java_test(",
+            "    name = ('import' + '_suffix'),",
+            ")");
+    assertNoErrors(file);
+  }
+
+  @Test
+  public void testInvalidValueInsideParenthesizedExpression() {
+    specProvider.setRules(ImmutableMap.of(JAVA_TEST.name, JAVA_TEST));
+    BuildFile file =
+        createBuildFile(
+            new WorkspacePath("java/com/google/BUILD"), "java_test(", "    name = (1),", ")");
+    assertHasError(
+        file,
+        String.format(
+            "Invalid value for attribute 'name'. Expected a value of type '%s'",
+            Discriminator.STRING));
+  }
+
+  @Test
+  public void testUnresolvedValueInsideParenthesizedExpression() {
+    specProvider.setRules(ImmutableMap.of(JAVA_TEST.name, JAVA_TEST));
+    BuildFile file =
+        createBuildFile(
+            new WorkspacePath("java/com/google/BUILD"), "java_test(", "    name = (ref),", ")");
+    assertNoErrors(file);
+  }
+
+  private void setRules(String... ruleNames) {
+    ImmutableMap.Builder<String, RuleDefinition> rules = ImmutableMap.builder();
+    for (String name : ruleNames) {
+      rules.put(name, new RuleDefinition(name, ImmutableMap.of(), null));
+    }
+    specProvider.setRules(rules.build());
+  }
+
+  private static class MockBuildLanguageSpecProvider implements BuildLanguageSpecProvider {
+
+    BuildLanguageSpec languageSpec = new BuildLanguageSpec(ImmutableMap.of());
+
+    void setRules(ImmutableMap<String, RuleDefinition> rules) {
+      languageSpec = new BuildLanguageSpec(rules);
+    }
+
+    @Nullable
+    @Override
+    public BuildLanguageSpec getLanguageSpec(Project project) {
+      return languageSpec;
+    }
+  }
+
+  private void assertNoErrors(BuildFile file) {
+    assertThat(validateFile(file)).isEmpty();
+  }
+
+  private void assertHasError(BuildFile file, String error) {
+    assertHasError(validateFile(file), error);
+  }
+
+  private static void assertHasError(List<Annotation> annotations, String error) {
+    List<String> messages =
+        annotations.stream().map(Annotation::getMessage).collect(Collectors.toList());
+
+    assertThat(messages).contains(error);
+  }
+
+  private List<Annotation> validateFile(BuildFile file) {
+    BuiltInRuleAnnotator annotator = createAnnotator(file);
+    PsiUtils.findAllChildrenOfClassRecursive(file, FuncallExpression.class)
+        .forEach(annotator::visitFuncallExpression);
+    return annotationHolder;
+  }
+
+  private BuiltInRuleAnnotator createAnnotator(PsiFile file) {
+    annotationHolder = new AnnotationHolderImpl(new AnnotationSession(file));
+    return new BuiltInRuleAnnotator() {
+      @Override
+      protected AnnotationHolder getHolder() {
+        return annotationHolder;
+      }
+    };
+  }
+
+  private AnnotationHolderImpl annotationHolder = null;
+}
diff --git a/base/tests/integrationtests/com/google/idea/blaze/base/sync/ImportRootsTest.java b/base/tests/integrationtests/com/google/idea/blaze/base/sync/ImportRootsTest.java
index 139ca30..84bf00f 100644
--- a/base/tests/integrationtests/com/google/idea/blaze/base/sync/ImportRootsTest.java
+++ b/base/tests/integrationtests/com/google/idea/blaze/base/sync/ImportRootsTest.java
@@ -127,4 +127,15 @@
             .build();
     assertThat(importRoots.rootDirectories()).containsExactly(new WorkspacePath("."));
   }
+
+  @Test
+  public void testOverlappingExcludesAreFiltered() {
+    ImportRoots importRoots =
+        ImportRoots.builder(workspaceRoot, BuildSystem.Blaze)
+            .add(new DirectoryEntry(new WorkspacePath("root"), false))
+            .add(new DirectoryEntry(new WorkspacePath("root"), false))
+            .add(new DirectoryEntry(new WorkspacePath("root/subdir"), false))
+            .build();
+    assertThat(importRoots.excludeDirectories()).containsExactly(new WorkspacePath("root"));
+  }
 }
diff --git a/base/tests/unittests/com/google/idea/blaze/base/actions/BlazeBuildServiceTest.java b/base/tests/unittests/com/google/idea/blaze/base/actions/BlazeBuildServiceTest.java
index 6ca947d..cce3237 100644
--- a/base/tests/unittests/com/google/idea/blaze/base/actions/BlazeBuildServiceTest.java
+++ b/base/tests/unittests/com/google/idea/blaze/base/actions/BlazeBuildServiceTest.java
@@ -85,7 +85,7 @@
     assertThat(service).isNotNull();
 
     // Can't mock BlazeExecutor.submitTask.
-    doNothing().when(service).buildTargetExpressions(any(), any(), any(), any(), any());
+    doNothing().when(service).buildTargetExpressions(any(), any(), any(), any());
   }
 
   @Test
@@ -94,7 +94,7 @@
         ImmutableList.of(new Label("//foo:bar"), new Label("//foo:baz"));
     List<TargetExpression> targets = Lists.newArrayList(labels);
     service.buildFile(project, "Foo.java", labels);
-    verify(service).buildTargetExpressions(eq(project), eq(targets), eq(viewSet), any(), any());
+    verify(service).buildTargetExpressions(eq(project), eq(targets), eq(viewSet), any());
   }
 
   @Test
@@ -104,7 +104,7 @@
         Lists.newArrayList(
             TargetExpression.fromString("//view/target:one"),
             TargetExpression.fromString("//view/target:two"));
-    verify(service).buildTargetExpressions(eq(project), eq(targets), eq(viewSet), any(), any());
+    verify(service).buildTargetExpressions(eq(project), eq(targets), eq(viewSet), any());
   }
 
   private static class MockProjectViewManager extends ProjectViewManager {
diff --git a/base/tests/unittests/com/google/idea/blaze/base/metrics/ActionTest.java b/base/tests/unittests/com/google/idea/blaze/base/metrics/ActionTest.java
deleted file mode 100644
index 2cbb7ce..0000000
--- a/base/tests/unittests/com/google/idea/blaze/base/metrics/ActionTest.java
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * Copyright 2016 The Bazel Authors. 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.idea.blaze.base.metrics;
-
-import com.google.common.collect.Sets;
-import java.util.HashSet;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-import org.junit.Assert;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.junit.runners.JUnit4;
-
-/** Test for metric actions. */
-@RunWith(JUnit4.class)
-public class ActionTest {
-
-  @Test
-  public void ensureAllActionEnumsHaveUniqueNames() {
-    HashSet<String> names = Sets.newHashSet();
-    for (Action action : Action.values()) {
-      String name = action.getName();
-      Assert.assertTrue(name + " is not unique", names.add(name));
-    }
-  }
-
-  @Test
-  public void ensureAllActionEnumNamesAreAlphanumeric() {
-    Pattern pattern = Pattern.compile("[a-zA-Z0-9]*");
-    for (Action action : Action.values()) {
-      String name = action.getName();
-      Matcher matcher = pattern.matcher(name);
-      Assert.assertTrue(name + " is not valid", matcher.matches());
-    }
-  }
-}
diff --git a/base/tests/unittests/com/google/idea/blaze/base/projectview/ProjectViewSetTest.java b/base/tests/unittests/com/google/idea/blaze/base/projectview/ProjectViewSetTest.java
index ed1fd3a..7758293 100644
--- a/base/tests/unittests/com/google/idea/blaze/base/projectview/ProjectViewSetTest.java
+++ b/base/tests/unittests/com/google/idea/blaze/base/projectview/ProjectViewSetTest.java
@@ -38,7 +38,6 @@
 import com.google.idea.blaze.base.projectview.section.sections.ExcludedSourceSection;
 import com.google.idea.blaze.base.projectview.section.sections.ImportSection;
 import com.google.idea.blaze.base.projectview.section.sections.ImportTargetOutputSection;
-import com.google.idea.blaze.base.projectview.section.sections.MetricsProjectSection;
 import com.google.idea.blaze.base.projectview.section.sections.RunConfigurationsSection;
 import com.google.idea.blaze.base.projectview.section.sections.Sections;
 import com.google.idea.blaze.base.projectview.section.sections.TargetSection;
@@ -90,7 +89,6 @@
                     .add(ScalarSection.builder(WorkspaceTypeSection.KEY).set(WorkspaceType.JAVA))
                     .add(
                         ListSection.builder(AdditionalLanguagesSection.KEY).add(LanguageClass.JAVA))
-                    .add(ScalarSection.builder(MetricsProjectSection.KEY).set("my project"))
                     .add(TextBlockSection.of(TextBlock.newLine()))
                     .add(
                         ListSection.builder(RunConfigurationsSection.KEY)
diff --git a/base/tests/unittests/com/google/idea/blaze/base/run/smrunner/BlazeXmlSchemaTest.java b/base/tests/unittests/com/google/idea/blaze/base/run/smrunner/BlazeXmlSchemaTest.java
new file mode 100644
index 0000000..5c7dc35
--- /dev/null
+++ b/base/tests/unittests/com/google/idea/blaze/base/run/smrunner/BlazeXmlSchemaTest.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.base.run.smrunner;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.idea.blaze.base.run.smrunner.BlazeXmlSchema.TestSuite;
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for {@link BlazeXmlSchema}. */
+@RunWith(JUnit4.class)
+public class BlazeXmlSchemaTest {
+
+  @Test
+  public void testNoTestSuitesOuterElement() {
+    List<String> lines =
+        ImmutableList.of(
+            "  <testsuite name=\"foo/bar\" tests=\"1\" time=\"19.268\">",
+            "      <testcase name=\"TestName\" result=\"completed\" status=\"run\" time=\"19.2\">",
+            "          <system-out>PASS&#xA;&#xA;</system-out>",
+            "      </testcase>",
+            "  </testsuite>");
+    InputStream stream =
+        new ByteArrayInputStream(Joiner.on('\n').join(lines).getBytes(StandardCharsets.UTF_8));
+    TestSuite parsed = BlazeXmlSchema.parse(stream);
+    assertThat(parsed).isNotNull();
+  }
+
+  @Test
+  public void testOuterTestSuitesElement() {
+    List<String> lines =
+        ImmutableList.of(
+            "<?xml version='1.0' encoding='UTF-8'?>",
+            "<testsuites>",
+            "  <testsuite name='foo' hostname='localhost' tests='331' failures='0' id='0'>",
+            "    <properties />",
+            "    <system-out />",
+            "    <system-err />",
+            "  </testsuite>",
+            "  <testsuite name='bar'>",
+            "    <testcase name='bar_test_1' time='12.2' />",
+            "    <system-out />",
+            "  </testsuite>",
+            "</testsuites>");
+    InputStream stream =
+        new ByteArrayInputStream(Joiner.on('\n').join(lines).getBytes(StandardCharsets.UTF_8));
+    TestSuite parsed = BlazeXmlSchema.parse(stream);
+    assertThat(parsed).isNotNull();
+  }
+}
diff --git a/base/tests/unittests/com/google/idea/blaze/base/run/testlogs/BlazeCommandLogParserTest.java b/base/tests/unittests/com/google/idea/blaze/base/run/testlogs/BlazeCommandLogParserTest.java
new file mode 100644
index 0000000..e3e63ad
--- /dev/null
+++ b/base/tests/unittests/com/google/idea/blaze/base/run/testlogs/BlazeCommandLogParserTest.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.base.run.testlogs;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.idea.blaze.base.model.primitives.Label;
+import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for {@link BlazeCommandLogParser}. */
+@RunWith(JUnit4.class)
+public class BlazeCommandLogParserTest {
+
+  @Test
+  public void testParseTestXmlLine() {
+    assertThat(BlazeCommandLogParser.parseTestTarget("//path/to:target    PASSED in 5.3s"))
+        .isEqualTo(new Label("//path/to:target"));
+
+    assertThat(BlazeCommandLogParser.parseTestTarget("//path/to:target    FAILED in 5.3s"))
+        .isEqualTo(new Label("//path/to:target"));
+
+    assertThat(BlazeCommandLogParser.parseTestTarget("//path/to:target (cached) PASSED in 5.3s"))
+        .isEqualTo(new Label("//path/to:target"));
+  }
+
+  @Test
+  public void testNonTestXmlLinesIgnored() {
+    assertThat(BlazeCommandLogParser.parseTestTarget("Executed 0 out of 1 test: 1 test passes."))
+        .isNull();
+    assertThat(BlazeCommandLogParser.parseTestTarget("INFO: Found 8 test targets...")).isNull();
+    assertThat(BlazeCommandLogParser.parseTestTarget("Target //golang:unit_tests up-to-date:"))
+        .isNull();
+    assertThat(BlazeCommandLogParser.parseTestTarget("  bazel-bin/golang/unit_tests.jar")).isNull();
+  }
+
+  @Test
+  public void testMultipleInputLines() {
+    List<String> lines =
+        ImmutableList.of(
+            "INFO: Found 3 test targets...",
+            "INFO: Elapsed time: 3.239s, Critical Path: 1.65s",
+            "//base:integration_tests                                (cached) PASSED in 27.9s",
+            "//base:unit_tests                                       (cached) PASSED in 4.3s",
+            "//golang:unit_tests                                              FAILED in 0.6s",
+            "Executed 1 out of 3 test: 2 test passes.");
+    assertThat(BlazeCommandLogParser.parseTestTargets(lines.stream()))
+        .containsExactly(
+            new Label("//base:integration_tests"),
+            new Label("//base:unit_tests"),
+            new Label("//golang:unit_tests"));
+  }
+}
diff --git a/base/tests/unittests/com/google/idea/blaze/base/run/testlogs/BlazeTestLogParserTest.java b/base/tests/unittests/com/google/idea/blaze/base/run/testlogs/BlazeTestLogParserTest.java
new file mode 100644
index 0000000..d490769
--- /dev/null
+++ b/base/tests/unittests/com/google/idea/blaze/base/run/testlogs/BlazeTestLogParserTest.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.base.run.testlogs;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import java.io.File;
+import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for {@link BlazeTestLogParser}. */
+@RunWith(JUnit4.class)
+public class BlazeTestLogParserTest {
+
+  @Test
+  public void testParseTestXmlLine() {
+    assertThat(BlazeTestLogParser.parseXmlLocation("    XML_OUTPUT_FILE=/tmp/test.xml \\"))
+        .isEqualTo(new File("/tmp/test.xml"));
+  }
+
+  @Test
+  public void testNonTestXmlLinesIgnored() {
+    assertThat(BlazeTestLogParser.parseXmlLocation("    TEST_TMPDIR=/tmp/test \\")).isNull();
+  }
+
+  @Test
+  public void testMultipleInputLines() {
+    List<String> lines =
+        ImmutableList.of(
+            "Test command:",
+            "cd /build/work/runfiles/workspace && \\",
+            "  env - \\",
+            "    JAVA_RUNFILES=/build/work/runfiles \\",
+            "    PWD=/build/work/runfiles/workspace \\",
+            "    XML_OUTPUT_FILE=/tmp/dir/test.xml \\",
+            "    USER=username \\");
+    assertThat(BlazeTestLogParser.parseTestXmlFile(lines.stream()))
+        .isEqualTo(new File("/tmp/dir/test.xml"));
+  }
+}
diff --git a/base/tests/unittests/com/google/idea/blaze/base/run/testmap/TestMapTest.java b/base/tests/unittests/com/google/idea/blaze/base/run/testmap/TestMapTest.java
index 9bea7c2..8a855ae 100644
--- a/base/tests/unittests/com/google/idea/blaze/base/run/testmap/TestMapTest.java
+++ b/base/tests/unittests/com/google/idea/blaze/base/run/testmap/TestMapTest.java
@@ -25,7 +25,6 @@
 import com.google.idea.blaze.base.ideinfo.TargetMap;
 import com.google.idea.blaze.base.ideinfo.TargetMapBuilder;
 import com.google.idea.blaze.base.model.primitives.Label;
-import com.google.idea.blaze.base.run.testmap.TestTargetFilterImpl.TestMap;
 import com.google.idea.blaze.base.sync.workspace.ArtifactLocationDecoder;
 import com.google.idea.blaze.base.targetmaps.ReverseDependencyMap;
 import com.google.idea.common.experiments.ExperimentService;
@@ -65,10 +64,11 @@
                     .addSource(sourceRoot("test/Test.java")))
             .build();
 
-    TestMap testMap = new TestMap(project, artifactLocationDecoder, targetMap);
+    FilteredTargetMap testMap =
+        TestTargetFilterImpl.computeTestMap(project, artifactLocationDecoder, targetMap);
     ImmutableMultimap<TargetKey, TargetKey> reverseDependencies =
         ReverseDependencyMap.createRdepsMap(targetMap);
-    assertThat(testMap.testTargetsForSourceFile(reverseDependencies, new File("/test/Test.java")))
+    assertThat(testMap.targetsForSourceFile(reverseDependencies, new File("/test/Test.java")))
         .containsExactly(new Label("//test:test"));
   }
 
@@ -90,10 +90,11 @@
                     .addSource(sourceRoot("test/Test.java")))
             .build();
 
-    TestMap testMap = new TestMap(project, artifactLocationDecoder, targetMap);
+    FilteredTargetMap testMap =
+        TestTargetFilterImpl.computeTestMap(project, artifactLocationDecoder, targetMap);
     ImmutableMultimap<TargetKey, TargetKey> reverseDependencies =
         ReverseDependencyMap.createRdepsMap(targetMap);
-    assertThat(testMap.testTargetsForSourceFile(reverseDependencies, new File("/test/Test.java")))
+    assertThat(testMap.targetsForSourceFile(reverseDependencies, new File("/test/Test.java")))
         .containsExactly(new Label("//test:test"));
   }
 
@@ -121,10 +122,11 @@
                     .addSource(sourceRoot("test/Test.java")))
             .build();
 
-    TestMap testMap = new TestMap(project, artifactLocationDecoder, targetMap);
+    FilteredTargetMap testMap =
+        TestTargetFilterImpl.computeTestMap(project, artifactLocationDecoder, targetMap);
     ImmutableMultimap<TargetKey, TargetKey> reverseDependencies =
         ReverseDependencyMap.createRdepsMap(targetMap);
-    assertThat(testMap.testTargetsForSourceFile(reverseDependencies, new File("/test/Test.java")))
+    assertThat(testMap.targetsForSourceFile(reverseDependencies, new File("/test/Test.java")))
         .containsExactly(new Label("//test:test"), new Label("//test:test2"));
   }
 
@@ -158,10 +160,11 @@
                     .addDependency("//test:lib"))
             .build();
 
-    TestMap testMap = new TestMap(project, artifactLocationDecoder, targetMap);
+    FilteredTargetMap testMap =
+        TestTargetFilterImpl.computeTestMap(project, artifactLocationDecoder, targetMap);
     ImmutableMultimap<TargetKey, TargetKey> reverseDependencies =
         ReverseDependencyMap.createRdepsMap(targetMap);
-    assertThat(testMap.testTargetsForSourceFile(reverseDependencies, new File("/test/Test.java")))
+    assertThat(testMap.targetsForSourceFile(reverseDependencies, new File("/test/Test.java")))
         .containsExactly(new Label("//test:test"), new Label("//test:test2"))
         .inOrder();
   }
@@ -196,10 +199,11 @@
                     .addSource(sourceRoot("test/Test.java")))
             .build();
 
-    TestMap testMap = new TestMap(project, artifactLocationDecoder, targetMap);
+    FilteredTargetMap testMap =
+        TestTargetFilterImpl.computeTestMap(project, artifactLocationDecoder, targetMap);
     ImmutableMultimap<TargetKey, TargetKey> reverseDependencies =
         ReverseDependencyMap.createRdepsMap(targetMap);
-    assertThat(testMap.testTargetsForSourceFile(reverseDependencies, new File("/test/Test.java")))
+    assertThat(testMap.targetsForSourceFile(reverseDependencies, new File("/test/Test.java")))
         .containsExactly(new Label("//test:test"), new Label("//test:test2"));
   }
 
@@ -228,17 +232,15 @@
                     .addSource(sourceRoot("test/Test.java")))
             .build();
 
-    TestMap testMap = new TestMap(project, artifactLocationDecoder, targetMap);
+    FilteredTargetMap testMap =
+        TestTargetFilterImpl.computeTestMap(project, artifactLocationDecoder, targetMap);
     ImmutableMultimap<TargetKey, TargetKey> reverseDependencies =
         ReverseDependencyMap.createRdepsMap(targetMap);
-    assertThat(testMap.testTargetsForSourceFile(reverseDependencies, new File("/test/Test.java")))
+    assertThat(testMap.targetsForSourceFile(reverseDependencies, new File("/test/Test.java")))
         .containsExactly(new Label("//test:test"));
   }
 
   private ArtifactLocation sourceRoot(String relativePath) {
-    return ArtifactLocation.builder()
-        .setRelativePath(relativePath)
-        .setIsSource(true)
-        .build();
+    return ArtifactLocation.builder().setRelativePath(relativePath).setIsSource(true).build();
   }
 }
diff --git a/base/tests/unittests/com/google/idea/blaze/base/sync/workspace/ExecutionRootPathResolverTest.java b/base/tests/unittests/com/google/idea/blaze/base/sync/workspace/ExecutionRootPathResolverTest.java
new file mode 100644
index 0000000..f880353
--- /dev/null
+++ b/base/tests/unittests/com/google/idea/blaze/base/sync/workspace/ExecutionRootPathResolverTest.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2016 The Bazel Authors. 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.idea.blaze.base.sync.workspace;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.idea.blaze.base.BlazeTestCase;
+import com.google.idea.blaze.base.model.primitives.ExecutionRootPath;
+import com.google.idea.blaze.base.model.primitives.WorkspacePath;
+import com.google.idea.blaze.base.model.primitives.WorkspaceRoot;
+import java.io.File;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Units tests for {@link ExecutionRootPathResolver}. */
+@RunWith(JUnit4.class)
+public class ExecutionRootPathResolverTest extends BlazeTestCase {
+
+  private static final WorkspaceRoot WORKSPACE_ROOT = new WorkspaceRoot(new File("/path/to/root"));
+  private static final String EXECUTION_ROOT = "/path/to/_blaze_user/1234bf129e/root";
+
+  private static final BlazeRoots BLAZE_ROOTS =
+      new BlazeRoots(
+          new File(EXECUTION_ROOT),
+          ImmutableList.of(WORKSPACE_ROOT.directory()),
+          new ExecutionRootPath("blaze-out/crosstool/bin"),
+          new ExecutionRootPath("blaze-out/crosstool/genfiles"),
+          null);
+
+  private final ExecutionRootPathResolver pathResolver =
+      new ExecutionRootPathResolver(
+          BLAZE_ROOTS, new WorkspacePathResolverImpl(WORKSPACE_ROOT, BLAZE_ROOTS));
+
+  @Test
+  public void testExternalWorkspacePathRelativeToExecRoot() {
+    ImmutableList<File> files =
+        pathResolver.resolveToIncludeDirectories(new ExecutionRootPath("external/guava/src"));
+    assertThat(files).containsExactly(new File(EXECUTION_ROOT, "external/guava/src"));
+  }
+
+  @Test
+  public void testGenfilesPathRelativeToExecRoot() {
+    ImmutableList<File> files =
+        pathResolver.resolveToIncludeDirectories(
+            new ExecutionRootPath("blaze-out/crosstool/genfiles/res/normal"));
+    assertThat(files)
+        .containsExactly(new File(EXECUTION_ROOT, "blaze-out/crosstool/genfiles/res/normal"));
+  }
+
+  @Test
+  public void testNonOutputPathsRelativeToWorkspaceRoot() {
+    ImmutableList<File> files =
+        pathResolver.resolveToIncludeDirectories(new ExecutionRootPath("tools/fast"));
+    assertThat(files).containsExactly(WORKSPACE_ROOT.fileForPath(new WorkspacePath("tools/fast")));
+  }
+}
diff --git a/base/tests/unittests/com/google/idea/blaze/base/sync/workspace/WorkspacePathResolverImplTest.java b/base/tests/unittests/com/google/idea/blaze/base/sync/workspace/WorkspacePathResolverImplTest.java
index ff130fb..282ea53 100644
--- a/base/tests/unittests/com/google/idea/blaze/base/sync/workspace/WorkspacePathResolverImplTest.java
+++ b/base/tests/unittests/com/google/idea/blaze/base/sync/workspace/WorkspacePathResolverImplTest.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.idea.blaze.base.BlazeTestCase;
 import com.google.idea.blaze.base.model.primitives.ExecutionRootPath;
+import com.google.idea.blaze.base.model.primitives.WorkspacePath;
 import com.google.idea.blaze.base.model.primitives.WorkspaceRoot;
 import java.io.File;
 import org.junit.Test;
@@ -45,7 +46,7 @@
     WorkspacePathResolver workspacePathResolver =
         new WorkspacePathResolverImpl(WORKSPACE_ROOT, BLAZE_CITC_ROOTS);
     ImmutableList<File> files =
-        workspacePathResolver.resolveToIncludeDirectories(new ExecutionRootPath("tools/fast"));
+        workspacePathResolver.resolveToIncludeDirectories(new WorkspacePath("tools/fast"));
     assertThat(files).containsExactly(new File("/path/to/root/tools/fast"));
   }
 
@@ -55,7 +56,7 @@
         new WorkspacePathResolverImpl(WORKSPACE_ROOT, BLAZE_CITC_ROOTS);
     ImmutableList<File> files =
         workspacePathResolver.resolveToIncludeDirectories(
-            new ExecutionRootPath("blaze-out/crosstool/bin/tools/fast"));
+            new WorkspacePath("blaze-out/crosstool/bin/tools/fast"));
     assertThat(files).containsExactly(new File("/path/to/root/blaze-out/crosstool/bin/tools/fast"));
   }
 }
diff --git a/base/tests/utils/integration/com/google/idea/blaze/base/BlazeIntegrationTestCase.java b/base/tests/utils/integration/com/google/idea/blaze/base/BlazeIntegrationTestCase.java
index bd21294..8027d58 100644
--- a/base/tests/utils/integration/com/google/idea/blaze/base/BlazeIntegrationTestCase.java
+++ b/base/tests/utils/integration/com/google/idea/blaze/base/BlazeIntegrationTestCase.java
@@ -17,6 +17,7 @@
 
 import com.google.idea.blaze.base.io.FileAttributeProvider;
 import com.google.idea.blaze.base.io.InputStreamProvider;
+import com.google.idea.blaze.base.io.VirtualFileSystemProvider;
 import com.google.idea.blaze.base.model.primitives.WorkspacePath;
 import com.google.idea.blaze.base.model.primitives.WorkspaceRoot;
 import com.google.idea.blaze.base.settings.Blaze.BuildSystem;
@@ -129,6 +130,8 @@
           }
           return vf.getInputStream();
         });
+    registerApplicationService(
+        VirtualFileSystemProvider.class, new TestFileSystem.TempVirtualFileSystemProvider());
   }
 
   @After
diff --git a/base/tests/utils/integration/com/google/idea/blaze/base/TestFileSystem.java b/base/tests/utils/integration/com/google/idea/blaze/base/TestFileSystem.java
index 834eeab..cf15e9f 100644
--- a/base/tests/utils/integration/com/google/idea/blaze/base/TestFileSystem.java
+++ b/base/tests/utils/integration/com/google/idea/blaze/base/TestFileSystem.java
@@ -19,10 +19,12 @@
 
 import com.google.common.base.Joiner;
 import com.google.idea.blaze.base.io.FileAttributeProvider;
+import com.google.idea.blaze.base.io.VirtualFileSystemProvider;
 import com.intellij.openapi.application.ReadAction;
 import com.intellij.openapi.application.Result;
 import com.intellij.openapi.project.Project;
 import com.intellij.openapi.util.io.FileUtil;
+import com.intellij.openapi.vfs.LocalFileSystem;
 import com.intellij.openapi.vfs.VirtualFile;
 import com.intellij.openapi.vfs.ex.temp.TempFileSystem;
 import com.intellij.psi.PsiDirectory;
@@ -32,6 +34,7 @@
 import com.intellij.testFramework.fixtures.TempDirTestFixture;
 import java.io.File;
 import java.io.IOException;
+import java.util.Arrays;
 
 /** Creates temp files for integration tests. */
 public class TestFileSystem {
@@ -165,8 +168,32 @@
       return vf != null && vf.exists() && !vf.isDirectory();
     }
 
+    @Override
+    public File[] listFiles(File file) {
+      VirtualFile vf = getVirtualFile(file);
+      if (vf == null) {
+        return null;
+      }
+      VirtualFile[] children = vf.getChildren();
+      if (children == null) {
+        return null;
+      }
+      return Arrays.stream(vf.getChildren()).map((f) -> new File(f.getPath())).toArray(File[]::new);
+    }
+
     private VirtualFile getVirtualFile(File file) {
       return fileSystem.findFileByPath(file.getPath());
     }
   }
+
+  /** Redirects VirtualFileSystem operations to the TempFileSystem used for these tests. */
+  public static class TempVirtualFileSystemProvider implements VirtualFileSystemProvider {
+
+    final TempFileSystem fileSystem = TempFileSystem.getInstance();
+
+    @Override
+    public LocalFileSystem getSystem() {
+      return fileSystem;
+    }
+  }
 }
diff --git a/base/tests/utils/integration/com/google/idea/blaze/base/sync/BlazeSyncIntegrationTestCase.java b/base/tests/utils/integration/com/google/idea/blaze/base/sync/BlazeSyncIntegrationTestCase.java
index a0ac661..53c6b53 100644
--- a/base/tests/utils/integration/com/google/idea/blaze/base/sync/BlazeSyncIntegrationTestCase.java
+++ b/base/tests/utils/integration/com/google/idea/blaze/base/sync/BlazeSyncIntegrationTestCase.java
@@ -109,7 +109,7 @@
               public void commit() {
                 // don't commit module changes,
                 // and make sure they're properly disposed when the test is finished
-                for (ModifiableRootModel model : modifiableModels) {
+                for (ModifiableRootModel model : modules.values()) {
                   Disposer.register(getTestRootDisposable(), model::dispose);
                   if (model.getModule().getName().equals(BlazeDataStorage.WORKSPACE_MODULE_NAME)) {
                     workspaceContentEntries = ImmutableList.copyOf(model.getContentEntries());
@@ -245,6 +245,16 @@
       return Futures.immediateFuture(workingSet);
     }
 
+    @Override
+    public ListenableFuture<String> getUpstreamContent(
+        Project project,
+        BlazeContext context,
+        WorkspaceRoot workspaceRoot,
+        WorkspacePath path,
+        ListeningExecutorService executor) {
+      return Futures.immediateFuture("");
+    }
+
     @Nullable
     @Override
     public BlazeVcsSyncHandler createSyncHandler(Project project, WorkspaceRoot workspaceRoot) {
diff --git a/build_defs/build_defs.bzl b/build_defs/build_defs.bzl
index 3104e0f..b695241 100644
--- a/build_defs/build_defs.bzl
+++ b/build_defs/build_defs.bzl
@@ -1,6 +1,8 @@
 """Custom build macros for IntelliJ plugin handling.
 """
 
+load(":intellij_plugin_debug_target.bzl", "intellij_plugin_debug_target")
+
 def merged_plugin_xml(name, srcs, **kwargs):
   """Merges N plugin.xml files together."""
   merge_tool = "//build_defs:merge_xml"
@@ -176,8 +178,17 @@
       tools = [api_version_txt_tool],
       **kwargs)
 
-def intellij_plugin(name, deps, plugin_xml, meta_inf_files=[], **kwargs):
-  """Creates an intellij plugin from the given deps and plugin.xml."""
+def intellij_plugin(name, deps, plugin_xml, meta_inf_files=[], jar_name=None, **kwargs):
+  """Creates an intellij plugin from the given deps and plugin.xml.
+
+  Args:
+    name: The name of the target
+    deps: Any java dependencies rolled up into the plugin jar.
+    plugin_xml: An xml file to be placed in META-INF/plugin.jar
+    meta_inf_files: Any further files to be placed in META-INF/plugin.jar
+    jar_name: The name of the final plugin jar, or <name>.jar if None
+    **kwargs: Any further arguments to be passed to the final target
+  """
   zip_tool = "//third_party:zip"
   binary_name = name + "_binary"
   deploy_jar = binary_name + "_deploy.jar"
@@ -201,12 +212,14 @@
     cmd.append("meta_inf_files='$(locations {meta_inf_file})'".format(meta_inf_file=meta_inf_file))
     cmd.append("for f in $$meta_inf_files; do cp $$f META-INF/; done")
   cmd.append("$(location {zip_tool}) -u $@ META-INF/* >/dev/null".format(zip_tool=zip_tool))
+  cmd.append("rm -rf META-INF")
 
+  jar_name = jar_name or (name + ".jar")
   native.genrule(
       name = name + "_genrule",
       srcs = srcs,
       tools = [zip_tool],
-      outs = [name + ".jar"],
+      outs = [jar_name],
       cmd = " ; ".join(cmd),
   )
 
diff --git a/build_defs/intellij_plugin_debug_target.bzl b/build_defs/intellij_plugin_debug_target.bzl
new file mode 100644
index 0000000..484a6fe
--- /dev/null
+++ b/build_defs/intellij_plugin_debug_target.bzl
@@ -0,0 +1,107 @@
+"""IntelliJ plugin debug target rule used for debugging IntelliJ plugins.
+
+Creates a plugin target debuggable from IntelliJ. Any files in
+the 'deps' attribute are deployed to the plugin sandbox.
+
+Any files are stripped of their prefix and installed into
+<sandbox>/plugins. If you need structure, first put the files
+into a pkgfilegroup. The files will be installed relative to the
+'plugins' directory if present in the pkgfilegroup prefix.
+
+intellij_plugin_debug_targets can be nested.
+
+pkgfilegroup(
+  name = "foo_files",
+  srcs = [
+    ":my_plugin_jar",
+    ":my_additional_plugin_files",
+  ],
+  prefix = "plugins/foo/lib",
+)
+
+intellij_plugin_debug_target(
+  name = "my_debug_target",
+  deps = [
+    ":my_jar",
+  ],
+)
+
+"""
+
+def _trim_start(path, prefix):
+  return path[len(prefix):] if path.startswith(prefix) else path
+
+def _pkgfilegroup_deploy_file(ctx, f):
+  strip_prefix = ctx.rule.attr.strip_prefix
+  prefix = ctx.rule.attr.prefix
+  if strip_prefix == ".":
+    stripped_relative_path = f.basename
+  elif strip_prefix.startswith("/"):
+    stripped_relative_path = _trim_start(f.short_path, strip_prefix[1:])
+  else:
+    stripped_relative_path = _trim_start(f.short_path, PACKAGE_NAME)
+    stripped_relative_path = _trim_start(stripped_relative_path, strip_prefix)
+  stripped_relative_path = _trim_start(stripped_relative_path, "/")
+
+  # If there's a 'plugins' directory, make destination relative to that
+  plugini = prefix.find("plugins/")
+  plugins_prefix = prefix[plugini + len("plugins/"):] if plugini >= 0 else prefix
+
+  # If the install location is still absolute, fail
+  if plugins_prefix.startswith("/"):
+    fail("Cannot compute plugins-relative install directory for pkgfilegroup")
+
+  dest = plugins_prefix + "/" + stripped_relative_path if plugins_prefix else stripped_relative_path
+  return struct(
+      src = f,
+      deploy_location = dest,
+  )
+
+def _flat_deploy_file(f):
+  return struct(
+      src = f,
+      deploy_location = f.basename,
+  )
+
+def _intellij_plugin_debug_target_aspect_impl(target, ctx):
+  aspect_intellij_plugin_deploy_info = None
+
+  if ctx.rule.kind == "intellij_plugin_debug_target":
+    aspect_intellij_plugin_deploy_info = target.intellij_plugin_deploy_info
+  elif ctx.rule.kind == "pkgfilegroup":
+    aspect_intellij_plugin_deploy_info = struct(
+        deploy_files = [_pkgfilegroup_deploy_file(ctx, f) for f in target.files],
+    )
+  else:
+    aspect_intellij_plugin_deploy_info = struct(
+        deploy_files = [_flat_deploy_file(f) for f in target.files],
+    )
+
+  return struct(
+      files = target.files,
+      aspect_intellij_plugin_deploy_info = aspect_intellij_plugin_deploy_info,
+  )
+
+_intellij_plugin_debug_target_aspect = aspect(
+    implementation = _intellij_plugin_debug_target_aspect_impl,
+)
+
+def _intellij_plugin_debug_target_impl(ctx):
+  files = set()
+  deploy_files = []
+  for target in ctx.attr.deps:
+    files = files | target.files
+    deploy_files.extend(target.aspect_intellij_plugin_deploy_info.deploy_files)
+  return struct(
+      files = files,
+      intellij_plugin_deploy_info = struct(
+          deploy_files = deploy_files,
+      )
+  )
+
+intellij_plugin_debug_target = rule(
+    implementation = _intellij_plugin_debug_target_impl,
+    attrs = {
+        "deps": attr.label_list(aspects = [_intellij_plugin_debug_target_aspect]),
+    },
+)
diff --git a/clwb/src/com/google/idea/blaze/clwb/run/BlazeCidrLauncher.java b/clwb/src/com/google/idea/blaze/clwb/run/BlazeCidrLauncher.java
index 9195ab0..410cf57 100644
--- a/clwb/src/com/google/idea/blaze/clwb/run/BlazeCidrLauncher.java
+++ b/clwb/src/com/google/idea/blaze/clwb/run/BlazeCidrLauncher.java
@@ -21,8 +21,6 @@
 import com.google.idea.blaze.base.command.BlazeCommandName;
 import com.google.idea.blaze.base.command.BlazeFlags;
 import com.google.idea.blaze.base.issueparser.IssueOutputLineProcessor;
-import com.google.idea.blaze.base.metrics.Action;
-import com.google.idea.blaze.base.metrics.LoggingService;
 import com.google.idea.blaze.base.model.primitives.TargetExpression;
 import com.google.idea.blaze.base.model.primitives.WorkspaceRoot;
 import com.google.idea.blaze.base.projectview.ProjectViewManager;
@@ -33,7 +31,6 @@
 import com.google.idea.blaze.base.run.state.BlazeCommandRunConfigurationCommonState;
 import com.google.idea.blaze.base.scope.BlazeContext;
 import com.google.idea.blaze.base.scope.scopes.IssuesScope;
-import com.google.idea.blaze.base.scope.scopes.LoggedTimingScope;
 import com.google.idea.blaze.base.settings.Blaze;
 import com.google.idea.blaze.base.settings.BlazeImportSettings;
 import com.google.idea.blaze.base.settings.BlazeImportSettingsManager;
@@ -111,8 +108,6 @@
           @Override
           public void onBlazeContextStart(BlazeContext context) {
             context
-                .push(new LoggedTimingScope(project, Action.BLAZE_COMMAND_USAGE))
-                .push(new LoggedTimingScope(project, Action.BLAZE_CLION_TEST_RUN))
                 .push(new IssuesScope(project));
           }
 
@@ -153,7 +148,6 @@
     CidrDebugProcess result =
         new CidrLocalDebugProcess(parameters, session, state.getConsoleBuilder());
 
-    LoggingService.reportEvent(project, Action.BLAZE_CLION_TEST_DEBUG);
     return result;
   }
 
diff --git a/clwb/src/com/google/idea/blaze/clwb/run/BlazeCidrRunConfigurationRunner.java b/clwb/src/com/google/idea/blaze/clwb/run/BlazeCidrRunConfigurationRunner.java
index 8c36f71..208cf11 100644
--- a/clwb/src/com/google/idea/blaze/clwb/run/BlazeCidrRunConfigurationRunner.java
+++ b/clwb/src/com/google/idea/blaze/clwb/run/BlazeCidrRunConfigurationRunner.java
@@ -26,7 +26,6 @@
 import com.google.idea.blaze.base.command.BlazeFlags;
 import com.google.idea.blaze.base.command.ExperimentalShowArtifactsLineProcessor;
 import com.google.idea.blaze.base.issueparser.IssueOutputLineProcessor;
-import com.google.idea.blaze.base.metrics.Action;
 import com.google.idea.blaze.base.model.primitives.WorkspaceRoot;
 import com.google.idea.blaze.base.projectview.ProjectViewManager;
 import com.google.idea.blaze.base.projectview.ProjectViewSet;
@@ -38,7 +37,6 @@
 import com.google.idea.blaze.base.scope.output.StatusOutput;
 import com.google.idea.blaze.base.scope.scopes.BlazeConsoleScope;
 import com.google.idea.blaze.base.scope.scopes.IssuesScope;
-import com.google.idea.blaze.base.scope.scopes.LoggedTimingScope;
 import com.google.idea.blaze.base.settings.Blaze;
 import com.google.idea.blaze.base.util.SaveUtil;
 import com.google.idea.common.experiments.BoolExperiment;
@@ -119,7 +117,6 @@
               @Override
               protected void execute(BlazeContext context) {
                 context
-                    .push(new LoggedTimingScope(project, Action.BLAZE_COMMAND_USAGE))
                     .push(new IssuesScope(project))
                     .push(new BlazeConsoleScope.Builder(project).build());
 
diff --git a/clwb/src/com/google/idea/blaze/clwb/run/producers/BlazeCidrTestConfigurationProducer.java b/clwb/src/com/google/idea/blaze/clwb/run/producers/BlazeCidrTestConfigurationProducer.java
index 2954d92..3589c9a 100644
--- a/clwb/src/com/google/idea/blaze/clwb/run/producers/BlazeCidrTestConfigurationProducer.java
+++ b/clwb/src/com/google/idea/blaze/clwb/run/producers/BlazeCidrTestConfigurationProducer.java
@@ -27,6 +27,7 @@
 import com.google.idea.blaze.base.settings.Blaze;
 import com.intellij.execution.Location;
 import com.intellij.execution.actions.ConfigurationContext;
+import com.intellij.openapi.actionSystem.LangDataKeys;
 import com.intellij.openapi.util.Couple;
 import com.intellij.openapi.util.Ref;
 import com.intellij.psi.PsiElement;
@@ -100,13 +101,28 @@
     super(BlazeCommandRunConfigurationType.getInstance());
   }
 
+  /** The single selected {@link PsiElement}. Returns null if multiple elements are selected. */
+  @Nullable
+  private static PsiElement selectedPsiElement(ConfigurationContext context) {
+    PsiElement[] psi = LangDataKeys.PSI_ELEMENT_ARRAY.getData(context.getDataContext());
+    if (psi != null && psi.length > 1) {
+      return null; // multiple elements selected.
+    }
+    Location<?> location = context.getLocation();
+    return location != null ? location.getPsiElement() : null;
+  }
+
   @Override
   protected boolean doSetupConfigFromContext(
       BlazeCommandRunConfiguration configuration,
       ConfigurationContext context,
       Ref<PsiElement> sourceElement) {
 
-    TestTarget testObject = findTestObject(context.getLocation());
+    PsiElement element = selectedPsiElement(context);
+    if (element == null) {
+      return false;
+    }
+    TestTarget testObject = findTestObject(element);
     if (testObject == null) {
       return false;
     }
@@ -144,7 +160,11 @@
     if (!Objects.equals(handlerState.getCommand(), BlazeCommandName.TEST)) {
       return false;
     }
-    TestTarget testObject = findTestObject(context.getLocation());
+    PsiElement element = selectedPsiElement(context);
+    if (element == null) {
+      return false;
+    }
+    TestTarget testObject = findTestObject(element);
     if (testObject == null) {
       return false;
     }
@@ -154,11 +174,9 @@
   }
 
   @Nullable
-  private static TestTarget findTestObject(Location<?> location) {
+  private static TestTarget findTestObject(PsiElement element) {
     // Copied from on CidrGoogleTestRunConfigurationProducer::findTestObject.
-
     // Precedence order (decreasing): class/function, macro, file
-    PsiElement element = location.getPsiElement();
     PsiElement parent =
         PsiTreeUtil.getNonStrictParentOfType(element, OCFunctionDefinition.class, OCStruct.class);
 
diff --git a/common/actionhelper/src/com/google/idea/common/actionhelper/ActionPresentationHelper.java b/common/actionhelper/src/com/google/idea/common/actionhelper/ActionPresentationHelper.java
index 96a7882..69c5e1e 100644
--- a/common/actionhelper/src/com/google/idea/common/actionhelper/ActionPresentationHelper.java
+++ b/common/actionhelper/src/com/google/idea/common/actionhelper/ActionPresentationHelper.java
@@ -169,7 +169,7 @@
 
     String text = enabled && hasSubject ? subjectText : this.text;
     if (text != null) {
-      presentation.setText(text);
+      presentation.setText(text, false);
     }
   }
 }
diff --git a/common/binaryhelper/src/com/google/idea/common/binaryhelper/HelperBinaryUtil.java b/common/binaryhelper/src/com/google/idea/common/binaryhelper/HelperBinaryUtil.java
index fb1be91..c5c92e7 100644
--- a/common/binaryhelper/src/com/google/idea/common/binaryhelper/HelperBinaryUtil.java
+++ b/common/binaryhelper/src/com/google/idea/common/binaryhelper/HelperBinaryUtil.java
@@ -30,7 +30,7 @@
 /** Binaries provided to IntelliJ at runtime */
 public final class HelperBinaryUtil {
 
-  private static final Logger LOG = Logger.getInstance(HelperBinaryUtil.class);
+  private static final Logger logger = Logger.getInstance(HelperBinaryUtil.class);
 
   private static File tempDirectory;
   private static final Map<String, File> cachedFiles = new HashMap<>();
@@ -53,7 +53,7 @@
 
     URL url = HelperBinaryUtil.class.getResource(binaryFilePath);
     if (url == null) {
-      LOG.error(String.format("Helper binary '%s' was not found", binaryFilePath));
+      logger.error(String.format("Helper binary '%s' was not found", binaryFilePath));
       return null;
     }
     try (InputStream inputStream = URLUtil.openResourceStream(url)) {
@@ -63,7 +63,7 @@
       cachedFiles.put(binaryName, file);
       return file;
     } catch (IOException e) {
-      LOG.error(String.format("Error loading helper binary '%s'", binaryFilePath));
+      logger.error(String.format("Error loading helper binary '%s'", binaryFilePath));
       return null;
     }
   }
diff --git a/cpp/BUILD b/cpp/BUILD
index d5361bf..c157efc 100644
--- a/cpp/BUILD
+++ b/cpp/BUILD
@@ -18,6 +18,7 @@
         "//base",
         "//common/experiments",
         "//intellij_platform_sdk:plugin_api",
+        "//sdkcompat",
         "@jsr305_annotations//jar",
     ],
 )
diff --git a/cpp/src/META-INF/blaze-cpp.xml b/cpp/src/META-INF/blaze-cpp.xml
index 3c71cad..aa0fb37 100644
--- a/cpp/src/META-INF/blaze-cpp.xml
+++ b/cpp/src/META-INF/blaze-cpp.xml
@@ -20,6 +20,7 @@
   <extensions defaultExtensionNs="com.google.idea.blaze">
     <SyncPlugin implementation="com.google.idea.blaze.cpp.BlazeCSyncPlugin"/>
     <PrefetchFileSource implementation="com.google.idea.blaze.cpp.CPrefetchFileSource"/>
+    <SyncListener implementation="com.google.idea.blaze.cpp.BlazeCppSymbolRebuildSyncListener"/>
   </extensions>
 
   <extensions defaultExtensionNs="cidr.lang">
diff --git a/cpp/src/com/google/idea/blaze/cpp/BlazeCSyncPlugin.java b/cpp/src/com/google/idea/blaze/cpp/BlazeCSyncPlugin.java
index 1c64fbc..4168385 100644
--- a/cpp/src/com/google/idea/blaze/cpp/BlazeCSyncPlugin.java
+++ b/cpp/src/com/google/idea/blaze/cpp/BlazeCSyncPlugin.java
@@ -16,6 +16,7 @@
 package com.google.idea.blaze.cpp;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.idea.blaze.base.io.VirtualFileSystemProvider;
 import com.google.idea.blaze.base.model.BlazeProjectData;
 import com.google.idea.blaze.base.model.primitives.LanguageClass;
 import com.google.idea.blaze.base.model.primitives.WorkspaceRoot;
@@ -25,13 +26,20 @@
 import com.google.idea.blaze.base.scope.Scope;
 import com.google.idea.blaze.base.scope.scopes.TimingScope;
 import com.google.idea.blaze.base.sync.BlazeSyncPlugin;
+import com.google.idea.common.experiments.BoolExperiment;
 import com.intellij.openapi.module.Module;
 import com.intellij.openapi.project.Project;
+import com.intellij.openapi.vfs.VfsUtil;
+import com.intellij.openapi.vfs.VirtualFile;
 import com.jetbrains.cidr.lang.workspace.OCWorkspace;
 import com.jetbrains.cidr.lang.workspace.OCWorkspaceManager;
 import java.util.Set;
 
 final class BlazeCSyncPlugin extends BlazeSyncPlugin.Adapter {
+
+  private static final BoolExperiment refreshExecRoot =
+      new BoolExperiment("refresh.exec.root.cpp", true);
+
   @Override
   public Set<LanguageClass> getSupportedLanguagesInWorkspace(WorkspaceType workspaceType) {
     if (workspaceType == WorkspaceType.C) {
@@ -64,4 +72,26 @@
           }
         });
   }
+
+  @Override
+  public void refreshVirtualFileSystem(BlazeProjectData blazeProjectData) {
+    if (!refreshExecRoot.getValue()) {
+      return;
+    }
+    refreshExecRoot(blazeProjectData);
+  }
+
+  private static void refreshExecRoot(BlazeProjectData blazeProjectData) {
+    // recursive refresh of the blaze execution root. This is required because:
+    // <li>Our blaze aspect can't tell us exactly which genfiles are required to resolve the project
+    // <li>Cidr caches the directory contents as part of symbol building, so we need to do this work
+    // up front.
+    VirtualFile execRoot =
+        VirtualFileSystemProvider.getInstance()
+            .getSystem()
+            .refreshAndFindFileByIoFile(blazeProjectData.blazeRoots.executionRoot);
+    if (execRoot != null) {
+      VfsUtil.markDirtyAndRefresh(false, true, true, execRoot);
+    }
+  }
 }
diff --git a/cpp/src/com/google/idea/blaze/cpp/BlazeCWorkspace.java b/cpp/src/com/google/idea/blaze/cpp/BlazeCWorkspace.java
index 1b4937b..abcb294 100644
--- a/cpp/src/com/google/idea/blaze/cpp/BlazeCWorkspace.java
+++ b/cpp/src/com/google/idea/blaze/cpp/BlazeCWorkspace.java
@@ -20,15 +20,10 @@
 import com.google.idea.blaze.base.model.BlazeProjectData;
 import com.google.idea.blaze.base.scope.BlazeContext;
 import com.google.idea.blaze.base.settings.Blaze;
-import com.google.idea.common.experiments.BoolExperiment;
-import com.intellij.openapi.application.ApplicationManager;
 import com.intellij.openapi.components.ServiceManager;
 import com.intellij.openapi.diagnostic.Logger;
 import com.intellij.openapi.project.Project;
-import com.intellij.openapi.vfs.LocalFileSystem;
-import com.intellij.openapi.vfs.VfsUtil;
 import com.intellij.openapi.vfs.VirtualFile;
-import com.intellij.openapi.vfs.ex.temp.TempFileSystem;
 import com.jetbrains.cidr.lang.symbols.OCSymbol;
 import com.jetbrains.cidr.lang.workspace.OCResolveConfiguration;
 import com.jetbrains.cidr.lang.workspace.OCWorkspace;
@@ -39,10 +34,7 @@
 
 /** Main entry point for C/CPP configuration data. */
 public final class BlazeCWorkspace implements OCWorkspace {
-  private static final Logger LOG = Logger.getInstance(BlazeCWorkspace.class);
-
-  private static final BoolExperiment refreshExecRoot =
-      new BoolExperiment("refresh.exec.root.cpp", true);
+  private static final Logger logger = Logger.getInstance(BlazeCWorkspace.class);
 
   @Nullable private final Project project;
   @Nullable private final OCWorkspaceModificationTrackers modTrackers;
@@ -65,47 +57,17 @@
   }
 
   public void update(BlazeContext context, BlazeProjectData blazeProjectData) {
-    LOG.assertTrue(project != null);
-    LOG.assertTrue(modTrackers != null);
-    LOG.assertTrue(configurationResolver != null);
+    logger.assertTrue(project != null);
+    logger.assertTrue(modTrackers != null);
+    logger.assertTrue(configurationResolver != null);
 
     long start = System.currentTimeMillis();
 
-    if (refreshExecRoot.getValue()) {
-      refreshExecRoot(blazeProjectData);
-    }
-
     // Non-incremental update to our c configurations.
     configurationResolver.update(context, blazeProjectData);
     long end = System.currentTimeMillis();
 
-    LOG.info(String.format("Blaze OCWorkspace update took: %d ms", (end - start)));
-
-    ApplicationManager.getApplication()
-        .runWriteAction(
-            () -> {
-              if (project.isDisposed()) {
-                return;
-              }
-              // TODO(salguarnieri) Avoid bumping all of these trackers; figure out what has changed
-              modTrackers.getProjectFilesListTracker().incModificationCount();
-              modTrackers.getSourceFilesListTracker().incModificationCount();
-              modTrackers.getBuildConfigurationChangesTracker().incModificationCount();
-              modTrackers.getBuildSettingsChangesTracker().incModificationCount();
-            });
-  }
-
-  private static void refreshExecRoot(BlazeProjectData blazeProjectData) {
-    // recursive refresh of the blaze execution root. This is required because:
-    // <li>Our blaze aspect can't tell us exactly which genfiles are required to resolve the project
-    // <li>Cidr caches the directory contents as part of symbol building, so we need to do this work
-    // up front.
-    VirtualFile execRoot =
-        getFileSystem().findFileByIoFile(blazeProjectData.blazeRoots.executionRoot);
-    if (execRoot != null) {
-      ApplicationManager.getApplication()
-          .runWriteAction(() -> VfsUtil.markDirtyAndRefresh(false, true, true, execRoot));
-    }
+    logger.info(String.format("Blaze OCWorkspace update took: %d ms", (end - start)));
   }
 
   @Override
@@ -143,7 +105,7 @@
 
   @Override
   public OCWorkspaceModificationTrackers getModificationTrackers() {
-    LOG.assertTrue(modTrackers != null);
+    logger.assertTrue(modTrackers != null);
     return modTrackers;
   }
 
@@ -163,11 +125,4 @@
     OCResolveConfiguration config = configurationResolver.getConfigurationForFile(sourceFile);
     return config == null ? ImmutableList.of() : ImmutableList.of(config);
   }
-
-  private static LocalFileSystem getFileSystem() {
-    if (ApplicationManager.getApplication().isUnitTestMode()) {
-      return TempFileSystem.getInstance();
-    }
-    return LocalFileSystem.getInstance();
-  }
 }
diff --git a/cpp/src/com/google/idea/blaze/cpp/BlazeConfigurationResolver.java b/cpp/src/com/google/idea/blaze/cpp/BlazeConfigurationResolver.java
index 7ba79d8..5e6e8b9 100644
--- a/cpp/src/com/google/idea/blaze/cpp/BlazeConfigurationResolver.java
+++ b/cpp/src/com/google/idea/blaze/cpp/BlazeConfigurationResolver.java
@@ -39,6 +39,7 @@
 import com.google.idea.blaze.base.scope.ScopedFunction;
 import com.google.idea.blaze.base.scope.output.IssueOutput;
 import com.google.idea.blaze.base.scope.scopes.TimingScope;
+import com.google.idea.blaze.base.sync.workspace.ExecutionRootPathResolver;
 import com.google.idea.blaze.base.sync.workspace.WorkspacePathResolver;
 import com.google.idea.blaze.base.targetmaps.SourceToTargetMap;
 import com.intellij.openapi.diagnostic.Logger;
@@ -108,6 +109,8 @@
 
   private static ImmutableMap<File, VirtualFile> doCollectHeaderRoots(
       BlazeContext context, BlazeProjectData projectData, Set<ExecutionRootPath> rootPaths) {
+    ExecutionRootPathResolver pathResolver =
+        new ExecutionRootPathResolver(projectData.blazeRoots, projectData.workspacePathResolver);
     ConcurrentMap<File, VirtualFile> rootsMap = Maps.newConcurrentMap();
     List<ListenableFuture<Void>> futures = Lists.newArrayListWithCapacity(rootPaths.size());
     for (ExecutionRootPath path : rootPaths) {
@@ -115,7 +118,7 @@
           submit(
               () -> {
                 ImmutableList<File> possibleDirectories =
-                    projectData.workspacePathResolver.resolveToIncludeDirectories(path);
+                    pathResolver.resolveToIncludeDirectories(path);
                 for (File file : possibleDirectories) {
                   VirtualFile vf = getVirtualFile(file);
                   if (vf != null) {
@@ -249,6 +252,8 @@
         BlazeResolveConfiguration config =
             BlazeResolveConfiguration.createConfigurationForTarget(
                 project,
+                new ExecutionRootPathResolver(
+                    blazeProjectData.blazeRoots, blazeProjectData.workspacePathResolver),
                 blazeProjectData.workspacePathResolver,
                 headerRoots,
                 blazeProjectData.targetMap.get(targetKey),
diff --git a/cpp/src/com/google/idea/blaze/cpp/BlazeCppSymbolRebuildSyncListener.java b/cpp/src/com/google/idea/blaze/cpp/BlazeCppSymbolRebuildSyncListener.java
new file mode 100644
index 0000000..07b1743
--- /dev/null
+++ b/cpp/src/com/google/idea/blaze/cpp/BlazeCppSymbolRebuildSyncListener.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2016 The Bazel Authors. 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.idea.blaze.cpp;
+
+import com.google.idea.blaze.base.model.BlazeProjectData;
+import com.google.idea.blaze.base.projectview.ProjectViewSet;
+import com.google.idea.blaze.base.scope.BlazeContext;
+import com.google.idea.blaze.base.settings.BlazeImportSettings;
+import com.google.idea.blaze.base.sync.BlazeSyncParams.SyncMode;
+import com.google.idea.blaze.base.sync.SyncListener;
+import com.google.idea.sdkcompat.transactions.Transactions;
+import com.intellij.openapi.application.ApplicationManager;
+import com.intellij.openapi.project.Project;
+import com.jetbrains.cidr.lang.workspace.OCWorkspace;
+import com.jetbrains.cidr.lang.workspace.OCWorkspaceManager;
+import com.jetbrains.cidr.lang.workspace.OCWorkspaceModificationTrackers;
+
+/** Runs after sync, triggering a rebuild of the symbol tables. */
+public class BlazeCppSymbolRebuildSyncListener extends SyncListener.Adapter {
+
+  @Override
+  public void onSyncComplete(
+      Project project,
+      BlazeContext context,
+      BlazeImportSettings importSettings,
+      ProjectViewSet projectViewSet,
+      BlazeProjectData blazeProjectData,
+      SyncMode syncMode,
+      SyncResult syncResult) {
+
+    OCWorkspace workspace = OCWorkspaceManager.getWorkspace(project);
+    if (!(workspace instanceof BlazeCWorkspace)) {
+      return;
+    }
+    rebuildSymbolTables((BlazeCWorkspace) workspace);
+  }
+
+  private static void rebuildSymbolTables(BlazeCWorkspace workspace) {
+    OCWorkspaceModificationTrackers modTrackers = workspace.getModificationTrackers();
+    Transactions.submitTransactionAndWait(
+        () ->
+            ApplicationManager.getApplication()
+                .runWriteAction(
+                    () -> {
+                      modTrackers.getProjectFilesListTracker().incModificationCount();
+                      modTrackers.getSourceFilesListTracker().incModificationCount();
+                      modTrackers.getBuildConfigurationChangesTracker().incModificationCount();
+                      modTrackers.getBuildSettingsChangesTracker().incModificationCount();
+                    }));
+  }
+}
diff --git a/cpp/src/com/google/idea/blaze/cpp/BlazeCustomHeaderProvider.java b/cpp/src/com/google/idea/blaze/cpp/BlazeCustomHeaderProvider.java
index 9130e7e..9492320 100644
--- a/cpp/src/com/google/idea/blaze/cpp/BlazeCustomHeaderProvider.java
+++ b/cpp/src/com/google/idea/blaze/cpp/BlazeCustomHeaderProvider.java
@@ -15,11 +15,9 @@
  */
 package com.google.idea.blaze.cpp;
 
-import com.intellij.openapi.application.ApplicationManager;
+import com.google.idea.blaze.base.io.VirtualFileSystemProvider;
 import com.intellij.openapi.project.Project;
-import com.intellij.openapi.vfs.LocalFileSystem;
 import com.intellij.openapi.vfs.VirtualFile;
-import com.intellij.openapi.vfs.ex.temp.TempFileSystem;
 import com.jetbrains.cidr.lang.CustomHeaderProvider;
 import com.jetbrains.cidr.lang.workspace.OCResolveConfiguration;
 import com.jetbrains.cidr.lang.workspace.OCResolveRootAndConfiguration;
@@ -58,7 +56,7 @@
         ((BlazeResolveConfiguration) configuration)
             .getWorkspacePathResolver()
             .resolveToFile(includeString);
-    return getFileSystem().findFileByIoFile(file);
+    return VirtualFileSystemProvider.getInstance().getSystem().findFileByIoFile(file);
   }
 
   @Nullable
@@ -73,11 +71,4 @@
       String serializationPath, Project project, VirtualFile currentFile) {
     return null;
   }
-
-  private static LocalFileSystem getFileSystem() {
-    if (ApplicationManager.getApplication().isUnitTestMode()) {
-      return TempFileSystem.getInstance();
-    }
-    return LocalFileSystem.getInstance();
-  }
 }
diff --git a/cpp/src/com/google/idea/blaze/cpp/BlazeResolveConfigurationTemporaryBase.java b/cpp/src/com/google/idea/blaze/cpp/BlazeResolveConfigurationTemporaryBase.java
index f32f97d..2b04ff2 100644
--- a/cpp/src/com/google/idea/blaze/cpp/BlazeResolveConfigurationTemporaryBase.java
+++ b/cpp/src/com/google/idea/blaze/cpp/BlazeResolveConfigurationTemporaryBase.java
@@ -31,6 +31,7 @@
 import com.google.idea.blaze.base.scope.BlazeContext;
 import com.google.idea.blaze.base.scope.Scope;
 import com.google.idea.blaze.base.scope.scopes.TimingScope;
+import com.google.idea.blaze.base.sync.workspace.ExecutionRootPathResolver;
 import com.google.idea.blaze.base.sync.workspace.WorkspacePathResolver;
 import com.intellij.openapi.diagnostic.Logger;
 import com.intellij.openapi.project.Project;
@@ -67,6 +68,7 @@
 
   public static final Logger LOG = Logger.getInstance(BlazeResolveConfiguration.class);
 
+  private final ExecutionRootPathResolver executionRootPathResolver;
   private final WorkspacePathResolver workspacePathResolver;
 
   /* project, label are protected instead of private just so v145 can access */
@@ -84,6 +86,7 @@
   @Nullable
   public static BlazeResolveConfiguration createConfigurationForTarget(
       Project project,
+      ExecutionRootPathResolver executionRootPathResolver,
       WorkspacePathResolver workspacePathResolver,
       ImmutableMap<File, VirtualFile> headerRoots,
       TargetIdeInfo target,
@@ -119,6 +122,7 @@
 
     return new BlazeResolveConfiguration(
         project,
+        executionRootPathResolver,
         workspacePathResolver,
         headerRoots,
         target.key,
@@ -175,6 +179,7 @@
 
   public BlazeResolveConfigurationTemporaryBase(
       Project project,
+      ExecutionRootPathResolver executionRootPathResolver,
       WorkspacePathResolver workspacePathResolver,
       ImmutableMap<File, VirtualFile> headerRoots,
       TargetKey targetKey,
@@ -189,6 +194,7 @@
       File cppCompilerExecutable,
       ImmutableList<String> cCompilerFlags,
       ImmutableList<String> cppCompilerFlags) {
+    this.executionRootPathResolver = executionRootPathResolver;
     this.workspacePathResolver = workspacePathResolver;
     this.project = project;
     this.targetKey = targetKey;
@@ -320,7 +326,7 @@
       boolean isUserHeader) {
     for (ExecutionRootPath executionRootPath : paths) {
       ImmutableList<File> possibleDirectories =
-          workspacePathResolver.resolveToIncludeDirectories(executionRootPath);
+          executionRootPathResolver.resolveToIncludeDirectories(executionRootPath);
       for (File f : possibleDirectories) {
         VirtualFile vf = virtualFileCache.get(f);
         if (vf != null) {
diff --git a/cpp/src/com/google/idea/blaze/cpp/versioned/v145/BlazeResolveConfiguration.java b/cpp/src/com/google/idea/blaze/cpp/versioned/v145/BlazeResolveConfiguration.java
index 0d99ab5..b2d63a0 100644
--- a/cpp/src/com/google/idea/blaze/cpp/versioned/v145/BlazeResolveConfiguration.java
+++ b/cpp/src/com/google/idea/blaze/cpp/versioned/v145/BlazeResolveConfiguration.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.idea.blaze.base.ideinfo.TargetKey;
 import com.google.idea.blaze.base.model.primitives.ExecutionRootPath;
+import com.google.idea.blaze.base.sync.workspace.ExecutionRootPathResolver;
 import com.google.idea.blaze.base.sync.workspace.WorkspacePathResolver;
 import com.intellij.openapi.project.Project;
 import com.intellij.openapi.vfs.VirtualFile;
@@ -36,6 +37,7 @@
 
   public BlazeResolveConfiguration(
       Project project,
+      ExecutionRootPathResolver executionRootPathResolver,
       WorkspacePathResolver workspacePathResolver,
       ImmutableMap<File, VirtualFile> headerRoots,
       TargetKey targetKey,
@@ -52,6 +54,7 @@
       ImmutableList<String> cppCompilerFlags) {
     super(
         project,
+        executionRootPathResolver,
         workspacePathResolver,
         headerRoots,
         targetKey,
diff --git a/cpp/src/com/google/idea/blaze/cpp/versioned/v162/BlazeResolveConfiguration.java b/cpp/src/com/google/idea/blaze/cpp/versioned/v162/BlazeResolveConfiguration.java
index 71ba414..959936b 100644
--- a/cpp/src/com/google/idea/blaze/cpp/versioned/v162/BlazeResolveConfiguration.java
+++ b/cpp/src/com/google/idea/blaze/cpp/versioned/v162/BlazeResolveConfiguration.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.idea.blaze.base.ideinfo.TargetKey;
 import com.google.idea.blaze.base.model.primitives.ExecutionRootPath;
+import com.google.idea.blaze.base.sync.workspace.ExecutionRootPathResolver;
 import com.google.idea.blaze.base.sync.workspace.WorkspacePathResolver;
 import com.intellij.openapi.project.Project;
 import com.intellij.openapi.vfs.VirtualFile;
@@ -29,6 +30,7 @@
 
   public BlazeResolveConfiguration(
       Project project,
+      ExecutionRootPathResolver executionRootPathResolver,
       WorkspacePathResolver workspacePathResolver,
       ImmutableMap<File, VirtualFile> headerRoots,
       TargetKey targetKey,
@@ -45,6 +47,7 @@
       ImmutableList<String> cppCompilerFlags) {
     super(
         project,
+        executionRootPathResolver,
         workspacePathResolver,
         headerRoots,
         targetKey,
diff --git a/intellij_platform_sdk/build_defs.bzl b/intellij_platform_sdk/build_defs.bzl
index e7c5c2d..6fa3efb 100644
--- a/intellij_platform_sdk/build_defs.bzl
+++ b/intellij_platform_sdk/build_defs.bzl
@@ -4,7 +4,7 @@
 INDIRECT_IJ_PRODUCTS = {
     "intellij-latest": "intellij-2016.3.1",
     "android-studio-latest": "android-studio-145.1617.8",
-    "android-studio-beta": "android-studio-2.3.0.3",
+    "android-studio-beta": "android-studio-2.3.0.4",
     "clion-latest": "clion-162.1967.7",
 }
 
diff --git a/java/src/META-INF/blaze-java.xml b/java/src/META-INF/blaze-java.xml
index 5466c27..e46fa6c 100644
--- a/java/src/META-INF/blaze-java.xml
+++ b/java/src/META-INF/blaze-java.xml
@@ -36,7 +36,7 @@
     </action>
 
     <group id="Blaze.Java.ProjectViewPopupMenu">
-      <add-to-group group-id="Blaze.ProjectViewPopupMenu"/>
+      <add-to-group group-id="Blaze.PerFileContextMenu"/>
       <reference id="Blaze.ExcludeLibraryAction"/>
       <reference id="Blaze.AttachSourceJarAction"/>
       <reference id="Blaze.AddLibraryTargetDirectoryToProjectView"/>
@@ -70,6 +70,8 @@
     <FileCache implementation="com.google.idea.blaze.java.libraries.JarCache$FileCacheAdapter"/>
     <PrefetchFileSource implementation="com.google.idea.blaze.java.sync.JavaPrefetchFileSource"/>
     <SyncListener implementation="com.google.idea.blaze.java.syncstatus.SyncStatusHelper$UpdateSyncStatusMap"/>
+    <BlazeTestEventsHandler implementation="com.google.idea.blaze.java.run.BlazeJavaTestEventsHandler"/>
+    <AttributeSpecificStringLiteralReferenceProvider implementation="com.google.idea.blaze.java.lang.build.references.JavaClassQualifiedNameReference"/>
   </extensions>
 
   <extensions defaultExtensionNs="com.intellij">
@@ -104,6 +106,7 @@
     <attachSourcesProvider implementation="com.google.idea.blaze.java.libraries.BlazeAttachSourceProvider"/>
     <applicationService serviceImplementation="com.google.idea.blaze.java.settings.BlazeJavaUserSettings"/>
     <projectService serviceImplementation="com.google.idea.blaze.java.syncstatus.SyncStatusHelper"/>
+    <psi.referenceContributor language="BUILD" implementation="com.google.idea.blaze.java.lang.build.references.JavaClassReferenceContributor"/>
   </extensions>
 
   <project-components>
diff --git a/java/src/com/google/idea/blaze/java/lang/build/references/JavaClassQualifiedNameReference.java b/java/src/com/google/idea/blaze/java/lang/build/references/JavaClassQualifiedNameReference.java
new file mode 100644
index 0000000..408247c
--- /dev/null
+++ b/java/src/com/google/idea/blaze/java/lang/build/references/JavaClassQualifiedNameReference.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.java.lang.build.references;
+
+import static com.intellij.patterns.PlatformPatterns.psiElement;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.idea.blaze.base.lang.buildfile.language.BuildFileLanguage;
+import com.google.idea.blaze.base.lang.buildfile.psi.Argument.Keyword;
+import com.google.idea.blaze.base.lang.buildfile.psi.StringLiteral;
+import com.google.idea.blaze.base.lang.buildfile.references.AttributeSpecificStringLiteralReferenceProvider;
+import com.intellij.patterns.ElementPattern;
+import com.intellij.patterns.PatternCondition;
+import com.intellij.patterns.PatternConditionPlus;
+import com.intellij.patterns.StandardPatterns;
+import com.intellij.psi.PsiReference;
+import com.intellij.psi.impl.PsiElementBase;
+import com.intellij.psi.impl.source.resolve.reference.ReferenceProvidersRegistry;
+import com.intellij.util.PairProcessor;
+import com.intellij.util.ProcessingContext;
+
+/** Handles attribute values which should be fully-qualified java class names. */
+public class JavaClassQualifiedNameReference
+    implements AttributeSpecificStringLiteralReferenceProvider {
+
+  private static final ImmutableSet<String> JAVA_CLASS_STRING_TYPES =
+      ImmutableSet.of("main_class", "test_class");
+
+  public static final ElementPattern<StringLiteral> PATTERN =
+      psiElement(StringLiteral.class)
+          .withLanguage(BuildFileLanguage.INSTANCE)
+          .inside(
+              psiElement(Keyword.class)
+                  .with(nameCondition(StandardPatterns.string().oneOf(JAVA_CLASS_STRING_TYPES))));
+
+  private static PatternCondition<PsiElementBase> nameCondition(final ElementPattern pattern) {
+    return new PatternConditionPlus<PsiElementBase, String>("_withPsiName", pattern) {
+      @Override
+      public boolean processValues(
+          PsiElementBase t,
+          ProcessingContext context,
+          PairProcessor<String, ProcessingContext> processor) {
+        return processor.process(t.getName(), context);
+      }
+    };
+  }
+
+  @Override
+  public PsiReference[] getReferences(String attributeName, StringLiteral literal) {
+    if (!JAVA_CLASS_STRING_TYPES.contains(attributeName)) {
+      return PsiReference.EMPTY_ARRAY;
+    }
+    return ReferenceProvidersRegistry.getReferencesFromProviders(literal);
+  }
+}
diff --git a/java/src/com/google/idea/blaze/java/lang/build/references/JavaClassReferenceContributor.java b/java/src/com/google/idea/blaze/java/lang/build/references/JavaClassReferenceContributor.java
new file mode 100644
index 0000000..7fefd48
--- /dev/null
+++ b/java/src/com/google/idea/blaze/java/lang/build/references/JavaClassReferenceContributor.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.java.lang.build.references;
+
+import com.intellij.psi.PsiReferenceContributor;
+import com.intellij.psi.PsiReferenceRegistrar;
+import com.intellij.psi.impl.source.resolve.reference.impl.providers.JavaClassReferenceProvider;
+import com.intellij.psi.util.ClassKind;
+
+/** Provides BUILD references to java classes from fully-qualified class names. */
+public class JavaClassReferenceContributor extends PsiReferenceContributor {
+
+  @Override
+  public void registerReferenceProviders(PsiReferenceRegistrar registrar) {
+    JavaClassReferenceProvider provider = new JavaClassReferenceProvider();
+    provider.setOption(JavaClassReferenceProvider.CLASS_KIND, ClassKind.CLASS);
+    registrar.registerReferenceProvider(
+        JavaClassQualifiedNameReference.PATTERN, new JavaClassReferenceProvider());
+  }
+}
diff --git a/java/src/com/google/idea/blaze/java/libraries/AddLibraryTargetDirectoryToProjectViewAction.java b/java/src/com/google/idea/blaze/java/libraries/AddLibraryTargetDirectoryToProjectViewAction.java
index a954942..ec05bb7 100644
--- a/java/src/com/google/idea/blaze/java/libraries/AddLibraryTargetDirectoryToProjectViewAction.java
+++ b/java/src/com/google/idea/blaze/java/libraries/AddLibraryTargetDirectoryToProjectViewAction.java
@@ -15,6 +15,8 @@
  */
 package com.google.idea.blaze.java.libraries;
 
+import static java.util.stream.Collectors.toList;
+
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Sets;
@@ -34,13 +36,13 @@
 import com.google.idea.blaze.base.sync.BlazeSyncManager;
 import com.google.idea.blaze.base.sync.BlazeSyncParams;
 import com.google.idea.blaze.base.sync.data.BlazeProjectDataManager;
+import com.google.idea.blaze.base.util.WorkspacePathUtil;
 import com.google.idea.blaze.java.sync.model.BlazeJarLibrary;
 import com.intellij.openapi.actionSystem.AnActionEvent;
 import com.intellij.openapi.actionSystem.Presentation;
 import com.intellij.openapi.project.Project;
 import com.intellij.openapi.roots.libraries.Library;
 import com.intellij.openapi.ui.Messages;
-import com.intellij.openapi.util.io.FileUtil;
 import java.io.File;
 import java.util.List;
 import java.util.Set;
@@ -107,13 +109,14 @@
       return null;
     }
     boolean exists =
-        projectViewSet
-            .listItems(DirectorySection.KEY)
-            .stream()
-            .anyMatch(
-                entry ->
-                    FileUtil.isAncestor(
-                        entry.directory.relativePath(), workspacePath.relativePath(), false));
+        WorkspacePathUtil.isUnderAnyWorkspacePath(
+            projectViewSet
+                .listItems(DirectorySection.KEY)
+                .stream()
+                .filter(entry -> entry.included)
+                .map(entry -> entry.directory)
+                .collect(toList()),
+            workspacePath);
     if (exists) {
       return null;
     }
diff --git a/java/src/com/google/idea/blaze/java/libraries/JarCache.java b/java/src/com/google/idea/blaze/java/libraries/JarCache.java
index 544e7ad..8617b8c 100644
--- a/java/src/com/google/idea/blaze/java/libraries/JarCache.java
+++ b/java/src/com/google/idea/blaze/java/libraries/JarCache.java
@@ -59,7 +59,7 @@
 
 /** Local cache of the jars referenced by the project. */
 public class JarCache {
-  private static final Logger LOG = Logger.getInstance(JarCache.class);
+  private static final Logger logger = Logger.getInstance(JarCache.class);
 
   private final Project project;
   private final BlazeImportSettings importSettings;
@@ -144,7 +144,7 @@
     // Ensure the cache dir exists
     if (!cacheDir.exists()) {
       if (!cacheDir.mkdirs()) {
-        LOG.error("Could not create jar cache directory");
+        logger.error("Could not create jar cache directory");
         return;
       }
     }
@@ -199,7 +199,7 @@
                       StandardCopyOption.REPLACE_EXISTING,
                       StandardCopyOption.COPY_ATTRIBUTES);
                 } catch (IOException e) {
-                  LOG.warn(e);
+                  logger.warn(e);
                 }
               }));
     }
@@ -213,7 +213,7 @@
                   try {
                     Files.deleteIfExists(Paths.get(cacheFile.getPath()));
                   } catch (IOException e) {
-                    LOG.warn(e);
+                    logger.warn(e);
                   }
                 }));
       }
@@ -223,9 +223,9 @@
       Futures.allAsList(futures).get();
     } catch (InterruptedException e) {
       Thread.currentThread().interrupt();
-      LOG.warn(e);
+      logger.warn(e);
     } catch (ExecutionException e) {
-      LOG.error(e);
+      logger.error(e);
     }
     if (context != null && updatedFiles.size() > 0) {
       context.output(PrintOutput.log(String.format("Copied %d jars", updatedFiles.size())));
@@ -247,7 +247,7 @@
                     "Total Jar Cache size: %d kB (%d files)",
                     total / 1024, finalCacheFiles.length)));
       } catch (Exception e) {
-        LOG.warn("Could not determine cache size", e);
+        logger.warn("Could not determine cache size", e);
       }
     }
   }
diff --git a/java/src/com/google/idea/blaze/java/projectview/ExcludedLibrarySection.java b/java/src/com/google/idea/blaze/java/projectview/ExcludedLibrarySection.java
index 908d48d..d2b0764 100644
--- a/java/src/com/google/idea/blaze/java/projectview/ExcludedLibrarySection.java
+++ b/java/src/com/google/idea/blaze/java/projectview/ExcludedLibrarySection.java
@@ -20,6 +20,7 @@
 import com.google.idea.blaze.base.projectview.section.ListSection;
 import com.google.idea.blaze.base.projectview.section.SectionKey;
 import com.google.idea.blaze.base.projectview.section.SectionParser;
+import javax.annotation.Nullable;
 
 /** Section for excluding libraries. */
 @Deprecated
@@ -31,5 +32,11 @@
         public boolean isDeprecated() {
           return true;
         }
+
+        @Nullable
+        @Override
+        public String getDeprecationMessage() {
+          return "excluded_libraries has been deprecated. Please use 'exclude_library' instead.";
+        }
       };
 }
diff --git a/java/src/com/google/idea/blaze/java/run/BlazeJavaRunProfileState.java b/java/src/com/google/idea/blaze/java/run/BlazeJavaRunProfileState.java
index 47b971b..6175856 100644
--- a/java/src/com/google/idea/blaze/java/run/BlazeJavaRunProfileState.java
+++ b/java/src/com/google/idea/blaze/java/run/BlazeJavaRunProfileState.java
@@ -22,21 +22,21 @@
 import com.google.idea.blaze.base.command.BlazeCommandName;
 import com.google.idea.blaze.base.command.BlazeFlags;
 import com.google.idea.blaze.base.issueparser.IssueOutputLineProcessor;
-import com.google.idea.blaze.base.metrics.Action;
 import com.google.idea.blaze.base.model.primitives.Kind;
 import com.google.idea.blaze.base.model.primitives.WorkspaceRoot;
 import com.google.idea.blaze.base.projectview.ProjectViewManager;
 import com.google.idea.blaze.base.projectview.ProjectViewSet;
 import com.google.idea.blaze.base.run.BlazeCommandRunConfiguration;
 import com.google.idea.blaze.base.run.DistributedExecutorSupport;
+import com.google.idea.blaze.base.run.filter.BlazeTargetFilter;
 import com.google.idea.blaze.base.run.processhandler.LineProcessingProcessAdapter;
 import com.google.idea.blaze.base.run.processhandler.ScopedBlazeProcessHandler;
+import com.google.idea.blaze.base.run.smrunner.BlazeTestEventsHandler;
 import com.google.idea.blaze.base.run.smrunner.SmRunnerUtils;
 import com.google.idea.blaze.base.run.state.BlazeCommandRunConfigurationCommonState;
 import com.google.idea.blaze.base.scope.BlazeContext;
 import com.google.idea.blaze.base.scope.scopes.IdeaLogScope;
 import com.google.idea.blaze.base.scope.scopes.IssuesScope;
-import com.google.idea.blaze.base.scope.scopes.LoggedTimingScope;
 import com.google.idea.blaze.base.settings.Blaze;
 import com.google.idea.blaze.base.settings.BlazeImportSettings;
 import com.google.idea.blaze.base.settings.BlazeImportSettingsManager;
@@ -102,10 +102,16 @@
 
     BlazeCommand blazeCommand;
     if (useTestUi()) {
-      BlazeJavaTestEventsHandler eventsHandler = new BlazeJavaTestEventsHandler();
+      BlazeTestEventsHandler eventsHandler =
+          BlazeTestEventsHandler.getHandlerForTarget(project, configuration.getTarget());
+      assert (eventsHandler != null);
       blazeCommand =
           getBlazeCommand(
-              project, configuration, projectViewSet, eventsHandler.getBlazeFlags(), debug);
+              project,
+              configuration,
+              projectViewSet,
+              BlazeTestEventsHandler.getBlazeFlags(project),
+              debug);
       setConsoleBuilder(
           new TextConsoleBuilderImpl(project) {
             @Override
@@ -118,6 +124,7 @@
       blazeCommand =
           getBlazeCommand(project, configuration, projectViewSet, ImmutableList.of(), debug);
     }
+    addConsoleFilters(new BlazeTargetFilter(project));
 
     WorkspaceRoot workspaceRoot = WorkspaceRoot.fromImportSettings(importSettings);
     return new ScopedBlazeProcessHandler(
@@ -127,10 +134,7 @@
         new ScopedBlazeProcessHandler.ScopedProcessHandlerDelegate() {
           @Override
           public void onBlazeContextStart(BlazeContext context) {
-            context
-                .push(new LoggedTimingScope(project, Action.BLAZE_COMMAND_USAGE))
-                .push(new IssuesScope(project))
-                .push(new IdeaLogScope());
+            context.push(new IssuesScope(project)).push(new IdeaLogScope());
           }
 
           @Override
@@ -158,7 +162,7 @@
         configuration.getHandlerStateIfType(BlazeCommandRunConfigurationCommonState.class);
     return state != null
         && BlazeCommandName.TEST.equals(state.getCommand())
-        && !Boolean.TRUE.equals(state.getRunOnDistributedExecutor());
+        && !state.getRunOnDistributedExecutor();
   }
 
   @Override
@@ -204,9 +208,11 @@
         command.addBlazeFlags(BlazeFlags.JAVA_TEST_DEBUG);
       }
     } else {
-      command.addBlazeFlags(
-          DistributedExecutorSupport.getBlazeFlags(
-              project, handlerState.getRunOnDistributedExecutor()));
+      boolean runDistributed = handlerState.getRunOnDistributedExecutor();
+      command.addBlazeFlags(DistributedExecutorSupport.getBlazeFlags(project, runDistributed));
+      if (!runDistributed) {
+        command.addBlazeFlags(BlazeFlags.TEST_OUTPUT_STREAMED);
+      }
     }
 
     command.addExeFlags(handlerState.getExeFlags());
diff --git a/java/src/com/google/idea/blaze/java/run/BlazeJavaTestEventsHandler.java b/java/src/com/google/idea/blaze/java/run/BlazeJavaTestEventsHandler.java
index 9af9b64..4a1024f 100644
--- a/java/src/com/google/idea/blaze/java/run/BlazeJavaTestEventsHandler.java
+++ b/java/src/com/google/idea/blaze/java/run/BlazeJavaTestEventsHandler.java
@@ -16,10 +16,11 @@
 package com.google.idea.blaze.java.run;
 
 import com.google.idea.blaze.base.command.BlazeFlags;
+import com.google.idea.blaze.base.model.primitives.Kind;
 import com.google.idea.blaze.base.run.smrunner.BlazeTestEventsHandler;
+import com.google.idea.blaze.base.run.smrunner.BlazeXmlSchema.TestSuite;
 import com.google.idea.blaze.java.run.producers.BlazeJUnitTestFilterFlags;
 import com.intellij.execution.Location;
-import com.intellij.execution.testframework.AbstractTestProxy;
 import com.intellij.execution.testframework.JavaTestLocator;
 import com.intellij.execution.testframework.sm.runner.SMTestLocator;
 import com.intellij.openapi.project.Project;
@@ -27,17 +28,36 @@
 import com.intellij.psi.PsiClass;
 import com.intellij.psi.PsiElement;
 import com.intellij.psi.PsiMethod;
-import com.intellij.psi.search.GlobalSearchScope;
-import com.intellij.util.containers.MultiMap;
 import com.intellij.util.io.URLUtil;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import javax.annotation.Nullable;
 
 /** Provides java-specific methods needed by the SM-runner test UI. */
 public class BlazeJavaTestEventsHandler extends BlazeTestEventsHandler {
 
-  public BlazeJavaTestEventsHandler() {
-    super("Blaze Java Test");
+  @Override
+  protected EnumSet<Kind> handledKinds() {
+    return EnumSet.of(Kind.JAVA_TEST, Kind.ANDROID_ROBOLECTRIC_TEST, Kind.GWT_TEST);
+  }
+
+  /** Overridden to support parameterized tests, which use nested test_suite XML elements. */
+  @Override
+  public boolean ignoreSuite(TestSuite suite) {
+    if (suite.testSuites.isEmpty()) {
+      return false;
+    }
+    for (TestSuite child : suite.testSuites) {
+      // target/class names are fully-qualified; unqualified names denote parameterized methods
+      if (!child.name.contains(".")) {
+        return false;
+      }
+    }
+    return true;
   }
 
   @Override
@@ -46,20 +66,34 @@
   }
 
   @Override
-  public String suiteLocationUrl(String name) {
+  public String suiteLocationUrl(@Nullable Kind kind, String name) {
     return JavaTestLocator.SUITE_PROTOCOL + URLUtil.SCHEME_SEPARATOR + name;
   }
 
   @Override
-  public String testLocationUrl(String name, @Nullable String classname) {
+  public String testLocationUrl(
+      @Nullable Kind kind, String parentSuite, String name, @Nullable String classname) {
     if (classname == null) {
       return null;
     }
-    return JavaTestLocator.TEST_PROTOCOL + URLUtil.SCHEME_SEPARATOR + classname + "." + name;
+    String classComponent = JavaTestLocator.TEST_PROTOCOL + URLUtil.SCHEME_SEPARATOR + classname;
+    String parameterComponent = extractParameterComponent(name);
+    if (parameterComponent != null) {
+      return classComponent + "." + parentSuite + parameterComponent;
+    }
+    return classComponent + "." + name;
+  }
+
+  @Nullable
+  private static String extractParameterComponent(String name) {
+    if (name.startsWith("[") && name.contains("]")) {
+      return name.substring(0, name.indexOf(']') + 1);
+    }
+    return null;
   }
 
   @Override
-  public String suiteDisplayName(String rawName) {
+  public String suiteDisplayName(@Nullable Kind kind, String rawName) {
     String name = StringUtil.trimEnd(rawName, '.');
     int lastPointIx = name.lastIndexOf('.');
     return lastPointIx != -1 ? name.substring(lastPointIx + 1, name.length()) : name;
@@ -67,28 +101,29 @@
 
   @Nullable
   @Override
-  public String getTestFilter(Project project, List<AbstractTestProxy> failedTests) {
-    GlobalSearchScope projectScope = GlobalSearchScope.allScope(project);
-    MultiMap<PsiClass, PsiMethod> failedMethodsPerClass = new MultiMap<>();
-    for (AbstractTestProxy test : failedTests) {
-      appendTest(failedMethodsPerClass, test.getLocation(project, projectScope));
+  public String getTestFilter(Project project, List<Location<?>> testLocations) {
+    Map<PsiClass, Collection<Location<?>>> failedClassesAndMethods = new HashMap<>();
+    for (Location<?> location : testLocations) {
+      appendTest(failedClassesAndMethods, location);
     }
-    String filter = BlazeJUnitTestFilterFlags.testFilterForClassesAndMethods(failedMethodsPerClass);
+    String filter =
+        BlazeJUnitTestFilterFlags.testFilterForClassesAndMethods(failedClassesAndMethods);
     return filter != null ? BlazeFlags.TEST_FILTER + "=" + filter : null;
   }
 
-  private static void appendTest(
-      MultiMap<PsiClass, PsiMethod> testMap, @Nullable Location<?> testLocation) {
-    if (testLocation == null) {
+  private static void appendTest(Map<PsiClass, Collection<Location<?>>> map, Location<?> location) {
+    PsiElement psi = location.getPsiElement();
+    if (psi instanceof PsiClass) {
+      map.computeIfAbsent((PsiClass) psi, k -> new HashSet<>());
       return;
     }
-    PsiElement method = testLocation.getPsiElement();
-    if (!(method instanceof PsiMethod)) {
+    if (!(psi instanceof PsiMethod)) {
       return;
     }
-    PsiClass psiClass = ((PsiMethod) method).getContainingClass();
-    if (psiClass != null) {
-      testMap.putValue(psiClass, (PsiMethod) method);
+    PsiClass psiClass = ((PsiMethod) psi).getContainingClass();
+    if (psiClass == null) {
+      return;
     }
+    map.computeIfAbsent(psiClass, k -> new HashSet<>()).add(location);
   }
 }
diff --git a/java/src/com/google/idea/blaze/java/run/producers/BlazeJUnitTestFilterFlags.java b/java/src/com/google/idea/blaze/java/run/producers/BlazeJUnitTestFilterFlags.java
index 8f2290f..4ee0508 100644
--- a/java/src/com/google/idea/blaze/java/run/producers/BlazeJUnitTestFilterFlags.java
+++ b/java/src/com/google/idea/blaze/java/run/producers/BlazeJUnitTestFilterFlags.java
@@ -19,13 +19,18 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.intellij.codeInsight.AnnotationUtil;
+import com.intellij.execution.Location;
 import com.intellij.execution.junit.JUnitUtil;
 import com.intellij.execution.junit2.PsiMemberParameterizedLocation;
+import com.intellij.openapi.util.text.StringUtil;
 import com.intellij.psi.PsiClass;
+import com.intellij.psi.PsiElement;
 import com.intellij.psi.PsiMethod;
-import com.intellij.util.containers.MultiMap;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
+import java.util.Map;
 import java.util.Map.Entry;
 import java.util.stream.Collectors;
 import javax.annotation.Nullable;
@@ -57,12 +62,34 @@
       PsiClass psiClass, Collection<PsiMethod> methods) {
     JUnitVersion version =
         JUnitUtil.isJUnit4TestClass(psiClass) ? JUnitVersion.JUNIT_4 : JUnitVersion.JUNIT_3;
-    return testFilterForClassAndMethods(psiClass, version, methods);
+    return testFilterForClassAndMethods(psiClass, version, extractMethodFilters(psiClass, methods));
+  }
+
+  /** Runs all parameterized versions of methods. */
+  private static List<String> extractMethodFilters(
+      PsiClass psiClass, Collection<PsiMethod> methods) {
+    // standard org.junit.runners.Parameterized class requires no per-test annotations
+    boolean parameterizedClass = isParameterized(psiClass);
+    return methods
+        .stream()
+        .map((method) -> methodFilter(method, parameterizedClass))
+        .sorted()
+        .collect(Collectors.toList());
+  }
+
+  private static boolean isParameterized(PsiClass testClass) {
+    return PsiMemberParameterizedLocation.getParameterizedLocation(testClass, null) != null;
+  }
+
+  private static String methodFilter(PsiMethod method, boolean parameterizedClass) {
+    boolean parameterized =
+        parameterizedClass || AnnotationUtil.findAnnotation(method, "Parameters") != null;
+    return parameterized ? method.getName() + "(\\[.+\\])?" : method.getName();
   }
 
   @Nullable
   public static String testFilterForClassesAndMethods(
-      MultiMap<PsiClass, PsiMethod> methodsPerClass) {
+      Map<PsiClass, Collection<Location<?>>> methodsPerClass) {
     // Note: this could be incorrect if there are no JUnit4 classes in this sample, but some in the
     // java_test target they're run from.
     JUnitVersion version =
@@ -72,15 +99,40 @@
 
   @Nullable
   public static String testFilterForClassesAndMethods(
-      MultiMap<PsiClass, PsiMethod> methodsPerClass, JUnitVersion version) {
-    StringBuilder output = new StringBuilder();
-    for (Entry<PsiClass, Collection<PsiMethod>> entry : methodsPerClass.entrySet()) {
-      String filter = testFilterForClassAndMethods(entry.getKey(), version, entry.getValue());
+      Map<PsiClass, Collection<Location<?>>> methodsPerClass, JUnitVersion version) {
+    List<String> classFilters = new ArrayList<>();
+    for (Entry<PsiClass, Collection<Location<?>>> entry : methodsPerClass.entrySet()) {
+      String filter =
+          testFilterForClassAndMethods(
+              entry.getKey(), version, extractMethodFilters(entry.getValue()));
       if (filter != null) {
-        output.append(filter);
+        classFilters.add(filter);
       }
     }
-    return Strings.emptyToNull(output.toString());
+    return version == JUnitVersion.JUNIT_4
+        ? String.join("|", classFilters)
+        : String.join(",", classFilters);
+  }
+
+  /** Only runs specified parameterized versions, where relevant. */
+  private static List<String> extractMethodFilters(Collection<Location<?>> methods) {
+    return methods
+        .stream()
+        .map(BlazeJUnitTestFilterFlags::testFilterForLocation)
+        .sorted()
+        .collect(Collectors.toList());
+  }
+
+  private static String testFilterForLocation(Location<?> location) {
+    PsiElement psi = location.getPsiElement();
+    assert (psi instanceof PsiMethod);
+    String methodName = ((PsiMethod) psi).getName();
+    if (location instanceof PsiMemberParameterizedLocation) {
+      return methodName
+          + StringUtil.escapeToRegexp(
+              ((PsiMemberParameterizedLocation) location).getParamSetName());
+    }
+    return methodName;
   }
 
   private static boolean hasJUnit4Test(Collection<PsiClass> classes) {
@@ -98,19 +150,12 @@
    */
   @Nullable
   private static String testFilterForClassAndMethods(
-      PsiClass psiClass, JUnitVersion version, Collection<PsiMethod> methods) {
+      PsiClass psiClass, JUnitVersion version, List<String> methodFilters) {
     String className = psiClass.getQualifiedName();
     if (className == null) {
       return null;
     }
-    // Sort so multiple configurations created with different selection orders are the same.
-    List<String> methodNames =
-        methods.stream().map(PsiMethod::getName).sorted().collect(Collectors.toList());
-    return testFilterForClassAndMethods(className, methodNames, version, isParameterized(psiClass));
-  }
-
-  private static boolean isParameterized(PsiClass testClass) {
-    return PsiMemberParameterizedLocation.getParameterizedLocation(testClass, null) != null;
+    return testFilterForClassAndMethods(className, version, methodFilters);
   }
 
   /**
@@ -119,10 +164,7 @@
    */
   @VisibleForTesting
   static String testFilterForClassAndMethods(
-      String className,
-      List<String> methodNames,
-      JUnitVersion jUnitVersion,
-      boolean parameterized) {
+      String className, JUnitVersion jUnitVersion, List<String> methodNames) {
     StringBuilder output = new StringBuilder(className);
     String methodNamePattern = concatenateMethodNames(methodNames, jUnitVersion);
     if (Strings.isNullOrEmpty(methodNamePattern)) {
@@ -137,10 +179,6 @@
     if (jUnitVersion == JUnitVersion.JUNIT_3) {
       return output.toString();
     }
-    // parameterized tests include their parameters between brackets after the method name
-    if (parameterized) {
-      output.append("(\\[.+\\])?");
-    }
     output.append('$');
     return output.toString();
   }
diff --git a/java/src/com/google/idea/blaze/java/run/producers/BlazeJavaMainClassRunConfigurationProducer.java b/java/src/com/google/idea/blaze/java/run/producers/BlazeJavaMainClassRunConfigurationProducer.java
index 6e9be07..b90826a 100644
--- a/java/src/com/google/idea/blaze/java/run/producers/BlazeJavaMainClassRunConfigurationProducer.java
+++ b/java/src/com/google/idea/blaze/java/run/producers/BlazeJavaMainClassRunConfigurationProducer.java
@@ -15,20 +15,18 @@
  */
 package com.google.idea.blaze.java.run.producers;
 
-import com.google.common.collect.ImmutableCollection;
-import com.google.common.collect.Sets;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
 import com.google.idea.blaze.base.command.BlazeCommandName;
 import com.google.idea.blaze.base.ideinfo.TargetIdeInfo;
-import com.google.idea.blaze.base.ideinfo.TargetKey;
 import com.google.idea.blaze.base.model.BlazeProjectData;
 import com.google.idea.blaze.base.model.primitives.Kind;
-import com.google.idea.blaze.base.model.primitives.Label;
 import com.google.idea.blaze.base.run.BlazeCommandRunConfiguration;
 import com.google.idea.blaze.base.run.BlazeCommandRunConfigurationType;
 import com.google.idea.blaze.base.run.producers.BlazeRunConfigurationProducer;
 import com.google.idea.blaze.base.run.state.BlazeCommandRunConfigurationCommonState;
-import com.google.idea.blaze.base.sync.data.BlazeProjectDataManager;
-import com.google.idea.blaze.base.targetmaps.SourceToTargetMap;
+import com.google.idea.blaze.base.run.testmap.FilteredTargetMap;
+import com.google.idea.blaze.base.sync.SyncCache;
 import com.google.idea.blaze.java.run.RunUtil;
 import com.intellij.execution.JavaExecutionUtil;
 import com.intellij.execution.Location;
@@ -41,16 +39,16 @@
 import com.intellij.psi.PsiMethod;
 import com.intellij.psi.util.PsiMethodUtil;
 import java.io.File;
-import java.util.ArrayDeque;
+import java.util.Collection;
 import java.util.Objects;
-import java.util.Queue;
-import java.util.Set;
 import org.jetbrains.annotations.Nullable;
 
 /** Creates run configurations for Java main classes sourced by java_binary targets. */
 public class BlazeJavaMainClassRunConfigurationProducer
     extends BlazeRunConfigurationProducer<BlazeCommandRunConfiguration> {
 
+  private static final String JAVA_BINARY_MAP_KEY = "BlazeJavaBinaryMap";
+
   public BlazeJavaMainClassRunConfigurationProducer() {
     super(BlazeCommandRunConfigurationType.getInstance());
   }
@@ -73,11 +71,11 @@
       sourceElement.set(mainMethod);
     }
 
-    Label label = getTargetLabel(context.getProject(), mainClass);
-    if (label == null) {
+    TargetIdeInfo target = getTarget(context.getProject(), mainClass);
+    if (target == null) {
       return false;
     }
-    configuration.setTarget(label);
+    configuration.setTarget(target.key.label);
     BlazeCommandRunConfigurationCommonState handlerState =
         configuration.getHandlerStateIfType(BlazeCommandRunConfigurationCommonState.class);
     if (handlerState == null) {
@@ -103,11 +101,11 @@
     if (mainClass == null) {
       return false;
     }
-    Label label = getTargetLabel(context.getProject(), mainClass);
-    if (label == null) {
+    TargetIdeInfo target = getTarget(context.getProject(), mainClass);
+    if (target == null) {
       return false;
     }
-    return Objects.equals(configuration.getTarget(), label);
+    return Objects.equals(configuration.getTarget(), target.key.label);
   }
 
   @Nullable
@@ -128,35 +126,60 @@
   }
 
   @Nullable
-  private static Label getTargetLabel(Project project, PsiClass mainClass) {
+  private static TargetIdeInfo getTarget(Project project, PsiClass mainClass) {
     File mainClassFile = RunUtil.getFileForClass(mainClass);
-    ImmutableCollection<TargetKey> targetKeys =
-        SourceToTargetMap.getInstance(project).getRulesForSourceFile(mainClassFile);
-    BlazeProjectData blazeProjectData =
-        BlazeProjectDataManager.getInstance(project).getBlazeProjectData();
-    if (blazeProjectData == null) {
+    if (mainClassFile == null) {
       return null;
     }
-    // Find the first java_binary, BFS
-    Queue<TargetKey> todo = new ArrayDeque<>();
-    todo.addAll(targetKeys);
-    Set<TargetKey> seen = Sets.newHashSet();
-    while (!todo.isEmpty()) {
-      TargetKey targetKey = todo.remove();
-      if (!seen.add(targetKey)) {
-        continue;
-      }
-      TargetIdeInfo target = blazeProjectData.targetMap.get(targetKey);
-      if (target == null) {
-        continue;
-      }
-      if (target.kind == Kind.JAVA_BINARY && target.isPlainTarget()) {
-        // Best-effort guess: the main_class attribute isn't exposed, but assume
-        // mainClass is the main_class because it is sourced by the java_binary.
-        return target.key.label;
-      }
-      todo.addAll(blazeProjectData.reverseDependencies.get(targetKey));
+    Collection<TargetIdeInfo> javaBinaryTargets = findJavaBinaryTargets(project, mainClassFile);
+
+    String qualifiedName = mainClass.getQualifiedName();
+    String className = mainClass.getName();
+    if (qualifiedName == null || className == null) {
+      // out of date psi element; just take the first match
+      return Iterables.getFirst(javaBinaryTargets, null);
     }
-    return null;
+
+    // first look for a matching main_class
+    TargetIdeInfo match =
+        javaBinaryTargets
+            .stream()
+            .filter(
+                target ->
+                    target.javaIdeInfo != null
+                        && qualifiedName.equals(target.javaIdeInfo.javaBinaryMainClass))
+            .findFirst()
+            .orElse(null);
+    if (match != null) {
+      return match;
+    }
+
+    match =
+        javaBinaryTargets
+            .stream()
+            .filter(target -> className.equals(target.key.label.targetName().toString()))
+            .findFirst()
+            .orElse(null);
+    if (match != null) {
+      return match;
+    }
+    return Iterables.getFirst(javaBinaryTargets, null);
+  }
+
+  /** Returns all java_binary targets reachable from the given source file. */
+  private static Collection<TargetIdeInfo> findJavaBinaryTargets(
+      Project project, File mainClassFile) {
+    FilteredTargetMap map =
+        SyncCache.getInstance(project)
+            .get(JAVA_BINARY_MAP_KEY, BlazeJavaMainClassRunConfigurationProducer::computeTargetMap);
+    return map != null ? map.targetsForSourceFile(mainClassFile) : ImmutableList.of();
+  }
+
+  private static FilteredTargetMap computeTargetMap(Project project, BlazeProjectData projectData) {
+    return new FilteredTargetMap(
+        project,
+        projectData.artifactLocationDecoder,
+        projectData.targetMap,
+        (targetIdeInfo) -> targetIdeInfo.kind == Kind.JAVA_BINARY && targetIdeInfo.isPlainTarget());
   }
 }
diff --git a/java/src/com/google/idea/blaze/java/run/producers/BlazeJavaTestClassConfigurationProducer.java b/java/src/com/google/idea/blaze/java/run/producers/BlazeJavaTestClassConfigurationProducer.java
index 95bc4ee..7d9a20f 100644
--- a/java/src/com/google/idea/blaze/java/run/producers/BlazeJavaTestClassConfigurationProducer.java
+++ b/java/src/com/google/idea/blaze/java/run/producers/BlazeJavaTestClassConfigurationProducer.java
@@ -15,7 +15,6 @@
  */
 package com.google.idea.blaze.java.run.producers;
 
-import com.google.common.collect.ImmutableList;
 import com.google.idea.blaze.base.command.BlazeCommandName;
 import com.google.idea.blaze.base.command.BlazeFlags;
 import com.google.idea.blaze.base.ideinfo.TargetIdeInfo;
@@ -24,6 +23,7 @@
 import com.google.idea.blaze.base.run.BlazeCommandRunConfigurationType;
 import com.google.idea.blaze.base.run.BlazeConfigurationNameBuilder;
 import com.google.idea.blaze.base.run.producers.BlazeRunConfigurationProducer;
+import com.google.idea.blaze.base.run.smrunner.SmRunnerUtils;
 import com.google.idea.blaze.base.run.state.BlazeCommandRunConfigurationCommonState;
 import com.google.idea.blaze.java.run.RunUtil;
 import com.intellij.execution.JavaExecutionUtil;
@@ -35,6 +35,8 @@
 import com.intellij.psi.PsiElement;
 import com.intellij.psi.PsiMethod;
 import com.intellij.psi.PsiModifier;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Objects;
 import org.jetbrains.annotations.NotNull;
 
@@ -59,6 +61,10 @@
       return false;
     }
 
+    if (!SmRunnerUtils.getSelectedSmRunnerTreeElements(context).isEmpty()) {
+      // handled by a different producer
+      return false;
+    }
     if (JUnitConfigurationUtil.isMultipleElementsSelected(context)) {
       return false;
     }
@@ -84,23 +90,22 @@
     if (handlerState == null) {
       return false;
     }
+    String testFilter = BlazeJUnitTestFilterFlags.testFilterForClass(testClass);
+    if (testFilter == null) {
+      return false;
+    }
     handlerState.setCommand(BlazeCommandName.TEST);
 
-    ImmutableList.Builder<String> flags = ImmutableList.builder();
-
-    String testFilter = BlazeJUnitTestFilterFlags.testFilterForClass(testClass);
-    if (testFilter != null) {
-      flags.add(BlazeFlags.TEST_FILTER + "=" + testFilter);
-    }
-    flags.add(BlazeFlags.TEST_OUTPUT_STREAMED);
-    flags.addAll(handlerState.getBlazeFlags());
-
-    handlerState.setBlazeFlags(flags.build());
+    // remove old test filter flag if present
+    List<String> flags = new ArrayList<>(handlerState.getBlazeFlags());
+    flags.removeIf((flag) -> flag.startsWith(BlazeFlags.TEST_FILTER));
+    flags.add(BlazeFlags.TEST_FILTER + "=" + testFilter);
+    handlerState.setBlazeFlags(flags);
 
     BlazeConfigurationNameBuilder nameBuilder = new BlazeConfigurationNameBuilder(configuration);
     nameBuilder.setTargetString(testClass.getName());
     configuration.setName(nameBuilder.build());
-
+    configuration.setNameChangedByUser(true); // don't revert to generated name
     return true;
   }
 
@@ -115,6 +120,10 @@
       return false;
     }
 
+    if (!SmRunnerUtils.getSelectedSmRunnerTreeElements(context).isEmpty()) {
+      // handled by a different producer
+      return false;
+    }
     if (JUnitConfigurationUtil.isMultipleElementsSelected(context)) {
       return false;
     }
@@ -143,6 +152,9 @@
       return false;
     }
     String filter = BlazeJUnitTestFilterFlags.testFilterForClass(testClass);
-    return Objects.equals(filter, handlerState.getTestFilterFlag());
+    if (filter == null) {
+      return false;
+    }
+    return Objects.equals(BlazeFlags.TEST_FILTER + "=" + filter, handlerState.getTestFilterFlag());
   }
 }
diff --git a/java/src/com/google/idea/blaze/java/run/producers/BlazeJavaTestMethodConfigurationProducer.java b/java/src/com/google/idea/blaze/java/run/producers/BlazeJavaTestMethodConfigurationProducer.java
index 1a7e149..e0453d1 100644
--- a/java/src/com/google/idea/blaze/java/run/producers/BlazeJavaTestMethodConfigurationProducer.java
+++ b/java/src/com/google/idea/blaze/java/run/producers/BlazeJavaTestMethodConfigurationProducer.java
@@ -15,7 +15,6 @@
  */
 package com.google.idea.blaze.java.run.producers;
 
-import com.google.common.collect.ImmutableList;
 import com.google.idea.blaze.base.command.BlazeCommandName;
 import com.google.idea.blaze.base.command.BlazeFlags;
 import com.google.idea.blaze.base.ideinfo.TargetIdeInfo;
@@ -24,6 +23,7 @@
 import com.google.idea.blaze.base.run.BlazeCommandRunConfigurationType;
 import com.google.idea.blaze.base.run.BlazeConfigurationNameBuilder;
 import com.google.idea.blaze.base.run.producers.BlazeRunConfigurationProducer;
+import com.google.idea.blaze.base.run.smrunner.SmRunnerUtils;
 import com.google.idea.blaze.base.run.state.BlazeCommandRunConfigurationCommonState;
 import com.google.idea.blaze.java.run.RunUtil;
 import com.intellij.execution.actions.ConfigurationContext;
@@ -31,10 +31,11 @@
 import com.intellij.psi.PsiClass;
 import com.intellij.psi.PsiElement;
 import com.intellij.psi.PsiMethod;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Objects;
 import java.util.stream.Collectors;
-import org.jetbrains.annotations.NotNull;
+import javax.annotation.Nullable;
 
 /** Producer for run configurations related to Java test methods in Blaze. */
 public class BlazeJavaTestMethodConfigurationProducer
@@ -64,9 +65,9 @@
 
   @Override
   protected boolean doSetupConfigFromContext(
-      @NotNull BlazeCommandRunConfiguration configuration,
-      @NotNull ConfigurationContext context,
-      @NotNull Ref<PsiElement> sourceElement) {
+      BlazeCommandRunConfiguration configuration,
+      ConfigurationContext context,
+      Ref<PsiElement> sourceElement) {
 
     SelectedMethodInfo methodInfo = getSelectedMethodInfo(context);
     if (methodInfo == null) {
@@ -93,12 +94,11 @@
     }
     handlerState.setCommand(BlazeCommandName.TEST);
 
-    ImmutableList.Builder<String> flags = ImmutableList.builder();
+    // remove old test filter flag if present
+    List<String> flags = new ArrayList<>(handlerState.getBlazeFlags());
+    flags.removeIf((flag) -> flag.startsWith(BlazeFlags.TEST_FILTER));
     flags.add(methodInfo.testFilterFlag);
-    flags.add(BlazeFlags.TEST_OUTPUT_STREAMED);
-    flags.addAll(handlerState.getBlazeFlags());
-
-    handlerState.setBlazeFlags(flags.build());
+    handlerState.setBlazeFlags(flags);
 
     BlazeConfigurationNameBuilder nameBuilder = new BlazeConfigurationNameBuilder(configuration);
     nameBuilder.setTargetString(
@@ -106,13 +106,13 @@
             "%s.%s",
             methodInfo.containingClass.getName(), String.join(",", methodInfo.methodNames)));
     configuration.setName(nameBuilder.build());
-
+    configuration.setNameChangedByUser(true); // don't revert to generated name
     return true;
   }
 
   @Override
   protected boolean doIsConfigFromContext(
-      @NotNull BlazeCommandRunConfiguration configuration, @NotNull ConfigurationContext context) {
+      BlazeCommandRunConfiguration configuration, ConfigurationContext context) {
     BlazeCommandRunConfigurationCommonState handlerState =
         configuration.getHandlerStateIfType(BlazeCommandRunConfigurationCommonState.class);
     if (handlerState == null) {
@@ -131,7 +131,12 @@
     return flags.contains(methodInfo.testFilterFlag);
   }
 
+  @Nullable
   private static SelectedMethodInfo getSelectedMethodInfo(ConfigurationContext context) {
+    if (!SmRunnerUtils.getSelectedSmRunnerTreeElements(context).isEmpty()) {
+      // handled by a different producer
+      return null;
+    }
     final List<PsiMethod> selectedMethods = TestMethodSelectionUtil.getSelectedMethods(context);
     if (selectedMethods == null) {
       return null;
diff --git a/java/src/com/google/idea/blaze/java/run/producers/NonBlazeProducerSuppressor.java b/java/src/com/google/idea/blaze/java/run/producers/NonBlazeProducerSuppressor.java
index 7c4f4ca..faf3545 100644
--- a/java/src/com/google/idea/blaze/java/run/producers/NonBlazeProducerSuppressor.java
+++ b/java/src/com/google/idea/blaze/java/run/producers/NonBlazeProducerSuppressor.java
@@ -19,9 +19,15 @@
 import com.google.idea.blaze.base.settings.Blaze;
 import com.intellij.execution.RunConfigurationProducerService;
 import com.intellij.execution.actions.RunConfigurationProducer;
+import com.intellij.ide.plugins.IdeaPluginDescriptor;
+import com.intellij.ide.plugins.PluginManager;
 import com.intellij.openapi.components.AbstractProjectComponent;
+import com.intellij.openapi.extensions.PluginId;
 import com.intellij.openapi.project.Project;
 import java.util.Collection;
+import java.util.Objects;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
 
 /** Suppresses certain non-Blaze configuration producers in Blaze projects. */
 public class NonBlazeProducerSuppressor extends AbstractProjectComponent {
@@ -33,7 +39,42 @@
               com.intellij.execution.junit.AllInDirectoryConfigurationProducer.class,
               com.intellij.execution.junit.AllInPackageConfigurationProducer.class,
               com.intellij.execution.junit.TestClassConfigurationProducer.class,
-              com.intellij.execution.junit.TestMethodConfigurationProducer.class);
+              com.intellij.execution.junit.TestMethodConfigurationProducer.class,
+              com.intellij.execution.junit.PatternConfigurationProducer.class);
+
+  private static final ImmutableList<String> KOTLIN_JUNIT_PRODUCERS =
+      ImmutableList.of(
+          "org.jetbrains.kotlin.idea.run.KotlinJUnitRunConfigurationProducer",
+          "org.jetbrains.kotlin.idea.run.KotlinPatternConfigurationProducer");
+
+  private static Collection<Class<? extends RunConfigurationProducer<?>>> getKotlinProducers() {
+    // rather than compiling against the Kotlin plugin, and including a switch in the our
+    // plugin.xml, just get the classes manually via the plugin class loader.
+    IdeaPluginDescriptor plugin = PluginManager.getPlugin(PluginId.getId("org.jetbrains.kotlin"));
+    if (plugin == null || !plugin.isEnabled()) {
+      return ImmutableList.of();
+    }
+    ClassLoader loader = plugin.getPluginClassLoader();
+    return KOTLIN_JUNIT_PRODUCERS
+        .stream()
+        .map((qualifiedName) -> loadClass(loader, qualifiedName))
+        .filter(Objects::nonNull)
+        .collect(Collectors.toList());
+  }
+
+  @Nullable
+  private static Class<RunConfigurationProducer<?>> loadClass(
+      ClassLoader loader, String qualifiedName) {
+    try {
+      Class<?> clazz = loader.loadClass(qualifiedName);
+      if (RunConfigurationProducer.class.isAssignableFrom(clazz)) {
+        return (Class<RunConfigurationProducer<?>>) clazz;
+      }
+      return null;
+    } catch (ClassNotFoundException ignored) {
+      return null;
+    }
+  }
 
   public NonBlazeProducerSuppressor(Project project) {
     super(project);
@@ -49,6 +90,10 @@
   private static void suppressProducers(Project project) {
     RunConfigurationProducerService producerService =
         RunConfigurationProducerService.getInstance(project);
-    PRODUCERS_TO_SUPPRESS.forEach(producerService::addIgnoredProducer);
+    ImmutableList.<Class<? extends RunConfigurationProducer<?>>>builder()
+        .addAll(PRODUCERS_TO_SUPPRESS)
+        .addAll(getKotlinProducers())
+        .build()
+        .forEach(producerService::addIgnoredProducer);
   }
 }
diff --git a/java/src/com/google/idea/blaze/java/sync/jdeps/JdepsFileReader.java b/java/src/com/google/idea/blaze/java/sync/jdeps/JdepsFileReader.java
index ed9bf6a..550c00c 100644
--- a/java/src/com/google/idea/blaze/java/sync/jdeps/JdepsFileReader.java
+++ b/java/src/com/google/idea/blaze/java/sync/jdeps/JdepsFileReader.java
@@ -51,7 +51,7 @@
 
 /** Reads jdeps from the ide info result. */
 public class JdepsFileReader {
-  private static final Logger LOG = Logger.getInstance(JdepsFileReader.class);
+  private static final Logger logger = Logger.getInstance(JdepsFileReader.class);
 
   static class JdepsState implements Serializable {
     private static final long serialVersionUID = 4L;
@@ -173,7 +173,7 @@
                     return new Result(updatedFile, targetKey, dependencyStringList);
                   }
                 } catch (FileNotFoundException e) {
-                  LOG.info("Could not open jdeps file: " + updatedFile);
+                  logger.info("Could not open jdeps file: " + updatedFile);
                 }
                 return null;
               }));
@@ -191,7 +191,7 @@
                   "Loaded %d jdeps files, total size %dkB",
                   updatedFiles.size(), totalSizeLoaded.get() / 1024)));
     } catch (InterruptedException | ExecutionException e) {
-      LOG.error(e);
+      logger.error(e);
       return null;
     }
     return state;
diff --git a/java/src/com/google/idea/blaze/java/sync/projectstructure/JavaSourceFolderProvider.java b/java/src/com/google/idea/blaze/java/sync/projectstructure/JavaSourceFolderProvider.java
index abe5804..3f7b512 100644
--- a/java/src/com/google/idea/blaze/java/sync/projectstructure/JavaSourceFolderProvider.java
+++ b/java/src/com/google/idea/blaze/java/sync/projectstructure/JavaSourceFolderProvider.java
@@ -19,21 +19,14 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
 import com.google.idea.blaze.base.sync.SourceFolderProvider;
+import com.google.idea.blaze.base.util.UrlUtil;
 import com.google.idea.blaze.java.sync.model.BlazeContentEntry;
 import com.google.idea.blaze.java.sync.model.BlazeJavaSyncData;
 import com.google.idea.blaze.java.sync.model.BlazeSourceDirectory;
-import com.intellij.openapi.application.ApplicationManager;
-import com.intellij.openapi.diagnostic.Logger;
 import com.intellij.openapi.roots.ContentEntry;
 import com.intellij.openapi.roots.SourceFolder;
 import com.intellij.openapi.util.io.FileUtil;
-import com.intellij.openapi.vfs.LocalFileSystem;
-import com.intellij.openapi.vfs.VfsUtilCore;
-import com.intellij.openapi.vfs.VirtualFile;
 import com.intellij.openapi.vfs.VirtualFileManager;
-import com.intellij.openapi.vfs.VirtualFileSystem;
-import com.intellij.openapi.vfs.ex.temp.TempFileSystem;
-import com.intellij.util.io.URLUtil;
 import java.io.File;
 import javax.annotation.Nullable;
 import org.jetbrains.jps.model.JpsElement;
@@ -43,7 +36,6 @@
 
 /** Edits source folders in IntelliJ content entries */
 public class JavaSourceFolderProvider implements SourceFolderProvider {
-  private static final Logger logger = Logger.getInstance(JavaSourceFolderProvider.class);
 
   private final ImmutableMap<File, BlazeContentEntry> blazeContentEntries;
 
@@ -64,20 +56,14 @@
   }
 
   @Override
-  public ImmutableMap<VirtualFile, SourceFolder> initializeSourceFolders(
-      ContentEntry contentEntry) {
-    ImmutableMap.Builder<VirtualFile, SourceFolder> output = ImmutableMap.builder();
-    VirtualFile virtualFile = contentEntry.getFile();
-    if (virtualFile == null) {
-      return output.build();
-    }
-
-    File contentRoot = new File(virtualFile.getPath());
-    BlazeContentEntry javaContentEntry = blazeContentEntries.get(contentRoot);
+  public ImmutableMap<File, SourceFolder> initializeSourceFolders(ContentEntry contentEntry) {
+    ImmutableMap.Builder<File, SourceFolder> output = ImmutableMap.builder();
+    BlazeContentEntry javaContentEntry =
+        blazeContentEntries.get(UrlUtil.urlToFile(contentEntry.getUrl()));
     if (javaContentEntry != null) {
       for (BlazeSourceDirectory sourceDirectory : javaContentEntry.sources) {
         SourceFolder sourceFolder = addSourceFolderToContentEntry(contentEntry, sourceDirectory);
-        output.put(sourceFolder.getFile(), sourceFolder);
+        output.put(UrlUtil.urlToFile(sourceFolder.getUrl()), sourceFolder);
       }
     }
     return output.build();
@@ -85,17 +71,15 @@
 
   @Override
   public SourceFolder setSourceFolderForLocation(
-      ContentEntry contentEntry,
-      SourceFolder parentFolder,
-      VirtualFile file,
-      boolean isTestSource) {
+      ContentEntry contentEntry, SourceFolder parentFolder, File file, boolean isTestSource) {
     SourceFolder sourceFolder;
     if (isResource(parentFolder)) {
       JavaResourceRootType resourceRootType =
           isTestSource ? JavaResourceRootType.TEST_RESOURCE : JavaResourceRootType.RESOURCE;
-      sourceFolder = contentEntry.addSourceFolder(pathToUrl(file.getPath()), resourceRootType);
+      sourceFolder =
+          contentEntry.addSourceFolder(UrlUtil.pathToUrl(file.getPath()), resourceRootType);
     } else {
-      sourceFolder = contentEntry.addSourceFolder(pathToUrl(file.getPath()), isTestSource);
+      sourceFolder = contentEntry.addSourceFolder(UrlUtil.pathToUrl(file.getPath()), isTestSource);
     }
     sourceFolder.setPackagePrefix(derivePackagePrefix(file, parentFolder));
     JpsModuleSourceRoot sourceRoot = sourceFolder.getJpsElement();
@@ -106,14 +90,17 @@
     return sourceFolder;
   }
 
-  private static String derivePackagePrefix(VirtualFile file, SourceFolder parentFolder) {
+  private static String derivePackagePrefix(File file, SourceFolder parentFolder) {
     String parentPackagePrefix = parentFolder.getPackagePrefix();
-    logger.assertTrue(parentFolder.getFile() != null);
-    String relativePath = VfsUtilCore.getRelativePath(file, parentFolder.getFile(), '.');
+    String parentPath = VirtualFileManager.extractPath(parentFolder.getUrl());
+    String relativePath =
+        FileUtil.toCanonicalPath(
+            FileUtil.getRelativePath(parentPath, file.getPath(), File.separatorChar));
     if (Strings.isNullOrEmpty(relativePath)) {
       return parentPackagePrefix;
     }
-    return parentPackagePrefix + "." + relativePath;
+
+    return parentPackagePrefix + "." + relativePath.replaceAll(File.separator, ".");
   }
 
   @VisibleForTesting
@@ -137,9 +124,9 @@
     if (sourceDirectory.getIsResource()) {
       sourceFolder =
           contentEntry.addSourceFolder(
-              pathToUrl(sourceDir.getPath()), JavaResourceRootType.RESOURCE);
+              UrlUtil.pathToUrl(sourceDir.getPath()), JavaResourceRootType.RESOURCE);
     } else {
-      sourceFolder = contentEntry.addSourceFolder(pathToUrl(sourceDir.getPath()), false);
+      sourceFolder = contentEntry.addSourceFolder(UrlUtil.pathToUrl(sourceDir.getPath()), false);
     }
     JpsModuleSourceRoot sourceRoot = sourceFolder.getJpsElement();
     JpsElement properties = sourceRoot.getProperties();
@@ -155,22 +142,4 @@
     }
     return sourceFolder;
   }
-
-  private static String pathToUrl(String filePath) {
-    filePath = FileUtil.toSystemIndependentName(filePath);
-    if (filePath.endsWith(".srcjar") || filePath.endsWith(".jar")) {
-      return URLUtil.JAR_PROTOCOL + URLUtil.SCHEME_SEPARATOR + filePath + URLUtil.JAR_SEPARATOR;
-    } else if (filePath.contains("src.jar!")) {
-      return URLUtil.JAR_PROTOCOL + URLUtil.SCHEME_SEPARATOR + filePath;
-    } else {
-      return VirtualFileManager.constructUrl(defaultFileSystem().getProtocol(), filePath);
-    }
-  }
-
-  private static VirtualFileSystem defaultFileSystem() {
-    if (ApplicationManager.getApplication().isUnitTestMode()) {
-      return TempFileSystem.getInstance();
-    }
-    return LocalFileSystem.getInstance();
-  }
 }
diff --git a/java/src/com/google/idea/blaze/java/sync/source/FilePathJavaPackageReader.java b/java/src/com/google/idea/blaze/java/sync/source/FilePathJavaPackageReader.java
index 0c1265a..2879324 100644
--- a/java/src/com/google/idea/blaze/java/sync/source/FilePathJavaPackageReader.java
+++ b/java/src/com/google/idea/blaze/java/sync/source/FilePathJavaPackageReader.java
@@ -15,10 +15,12 @@
  */
 package com.google.idea.blaze.java.sync.source;
 
+import com.google.common.base.Strings;
 import com.google.idea.blaze.base.model.primitives.WorkspacePath;
 import com.google.idea.blaze.base.scope.BlazeContext;
 import com.google.idea.blaze.base.sync.workspace.ArtifactLocationDecoder;
 import com.google.idea.blaze.base.util.PackagePrefixCalculator;
+import java.io.File;
 
 /** Gets the package from a java file by its file path alone (i.e. without opening the file). */
 public final class FilePathJavaPackageReader extends JavaPackageReader {
@@ -27,11 +29,8 @@
       BlazeContext context,
       ArtifactLocationDecoder artifactLocationDecoder,
       SourceArtifact sourceArtifact) {
-    String directory = sourceArtifact.artifactLocation.getRelativePath();
-    int i = directory.lastIndexOf('/');
-    if (i >= 0) {
-      directory = directory.substring(0, i);
-    }
-    return PackagePrefixCalculator.packagePrefixOf(new WorkspacePath(directory));
+    String parentPath = new File(sourceArtifact.artifactLocation.relativePath).getParent();
+    return PackagePrefixCalculator.packagePrefixOf(
+        new WorkspacePath(Strings.nullToEmpty(parentPath)));
   }
 }
diff --git a/java/src/com/google/idea/blaze/java/sync/source/JavaSourcePackageReader.java b/java/src/com/google/idea/blaze/java/sync/source/JavaSourcePackageReader.java
index f67f76c..64e1cb5 100644
--- a/java/src/com/google/idea/blaze/java/sync/source/JavaSourcePackageReader.java
+++ b/java/src/com/google/idea/blaze/java/sync/source/JavaSourcePackageReader.java
@@ -40,7 +40,7 @@
     return ServiceManager.getService(JavaSourcePackageReader.class);
   }
 
-  private static final Logger LOG = Logger.getInstance(SourceDirectoryCalculator.class);
+  private static final Logger logger = Logger.getInstance(SourceDirectoryCalculator.class);
 
   private static final Pattern JAVA_PACKAGE_PATTERN =
       Pattern.compile("^\\s*package\\s+([\\w\\.]+);");
@@ -76,7 +76,7 @@
           .submit(context);
       return null;
     } catch (IOException e) {
-      LOG.error(e);
+      logger.error(e);
       return null;
     }
   }
diff --git a/java/src/com/google/idea/blaze/java/sync/source/PackageManifestReader.java b/java/src/com/google/idea/blaze/java/sync/source/PackageManifestReader.java
index 6338e14..52fced0 100644
--- a/java/src/com/google/idea/blaze/java/sync/source/PackageManifestReader.java
+++ b/java/src/com/google/idea/blaze/java/sync/source/PackageManifestReader.java
@@ -44,7 +44,7 @@
 
 /** Reads package manifests. */
 public class PackageManifestReader {
-  private static final Logger LOG = Logger.getInstance(SourceDirectoryCalculator.class);
+  private static final Logger logger = Logger.getInstance(SourceDirectoryCalculator.class);
 
   public static PackageManifestReader getInstance() {
     return ServiceManager.getService(PackageManifestReader.class);
@@ -105,7 +105,7 @@
     try {
       Futures.allAsList(futures).get();
     } catch (ExecutionException | InterruptedException e) {
-      LOG.error(e);
+      logger.error(e);
       throw new IllegalStateException("Could not read sources");
     }
     return manifestMap;
@@ -131,7 +131,7 @@
       }
       return outputMap;
     } catch (IOException e) {
-      LOG.error(e);
+      logger.error(e);
       return outputMap;
     }
   }
diff --git a/java/src/com/google/idea/blaze/java/sync/source/SourceDirectoryCalculator.java b/java/src/com/google/idea/blaze/java/sync/source/SourceDirectoryCalculator.java
index 133625e..e1e4203 100644
--- a/java/src/com/google/idea/blaze/java/sync/source/SourceDirectoryCalculator.java
+++ b/java/src/com/google/idea/blaze/java/sync/source/SourceDirectoryCalculator.java
@@ -63,7 +63,7 @@
  */
 public final class SourceDirectoryCalculator {
 
-  private static final Logger LOG = Logger.getInstance(SourceDirectoryCalculator.class);
+  private static final Logger logger = Logger.getInstance(SourceDirectoryCalculator.class);
 
   private static final Splitter PACKAGE_SPLITTER = Splitter.on('.');
   private static final Splitter PATH_SPLITTER = Splitter.on('/');
@@ -250,7 +250,7 @@
         }
       }
     } catch (ExecutionException | InterruptedException e) {
-      LOG.error(e);
+      logger.error(e);
       throw new IllegalStateException("Could not read sources");
     }
 
@@ -464,9 +464,8 @@
           .submit(context);
       return null;
     }
-    return new SourceRoot(
-        new WorkspacePath(new File(sourceArtifact.artifactLocation.getRelativePath()).getParent()),
-        declaredPackage);
+    String parentPath = new File(sourceArtifact.artifactLocation.relativePath).getParent();
+    return new SourceRoot(new WorkspacePath(Strings.nullToEmpty(parentPath)), declaredPackage);
   }
 
   static class SourceRoot {
diff --git a/java/tests/integrationtests/com/google/idea/blaze/java/lang/build/completion/JavaClassQualifiedNameCompletionTest.java b/java/tests/integrationtests/com/google/idea/blaze/java/lang/build/completion/JavaClassQualifiedNameCompletionTest.java
new file mode 100644
index 0000000..ceb0589
--- /dev/null
+++ b/java/tests/integrationtests/com/google/idea/blaze/java/lang/build/completion/JavaClassQualifiedNameCompletionTest.java
@@ -0,0 +1,112 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.java.lang.build.completion;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.idea.blaze.base.lang.buildfile.BuildFileIntegrationTestCase;
+import com.google.idea.blaze.base.lang.buildfile.psi.BuildFile;
+import com.google.idea.blaze.base.model.primitives.WorkspacePath;
+import com.intellij.codeInsight.completion.CompletionType;
+import com.intellij.codeInsight.lookup.LookupElement;
+import com.intellij.openapi.editor.Editor;
+import com.intellij.testFramework.fixtures.CompletionAutoPopupTester;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for code completion of funcall arguments. */
+@RunWith(JUnit4.class)
+public class JavaClassQualifiedNameCompletionTest extends BuildFileIntegrationTestCase {
+
+  private CompletionAutoPopupTester completionTester;
+
+  @Before
+  public final void before() {
+    completionTester = new CompletionAutoPopupTester(testFixture);
+  }
+
+  /** Completion UI testing can't be run on the EDT. */
+  @Override
+  protected boolean runTestsOnEdt() {
+    return false;
+  }
+
+  @Test
+  public void testCompleteClassName() {
+    completionTester.runWithAutoPopupEnabled(
+        () -> {
+          workspace.createPsiFile(
+              new WorkspacePath("java/com/google/bin/Main.java"),
+              "package com.google.bin;",
+              "public class Main {",
+              "  public void main() {}",
+              "}");
+          BuildFile file =
+              createBuildFile(
+                  new WorkspacePath("java/com/google/BUILD"),
+                  "java_binary(",
+                  "    name = 'binary',",
+                  "    main_class = 'com.google.bin.M',",
+                  ")");
+
+          Editor editor = editorTest.openFileInEditor(file.getVirtualFile());
+          editorTest.setCaretPosition(editor, 2, "    main_class = 'com.google.bin.M".length());
+
+          testFixture.complete(CompletionType.CLASS_NAME);
+          assertFileContents(
+              file,
+              "java_binary(",
+              "    name = 'binary',",
+              "    main_class = 'com.google.bin.Main',",
+              ")");
+        });
+  }
+
+  @Test
+  public void testNoCompletionForOtherAttributes() {
+    completionTester.runWithAutoPopupEnabled(
+        () -> {
+          workspace.createPsiFile(
+              new WorkspacePath("java/com/google/bin/Main.java"),
+              "package com.google.bin;",
+              "public class Main {",
+              "  public void main() {}",
+              "}");
+          BuildFile file =
+              createBuildFile(
+                  new WorkspacePath("java/com/google/BUILD"),
+                  "java_binary(",
+                  "    name = 'binary',",
+                  "    main_clazz = 'com.google.bin.M',",
+                  ")");
+
+          Editor editor = editorTest.openFileInEditor(file.getVirtualFile());
+          editorTest.setCaretPosition(editor, 2, "    main_clazz = 'com.google.bin.M".length());
+
+          LookupElement[] completionItems = testFixture.complete(CompletionType.CLASS_NAME);
+          assertThat(completionItems).isEmpty();
+
+          assertFileContents(
+              file,
+              "java_binary(",
+              "    name = 'binary',",
+              "    main_clazz = 'com.google.bin.M',",
+              ")");
+        });
+  }
+}
diff --git a/java/tests/integrationtests/com/google/idea/blaze/java/lang/build/references/JavaClassQualifiedNameReferenceTest.java b/java/tests/integrationtests/com/google/idea/blaze/java/lang/build/references/JavaClassQualifiedNameReferenceTest.java
new file mode 100644
index 0000000..7565fcc
--- /dev/null
+++ b/java/tests/integrationtests/com/google/idea/blaze/java/lang/build/references/JavaClassQualifiedNameReferenceTest.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.java.lang.build.references;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.idea.blaze.base.lang.buildfile.BuildFileIntegrationTestCase;
+import com.google.idea.blaze.base.lang.buildfile.psi.ArgumentList;
+import com.google.idea.blaze.base.lang.buildfile.psi.BuildFile;
+import com.google.idea.blaze.base.lang.buildfile.psi.FuncallExpression;
+import com.google.idea.blaze.base.model.primitives.WorkspacePath;
+import com.intellij.psi.PsiClass;
+import com.intellij.psi.PsiClassOwner;
+import com.intellij.psi.PsiFile;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Integration tests for {@link JavaClassQualifiedNameReference}. */
+@RunWith(JUnit4.class)
+public class JavaClassQualifiedNameReferenceTest extends BuildFileIntegrationTestCase {
+
+  @Test
+  public void testReferencesJavaClass() {
+    PsiFile javaFile =
+        workspace.createPsiFile(
+            new WorkspacePath("java/com/google/bin/Main.java"),
+            "package com.google.bin;",
+            "public class Main {",
+            "  public void main() {}",
+            "}");
+    PsiClass javaClass = ((PsiClassOwner) javaFile).getClasses()[0];
+
+    BuildFile file =
+        createBuildFile(
+            new WorkspacePath("java/com/google/BUILD"),
+            "java_binary(",
+            "    name = 'binary',",
+            "    main_class = 'com.google.bin.Main',",
+            ")");
+
+    ArgumentList args = file.firstChildOfClass(FuncallExpression.class).getArgList();
+    assertThat(args.getKeywordArgument("main_class").getValue().getReferencedElement())
+        .isEqualTo(javaClass);
+  }
+}
diff --git a/java/tests/integrationtests/com/google/idea/blaze/java/run/BlazeJavaTestEventsHandlerTest.java b/java/tests/integrationtests/com/google/idea/blaze/java/run/BlazeJavaTestEventsHandlerTest.java
new file mode 100644
index 0000000..ddde4ee
--- /dev/null
+++ b/java/tests/integrationtests/com/google/idea/blaze/java/run/BlazeJavaTestEventsHandlerTest.java
@@ -0,0 +1,107 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.java.run;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.Iterables;
+import com.google.idea.blaze.base.BlazeIntegrationTestCase;
+import com.google.idea.blaze.base.model.primitives.WorkspacePath;
+import com.intellij.execution.Location;
+import com.intellij.openapi.vfs.VirtualFileManager;
+import com.intellij.psi.PsiClass;
+import com.intellij.psi.PsiClassOwner;
+import com.intellij.psi.PsiFile;
+import com.intellij.psi.PsiMethod;
+import com.intellij.psi.search.GlobalSearchScope;
+import javax.annotation.Nullable;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Integration tests for {@link BlazeJavaTestEventsHandler}. */
+@RunWith(JUnit4.class)
+public class BlazeJavaTestEventsHandlerTest extends BlazeIntegrationTestCase {
+
+  private final BlazeJavaTestEventsHandler handler = new BlazeJavaTestEventsHandler();
+
+  @Test
+  public void testSuiteLocationResolves() {
+    PsiFile javaFile =
+        workspace.createPsiFile(
+            new WorkspacePath("java/com/google/lib/JavaClass.java"),
+            "package com.google.lib;",
+            "public class JavaClass {}");
+    PsiClass javaClass = ((PsiClassOwner) javaFile).getClasses()[0];
+    assertThat(javaClass).isNotNull();
+
+    String url = handler.suiteLocationUrl(null, "com.google.lib.JavaClass");
+    Location<?> location = getLocation(url);
+    assertThat(location.getPsiElement()).isEqualTo(javaClass);
+  }
+
+  @Test
+  public void testMethodLocationResolves() {
+    PsiFile javaFile =
+        workspace.createPsiFile(
+            new WorkspacePath("java/com/google/lib/JavaClass.java"),
+            "package com.google.lib;",
+            "public class JavaClass {",
+            "  public void testMethod() {}",
+            "}");
+    PsiClass javaClass = ((PsiClassOwner) javaFile).getClasses()[0];
+    PsiMethod method = javaClass.findMethodsByName("testMethod", false)[0];
+    assertThat(method).isNotNull();
+
+    String url = handler.testLocationUrl(null, null, "testMethod", "com.google.lib.JavaClass");
+    Location<?> location = getLocation(url);
+    assertThat(location.getPsiElement()).isEqualTo(method);
+  }
+
+  @Test
+  public void testParameterizedMethodLocationResolves() {
+    PsiFile javaFile =
+        workspace.createPsiFile(
+            new WorkspacePath("java/com/google/lib/JavaClass.java"),
+            "package com.google.lib;",
+            "public class JavaClass {",
+            "  public void testMethod() {}",
+            "}");
+    PsiClass javaClass = ((PsiClassOwner) javaFile).getClasses()[0];
+    PsiMethod method = javaClass.findMethodsByName("testMethod", false)[0];
+    assertThat(method).isNotNull();
+
+    String url =
+        handler.testLocationUrl(
+            null, "testMethod", "[0] true (testMethod)", "com.google.lib.JavaClass");
+    Location<?> location = getLocation(url);
+    assertThat(location.getPsiElement()).isEqualTo(method);
+  }
+
+  @Nullable
+  private Location<?> getLocation(String url) {
+    String protocol = VirtualFileManager.extractProtocol(url);
+    String path = VirtualFileManager.extractPath(url);
+    if (protocol == null) {
+      return null;
+    }
+    return Iterables.getFirst(
+        handler
+            .getTestLocator()
+            .getLocation(protocol, path, getProject(), GlobalSearchScope.allScope(getProject())),
+        null);
+  }
+}
diff --git a/java/tests/integrationtests/com/google/idea/blaze/java/run/producers/BlazeJUnitTestFilterFlagsIntegrationTest.java b/java/tests/integrationtests/com/google/idea/blaze/java/run/producers/BlazeJUnitTestFilterFlagsIntegrationTest.java
new file mode 100644
index 0000000..14159ac
--- /dev/null
+++ b/java/tests/integrationtests/com/google/idea/blaze/java/run/producers/BlazeJUnitTestFilterFlagsIntegrationTest.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.java.run.producers;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.idea.blaze.base.BlazeIntegrationTestCase;
+import com.google.idea.blaze.base.model.primitives.WorkspacePath;
+import com.intellij.execution.Location;
+import com.intellij.execution.PsiLocation;
+import com.intellij.execution.junit2.PsiMemberParameterizedLocation;
+import com.intellij.psi.PsiClass;
+import com.intellij.psi.PsiClassOwner;
+import com.intellij.psi.PsiFile;
+import com.intellij.psi.PsiMethod;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Integration tests for {@link BlazeJUnitTestFilterFlags}. The functionality that relies on
+ * PsiElements, so can't go in the unit tests.
+ */
+@RunWith(JUnit4.class)
+public class BlazeJUnitTestFilterFlagsIntegrationTest extends BlazeIntegrationTestCase {
+
+  @Before
+  public final void doSetup() {
+    // required for IntelliJ to recognize annotations, JUnit version, etc.
+    workspace.createPsiFile(
+        new WorkspacePath("org/junit/runner/RunWith.java"),
+        "package org.junit.runner;"
+            + "public @interface RunWith {"
+            + "    Class<? extends Runner> value();"
+            + "}");
+    workspace.createPsiFile(
+        new WorkspacePath("org/junit/Test"), "package org.junit;", "public @interface Test {}");
+    workspace.createPsiFile(
+        new WorkspacePath("org/junit/runners/JUnit4"),
+        "package org.junit.runners;",
+        "public class JUnit4 {}");
+  }
+
+  @Test
+  public void testParameterizedMethods() {
+    PsiFile javaFile =
+        workspace.createPsiFile(
+            new WorkspacePath("java/com/google/lib/JavaClass.java"),
+            "package com.google.lib;",
+            "import org.junit.Test;",
+            "import org.junit.runner.RunWith;",
+            "import org.junit.runners.JUnit4;",
+            "@RunWith(JUnit4.class)",
+            "public class JavaClass {",
+            "  @Test",
+            "  public void testMethod1() {}",
+            "  @Test",
+            "  public void testMethod2() {}",
+            "}");
+    PsiClass javaClass = ((PsiClassOwner) javaFile).getClasses()[0];
+
+    PsiMethod method1 = javaClass.findMethodsByName("testMethod1", false)[0];
+    Location<?> location1 =
+        new PsiMemberParameterizedLocation(getProject(), method1, javaClass, "[param]");
+
+    PsiMethod method2 = javaClass.findMethodsByName("testMethod2", false)[0];
+    Location<?> location2 =
+        new PsiMemberParameterizedLocation(getProject(), method2, javaClass, "[3]");
+
+    assertThat(
+            BlazeJUnitTestFilterFlags.testFilterForClassesAndMethods(
+                ImmutableMap.of(javaClass, ImmutableList.of(location1, location2))))
+        .isEqualTo("com.google.lib.JavaClass#(testMethod1\\[param\\]|testMethod2\\[3\\])$");
+  }
+
+  @Test
+  public void testMultipleClassesWithParameterizedMethods() {
+    PsiFile javaFile1 =
+        workspace.createPsiFile(
+            new WorkspacePath("java/com/google/lib/JavaClass1.java"),
+            "package com.google.lib;",
+            "import org.junit.Test;",
+            "import org.junit.runner.RunWith;",
+            "import org.junit.runners.JUnit4;",
+            "@RunWith(JUnit4.class)",
+            "public class JavaClass1 {",
+            "  @Test",
+            "  public void testMethod1() {}",
+            "  @Test",
+            "  public void testMethod2() {}",
+            "}");
+    PsiFile javaFile2 =
+        workspace.createPsiFile(
+            new WorkspacePath("java/com/google/lib/JavaClass2.java"),
+            "package com.google.lib;",
+            "import org.junit.Test;",
+            "import org.junit.runner.RunWith;",
+            "import org.junit.runners.JUnit4;",
+            "@RunWith(JUnit4.class)",
+            "public class JavaClass2 {",
+            "  @Test",
+            "  public void testMethod() {}",
+            "}");
+    PsiClass javaClass1 = ((PsiClassOwner) javaFile1).getClasses()[0];
+
+    PsiMethod class1Method1 = javaClass1.findMethodsByName("testMethod1", false)[0];
+    Location<?> class1Location1 =
+        new PsiMemberParameterizedLocation(getProject(), class1Method1, javaClass1, "[param]");
+
+    PsiMethod class1Method2 = javaClass1.findMethodsByName("testMethod2", false)[0];
+    Location<?> class1Location2 =
+        new PsiMemberParameterizedLocation(getProject(), class1Method2, javaClass1, "[3]");
+
+    PsiClass javaClass2 = ((PsiClassOwner) javaFile2).getClasses()[0];
+    PsiMethod class2Method = javaClass2.findMethodsByName("testMethod", false)[0];
+
+    assertThat(
+            BlazeJUnitTestFilterFlags.testFilterForClassesAndMethods(
+                ImmutableMap.of(
+                    javaClass1,
+                    ImmutableList.of(class1Location1, class1Location2),
+                    javaClass2,
+                    ImmutableList.of(new PsiLocation<>(class2Method)))))
+        .isEqualTo(
+            Joiner.on('|')
+                .join(
+                    "com.google.lib.JavaClass1#(testMethod1\\[param\\]|testMethod2\\[3\\])$",
+                    "com.google.lib.JavaClass2#testMethod$"));
+  }
+}
diff --git a/java/tests/integrationtests/com/google/idea/blaze/java/run/producers/BlazeJavaMainClassConfigurationProducerTest.java b/java/tests/integrationtests/com/google/idea/blaze/java/run/producers/BlazeJavaMainClassConfigurationProducerTest.java
new file mode 100644
index 0000000..6135b2f
--- /dev/null
+++ b/java/tests/integrationtests/com/google/idea/blaze/java/run/producers/BlazeJavaMainClassConfigurationProducerTest.java
@@ -0,0 +1,264 @@
+/*
+ * Copyright 2017 The Bazel Authors. 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.idea.blaze.java.run.producers;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.idea.blaze.base.BlazeIntegrationTestCase;
+import com.google.idea.blaze.base.EditorTestHelper;
+import com.google.idea.blaze.base.ideinfo.ArtifactLocation;
+import com.google.idea.blaze.base.ideinfo.JavaIdeInfo;
+import com.google.idea.blaze.base.ideinfo.TargetIdeInfo;
+import com.google.idea.blaze.base.ideinfo.TargetMap;
+import com.google.idea.blaze.base.ideinfo.TargetMapBuilder;
+import com.google.idea.blaze.base.model.MockBlazeProjectDataBuilder;
+import com.google.idea.blaze.base.model.MockBlazeProjectDataManager;
+import com.google.idea.blaze.base.model.primitives.ExecutionRootPath;
+import com.google.idea.blaze.base.model.primitives.TargetExpression;
+import com.google.idea.blaze.base.model.primitives.WorkspacePath;
+import com.google.idea.blaze.base.run.BlazeRunConfiguration;
+import com.google.idea.blaze.base.sync.SyncCache;
+import com.google.idea.blaze.base.sync.data.BlazeProjectDataManager;
+import com.google.idea.blaze.base.sync.workspace.BlazeRoots;
+import com.intellij.execution.Location;
+import com.intellij.execution.PsiLocation;
+import com.intellij.execution.RunnerAndConfigurationSettings;
+import com.intellij.execution.actions.ConfigurationContext;
+import com.intellij.execution.configurations.RunConfiguration;
+import com.intellij.openapi.actionSystem.CommonDataKeys;
+import com.intellij.openapi.actionSystem.LangDataKeys;
+import com.intellij.openapi.module.ModuleUtil;
+import com.intellij.openapi.vfs.VirtualFile;
+import com.intellij.psi.PsiFile;
+import com.intellij.testFramework.MapDataContext;
+import java.io.File;
+import org.jetbrains.annotations.Nullable;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Integration tests for {@link BlazeJavaMainClassConfigurationProducer}. */
+@RunWith(JUnit4.class)
+public class BlazeJavaMainClassConfigurationProducerTest extends BlazeIntegrationTestCase {
+
+  private EditorTestHelper editorTest;
+
+  @Before
+  public final void doSetup() {
+    BlazeProjectDataManager mockProjectDataManager =
+        new MockBlazeProjectDataManager(getMockBlazeProjectDataBuilder().build());
+    registerProjectService(BlazeProjectDataManager.class, mockProjectDataManager);
+    editorTest = new EditorTestHelper(getProject(), testFixture);
+  }
+
+  @After
+  public final void doTearDown() {
+    SyncCache.getInstance(getProject()).clear();
+  }
+
+  @Test
+  public void testUniqueJavaBinaryChosen() {
+    setTargets(
+        TargetMapBuilder.builder()
+            .addTarget(
+                TargetIdeInfo.builder()
+                    .setKind("java_binary")
+                    .setLabel("//com/google/binary:UnrelatedName")
+                    .addSource(sourceRoot("com/google/binary/MainClass.java"))
+                    .build())
+            .build());
+
+    PsiFile javaClass =
+        workspace.createPsiFile(
+            WorkspacePath.createIfValid("com/google/binary/MainClass.java"),
+            "package com.google.binary;",
+            "import java.lang.String;",
+            "public class MainClass {",
+            "  public static void main(String[] args) {}",
+            "}");
+
+    RunConfiguration config = createConfigurationFromLocation(javaClass);
+
+    assertThat(config).isInstanceOf(BlazeRunConfiguration.class);
+    BlazeRunConfiguration blazeConfig = (BlazeRunConfiguration) config;
+    assertThat(blazeConfig.getTarget())
+        .isEqualTo(TargetExpression.fromString("//com/google/binary:UnrelatedName"));
+  }
+
+  @Test
+  public void testNoJavaBinaryChosenIfNotInRDeps() {
+    setTargets(
+        TargetMapBuilder.builder()
+            .addTarget(
+                TargetIdeInfo.builder()
+                    .setKind("java_binary")
+                    .setLabel("//com/google/binary:MainClass")
+                    .addSource(sourceRoot("com/google/binary/OtherClass.java"))
+                    .build())
+            .build());
+
+    PsiFile javaClass =
+        workspace.createPsiFile(
+            WorkspacePath.createIfValid("com/google/binary/MainClass.java"),
+            "package com.google.binary;",
+            "import java.lang.String;",
+            "public class MainClass {",
+            "  public static void main(String[] args) {}",
+            "}");
+
+    assertThat(createConfigurationFromLocation(javaClass))
+        .isNotInstanceOf(BlazeRunConfiguration.class);
+  }
+
+  @Test
+  public void testNoResultForClassWithoutMainMethod() {
+    setTargets(
+        TargetMapBuilder.builder()
+            .addTarget(
+                TargetIdeInfo.builder()
+                    .setKind("java_binary")
+                    .setLabel("//com/google/binary:MainClass")
+                    .addSource(sourceRoot("com/google/binary/MainClass.java"))
+                    .setJavaInfo(JavaIdeInfo.builder().setMainClass("com.google.binary.MainClass"))
+                    .build())
+            .build());
+
+    PsiFile javaClass =
+        workspace.createPsiFile(
+            WorkspacePath.createIfValid("com/google/binary/MainClass.java"),
+            "package com.google.binary;",
+            "public class MainClass {}");
+
+    assertThat(createConfigurationFromLocation(javaClass)).isNull();
+  }
+
+  @Test
+  public void testJavaBinaryWithMatchingNameChosen() {
+    setTargets(
+        TargetMapBuilder.builder()
+            .addTarget(
+                TargetIdeInfo.builder()
+                    .setKind("java_binary")
+                    .setLabel("//com/google/binary:UnrelatedName")
+                    .addSource(sourceRoot("com/google/binary/MainClass.java"))
+                    .build())
+            .addTarget(
+                TargetIdeInfo.builder()
+                    .setKind("java_binary")
+                    .setLabel("//com/google/binary:MainClass")
+                    .addSource(sourceRoot("com/google/binary/MainClass.java"))
+                    .build())
+            .build());
+
+    PsiFile javaClass =
+        workspace.createPsiFile(
+            WorkspacePath.createIfValid("com/google/binary/MainClass.java"),
+            "package com.google.binary;",
+            "import java.lang.String;",
+            "public class MainClass {",
+            "  public static void main(String[] args) {}",
+            "}");
+
+    RunConfiguration config = createConfigurationFromLocation(javaClass);
+    assertThat(config).isInstanceOf(BlazeRunConfiguration.class);
+    BlazeRunConfiguration blazeConfig = (BlazeRunConfiguration) config;
+    assertThat(blazeConfig.getTarget())
+        .isEqualTo(TargetExpression.fromString("//com/google/binary:MainClass"));
+  }
+
+  @Test
+  public void testJavaBinaryWithMatchingMainClassChosen() {
+    setTargets(
+        TargetMapBuilder.builder()
+            .addTarget(
+                TargetIdeInfo.builder()
+                    .setKind("java_binary")
+                    .setLabel("//com/google/binary:UnrelatedName")
+                    .addSource(sourceRoot("com/google/binary/MainClass.java"))
+                    .build())
+            .addTarget(
+                TargetIdeInfo.builder()
+                    .setKind("java_binary")
+                    .setLabel("//com/google/binary:OtherName")
+                    .setJavaInfo(JavaIdeInfo.builder().setMainClass("com.google.binary.MainClass"))
+                    .addSource(sourceRoot("com/google/binary/MainClass.java"))
+                    .build())
+            .build());
+
+    PsiFile javaClass =
+        workspace.createPsiFile(
+            WorkspacePath.createIfValid("com/google/binary/MainClass.java"),
+            "package com.google.binary;",
+            "import java.lang.String;",
+            "public class MainClass {",
+            "  public static void main(String[] args) {}",
+            "}");
+
+    RunConfiguration config = createConfigurationFromLocation(javaClass);
+
+    assertThat(config).isInstanceOf(BlazeRunConfiguration.class);
+    BlazeRunConfiguration blazeConfig = (BlazeRunConfiguration) config;
+    assertThat(blazeConfig.getTarget())
+        .isEqualTo(TargetExpression.fromString("//com/google/binary:OtherName"));
+  }
+
+  @Nullable
+  private RunConfiguration createConfigurationFromLocation(PsiFile psiFile) {
+    // a nauseating hack to force IntelliJ to recognize 'main' methods...
+    workspace.createPsiFile(
+        WorkspacePath.createIfValid("java/lang/String.java"),
+        "package java.lang;",
+        "public class String {}");
+    editorTest.openFileInEditor(psiFile);
+
+    final MapDataContext dataContext = new MapDataContext();
+
+    dataContext.put(CommonDataKeys.PROJECT, getProject());
+    dataContext.put(LangDataKeys.MODULE, ModuleUtil.findModuleForPsiElement(psiFile));
+    dataContext.put(Location.DATA_KEY, PsiLocation.fromPsiElement(psiFile));
+    RunnerAndConfigurationSettings settings =
+        ConfigurationContext.getFromContext(dataContext).getConfiguration();
+    return settings != null ? settings.getConfiguration() : null;
+  }
+
+  private MockBlazeProjectDataBuilder getMockBlazeProjectDataBuilder() {
+    String executionRootPath = "usr/local/_blaze_";
+    VirtualFile vf = fileSystem.createDirectory(executionRootPath);
+    BlazeRoots fakeRoots =
+        new BlazeRoots(
+            new File(vf.getPath()),
+            ImmutableList.of(workspaceRoot.directory()),
+            new ExecutionRootPath("out/crosstool/bin"),
+            new ExecutionRootPath("out/crosstool/gen"),
+            null);
+    return MockBlazeProjectDataBuilder.builder(workspaceRoot).setBlazeRoots(fakeRoots);
+  }
+
+  private void setTargets(TargetMap targets) {
+    BlazeProjectDataManager mockProjectDataManager =
+        new MockBlazeProjectDataManager(
+            getMockBlazeProjectDataBuilder().setTargetMap(targets).build());
+    registerProjectService(BlazeProjectDataManager.class, mockProjectDataManager);
+    SyncCache.getInstance(getProject()).clear();
+  }
+
+  private static ArtifactLocation sourceRoot(String relativePath) {
+    return ArtifactLocation.builder().setRelativePath(relativePath).setIsSource(true).build();
+  }
+}
diff --git a/java/tests/integrationtests/com/google/idea/blaze/java/sync/projectstructure/JavaSourceFolderProviderTest.java b/java/tests/integrationtests/com/google/idea/blaze/java/sync/projectstructure/JavaSourceFolderProviderTest.java
index 73e67a3..bd15bde 100644
--- a/java/tests/integrationtests/com/google/idea/blaze/java/sync/projectstructure/JavaSourceFolderProviderTest.java
+++ b/java/tests/integrationtests/com/google/idea/blaze/java/sync/projectstructure/JavaSourceFolderProviderTest.java
@@ -31,6 +31,7 @@
 import com.intellij.openapi.roots.ModuleRootManager;
 import com.intellij.openapi.roots.SourceFolder;
 import com.intellij.openapi.vfs.VirtualFile;
+import java.io.File;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
@@ -77,21 +78,21 @@
     VirtualFile gen = workspace.createDirectory(new WorkspacePath("java/apps/gen"));
     VirtualFile res = workspace.createDirectory(new WorkspacePath("java/apps/resources"));
 
-    ImmutableMap<VirtualFile, SourceFolder> sourceFolders =
+    ImmutableMap<File, SourceFolder> sourceFolders =
         provider.initializeSourceFolders(getContentEntry(root));
     assertThat(sourceFolders).hasSize(3);
 
-    SourceFolder rootSource = sourceFolders.get(root);
+    SourceFolder rootSource = sourceFolders.get(new File(root.getPath()));
     assertThat(rootSource.getPackagePrefix()).isEqualTo("apps");
     assertThat(JavaSourceFolderProvider.isGenerated(rootSource)).isFalse();
     assertThat(JavaSourceFolderProvider.isResource(rootSource)).isFalse();
 
-    SourceFolder genSource = sourceFolders.get(gen);
+    SourceFolder genSource = sourceFolders.get(new File(gen.getPath()));
     assertThat(genSource.getPackagePrefix()).isEqualTo("apps.gen");
     assertThat(JavaSourceFolderProvider.isGenerated(genSource)).isTrue();
     assertThat(JavaSourceFolderProvider.isResource(genSource)).isFalse();
 
-    SourceFolder resSource = sourceFolders.get(res);
+    SourceFolder resSource = sourceFolders.get(new File(res.getPath()));
     assertThat(JavaSourceFolderProvider.isGenerated(resSource)).isFalse();
     assertThat(JavaSourceFolderProvider.isResource(resSource)).isTrue();
 
@@ -99,7 +100,8 @@
     sourceFolders = provider.initializeSourceFolders(getContentEntry(testRoot));
 
     assertThat(sourceFolders).hasSize(1);
-    assertThat(sourceFolders.get(testRoot).getPackagePrefix()).isEqualTo("apps.example");
+    assertThat(sourceFolders.get(new File(testRoot.getPath())).getPackagePrefix())
+        .isEqualTo("apps.example");
   }
 
   @Test
@@ -123,16 +125,19 @@
     VirtualFile root = workspace.createDirectory(new WorkspacePath("java/apps"));
     ContentEntry contentEntry = getContentEntry(root);
 
-    ImmutableMap<VirtualFile, SourceFolder> sourceFolders =
-        provider.initializeSourceFolders(contentEntry);
+    ImmutableMap<File, SourceFolder> sourceFolders = provider.initializeSourceFolders(contentEntry);
     assertThat(sourceFolders).hasSize(1);
 
-    VirtualFile testRoot = workspace.createDirectory(new WorkspacePath("java/apps/tests"));
+    VirtualFile testRoot = workspace.createDirectory(new WorkspacePath("java/apps/tests/model"));
 
     SourceFolder testSourceChild =
-        provider.setSourceFolderForLocation(contentEntry, sourceFolders.get(root), testRoot, true);
+        provider.setSourceFolderForLocation(
+            contentEntry,
+            sourceFolders.get(new File(root.getPath())),
+            new File(testRoot.getPath()),
+            true);
     assertThat(testSourceChild.isTestSource()).isTrue();
-    assertThat(testSourceChild.getPackagePrefix()).isEqualTo("apps.tests");
+    assertThat(testSourceChild.getPackagePrefix()).isEqualTo("apps.tests.model");
   }
 
   private ContentEntry getContentEntry(VirtualFile root) {
diff --git a/java/tests/unittests/com/google/idea/blaze/java/run/BlazeJavaRunProfileStateTest.java b/java/tests/unittests/com/google/idea/blaze/java/run/BlazeJavaRunProfileStateTest.java
index 10e43eb..622105e 100644
--- a/java/tests/unittests/com/google/idea/blaze/java/run/BlazeJavaRunProfileStateTest.java
+++ b/java/tests/unittests/com/google/idea/blaze/java/run/BlazeJavaRunProfileStateTest.java
@@ -102,6 +102,7 @@
                 BlazeFlags.getToolTagFlag(),
                 "--flag1",
                 "--flag2",
+                "--test_output=streamed",
                 "--",
                 "//label:rule"));
   }
diff --git a/java/tests/unittests/com/google/idea/blaze/java/run/producers/BlazeJUnitTestFilterFlagsTest.java b/java/tests/unittests/com/google/idea/blaze/java/run/producers/BlazeJUnitTestFilterFlagsTest.java
index d5d2cde..2f5917c 100644
--- a/java/tests/unittests/com/google/idea/blaze/java/run/producers/BlazeJUnitTestFilterFlagsTest.java
+++ b/java/tests/unittests/com/google/idea/blaze/java/run/producers/BlazeJUnitTestFilterFlagsTest.java
@@ -42,7 +42,7 @@
   public void testSingleJUnit4ClassFilter() {
     assertThat(
             BlazeJUnitTestFilterFlags.testFilterForClassAndMethods(
-                "com.google.idea.ClassName", ImmutableList.of(), JUnitVersion.JUNIT_4, false))
+                "com.google.idea.ClassName", JUnitVersion.JUNIT_4, ImmutableList.of()))
         .isEqualTo("com.google.idea.ClassName#");
   }
 
@@ -50,7 +50,7 @@
   public void testSingleJUnit3ClassFilter() {
     assertThat(
             BlazeJUnitTestFilterFlags.testFilterForClassAndMethods(
-                "com.google.idea.ClassName", ImmutableList.of(), JUnitVersion.JUNIT_3, false))
+                "com.google.idea.ClassName", JUnitVersion.JUNIT_3, ImmutableList.of()))
         .isEqualTo("com.google.idea.ClassName");
   }
 
@@ -58,7 +58,7 @@
   public void testParameterizedIgnoredForSingleClass() {
     assertThat(
             BlazeJUnitTestFilterFlags.testFilterForClassAndMethods(
-                "com.google.idea.ClassName", ImmutableList.of(), JUnitVersion.JUNIT_4, true))
+                "com.google.idea.ClassName", JUnitVersion.JUNIT_4, ImmutableList.of()))
         .isEqualTo("com.google.idea.ClassName#");
   }
 
@@ -66,10 +66,7 @@
   public void testJUnit4ClassAndSingleMethod() {
     assertThat(
             BlazeJUnitTestFilterFlags.testFilterForClassAndMethods(
-                "com.google.idea.ClassName",
-                ImmutableList.of("testMethod1"),
-                JUnitVersion.JUNIT_4,
-                false))
+                "com.google.idea.ClassName", JUnitVersion.JUNIT_4, ImmutableList.of("testMethod1")))
         .isEqualTo("com.google.idea.ClassName#testMethod1$");
   }
 
@@ -77,10 +74,7 @@
   public void testJUnit3ClassAndSingleMethod() {
     assertThat(
             BlazeJUnitTestFilterFlags.testFilterForClassAndMethods(
-                "com.google.idea.ClassName",
-                ImmutableList.of("testMethod1"),
-                JUnitVersion.JUNIT_3,
-                false))
+                "com.google.idea.ClassName", JUnitVersion.JUNIT_3, ImmutableList.of("testMethod1")))
         .isEqualTo("com.google.idea.ClassName#testMethod1");
   }
 
@@ -89,9 +83,8 @@
     assertThat(
             BlazeJUnitTestFilterFlags.testFilterForClassAndMethods(
                 "com.google.idea.ClassName",
-                ImmutableList.of("testMethod1", "testMethod2"),
                 JUnitVersion.JUNIT_4,
-                false))
+                ImmutableList.of("testMethod1", "testMethod2")))
         .isEqualTo("com.google.idea.ClassName#(testMethod1|testMethod2)$");
   }
 
@@ -100,10 +93,9 @@
     assertThat(
             BlazeJUnitTestFilterFlags.testFilterForClassAndMethods(
                 "com.google.idea.ClassName",
-                ImmutableList.of("testMethod1", "testMethod2"),
                 JUnitVersion.JUNIT_4,
-                true))
-        .isEqualTo("com.google.idea.ClassName#(testMethod1|testMethod2)(\\[.+\\])?$");
+                ImmutableList.of("testMethod1(\\[.+\\])?", "testMethod2(\\[.+\\])?")))
+        .isEqualTo("com.google.idea.ClassName#(testMethod1(\\[.+\\])?|testMethod2(\\[.+\\])?)$");
   }
 
   @Test
@@ -111,20 +103,8 @@
     assertThat(
             BlazeJUnitTestFilterFlags.testFilterForClassAndMethods(
                 "com.google.idea.ClassName",
-                ImmutableList.of("testMethod1", "testMethod2"),
                 JUnitVersion.JUNIT_3,
-                false))
-        .isEqualTo("com.google.idea.ClassName#testMethod1,testMethod2");
-  }
-
-  @Test
-  public void testParameterizedIgnoredForJUnit3() {
-    assertThat(
-            BlazeJUnitTestFilterFlags.testFilterForClassAndMethods(
-                "com.google.idea.ClassName",
-                ImmutableList.of("testMethod1", "testMethod2"),
-                JUnitVersion.JUNIT_3,
-                true))
+                ImmutableList.of("testMethod1", "testMethod2")))
         .isEqualTo("com.google.idea.ClassName#testMethod1,testMethod2");
   }
 }
diff --git a/java/tests/unittests/com/google/idea/blaze/java/sync/source/SourceDirectoryCalculatorTest.java b/java/tests/unittests/com/google/idea/blaze/java/sync/source/SourceDirectoryCalculatorTest.java
index cd5b051..25fac98 100644
--- a/java/tests/unittests/com/google/idea/blaze/java/sync/source/SourceDirectoryCalculatorTest.java
+++ b/java/tests/unittests/com/google/idea/blaze/java/sync/source/SourceDirectoryCalculatorTest.java
@@ -159,6 +159,33 @@
   }
 
   @Test
+  public void testHandlesSourceAtProjectRoot() throws Exception {
+    mockInputStreamProvider.addFile("/root/Bla.java", "package com.google;\n public class Bla {}");
+    List<SourceArtifact> sourceArtifacts =
+        ImmutableList.of(
+            SourceArtifact.builder(TargetKey.forPlainTarget(LABEL))
+                .setArtifactLocation(
+                    ArtifactLocation.builder().setRelativePath("Bla.java").setIsSource(true))
+                .build());
+    ImmutableList<BlazeContentEntry> result =
+        sourceDirectoryCalculator.calculateContentEntries(
+            project,
+            context,
+            workspaceRoot,
+            decoder,
+            ImmutableList.of(new WorkspacePath("")),
+            sourceArtifacts,
+            NO_MANIFESTS);
+    assertThat(result)
+        .containsExactly(
+            BlazeContentEntry.builder("/root")
+                .addSource(
+                    BlazeSourceDirectory.builder("/root").setPackagePrefix("com.google").build())
+                .build());
+    issues.assertNoIssues();
+  }
+
+  @Test
   public void testSourcesToSourceDirectories_testReturnsTest() throws Exception {
     mockInputStreamProvider.addFile(
         "/root/java/com/google/Bla.java", "package com.google;\n public class Bla {}");
diff --git a/plugin_dev/src/com/google/idea/blaze/plugin/IntellijPluginRule.java b/plugin_dev/src/com/google/idea/blaze/plugin/IntellijPluginRule.java
index c669c8f..3947b69 100644
--- a/plugin_dev/src/com/google/idea/blaze/plugin/IntellijPluginRule.java
+++ b/plugin_dev/src/com/google/idea/blaze/plugin/IntellijPluginRule.java
@@ -25,7 +25,13 @@
   public static final String TARGET_TAG_IJ_PLUGIN_BUNDLE = "intellij-plugin-bundle";
 
   public static boolean isPluginTarget(TargetIdeInfo target) {
-    return isPluginBundle(target) || isSinglePluginTarget(target);
+    return isIntellijPluginDebugTarget(target)
+        || isPluginBundle(target)
+        || isSinglePluginTarget(target);
+  }
+
+  public static boolean isIntellijPluginDebugTarget(TargetIdeInfo target) {
+    return target.intellijPluginDeployInfo != null;
   }
 
   public static boolean isPluginBundle(TargetIdeInfo target) {
@@ -36,5 +42,4 @@
   public static boolean isSinglePluginTarget(TargetIdeInfo target) {
     return target.kindIsOneOf(Kind.JAVA_IMPORT) && target.tags.contains(TARGET_TAG_IJ_PLUGIN);
   }
-
 }
diff --git a/plugin_dev/src/com/google/idea/blaze/plugin/run/BlazeIntellijPluginConfiguration.java b/plugin_dev/src/com/google/idea/blaze/plugin/run/BlazeIntellijPluginConfiguration.java
index a68f89b..528cac4 100644
--- a/plugin_dev/src/com/google/idea/blaze/plugin/run/BlazeIntellijPluginConfiguration.java
+++ b/plugin_dev/src/com/google/idea/blaze/plugin/run/BlazeIntellijPluginConfiguration.java
@@ -18,18 +18,12 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Ordering;
 import com.google.idea.blaze.base.command.BlazeCommand;
 import com.google.idea.blaze.base.command.BlazeCommandName;
 import com.google.idea.blaze.base.command.BlazeFlags;
-import com.google.idea.blaze.base.ideinfo.Dependency;
-import com.google.idea.blaze.base.ideinfo.Dependency.DependencyType;
-import com.google.idea.blaze.base.ideinfo.JavaIdeInfo;
-import com.google.idea.blaze.base.ideinfo.LibraryArtifact;
 import com.google.idea.blaze.base.ideinfo.TargetIdeInfo;
-import com.google.idea.blaze.base.model.BlazeProjectData;
 import com.google.idea.blaze.base.model.primitives.Label;
 import com.google.idea.blaze.base.model.primitives.TargetExpression;
 import com.google.idea.blaze.base.projectview.ProjectViewSet;
@@ -39,8 +33,6 @@
 import com.google.idea.blaze.base.run.state.RunConfigurationStateEditor;
 import com.google.idea.blaze.base.run.targetfinder.TargetFinder;
 import com.google.idea.blaze.base.settings.Blaze;
-import com.google.idea.blaze.base.sync.data.BlazeProjectDataManager;
-import com.google.idea.blaze.base.sync.workspace.ArtifactLocationDecoder;
 import com.google.idea.blaze.base.ui.UiUtil;
 import com.google.idea.blaze.plugin.IntellijPluginRule;
 import com.intellij.execution.ExecutionException;
@@ -59,8 +51,6 @@
 import com.intellij.execution.process.ProcessAdapter;
 import com.intellij.execution.process.ProcessEvent;
 import com.intellij.execution.runners.ExecutionEnvironment;
-import com.intellij.ide.plugins.IdeaPluginDescriptor;
-import com.intellij.ide.plugins.PluginManagerCore;
 import com.intellij.openapi.application.JetBrainsProtocolHandler;
 import com.intellij.openapi.module.Module;
 import com.intellij.openapi.options.ConfigurationException;
@@ -73,7 +63,6 @@
 import com.intellij.openapi.roots.ui.configuration.projectRoot.ProjectSdksModel;
 import com.intellij.openapi.ui.ComboBox;
 import com.intellij.openapi.ui.LabeledComponent;
-import com.intellij.openapi.util.BuildNumber;
 import com.intellij.openapi.util.InvalidDataException;
 import com.intellij.openapi.util.WriteExternalException;
 import com.intellij.ui.ListCellRendererWrapper;
@@ -83,11 +72,8 @@
 import java.awt.BorderLayout;
 import java.io.File;
 import java.io.IOException;
-import java.nio.file.Files;
 import java.nio.file.Paths;
-import java.nio.file.StandardCopyOption;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.List;
 import javax.annotation.Nullable;
 import javax.swing.DefaultComboBoxModel;
@@ -181,56 +167,6 @@
     return result;
   }
 
-  private ImmutableList<File> findPluginJars() throws ExecutionException {
-    BlazeProjectData blazeProjectData =
-        BlazeProjectDataManager.getInstance(getProject()).getBlazeProjectData();
-    if (blazeProjectData == null) {
-      throw new ExecutionException("Not synced yet, please sync project");
-    }
-    TargetIdeInfo target = TargetFinder.getInstance().targetForLabel(getProject(), getTarget());
-    if (target == null) {
-      throw new ExecutionException(
-          buildSystem + " target '" + getTarget() + "' not imported during sync");
-    }
-    return IntellijPluginRule.isPluginBundle(target)
-        ? findBundledJars(blazeProjectData.artifactLocationDecoder, target)
-        : ImmutableList.of(findPluginJar(blazeProjectData.artifactLocationDecoder, target));
-  }
-
-  private ImmutableList<File> findBundledJars(
-      ArtifactLocationDecoder artifactLocationDecoder, TargetIdeInfo target)
-      throws ExecutionException {
-    ImmutableList.Builder<File> jars = ImmutableList.builder();
-    for (Dependency dep : target.dependencies) {
-      if (dep.dependencyType == DependencyType.COMPILE_TIME && dep.targetKey.isPlainTarget()) {
-        TargetIdeInfo depTarget =
-            TargetFinder.getInstance().targetForLabel(getProject(), dep.targetKey.label);
-        if (depTarget != null && IntellijPluginRule.isSinglePluginTarget(depTarget)) {
-          jars.add(findPluginJar(artifactLocationDecoder, depTarget));
-        }
-      }
-    }
-    return jars.build();
-  }
-
-  private File findPluginJar(ArtifactLocationDecoder artifactLocationDecoder, TargetIdeInfo target)
-      throws ExecutionException {
-    JavaIdeInfo javaIdeInfo = target.javaIdeInfo;
-    if (!IntellijPluginRule.isSinglePluginTarget(target) || javaIdeInfo == null) {
-      throw new ExecutionException(
-          buildSystem + " target '" + target + "' is not a valid intellij_plugin target");
-    }
-    Collection<LibraryArtifact> jars = javaIdeInfo.jars;
-    if (javaIdeInfo.jars.size() > 1) {
-      throw new ExecutionException("Invalid IntelliJ plugin target: it has multiple output jars");
-    }
-    LibraryArtifact artifact = jars.isEmpty() ? null : jars.iterator().next();
-    if (artifact == null || artifact.classJar == null) {
-      throw new ExecutionException("No output plugin jar found for '" + target + "'");
-    }
-    return artifactLocationDecoder.decode(artifact.classJar);
-  }
-
   /**
    * Plugin jar has been previously created via blaze build. This method: - copies jar to sandbox
    * environment - cracks open jar and finds plugin.xml (with ID, etc., needed for JVM args) - sets
@@ -254,15 +190,11 @@
     } catch (IOException e) {
       throw new ExecutionException("No sandbox specified for IntelliJ Platform Plugin SDK");
     }
-    final String canonicalSandbox = sandboxHome;
-    final ImmutableList<File> pluginJars = findPluginJars();
-    for (File file : pluginJars) {
-      if (!file.exists()) {
-        throw new ExecutionException(
-            String.format(
-                "Plugin jar '%s' not found. Did the %s build fail?", file.getName(), buildSystem));
-      }
-    }
+    String buildNumber = IdeaJdkHelper.getBuildNumber(ideaJdk);
+    final BlazeIntellijPluginDeployer deployer =
+        new BlazeIntellijPluginDeployer(getProject(), sandboxHome, buildNumber);
+    deployer.addTarget(getTarget());
+
     // copy license from running instance of idea
     IdeaJdkHelper.copyIDEALicense(sandboxHome);
 
@@ -270,11 +202,7 @@
         new JavaCommandLineState(env) {
           @Override
           protected JavaParameters createJavaParameters() throws ExecutionException {
-            String buildNumber = IdeaJdkHelper.getBuildNumber(ideaJdk);
-            List<String> pluginIds = Lists.newArrayList();
-            for (File jar : pluginJars) {
-              pluginIds.add(copyPluginJarToSandbox(jar, buildNumber, canonicalSandbox));
-            }
+            List<String> pluginIds = deployer.deploy();
 
             final JavaParameters params = new JavaParameters();
 
@@ -304,9 +232,7 @@
                 new ProcessAdapter() {
                   @Override
                   public void processTerminated(ProcessEvent event) {
-                    for (File jar : pluginJars) {
-                      pluginDestination(jar, canonicalSandbox).delete();
-                    }
+                    deployer.deleteDeployment();
                   }
                 });
             return handler;
@@ -315,31 +241,6 @@
     return state;
   }
 
-  private static File pluginDestination(File jar, String sandboxPath) {
-    return new File(sandboxPath, "plugins/" + jar.getName());
-  }
-
-  /** Copies the plugin jar to the sandbox, and returns the plugin ID. */
-  private static String copyPluginJarToSandbox(File jar, String buildNumber, String sandboxPath)
-      throws ExecutionException {
-    IdeaPluginDescriptor pluginDescriptor = PluginManagerCore.loadDescriptor(jar, "plugin.xml");
-    if (PluginManagerCore.isIncompatible(pluginDescriptor, BuildNumber.fromString(buildNumber))) {
-      throw new ExecutionException(
-          String.format(
-              "Plugin SDK version '%s' is incompatible with this plugin "
-                  + "(since: '%s', until: '%s')",
-              buildNumber, pluginDescriptor.getSinceBuild(), pluginDescriptor.getUntilBuild()));
-    }
-    File pluginJarDestination = pluginDestination(jar, sandboxPath);
-    try {
-      pluginJarDestination.getParentFile().mkdirs();
-      Files.copy(jar.toPath(), pluginJarDestination.toPath(), StandardCopyOption.REPLACE_EXISTING);
-    } catch (IOException e) {
-      throw new ExecutionException("Error copying plugin jar to sandbox", e);
-    }
-    return pluginDescriptor.getPluginId().getIdString();
-  }
-
   private static void fillParameterList(ParametersList list, @Nullable String value) {
     if (value == null) {
       return;
diff --git a/plugin_dev/src/com/google/idea/blaze/plugin/run/BlazeIntellijPluginDeployer.java b/plugin_dev/src/com/google/idea/blaze/plugin/run/BlazeIntellijPluginDeployer.java
new file mode 100644
index 0000000..c06caf2
--- /dev/null
+++ b/plugin_dev/src/com/google/idea/blaze/plugin/run/BlazeIntellijPluginDeployer.java
@@ -0,0 +1,206 @@
+/*
+ * Copyright 2016 The Bazel Authors. 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.idea.blaze.plugin.run;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.idea.blaze.base.ideinfo.Dependency;
+import com.google.idea.blaze.base.ideinfo.Dependency.DependencyType;
+import com.google.idea.blaze.base.ideinfo.IntellijPluginDeployInfo;
+import com.google.idea.blaze.base.ideinfo.IntellijPluginDeployInfo.IntellijPluginDeployFile;
+import com.google.idea.blaze.base.ideinfo.JavaIdeInfo;
+import com.google.idea.blaze.base.ideinfo.LibraryArtifact;
+import com.google.idea.blaze.base.ideinfo.TargetIdeInfo;
+import com.google.idea.blaze.base.ideinfo.TargetKey;
+import com.google.idea.blaze.base.ideinfo.TargetMap;
+import com.google.idea.blaze.base.model.BlazeProjectData;
+import com.google.idea.blaze.base.model.primitives.Label;
+import com.google.idea.blaze.base.sync.data.BlazeProjectDataManager;
+import com.google.idea.blaze.base.sync.workspace.ArtifactLocationDecoder;
+import com.google.idea.blaze.plugin.IntellijPluginRule;
+import com.intellij.execution.ExecutionException;
+import com.intellij.ide.plugins.IdeaPluginDescriptor;
+import com.intellij.ide.plugins.PluginManagerCore;
+import com.intellij.openapi.project.Project;
+import com.intellij.openapi.util.BuildNumber;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.StandardCopyOption;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import org.jetbrains.annotations.Nullable;
+
+/** Handles finding files to deploy and copying these into the sandbox. */
+class BlazeIntellijPluginDeployer {
+  private final String sandboxHome;
+  private final String buildNumber;
+  private final TargetMap targetMap;
+  private final ArtifactLocationDecoder artifactLocationDecoder;
+  private Map<File, File> filesToDeploy = Maps.newHashMap();
+
+  BlazeIntellijPluginDeployer(Project project, String sandboxHome, String buildNumber)
+      throws ExecutionException {
+    BlazeProjectData blazeProjectData =
+        BlazeProjectDataManager.getInstance(project).getBlazeProjectData();
+    if (blazeProjectData == null) {
+      throw new ExecutionException("Not synced yet, please sync project");
+    }
+    this.sandboxHome = sandboxHome;
+    this.buildNumber = buildNumber;
+    this.targetMap = blazeProjectData.targetMap;
+    this.artifactLocationDecoder = blazeProjectData.artifactLocationDecoder;
+  }
+
+  /** Adds an intellij plugin target to deploy */
+  void addTarget(Label label) throws ExecutionException {
+    ImmutableList<IntellijPluginDeployInfo> deployInfos = findDeployInfo(label);
+    ImmutableMap<File, File> filesToDeploy = getFilesToDeploy(deployInfos);
+    this.filesToDeploy.putAll(filesToDeploy);
+  }
+
+  List<String> deploy() throws ExecutionException {
+    for (File file : filesToDeploy.keySet()) {
+      if (!file.exists()) {
+        throw new ExecutionException(
+            String.format("Plugin file '%s' not found. Did the build fail?", file.getName()));
+      }
+    }
+    List<String> pluginIds = readPluginIds(filesToDeploy.keySet());
+    for (Map.Entry<File, File> entry : filesToDeploy.entrySet()) {
+      copyFileToSandbox(entry.getKey(), entry.getValue());
+    }
+    return pluginIds;
+  }
+
+  void deleteDeployment() {
+    for (File file : filesToDeploy.values()) {
+      if (file.exists()) {
+        file.delete();
+      }
+    }
+  }
+
+  private ImmutableList<IntellijPluginDeployInfo> findDeployInfo(Label label)
+      throws ExecutionException {
+    TargetIdeInfo target = targetMap.get(TargetKey.forPlainTarget(label));
+    if (target == null) {
+      throw new ExecutionException("Target '" + label + "' not imported during sync");
+    }
+    if (IntellijPluginRule.isIntellijPluginDebugTarget(target)) {
+      assert target.intellijPluginDeployInfo != null;
+      return ImmutableList.of(target.intellijPluginDeployInfo);
+    } else if (IntellijPluginRule.isSinglePluginTarget(target)) {
+      return ImmutableList.of(deployInfoForIntellijPlugin(target));
+    } else if (IntellijPluginRule.isPluginBundle(target)) {
+      return deployInfoForLegacyBundle(target);
+    }
+    throw new ExecutionException("Target is not a supported intellij plugin type.");
+  }
+
+  private ImmutableList<IntellijPluginDeployInfo> deployInfoForLegacyBundle(TargetIdeInfo target)
+      throws ExecutionException {
+    ImmutableList.Builder<IntellijPluginDeployInfo> deployInfoBuilder = ImmutableList.builder();
+    for (Dependency dep : target.dependencies) {
+      if (dep.dependencyType == DependencyType.COMPILE_TIME && dep.targetKey.isPlainTarget()) {
+        TargetIdeInfo depTarget = targetMap.get(dep.targetKey);
+        if (depTarget != null && IntellijPluginRule.isSinglePluginTarget(depTarget)) {
+          deployInfoBuilder.add(deployInfoForIntellijPlugin(depTarget));
+        }
+      }
+    }
+    return deployInfoBuilder.build();
+  }
+
+  private static IntellijPluginDeployInfo deployInfoForIntellijPlugin(TargetIdeInfo target)
+      throws ExecutionException {
+    JavaIdeInfo javaIdeInfo = target.javaIdeInfo;
+    if (!IntellijPluginRule.isSinglePluginTarget(target) || javaIdeInfo == null) {
+      throw new ExecutionException("Target '" + target + "' is not a valid intellij_plugin target");
+    }
+    Collection<LibraryArtifact> jars = javaIdeInfo.jars;
+    if (javaIdeInfo.jars.size() > 1) {
+      throw new ExecutionException("Invalid IntelliJ plugin target: it has multiple output jars");
+    }
+    LibraryArtifact artifact = jars.isEmpty() ? null : jars.iterator().next();
+    if (artifact == null || artifact.classJar == null) {
+      throw new ExecutionException("No output plugin jar found for '" + target + "'");
+    }
+    IntellijPluginDeployFile deployFile =
+        new IntellijPluginDeployFile(
+            artifact.classJar, new File(artifact.classJar.relativePath).getName());
+    return new IntellijPluginDeployInfo(ImmutableList.of(deployFile));
+  }
+
+  private ImmutableMap<File, File> getFilesToDeploy(
+      Collection<IntellijPluginDeployInfo> deployInfos) {
+    ImmutableMap.Builder<File, File> result = ImmutableMap.builder();
+    for (IntellijPluginDeployInfo deployInfo : deployInfos) {
+      for (IntellijPluginDeployFile deployFile : deployInfo.deployFiles) {
+        File src = artifactLocationDecoder.decode(deployFile.src);
+        File dest = new File(sandboxPluginDirectory(sandboxHome), deployFile.deployLocation);
+        result.put(src, dest);
+      }
+    }
+    return result.build();
+  }
+
+  private static File sandboxPluginDirectory(String sandboxHome) {
+    return new File(sandboxHome, "plugins");
+  }
+
+  private List<String> readPluginIds(Collection<File> files) throws ExecutionException {
+    List<String> pluginIds = Lists.newArrayList();
+    for (File file : files) {
+      if (file.getName().endsWith(".jar")) {
+        String pluginId = readPluginIdFromJar(buildNumber, file);
+        if (pluginId != null) {
+          pluginIds.add(pluginId);
+        }
+      }
+    }
+    return pluginIds;
+  }
+
+  @Nullable
+  private static String readPluginIdFromJar(String buildNumber, File jar)
+      throws ExecutionException {
+    IdeaPluginDescriptor pluginDescriptor = PluginManagerCore.loadDescriptor(jar, "plugin.xml");
+    if (pluginDescriptor == null) {
+      return null;
+    }
+    if (PluginManagerCore.isIncompatible(pluginDescriptor, BuildNumber.fromString(buildNumber))) {
+      throw new ExecutionException(
+          String.format(
+              "Plugin SDK version '%s' is incompatible with this plugin "
+                  + "(since: '%s', until: '%s')",
+              buildNumber, pluginDescriptor.getSinceBuild(), pluginDescriptor.getUntilBuild()));
+    }
+    return pluginDescriptor.getPluginId().getIdString();
+  }
+
+  private static void copyFileToSandbox(File src, File dest) throws ExecutionException {
+    try {
+      dest.getParentFile().mkdirs();
+      Files.copy(src.toPath(), dest.toPath(), StandardCopyOption.REPLACE_EXISTING);
+    } catch (IOException e) {
+      throw new ExecutionException("Error copying plugin file to sandbox", e);
+    }
+  }
+}
diff --git a/plugin_dev/src/com/google/idea/blaze/plugin/run/BuildPluginBeforeRunTaskProvider.java b/plugin_dev/src/com/google/idea/blaze/plugin/run/BuildPluginBeforeRunTaskProvider.java
index de99b7a..2a9d071 100644
--- a/plugin_dev/src/com/google/idea/blaze/plugin/run/BuildPluginBeforeRunTaskProvider.java
+++ b/plugin_dev/src/com/google/idea/blaze/plugin/run/BuildPluginBeforeRunTaskProvider.java
@@ -24,7 +24,6 @@
 import com.google.idea.blaze.base.experiments.ExperimentScope;
 import com.google.idea.blaze.base.filecache.FileCaches;
 import com.google.idea.blaze.base.issueparser.IssueOutputLineProcessor;
-import com.google.idea.blaze.base.metrics.Action;
 import com.google.idea.blaze.base.model.primitives.WorkspaceRoot;
 import com.google.idea.blaze.base.projectview.ProjectViewManager;
 import com.google.idea.blaze.base.projectview.ProjectViewSet;
@@ -35,7 +34,6 @@
 import com.google.idea.blaze.base.scope.scopes.BlazeConsoleScope;
 import com.google.idea.blaze.base.scope.scopes.IdeaLogScope;
 import com.google.idea.blaze.base.scope.scopes.IssuesScope;
-import com.google.idea.blaze.base.scope.scopes.LoggedTimingScope;
 import com.google.idea.blaze.base.settings.Blaze;
 import com.google.idea.blaze.base.settings.BlazeUserSettings;
 import com.google.idea.blaze.base.util.SaveUtil;
@@ -177,7 +175,7 @@
                               LineProcessingOutputStream.of(
                                   new IssueOutputLineProcessor(project, context, workspaceRoot)))
                           .build()
-                          .run(new LoggedTimingScope(project, Action.BLAZE_BUILD));
+                          .run();
                   if (retVal != 0) {
                     context.setHasError();
                   }
diff --git a/proto_deps/proto_deps.jar b/proto_deps/proto_deps.jar
index 6f79519..ac441be 100755
--- a/proto_deps/proto_deps.jar
+++ b/proto_deps/proto_deps.jar
Binary files differ
diff --git a/version.bzl b/version.bzl
index 1bcca22..85514a2 100644
--- a/version.bzl
+++ b/version.bzl
@@ -1,3 +1,3 @@
 """Version of the blaze plugin."""
 
-VERSION = "2017.01.09.1"
+VERSION = "2017.01.30.4"