Refactor StandardLinkerContext output/extra/file/jar implementation to not suck.

Patch by: spoon
Review by: me


git-svn-id: https://google-web-toolkit.googlecode.com/svn/trunk@6497 8db76d5a-ed1c-0410-87a9-c151d255dfc7
diff --git a/dev/core/src/com/google/gwt/core/ext/linker/impl/StandardLinkerContext.java b/dev/core/src/com/google/gwt/core/ext/linker/impl/StandardLinkerContext.java
index aecb67b..967ee85 100644
--- a/dev/core/src/com/google/gwt/core/ext/linker/impl/StandardLinkerContext.java
+++ b/dev/core/src/com/google/gwt/core/ext/linker/impl/StandardLinkerContext.java
@@ -51,29 +51,22 @@
 import com.google.gwt.dev.js.ast.JsProgram;
 import com.google.gwt.dev.js.ast.JsScope;
 import com.google.gwt.dev.util.DefaultTextOutput;
-import com.google.gwt.dev.util.FileBackedObject;
+import com.google.gwt.dev.util.OutputFileSet;
 import com.google.gwt.dev.util.Util;
-import com.google.gwt.util.tools.Utility;
 
-import java.io.BufferedOutputStream;
 import java.io.File;
-import java.io.FileNotFoundException;
-import java.io.FileOutputStream;
 import java.io.IOException;
+import java.io.OutputStream;
 import java.io.Reader;
 import java.io.StringReader;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Set;
 import java.util.SortedSet;
 import java.util.TreeSet;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipOutputStream;
 
 /**
  * An implementation of {@link LinkerContext} that is initialized from a
@@ -118,67 +111,6 @@
     }
   };
 
-  /**
-   * Returns the parent path of forward-slash based partial path. Assumes the
-   * given path does not end with a trailing slash.
-   */
-  private static String getParentPath(String path) {
-    assert !path.endsWith("/");
-    int pos = path.lastIndexOf('/');
-    return (pos >= 0) ? path.substring(0, pos) : null;
-  }
-
-  /**
-   * A faster bulk version of {@link File#mkdirs()} that takes advantage of
-   * cached state to avoid a lot of file system access.
-   */
-  private static boolean mkdirs(File dir, Set<String> createdDirs) {
-    if (dir == null) {
-      return true;
-    }
-    String path = dir.getPath();
-    if (createdDirs.contains(path)) {
-      return true;
-    }
-    if (!dir.exists()) {
-      if (!mkdirs(dir.getParentFile(), createdDirs)) {
-        return false;
-      }
-      if (!dir.mkdir()) {
-        return false;
-      }
-    }
-    createdDirs.add(path);
-    return true;
-  }
-
-  /**
-   * Creates directory entries within a zip archive. This is consistent with how
-   * most tools operate.
-   * 
-   * @param path the path of a directory within the archive to create
-   * @param zipOutputStream the archive we're creating
-   * @param createdDirs the set of already-created directories to avoid
-   *          duplication
-   */
-  private static void mkzipDirs(String path, ZipOutputStream zipOutputStream,
-      Set<String> createdDirs) throws IOException {
-    if (path == null) {
-      return;
-    }
-    if (createdDirs.contains(path)) {
-      return;
-    }
-    mkzipDirs(getParentPath(path), zipOutputStream, createdDirs);
-    ZipEntry entry = new ZipEntry(path + '/');
-    entry.setSize(0);
-    entry.setCompressedSize(0);
-    entry.setCrc(0);
-    entry.setMethod(ZipOutputStream.STORED);
-    zipOutputStream.putNextEntry(entry);
-    createdDirs.add(path);
-  }
-
   private final ArtifactSet artifacts = new ArtifactSet();
 
   private final SortedSet<ConfigurationProperty> configurationProperties;
@@ -294,24 +226,9 @@
       configurationProperties = Collections.unmodifiableSortedSet(mutableConfigurationProperties);
     }
 
-    {
-      int index = 0;
-      for (Script script : module.getScripts()) {
-        artifacts.add(new StandardScriptReference(script.getSrc(), index++));
-        logger.log(TreeLogger.SPAM, "Added script " + script.getSrc(), null);
-      }
-    }
-
-    {
-      int index = 0;
-      for (String style : module.getStyles()) {
-        artifacts.add(new StandardStylesheetReference(style, index++));
-        logger.log(TreeLogger.SPAM, "Added style " + style, null);
-      }
-    }
-
-    // Generated files should be passed in via addArtifacts()
-
+    /*
+     * Add static resources in the specified module as artifacts.
+     */
     for (String path : module.getAllPublicFiles()) {
       String partialPath = path.replace(File.separatorChar, '/');
       PublicResource resource = new StandardPublicResource(partialPath,
@@ -319,6 +236,8 @@
       artifacts.add(resource);
       logger.log(TreeLogger.SPAM, "Added public resource " + resource, null);
     }
+
+    recordStaticReferences(logger, module);
   }
 
   /**
@@ -340,27 +259,20 @@
   /**
    * Gets or creates a CompilationResult for the given JavaScript program.
    */
