Introduce AccumulateClassNames rule to produce the list of .class files in a Java artifact.

Summary: This is needed for the upcoming pre-dexing diff.

Test Plan: Sandcastle builds.
diff --git a/src/com/facebook/buck/java/AccumulateClassNames.java b/src/com/facebook/buck/java/AccumulateClassNames.java
new file mode 100644
index 0000000..880012a
--- /dev/null
+++ b/src/com/facebook/buck/java/AccumulateClassNames.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2013-present Facebook, Inc.
+ *
+ * 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.facebook.buck.java;
+
+import com.facebook.buck.model.BuildTarget;
+import com.facebook.buck.rules.AbstractBuildRuleBuilderParams;
+import com.facebook.buck.rules.AbstractBuildable;
+import com.facebook.buck.rules.BuildContext;
+import com.facebook.buck.rules.BuildRuleParams;
+import com.facebook.buck.rules.BuildRuleResolver;
+import com.facebook.buck.rules.BuildRuleType;
+import com.facebook.buck.rules.Buildable;
+import com.facebook.buck.rules.BuildableContext;
+import com.facebook.buck.step.Step;
+import com.facebook.buck.step.fs.MkdirStep;
+import com.facebook.buck.step.fs.RmStep;
+import com.facebook.buck.util.BuckConstant;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSortedSet;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+
+/**
+ * {@link Buildable} that writes the list of {@code .class} files found in a zip or directory to a
+ * file.
+ */
+public class AccumulateClassNames extends AbstractBuildable {
+
+  private final JavaLibraryRule javaLibraryRule;
+  private final Path pathToOutputFile;
+
+  private AccumulateClassNames(BuildTarget buildTarget, JavaLibraryRule javaLibraryRule) {
+    Preconditions.checkNotNull(buildTarget);
+    this.javaLibraryRule = Preconditions.checkNotNull(javaLibraryRule);
+    this.pathToOutputFile = Paths.get(
+        BuckConstant.GEN_DIR,
+        buildTarget.getBasePath(),
+        buildTarget.getShortName() + ".classes.txt");
+  }
+
+  @Override
+  public Iterable<String> getInputsToCompareToOutput() {
+    // The deps of this rule already capture all of the inputs that should affect the cache key.
+    return ImmutableSortedSet.of();
+  }
+
+  @Override
+  public String getPathToOutputFile() {
+    return pathToOutputFile.toString();
+  }
+
+  @Override
+  public List<Step> getBuildSteps(BuildContext context, BuildableContext buildableContext)
+      throws IOException {
+    ImmutableList.Builder<Step> steps = ImmutableList.builder();
+
+    steps.add(new RmStep(getPathToOutputFile(), /* shouldForceDeletion */ true));
+
+    // Make sure that the output directory exists for the output file.
+    steps.add(new MkdirStep(pathToOutputFile.getParent()));
+
+    steps.add(new AccumulateClassNamesStep(
+        Paths.get(javaLibraryRule.getPathToOutputFile()),
+        Paths.get(getPathToOutputFile())));
+
+    return steps.build();
+  }
+
+  public static Builder newAccumulateClassNamesBuilder(AbstractBuildRuleBuilderParams params) {
+    return new Builder(params);
+  }
+
+  public static class Builder extends AbstractBuildable.Builder {
+
+    private JavaLibraryRule javaLibraryRule;
+
+    private Builder(AbstractBuildRuleBuilderParams params) {
+      super(params);
+    }
+
+    @Override
+    protected BuildRuleType getType() {
+      return BuildRuleType._CLASS_NAMES;
+    }
+
+    @Override
+    protected AccumulateClassNames newBuildable(BuildRuleParams params,
+        BuildRuleResolver resolver) {
+      return new AccumulateClassNames(params.getBuildTarget(), javaLibraryRule);
+    }
+
+    public Builder setJavaLibraryToDex(JavaLibraryRule javaLibraryRule) {
+      this.javaLibraryRule = javaLibraryRule;
+      this.addDep(javaLibraryRule.getBuildTarget());
+      return this;
+    }
+
+  }
+}
diff --git a/src/com/facebook/buck/java/AccumulateClassNamesStep.java b/src/com/facebook/buck/java/AccumulateClassNamesStep.java
new file mode 100644
index 0000000..0eb7c2f
--- /dev/null
+++ b/src/com/facebook/buck/java/AccumulateClassNamesStep.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2013-present Facebook, Inc.
+ *
+ * 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.facebook.buck.java;
+
+import com.facebook.buck.event.ThrowableLogEvent;
+import com.facebook.buck.java.classes.ClasspathTraversal;
+import com.facebook.buck.java.classes.DefaultClasspathTraverser;
+import com.facebook.buck.java.classes.FileLike;
+import com.facebook.buck.step.AbstractExecutionStep;
+import com.facebook.buck.step.ExecutionContext;
+import com.facebook.buck.step.Step;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableSortedSet;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Collections;
+
+/**
+ * {@link Step} that takes a directory or zip of {@code .class} files and traverses it to get the
+ * total set of {@code .class} files included by the directory or zip.
+ */
+public class AccumulateClassNamesStep extends AbstractExecutionStep {
+
+  /**
+   * Entries in the {@link #pathToJarOrClassesDirectory} that end with this suffix will be written
+   * to {@link #whereClassNamesShouldBeWritten}. Since they all share the same suffix, the suffix
+   * will be stripped when written to {@link #whereClassNamesShouldBeWritten}.
+   */
+  private static final String CLASS_NAME_SUFFIX = ".class";
+
+  private final Path pathToJarOrClassesDirectory;
+  private final Path whereClassNamesShouldBeWritten;
+
+  public AccumulateClassNamesStep(Path pathToJarOrClassesDirectory,
+      Path whereClassNamesShouldBeWritten) {
+    super("get_class_names " + pathToJarOrClassesDirectory + " > " + whereClassNamesShouldBeWritten);
+    this.pathToJarOrClassesDirectory = Preconditions.checkNotNull(pathToJarOrClassesDirectory);
+    this.whereClassNamesShouldBeWritten = Preconditions.checkNotNull(whereClassNamesShouldBeWritten);
+  }
+
+  @Override
+  public int execute(ExecutionContext context) {
+    final ImmutableSortedSet.Builder<String> classNamesBuilder = ImmutableSortedSet.naturalOrder();
+    Path path = context.getProjectFilesystem().resolve(pathToJarOrClassesDirectory);
+    ClasspathTraversal traversal = new ClasspathTraversal(Collections.singleton(path)) {
+      @Override
+      public void visit(FileLike fileLike) throws IOException {
+        String name = fileLike.getRelativePath();
+
+        // When traversing a JAR file, it may have resources or directory entries that do not end
+        // in .class, which should be ignored.
+        if (name.endsWith(CLASS_NAME_SUFFIX)) {
+          classNamesBuilder.add(name.substring(0, name.length() - CLASS_NAME_SUFFIX.length()));
+        }
+      }
+    };
+
+    try {
+      new DefaultClasspathTraverser().traverse(traversal);
+    } catch (IOException e) {
+      e.printStackTrace(context.getStdErr());
+      return 1;
+    }
+
+    ImmutableSortedSet<String> classNames = classNamesBuilder.build();
+    try {
+      context.getProjectFilesystem().writeLinesToPath(classNames, whereClassNamesShouldBeWritten);
+    } catch (IOException e) {
+      context.getBuckEventBus().post(ThrowableLogEvent.create(e,
+          "There was an error writing the list of .class files to %s.",
+          whereClassNamesShouldBeWritten));
+      return 1;
+    }
+
+    return 0;
+  }
+
+}
diff --git a/src/com/facebook/buck/java/BUCK b/src/com/facebook/buck/java/BUCK
index 909d9eb..3adf317 100644
--- a/src/com/facebook/buck/java/BUCK
+++ b/src/com/facebook/buck/java/BUCK
@@ -26,6 +26,7 @@
 java_library(
   name = 'rules',
   srcs = [
+    'AccumulateClassNames.java',
     'Classpaths.java',
     'DefaultJavaLibraryRule.java',
     'HasJavaSrcs.java',
@@ -68,6 +69,7 @@
 java_library(
   name = 'steps',
   srcs = [
+    'AccumulateClassNamesStep.java',
     'GenerateCodeCoverageReportStep.java',
     'InstrumentStep.java',
     'JarDirectoryStep.java',
@@ -81,6 +83,7 @@
     '//lib:jsr305',
     '//src/com/facebook/buck/android:exceptions',
     '//src/com/facebook/buck/java/abi:protocol',
+    '//src/com/facebook/buck/java/classes:classes',
     '//src/com/facebook/buck/event:event',
     '//src/com/facebook/buck/model:model',
     '//src/com/facebook/buck/rules:build_rule',
diff --git a/src/com/facebook/buck/rules/BuildRuleType.java b/src/com/facebook/buck/rules/BuildRuleType.java
index e85c016..385eba5 100644
--- a/src/com/facebook/buck/rules/BuildRuleType.java
+++ b/src/com/facebook/buck/rules/BuildRuleType.java
@@ -44,6 +44,9 @@
   public static BuildRuleType SH_BINARY = new BuildRuleType("sh_binary");
   public static BuildRuleType SH_TEST = new BuildRuleType("sh_test");
 
+  // Internal rule types. Denoted by leading trailing underscore.
+  public static BuildRuleType _CLASS_NAMES = new BuildRuleType("_class_names");
+
   private final String name;
   private final boolean isTestRule;
 
diff --git a/test/com/facebook/buck/java/AccumulateClassNamesStepTest.java b/test/com/facebook/buck/java/AccumulateClassNamesStepTest.java
new file mode 100644
index 0000000..85e46b8
--- /dev/null
+++ b/test/com/facebook/buck/java/AccumulateClassNamesStepTest.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright 2013-present Facebook, Inc.
+ *
+ * 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.facebook.buck.java;
+
+import static org.junit.Assert.assertEquals;
+
+import com.facebook.buck.step.ExecutionContext;
+import com.facebook.buck.step.TestExecutionContext;
+import com.facebook.buck.util.ProjectFilesystem;
+import com.google.common.base.Charsets;
+import com.google.common.base.Joiner;
+import com.google.common.io.Files;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.file.Paths;
+import java.util.jar.JarOutputStream;
+import java.util.zip.ZipEntry;
+
+public class AccumulateClassNamesStepTest {
+
+  @Rule
+  public TemporaryFolder tmp = new TemporaryFolder();
+
+  @Test
+  public void testExecuteAccumulateClassNamesStepOnJarFile() throws IOException {
+    // Create a JAR file.
+    String name = "example.jar";
+    File jarFile = tmp.newFile(name);
+    try (JarOutputStream out = new JarOutputStream(
+        new BufferedOutputStream(
+            new FileOutputStream(jarFile)))) {
+      out.putNextEntry(new ZipEntry("com/example/Foo.class"));
+      out.closeEntry();
+      out.putNextEntry(new ZipEntry("com/example/Bar.class"));
+      out.closeEntry();
+      out.putNextEntry(new ZipEntry("com/example/not_a_class.png"));
+      out.closeEntry();
+      out.putNextEntry(new ZipEntry("com/example/subpackage/Baz.class"));
+      out.closeEntry();
+    }
+
+    // Create the AccumulateClassNamesStep and execute it.
+    AccumulateClassNamesStep accumulateClassNamesStep = new AccumulateClassNamesStep(
+        Paths.get(name), Paths.get("output.txt"));
+    ExecutionContext context = TestExecutionContext
+        .newBuilder()
+        .setProjectFilesystem(new ProjectFilesystem(tmp.getRoot()))
+        .build();
+    accumulateClassNamesStep.execute(context);
+
+    String contents = Files.toString(new File(tmp.getRoot(), "output.txt"), Charsets.UTF_8);
+    assertEquals(
+        "Verify that the contents are sorted alphabetically and ignore non-.class files.",
+        Joiner.on('\n').join(
+            "com/example/Bar",
+            "com/example/Foo",
+            "com/example/subpackage/Baz") + '\n',
+        contents);
+  }
+
+  @Test
+  public void testExecuteAccumulateClassNamesStepOnDirectory() throws IOException {
+    // Create a directory.
+    String name = "dir";
+    tmp.newFolder(name);
+
+    tmp.newFolder("dir/com");
+    tmp.newFolder("dir/com/example");
+    tmp.newFolder("dir/com/example/subpackage");
+
+    tmp.newFile("dir/com/example/Foo.class");
+    tmp.newFile("dir/com/example/Bar.class");
+    tmp.newFile("dir/com/example/not_a_class.png");
+    tmp.newFile("dir/com/example/subpackage/Baz.class");
+
+    // Create the AccumulateClassNamesStep and execute it.
+    AccumulateClassNamesStep accumulateClassNamesStep = new AccumulateClassNamesStep(
+        Paths.get(name), Paths.get("output.txt"));
+    ExecutionContext context = TestExecutionContext
+        .newBuilder()
+        .setProjectFilesystem(new ProjectFilesystem(tmp.getRoot()))
+        .build();
+    accumulateClassNamesStep.execute(context);
+
+    String contents = Files.toString(new File(tmp.getRoot(), "output.txt"), Charsets.UTF_8);
+    assertEquals(
+        "Verify that the contents are sorted alphabetically and ignore non-.class files.",
+        Joiner.on('\n').join(
+            "com/example/Bar",
+            "com/example/Foo",
+            "com/example/subpackage/Baz") + '\n',
+        contents);
+  }
+}
diff --git a/test/com/facebook/buck/java/AccumulateClassNamesTest.java b/test/com/facebook/buck/java/AccumulateClassNamesTest.java
new file mode 100644
index 0000000..f04c5d2
--- /dev/null
+++ b/test/com/facebook/buck/java/AccumulateClassNamesTest.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright 2013-present Facebook, Inc.
+ *
+ * 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.facebook.buck.java;
+
+import static org.junit.Assert.assertEquals;
+
+import com.facebook.buck.model.BuildTarget;
+import com.facebook.buck.rules.AbstractBuildRuleBuilderParams;
+import com.facebook.buck.rules.BuildContext;
+import com.facebook.buck.rules.BuildRule;
+import com.facebook.buck.rules.BuildRuleResolver;
+import com.facebook.buck.rules.BuildRuleType;
+import com.facebook.buck.rules.BuildableContext;
+import com.facebook.buck.rules.FakeAbstractBuildRuleBuilderParams;
+import com.facebook.buck.step.ExecutionContext;
+import com.facebook.buck.step.Step;
+import com.facebook.buck.step.TestExecutionContext;
+import com.facebook.buck.testutil.MoreAsserts;
+import com.facebook.buck.util.ProjectFilesystem;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSortedSet;
+
+import org.easymock.EasyMockSupport;
+import org.junit.Test;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+
+public class AccumulateClassNamesTest extends EasyMockSupport {
+
+  @Test
+  public void testObserversForAccumulateClassNames() throws IOException {
+    // Create a JavaLibraryRule with classes to accumulate.
+    BuildRuleResolver buildRuleResolver = new BuildRuleResolver();
+    JavaLibraryRule javaLibraryRule = buildRuleResolver.buildAndAddToIndex(PrebuiltJarRule
+        .newPrebuiltJarRuleBuilder(new FakeAbstractBuildRuleBuilderParams())
+        .setBuildTarget(new BuildTarget("//foo/bar", "jar"))
+        .setBinaryJar("foo/bar/example.jar"));
+
+    // Create the Builder.
+    AbstractBuildRuleBuilderParams params = new FakeAbstractBuildRuleBuilderParams();
+    AccumulateClassNames.Builder builder = AccumulateClassNames.newAccumulateClassNamesBuilder(
+        params);
+    assertEquals(BuildRuleType._CLASS_NAMES, builder.getType());
+
+
+    // Construct the Buildable.
+    BuildTarget buildTarget = new BuildTarget("//foo/bar", "baz", "class_names");
+    builder.setBuildTarget(buildTarget);
+    builder.setJavaLibraryToDex(javaLibraryRule);
+    BuildRule buildRule = buildRuleResolver.buildAndAddToIndex(builder);
+    AccumulateClassNames accumulateClassNames = (AccumulateClassNames) buildRule.getBuildable();
+
+    // Test the observers.
+    String pathToOutput = "buck-out/gen/foo/bar/baz#class_names.classes.txt";
+    assertEquals(pathToOutput,
+        accumulateClassNames.getPathToOutputFile());
+    assertEquals("There should not be any input files that factor into the cache key.",
+        ImmutableSortedSet.of(),
+        accumulateClassNames.getInputsToCompareToOutput());
+
+    // Mock out objects so getBuildSteps() can be invoked.
+    BuildContext buildContext = createMock(BuildContext.class);
+    BuildableContext buildableContext = createMock(BuildableContext.class);
+
+    // Create the build steps.
+    replayAll();
+    List<Step> steps = accumulateClassNames.getBuildSteps(buildContext, buildableContext);
+    verifyAll();
+
+    // Verify the build steps.
+    ProjectFilesystem projectFilesystem = new ProjectFilesystem(new File("."));
+    ExecutionContext context = TestExecutionContext
+        .newBuilder()
+        .setProjectFilesystem(projectFilesystem)
+        .build();
+    Path absolutePathToOutput = projectFilesystem.resolve(Paths.get(pathToOutput));
+    MoreAsserts.assertSteps("Delete old classes.txt file, if present, and then write a new one.",
+        ImmutableList.of(
+          "rm -f " + absolutePathToOutput,
+          "mkdir -p " + absolutePathToOutput.getParent(),
+          "get_class_names foo/bar/example.jar > " + pathToOutput),
+        steps,
+        context);
+  }
+}