-  public StandardCompilationResult getCompilation(TreeLogger logger,
-      FileBackedObject<PermutationResult> resultFile)
-      throws UnableToCompleteException {
-    PermutationResult permutationResult = resultFile.newInstance(logger);
-
+  public StandardCompilationResult getCompilation(
+      PermutationResult permutationResult) {
     byte[][] js = permutationResult.getJs();
     String strongName = Util.computeStrongName(js);
     StandardCompilationResult result = resultsByStrongName.get(strongName);
     if (result == null) {
       result = new StandardCompilationResult(strongName, js,
           permutationResult.getSerializedSymbolMap(),
-          permutationResult.getStatementRanges(), permutationResult.getPermutationId());
+          permutationResult.getStatementRanges(),
+          permutationResult.getPermutationId());
       resultsByStrongName.put(result.getStrongName(), result);
       artifacts.add(result);
-
-      // Add any other Permutations
-      ArtifactSet otherArtifacts = permutationResult.getArtifacts();
-      if (otherArtifacts != null) {
-        artifacts.addAll(otherArtifacts);
-      }
     }
+    artifacts.addAll(permutationResult.getArtifacts());
     return result;
   }
 
@@ -500,138 +412,45 @@
   }
 
   /**
-   * Writes artifacts into the extra directory in the standard way.
+   * Emit EmittedArtifacts artifacts onto <code>out</code>. Does not close
+   * <code>out</code>.
    * 
-   * @param logger logs the operation
-   * @param artifacts the set of artifacts to write
-   * @param extraPath optional extra path for non-deployable artifacts
-   * @throws UnableToCompleteException
+   * @param logger where to log progress
+   * @param artifacts the artifacts to emit
+   * @param emitPrivates whether to emit the private artifacts only, vs. the
+   *          public artifacts only
+   * @param out where to emit the artifact contents
    */
-  public void produceExtraDirectory(TreeLogger logger, ArtifactSet artifacts,
-      File extraPath) throws UnableToCompleteException {
-    extraPath = extraPath.getAbsoluteFile();
-    logger = logger.branch(TreeLogger.TRACE, "Writing extras into "
-        + extraPath.getPath(), null);
+  public void produceOutput(TreeLogger logger, ArtifactSet artifacts,
+      boolean emitPrivates, OutputFileSet out) throws UnableToCompleteException {
+    String publicness = emitPrivates ? "private" : "public";
+    logger = logger.branch(TreeLogger.TRACE, "Linking " + publicness
+        + " artifacts into " + out.getPathDescription(), null);
 
-    Set<String> createdDirs = new HashSet<String>();
     for (EmittedArtifact artifact : artifacts.find(EmittedArtifact.class)) {
       TreeLogger artifactLogger = logger.branch(TreeLogger.DEBUG,
           "Emitting resource " + artifact.getPartialPath(), null);
 
-      if (!artifact.isPrivate()) {
+      if (artifact.isPrivate() != emitPrivates) {
         continue;
       }
 
-      File outFile = new File(extraPath, getExtraPathForLinker(
-          artifact.getLinker(), artifact.getPartialPath()));
-      writeArtifactToFile(artifactLogger, artifact, outFile, createdDirs);
-    }
-  }
-
-  /**
-   * Writes artifacts into an extra zip in the standard way.
-   * 
-   * @param logger logs the operation
-   * @param artifacts the set of artifacts to write
-   * @param extraZip the output zip for deployable artifacts
-   * @param pathPrefix path within the zip to write into; if non-empty must end
-   *          with a trailing slash
-   * @throws UnableToCompleteException
-   */
-  public void produceExtraZip(TreeLogger logger, ArtifactSet artifacts,
-      File extraZip, String pathPrefix) throws UnableToCompleteException {
-    extraZip = extraZip.getAbsoluteFile();
-    logger = logger.branch(TreeLogger.TRACE, "Linking compilation into "
-        + extraZip.getPath(), null);
-
-    try {
-      Set<String> createdDirs = new HashSet<String>();
-      ZipOutputStream zipOutputStream = new ZipOutputStream(
-          new BufferedOutputStream(new FileOutputStream(extraZip)));
-      for (EmittedArtifact artifact : artifacts.find(EmittedArtifact.class)) {
-        TreeLogger artifactLogger = logger.branch(TreeLogger.DEBUG,
-            "Emitting resource " + artifact.getPartialPath(), null);
-
-        if (!artifact.isPrivate()) {
-          continue;
-        }
-        String path = pathPrefix
-            + getExtraPathForLinker(artifact.getLinker(),
-                artifact.getPartialPath());
-        writeArtifactToZip(artifactLogger, artifact, path, zipOutputStream,
-            createdDirs);
-      }
-      Utility.close(zipOutputStream);
-    } catch (FileNotFoundException e) {
-      logger.log(TreeLogger.ERROR, "Unable to create extra archive "
-          + extraZip.getPath(), e);
-      throw new UnableToCompleteException();
-    }
-  }
-
-  /**
-   * Writes artifacts into output directory in the standard way.
-   * 
-   * @param logger logs the operation
-   * @param artifacts the set of artifacts to write
-   * @param outputPath the output path for deployable artifacts
-   * @throws UnableToCompleteException
-   */
-  public void produceOutputDirectory(TreeLogger logger, ArtifactSet artifacts,
-      File outputPath) throws UnableToCompleteException {
-    outputPath = outputPath.getAbsoluteFile();
-    logger = logger.branch(TreeLogger.TRACE, "Linking compilation into "
-        + outputPath.getPath(), null);
-
-    Set<String> createdDirs = new HashSet<String>();
-    for (EmittedArtifact artifact : artifacts.find(EmittedArtifact.class)) {
-      TreeLogger artifactLogger = logger.branch(TreeLogger.DEBUG,
-          "Emitting resource " + artifact.getPartialPath(), null);
-
+      String partialPath = artifact.getPartialPath();
       if (artifact.isPrivate()) {
-        continue;
-      }
-      File outFile = new File(outputPath, artifact.getPartialPath());
-      writeArtifactToFile(artifactLogger, artifact, outFile, createdDirs);
-    }
-  }
-
-  /**
-   * Writes artifacts into an output zip in the standard way.
-   * 
-   * @param logger logs the operation
-   * @param artifacts the set of artifacts to write
-   * @param outZip the output zip for deployable artifacts
-   * @param pathPrefix path within the zip to write into; if non-empty must end
-   *          with a trailing slash
-   * @throws UnableToCompleteException
-   */
-  public void produceOutputZip(TreeLogger logger, ArtifactSet artifacts,
-      File outZip, String pathPrefix) throws UnableToCompleteException {
-    outZip = outZip.getAbsoluteFile();
-    logger = logger.branch(TreeLogger.TRACE, "Linking compilation into "
-        + outZip.getPath(), null);
-
-    try {
-      ZipOutputStream zipOutputStream = new ZipOutputStream(
-          new BufferedOutputStream(new FileOutputStream(outZip)));
-      Set<String> createdDirs = new HashSet<String>();
-      for (EmittedArtifact artifact : artifacts.find(EmittedArtifact.class)) {
-        TreeLogger artifactLogger = logger.branch(TreeLogger.DEBUG,
-            "Emitting resource " + artifact.getPartialPath(), null);
-
-        if (artifact.isPrivate()) {
-          continue;
+        partialPath = getExtraPathForLinker(artifact.getLinker(), partialPath);
+        if (partialPath.startsWith("/")) {
+          partialPath = partialPath.substring(1);
         }
-        String path = pathPrefix + artifact.getPartialPath();
-        writeArtifactToZip(artifactLogger, artifact, path, zipOutputStream,
-            createdDirs);
       }
-      Utility.close(zipOutputStream);
-    } catch (FileNotFoundException e) {
-      logger.log(TreeLogger.ERROR, "Unable to create output archive "
-          + outZip.getPath(), e);
-      throw new UnableToCompleteException();
+      try {
+        OutputStream artifactStream = out.openForWrite(partialPath,
+            artifact.getLastModified());
+        artifact.writeTo(artifactLogger, artifactStream);
+        artifactStream.close();
+      } catch (IOException e) {
+        artifactLogger.log(TreeLogger.ERROR,
+            "Fatal error emitting this artifact", e);
+      }
     }
   }
 
@@ -646,42 +465,26 @@
     return linkerShortNames.get(linkerType) + '/' + partialPath;
   }
 
-  private void writeArtifactToFile(TreeLogger logger, EmittedArtifact artifact,
-      File outFile, Set<String> createdDirs) throws UnableToCompleteException {
-    if (!outFile.exists()
-        || (outFile.lastModified() < artifact.getLastModified())) {
-      if (!mkdirs(outFile.getParentFile(), createdDirs)) {
-        logger.log(TreeLogger.ERROR, "Unable to create directory for file '"
-            + outFile.getAbsolutePath() + "'");
-      } else {
-        try {
-          FileOutputStream out = new FileOutputStream(outFile);
-          artifact.writeTo(logger, out);
-          out.close();
-        } catch (IOException e) {
-          logger.log(TreeLogger.ERROR, "Unable to create file '"
-              + outFile.getAbsolutePath() + "'", e);
-          throw new UnableToCompleteException();
-        }
-        outFile.setLastModified(artifact.getLastModified());
+  /**
+   * Record script references and CSS references that are listed in the module
+   * file.
+   */
+  private void recordStaticReferences(TreeLogger logger, ModuleDef module) {
+    {
+      int index = 0;
+      for (Script script : module.getScripts()) {
+        String url = script.getSrc();
+        artifacts.add(new StandardScriptReference(url, index++));
+        logger.log(TreeLogger.SPAM, "Added script " + url, null);
       }
     }
-  }
 
-  private void writeArtifactToZip(TreeLogger logger, EmittedArtifact artifact,
-      String path, ZipOutputStream zipOutputStream, Set<String> createdDirs)
-      throws UnableToCompleteException {
-    try {
-      mkzipDirs(getParentPath(path), zipOutputStream, createdDirs);
-      ZipEntry zipEntry = new ZipEntry(path);
-      zipEntry.setTime(artifact.getLastModified());
-      zipOutputStream.putNextEntry(zipEntry);
-      artifact.writeTo(logger, zipOutputStream);
-      zipOutputStream.closeEntry();
-    } catch (IOException e) {
-      logger.log(TreeLogger.ERROR, "Unable to write out artifact '"
-          + artifact.getPartialPath() + "'", e);
-      throw new UnableToCompleteException();
+    {
+      int index = 0;
+      for (String style : module.getStyles()) {
+        artifacts.add(new StandardStylesheetReference(style, index++));
+        logger.log(TreeLogger.SPAM, "Added style " + style, null);
+      }
     }
   }
 }
diff --git a/dev/core/src/com/google/gwt/dev/DevMode.java b/dev/core/src/com/google/gwt/dev/DevMode.java
index 9b41b03..6175e98 100644
--- a/dev/core/src/com/google/gwt/dev/DevMode.java
+++ b/dev/core/src/com/google/gwt/dev/DevMode.java
@@ -28,6 +28,9 @@
 import com.google.gwt.dev.ui.RestartServerCallback;
 import com.google.gwt.dev.ui.RestartServerEvent;
 import com.google.gwt.dev.util.InstalledHelpInfo;
+import com.google.gwt.dev.util.NullOutputFileSet;
+import com.google.gwt.dev.util.OutputFileSet;
+import com.google.gwt.dev.util.OutputFileSetOnDirectory;
 import com.google.gwt.dev.util.Util;
 import com.google.gwt.dev.util.arg.ArgHandlerExtraDir;
 import com.google.gwt.dev.util.arg.ArgHandlerLocalWorkers;
@@ -486,11 +489,26 @@
   private void produceOutput(TreeLogger logger,
       StandardLinkerContext linkerStack, ArtifactSet artifacts, ModuleDef module)
       throws UnableToCompleteException {
-    File moduleOutDir = new File(options.getWarDir(), module.getName());
-    linkerStack.produceOutputDirectory(logger, artifacts, moduleOutDir);
-    if (options.getExtraDir() != null) {
-      File moduleExtraDir = new File(options.getExtraDir(), module.getName());
-      linkerStack.produceExtraDirectory(logger, artifacts, moduleExtraDir);
+    TreeLogger linkLogger = logger.branch(TreeLogger.DEBUG, "Linking module '"
+        + module.getName() + "'");
+
+    try {
+      OutputFileSetOnDirectory outFileSet = new OutputFileSetOnDirectory(
+          options.getWarDir(), module.getName() + "/");
+      OutputFileSet extraFileSet = new NullOutputFileSet();
+      if (options.getExtraDir() != null) {
+        extraFileSet = new OutputFileSetOnDirectory(options.getExtraDir(),
+            module.getName() + "/");
+      }
+
+      linkerStack.produceOutput(linkLogger, artifacts, false, outFileSet);
+      linkerStack.produceOutput(linkLogger, artifacts, true, extraFileSet);
+
+      outFileSet.close();
+      extraFileSet.close();
+    } catch (IOException e) {
+      linkLogger.log(TreeLogger.ERROR, "I/O exception", e);
+      throw new UnableToCompleteException();
     }
   }
 
diff --git a/dev/core/src/com/google/gwt/dev/Link.java b/dev/core/src/com/google/gwt/dev/Link.java
index 1305ccf..733b25a 100644
--- a/dev/core/src/com/google/gwt/dev/Link.java
+++ b/dev/core/src/com/google/gwt/dev/Link.java
@@ -30,6 +30,10 @@
 import com.google.gwt.dev.jjs.PermutationResult;
 import com.google.gwt.dev.jjs.impl.CodeSplitter;
 import com.google.gwt.dev.util.FileBackedObject;
+import com.google.gwt.dev.util.NullOutputFileSet;
+import com.google.gwt.dev.util.OutputFileSet;
+import com.google.gwt.dev.util.OutputFileSetOnDirectory;
+import com.google.gwt.dev.util.OutputFileSetOnJar;
 import com.google.gwt.dev.util.Util;
 import com.google.gwt.dev.util.arg.ArgHandlerExtraDir;
 import com.google.gwt.dev.util.arg.ArgHandlerWarDir;
@@ -86,8 +90,8 @@
       LinkOptions {
 
     private File extraDir;
-    private File warDir;
     private File outDir;
+    private File warDir;
 
     public LinkOptionsImpl() {
     }
@@ -133,24 +137,31 @@
   public static void legacyLink(TreeLogger logger, ModuleDef module,
       ArtifactSet generatedArtifacts, Permutation[] permutations,
       List<FileBackedObject<PermutationResult>> resultFiles, File outDir,
-      JJSOptions precompileOptions) throws UnableToCompleteException {
+      JJSOptions precompileOptions) throws UnableToCompleteException,
+      IOException {
     StandardLinkerContext linkerContext = new StandardLinkerContext(logger,
         module, precompileOptions);
     ArtifactSet artifacts = doLink(logger, linkerContext, generatedArtifacts,
         permutations, resultFiles);
-    doProduceLegacyOutput(logger, artifacts, linkerContext, module, outDir);
+    OutputFileSet outFileSet = new OutputFileSetOnDirectory(outDir,
+        module.getName() + "/");
+    OutputFileSet extraFileSet = new OutputFileSetOnDirectory(outDir,
+        module.getName() + "-aux/");
+    doProduceOutput(logger, artifacts, linkerContext, outFileSet, extraFileSet);
   }
 
   public static void link(TreeLogger logger, ModuleDef module,
       ArtifactSet generatedArtifacts, Permutation[] permutations,
       List<FileBackedObject<PermutationResult>> resultFiles, File outDir,
       File extrasDir, JJSOptions precompileOptions)
-      throws UnableToCompleteException {
+      throws UnableToCompleteException, IOException {
     StandardLinkerContext linkerContext = new StandardLinkerContext(logger,
         module, precompileOptions);
     ArtifactSet artifacts = doLink(logger, linkerContext, generatedArtifacts,
         permutations, resultFiles);
-    doProduceOutput(logger, artifacts, linkerContext, module, outDir, extrasDir);
+    doProduceOutput(logger, artifacts, linkerContext, chooseOutputFileSet(
+        outDir, module.getName() + "/"), chooseOutputFileSet(extrasDir,
+        module.getName() + "/"));
   }
 
   public static void main(String[] args) {
@@ -177,6 +188,37 @@
     System.exit(1);
   }
 
+  /**
+   * Choose an output file set for the given <code>dirOrJar</code> based on
+   * its name, whether it's null, and whether it already exists as a directory.
+   */
+  private static OutputFileSet chooseOutputFileSet(File dirOrJar,
+      String pathPrefix) throws IOException {
+    return chooseOutputFileSet(dirOrJar, pathPrefix, pathPrefix);
+  }
+
+  /**
+   * A version of {@link #chooseOutputFileSet(File, String)} that allows
+   * choosing a separate path prefix depending on whether the output is a
+   * directory or a jar file.
+   */
+  private static OutputFileSet chooseOutputFileSet(File dirOrJar,
+      String jarPathPrefix, String dirPathPrefix) throws IOException {
+
+    if (dirOrJar == null) {
+      return new NullOutputFileSet();
+    }
+
+    String name = dirOrJar.getName();
+    if (!dirOrJar.isDirectory()
+        && (name.endsWith(".war") || name.endsWith(".jar") || name.endsWith(".zip"))) {
+      return new OutputFileSetOnJar(dirOrJar, jarPathPrefix);
+    } else {
+      Util.recursiveDelete(new File(dirOrJar, dirPathPrefix), true);
+      return new OutputFileSetOnDirectory(dirOrJar, dirPathPrefix);
+    }
+  }
+
   private static ArtifactSet doLink(TreeLogger logger,
       StandardLinkerContext linkerContext, ArtifactSet generatedArtifacts,
       Permutation[] perms, List<FileBackedObject<PermutationResult>> resultFiles)
@@ -194,52 +236,26 @@
     return linkerContext.invokeLink(logger);
   }
 
-  private static void doProduceLegacyOutput(TreeLogger logger,
-      ArtifactSet artifacts, StandardLinkerContext linkerContext,
-      ModuleDef module, File outDir) throws UnableToCompleteException {
-    File moduleOutDir = new File(outDir, module.getName());
-    File moduleExtraDir = new File(outDir, module.getName() + "-aux");
-    Util.recursiveDelete(moduleOutDir, true);
-    Util.recursiveDelete(moduleExtraDir, true);
-    linkerContext.produceOutputDirectory(logger, artifacts, moduleOutDir);
-    linkerContext.produceExtraDirectory(logger, artifacts, moduleExtraDir);
-    logger.log(TreeLogger.INFO, "Link succeeded");
-  }
-
+  /**
+   * Emit final output.
+   */
   private static void doProduceOutput(TreeLogger logger, ArtifactSet artifacts,
-      StandardLinkerContext linkerContext, ModuleDef module, File outDir,
-      File extraDir) throws UnableToCompleteException {
-    String outPath = outDir.getPath();
-    if (!outDir.isDirectory()
-        && (outPath.endsWith(".war") || outPath.endsWith(".jar") || outPath.endsWith(".zip"))) {
-      linkerContext.produceOutputZip(logger, artifacts, outDir,
-          module.getName() + '/');
-    } else {
-      File moduleOutDir = new File(outDir, module.getName());
-      Util.recursiveDelete(moduleOutDir, true);
-      linkerContext.produceOutputDirectory(logger, artifacts, moduleOutDir);
-    }
+      StandardLinkerContext linkerContext, OutputFileSet outFileSet,
+      OutputFileSet extraFileSet) throws UnableToCompleteException, IOException {
+    linkerContext.produceOutput(logger, artifacts, false, outFileSet);
+    linkerContext.produceOutput(logger, artifacts, true, extraFileSet);
 
-    if (extraDir != null) {
-      String extraPath = extraDir.getPath();
-      if (!extraDir.isDirectory()
-          && (extraPath.endsWith(".war") || extraPath.endsWith(".jar") || extraPath.endsWith(".zip"))) {
-        linkerContext.produceExtraZip(logger, artifacts, extraDir,
-            module.getName() + '/');
-      } else {
-        File moduleExtraDir = new File(extraDir, module.getName());
-        Util.recursiveDelete(moduleExtraDir, true);
-        linkerContext.produceExtraDirectory(logger, artifacts, moduleExtraDir);
-      }
-    }
+    outFileSet.close();
+    extraFileSet.close();
+
     logger.log(TreeLogger.INFO, "Link succeeded");
   }
 
   private static void finishPermuation(TreeLogger logger, Permutation perm,
       FileBackedObject<PermutationResult> resultFile,
       StandardLinkerContext linkerContext) throws UnableToCompleteException {
-    StandardCompilationResult compilation = linkerContext.getCompilation(
-        logger, resultFile);
+    PermutationResult permutationResult = resultFile.newInstance(logger);
+    StandardCompilationResult compilation = linkerContext.getCompilation(permutationResult);
     StaticPropertyOracle[] propOracles = perm.getPropertyOracles();
     for (StaticPropertyOracle propOracle : propOracles) {
       BindingProperty[] orderedProps = propOracle.getOrderedProps();
@@ -312,6 +328,35 @@
 
       ModuleDef module = ModuleDefLoader.loadFromClassPath(logger, moduleName);
 
+      OutputFileSet outFileSet;
+      OutputFileSet extraFileSet;
+      try {
+        if (options.getOutDir() == null) {
+          outFileSet = chooseOutputFileSet(options.getWarDir(),
+              module.getName() + "/");
+          extraFileSet = chooseOutputFileSet(options.getExtraDir(),
+              module.getName() + "/");
+        } else {
+          outFileSet = chooseOutputFileSet(options.getOutDir(),
+              module.getName() + "/");
+          if (options.getExtraDir() != null) {
+            extraFileSet = chooseOutputFileSet(options.getExtraDir(),
+                module.getName() + "-aux/", "");
+          } else if (outFileSet instanceof OutputFileSetOnDirectory) {
+            // Automatically emit extras into the output directory, if it's in
+            // fact a directory
+            extraFileSet = chooseOutputFileSet(options.getOutDir(),
+                module.getName() + "-aux/");
+          } else {
+            extraFileSet = new NullOutputFileSet();
+          }
+        }
+      } catch (IOException e) {
+        logger.log(TreeLogger.ERROR,
+            "Unexpected exception while producing output", e);
+        throw new UnableToCompleteException();
+      }
+
       if (precomps.isEmpty()) {
         logger.log(TreeLogger.ERROR, "No precompilation files found in '"
             + compilerWorkDir.getAbsolutePath()
@@ -359,12 +404,12 @@
       ArtifactSet artifacts = doLink(branch, linkerContext, generatedArtifacts,
           perms, resultFiles);
 
-      if (options.getOutDir() == null) {
-        doProduceOutput(branch, artifacts, linkerContext, module,
-            options.getWarDir(), options.getExtraDir());
-      } else {
-        doProduceLegacyOutput(branch, artifacts, linkerContext, module,
-            options.getOutDir());
+      try {
+        doProduceOutput(branch, artifacts, linkerContext, outFileSet,
+            extraFileSet);
+      } catch (IOException e) {
+        logger.log(TreeLogger.ERROR,
+            "Unexpected exception while producing output", e);
       }
     }
     return true;
diff --git a/dev/core/src/com/google/gwt/dev/util/NullOutputFileSet.java b/dev/core/src/com/google/gwt/dev/util/NullOutputFileSet.java
new file mode 100644
index 0000000..6fcaf5e
--- /dev/null
+++ b/dev/core/src/com/google/gwt/dev/util/NullOutputFileSet.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2009 Google 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.google.gwt.dev.util;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * An {@link OutputFileSet} that discards all data sent to it.
+ */
+public class NullOutputFileSet extends OutputFileSet {
+  private static class NullOutputStream extends OutputStream {
+    @Override
+    public void write(byte[] b) throws IOException {
+    }
+
+    @Override
+    public void write(byte[] b, int i, int j) throws IOException {
+    }
+
+    @Override
+    public void write(int b) throws IOException {
+    }
+  }
+
+  public NullOutputFileSet() {
+    super("NULL");
+  }
+
+  @Override
+  public void close() {
+  }
+
+  @Override
+  public OutputStream openForWrite(String path, long lastModifiedTime) {
+    return new NullOutputStream();
+  }
+}
diff --git a/dev/core/src/com/google/gwt/dev/util/OutputFileSet.java b/dev/core/src/com/google/gwt/dev/util/OutputFileSet.java
new file mode 100644
index 0000000..b29d355
--- /dev/null
+++ b/dev/core/src/com/google/gwt/dev/util/OutputFileSet.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2009 Google 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.google.gwt.dev.util;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * An abstract set of files that a linker links into.
+ */
+public abstract class OutputFileSet {
+  private final String pathDescription;
+
+  protected OutputFileSet(String pathDescription) {
+    this.pathDescription = pathDescription;
+  }
+
+  public abstract void close() throws IOException;
+
+  /**
+   * Return a description of this output file set's path. The precise meaning is
+   * unspecified, except that it should be informative when used in log
+   * messages.
+   */
+  public String getPathDescription() {
+    return pathDescription;
+  }
+
+  public abstract OutputStream openForWrite(String path, long lastModifiedTime)
+      throws IOException;
+}
diff --git a/dev/core/src/com/google/gwt/dev/util/OutputFileSetOnDirectory.java b/dev/core/src/com/google/gwt/dev/util/OutputFileSetOnDirectory.java
new file mode 100644
index 0000000..522577e
--- /dev/null
+++ b/dev/core/src/com/google/gwt/dev/util/OutputFileSetOnDirectory.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2009 Google 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.google.gwt.dev.util;
+
+import com.google.gwt.dev.util.collect.HashSet;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.Set;
+
+/**
+ * An {@link OutputFileSet} on a directory.
+ */
+public class OutputFileSetOnDirectory extends OutputFileSet {
+  private final Set<String> createdDirs = new HashSet<String>();
+  private final File dir;
+  private final String prefix;
+
+  public OutputFileSetOnDirectory(File dir, String prefix) {
+    super(dir.getAbsolutePath());
+    this.dir = dir;
+    this.prefix = prefix;
+  }
+
+  @Override
+  public void close() {
+  }
+
+  @Override
+  public OutputStream openForWrite(String path, long lastModifiedTime)
+      throws IOException {
+    File file = dir;
+    for (String part : (prefix + path).split("/")) {
+      file = new File(file, part);
+    }
+    mkdirs(file.getParentFile());
+    return new FileOutputStream(file);
+  }
+
+  /**
+   * A faster bulk version of {@link File#mkdirs()} that avoids recreating the
+   * same directory multiple times.
+   */
+  private void mkdirs(File dir) {
+    if (dir == null) {
+      return;
+    }
+    String path = dir.getPath();
+    if (createdDirs.contains(path)) {
+      return;
+    }
+    createdDirs.add(path);
+    if (!dir.exists()) {
+      mkdirs(dir.getParentFile());
+      dir.mkdir();
+    }
+  }
+}
diff --git a/dev/core/src/com/google/gwt/dev/util/OutputFileSetOnJar.java b/dev/core/src/com/google/gwt/dev/util/OutputFileSetOnJar.java
new file mode 100644
index 0000000..856533a
--- /dev/null
+++ b/dev/core/src/com/google/gwt/dev/util/OutputFileSetOnJar.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright 2009 Google 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.google.gwt.dev.util;
+
+import com.google.gwt.dev.util.collect.HashSet;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.Set;
+import java.util.jar.JarOutputStream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+/**
+ * An {@link OutputFileSet} on a jar file.
+ */
+public class OutputFileSetOnJar extends OutputFileSet {
+  /**
+   * An output stream on a jar entry for <code>jar</code>. It is assumed that
+   * the entry has already been written, so this class only has to forward the
+   * writes.
+   */
+  private final class OutputStreamOnJarEntry extends OutputStream {
+    @Override
+    public void close() throws IOException {
+      jar.closeEntry();
+    }
+
+    @Override
+    public void write(byte b[], int off, int len) throws IOException {
+      jar.write(b, off, len);
+    }
+
+    @Override
+    public void write(int b) throws IOException {
+      jar.write(b);
+    }
+  }
+
+  /**
+   * Returns the parent path of forward-slash based partial path. Assumes the
+   * given path does not end with a trailing slash.
+   */
+  private static String getParentPath(String path) {
+    assert !path.endsWith("/");
+    int pos = path.lastIndexOf('/');
+    return (pos >= 0) ? path.substring(0, pos) : null;
+  }
+
+  private Set<String> createdDirs = new HashSet<String>();
+
+  private final JarOutputStream jar;
+
+  private final String pathPrefix;
+
+  public OutputFileSetOnJar(File jarFile, String pathPrefix) throws IOException {
+    super(jarFile.getAbsolutePath());
+    jarFile.delete();
+    jar = new JarOutputStream(new FileOutputStream(jarFile));
+    this.pathPrefix = pathPrefix;
+  }
+
+  @Override
+  public void close() throws IOException {
+    jar.close();
+  }
+
+  @Override
+  public OutputStream openForWrite(String path, long lastModifiedTime)
+      throws IOException {
+    mkzipDirs(getParentPath(pathPrefix + path));
+
+    ZipEntry zipEntry = new ZipEntry(pathPrefix + path);
+    if (lastModifiedTime >= 0) {
+      zipEntry.setTime(lastModifiedTime);
+    }
+    jar.putNextEntry(zipEntry);
+
+    return new OutputStreamOnJarEntry();
+  }
+
+  /**
+   * Creates directory entries within a zip archive. Uses
+   * <code>createdDirs</code> to avoid creating entries for the same path twice.
+   * 
+   * @param path the path of a directory within the archive to create
+   */
+  private void mkzipDirs(String path) throws IOException {
+    if (path == null) {
+      return;
+    }
+    if (createdDirs.contains(path)) {
+      return;
+    }
+    mkzipDirs(getParentPath(path));
+    ZipEntry entry = new ZipEntry(path + '/');
+    entry.setSize(0);
+    entry.setCompressedSize(0);
+    entry.setCrc(0);
+    entry.setMethod(ZipOutputStream.STORED);
+    jar.putNextEntry(entry);
+    createdDirs.add(path);
+  }
+}