Recompiler.java

/*
 * Copyright 2011 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.codeserver;

import com.google.gwt.core.ext.Linker;
import com.google.gwt.core.ext.TreeLogger;
import com.google.gwt.core.ext.TreeLogger.Type;
import com.google.gwt.core.ext.UnableToCompleteException;
import com.google.gwt.core.ext.linker.impl.PropertiesUtil;
import com.google.gwt.core.linker.CrossSiteIframeLinker;
import com.google.gwt.core.linker.IFrameLinker;
import com.google.gwt.dev.Compiler;
import com.google.gwt.dev.CompilerContext;
import com.google.gwt.dev.CompilerOptions;
import com.google.gwt.dev.MinimalRebuildCache;
import com.google.gwt.dev.MinimalRebuildCacheManager;
import com.google.gwt.dev.NullRebuildCache;
import com.google.gwt.dev.cfg.BindingProperty;
import com.google.gwt.dev.cfg.ConfigurationProperties;
import com.google.gwt.dev.cfg.ConfigurationProperty;
import com.google.gwt.dev.cfg.ModuleDef;
import com.google.gwt.dev.cfg.ModuleDefLoader;
import com.google.gwt.dev.cfg.PropertyCombinations;
import com.google.gwt.dev.cfg.PropertyCombinations.PermutationDescription;
import com.google.gwt.dev.cfg.ResourceLoader;
import com.google.gwt.dev.cfg.ResourceLoaders;
import com.google.gwt.dev.codeserver.Job.Result;
import com.google.gwt.dev.codeserver.JobEvent.CompileStrategy;
import com.google.gwt.dev.javac.UnitCache;
import com.google.gwt.dev.resource.impl.ResourceOracleImpl;
import com.google.gwt.dev.resource.impl.ZipFileClassPathEntry;
import com.google.gwt.dev.util.log.CompositeTreeLogger;
import com.google.gwt.dev.util.log.PrintWriterTreeLogger;
import com.google.gwt.thirdparty.guava.common.base.Charsets;
import com.google.gwt.thirdparty.guava.common.base.Joiner;
import com.google.gwt.thirdparty.guava.common.base.Objects;
import com.google.gwt.thirdparty.guava.common.collect.ImmutableMap;
import com.google.gwt.thirdparty.guava.common.collect.Maps;
import com.google.gwt.thirdparty.guava.common.io.Files;
import com.google.gwt.thirdparty.guava.common.io.Resources;

import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;

/**
 * Recompiles a GWT module on demand.
 */
public class Recompiler {

  private final OutboxDir outboxDir;
  private final LauncherDir launcherDir;
  private final MinimalRebuildCacheManager minimalRebuildCacheManager;
  private final String inputModuleName;

  private String serverPrefix;
  private int compilesDone = 0;

  // after renaming
  private AtomicReference<String> outputModuleName = new AtomicReference<String>(null);

  private final AtomicReference<CompileDir> lastBuild = new AtomicReference<CompileDir>();

  private InputSummary previousInputSummary;

  private CompileDir publishedCompileDir;
  private final AtomicReference<ResourceLoader> resourceLoader =
      new AtomicReference<ResourceLoader>();
  private final CompilerContext.Builder compilerContextBuilder = new CompilerContext.Builder();
  private CompilerContext compilerContext;
  private final Options options;
  private final UnitCache unitCache;

  Recompiler(OutboxDir outboxDir, LauncherDir launcherDir,
      String inputModuleName, Options options,
      UnitCache unitCache, MinimalRebuildCacheManager minimalRebuildCacheManager) {
    this.outboxDir = outboxDir;
    this.launcherDir = launcherDir;
    this.inputModuleName = inputModuleName;
    this.options = options;
    this.unitCache = unitCache;
    this.minimalRebuildCacheManager = minimalRebuildCacheManager;
    this.serverPrefix = options.getPreferredHost() + ":" + options.getPort();
    compilerContext = compilerContextBuilder.build();
  }

  /**
   * Forces the next recompile even if no input files have changed.
   */
  void forceNextRecompile() {
    previousInputSummary = null;
  }

  /**
   * Compiles the first time, while Super Dev Mode is starting up.
   * Either this method or {@link #initWithoutPrecompile} should be called first.
   */
  synchronized Job.Result precompile(Job job) throws UnableToCompleteException {
    Result result = compile(job);
    job.onFinished(result);
    assert result.isOk();
    return result;
  }

  /**
   * Recompiles the module.
   *
   * <p>Prerequisite: either {@link #precompile} or {@link #initWithoutPrecompile} should have been
   * called first.
   *
   * <p>Sets the job's result and returns normally whether the compile succeeds or not.
   *
   * @param job should already be in the "in progress" state.
   */
  synchronized Job.Result recompile(Job job) {

    Job.Result result;
    try {
      result = compile(job);
    } catch (UnableToCompleteException e) {
      // No point in logging a stack trace for this exception
      job.getLogger().log(TreeLogger.Type.WARN, "recompile failed");
      result = new Result(null, null, e);
    } catch (Throwable error) {
      job.getLogger().log(TreeLogger.Type.WARN, "recompile failed", error);
      result = new Result(null, null, error);
    }

    job.onFinished(result);
    return result;
  }

  /**
   * Calls the GWT compiler with the appropriate settings.
   * Side-effect: a MinimalRebuildCache for the current binding properties will be found or created.
   *
   * @param job used for reporting progress. (Its result will not be set.)
   * @return a non-error Job.Result if successful.
   * @throws UnableToCompleteException for compile failures.
   */
  private Job.Result compile(Job job) throws UnableToCompleteException {

    assert job.wasSubmitted();

    if (compilesDone == 0) {
      System.setProperty("java.awt.headless", "true");
      if (System.getProperty("gwt.speedtracerlog") == null) {
        System.setProperty("gwt.speedtracerlog",
            outboxDir.getSpeedTracerLogFile().getAbsolutePath());
      }
      compilerContext = compilerContextBuilder.unitCache(unitCache).build();
    }

    long startTime = System.currentTimeMillis();
    int compileId = ++compilesDone;
    CompileDir compileDir = outboxDir.makeCompileDir(job.getLogger());
    TreeLogger compileLogger = makeCompileLogger(compileDir, job.getLogger());
    try {
      job.onStarted(compileId, compileDir);
      boolean success = doCompile(compileLogger, compileDir, job);
      if (!success) {
        compileLogger.log(TreeLogger.Type.ERROR, "Compiler returned false");
        throw new UnableToCompleteException();
      }
    } finally {
      // Make the error log available no matter what happens
      lastBuild.set(compileDir);
    }

    long elapsedTime = System.currentTimeMillis() - startTime;
    compileLogger.log(TreeLogger.Type.INFO,
        String.format("%.3fs total -- Compile completed", elapsedTime / 1000d));

    return new Result(publishedCompileDir, outputModuleName.get(), null);
  }

  /**
   * Creates a dummy output directory without compiling the module.
   * Either this method or {@link #precompile} should be called first.
   */
  synchronized Job.Result initWithoutPrecompile(TreeLogger logger)
      throws UnableToCompleteException {

    long startTime = System.currentTimeMillis();
    CompileDir compileDir = outboxDir.makeCompileDir(logger);
    TreeLogger compileLogger = makeCompileLogger(compileDir, logger);
    ModuleDef module;
    try {
      module = loadModule(compileLogger);

      logger.log(TreeLogger.INFO, "Loading Java files in " + inputModuleName + ".");
      CompilerOptions loadOptions = new CompilerOptionsImpl(compileDir, inputModuleName, options);
      compilerContext = compilerContextBuilder.options(loadOptions).unitCache(unitCache).build();

      // Loads and parses all the Java files in the GWT application using the JDT.
      // (This is warmup to make compiling faster later; we stop at this point to avoid
      // needing to know the binding properties.)
      module.getCompilationState(compileLogger, compilerContext);

      setUpCompileDir(compileDir, module, compileLogger);
      if (launcherDir != null) {
        launcherDir.update(module, compileDir, compileLogger);
      }

      outputModuleName.set(module.getName());
    } finally {
      // Make the compile log available no matter what happens.
      lastBuild.set(compileDir);
    }

    long elapsedTime = System.currentTimeMillis() - startTime;
    compileLogger.log(TreeLogger.Type.INFO, "Module setup completed in " + elapsedTime + " ms");

    return new Result(compileDir, module.getName(), null);
  }

  /**
   * Prepares a stub compile directory.
   * It will include all "public" resources and a nocache.js file that invokes the compiler.
   */
  private void setUpCompileDir(CompileDir compileDir, ModuleDef module,
      TreeLogger compileLogger) throws UnableToCompleteException {
    try {
      String currentModuleName = module.getName();

      // Create the directory.
      File outputDir = new File(
          compileDir.getWarDir().getCanonicalPath() + "/" + currentModuleName);
      if (!outputDir.exists()) {
        if (!outputDir.mkdir()) {
          compileLogger.log(Type.WARN, "cannot create directory: " + outputDir);
        }
      }
      LauncherDir.writePublicResources(outputDir, module, compileLogger);

      // write no cache that will inject recompile.nocache.js
      String stub = LauncherDir.generateStubNocacheJs(module.getName(), options);
      File noCacheJs = new File(outputDir.getCanonicalPath(), module.getName() + ".nocache.js");
      Files.write(stub, noCacheJs, Charsets.UTF_8);

      // Create a "module_name.recompile.nocache.js" that calculates the permutation
      // and forces a recompile.
      String recompileNoCache = generateModuleRecompileJs(module, compileLogger);
      writeRecompileNoCacheJs(outputDir, currentModuleName, recompileNoCache, compileLogger);
    } catch (IOException e) {
      compileLogger.log(Type.ERROR, "Error creating stub compile directory.", e);
      UnableToCompleteException wrapped = new UnableToCompleteException();
      wrapped.initCause(e);
      throw wrapped;
    }
  }

  /**
   * Generates the nocache.js file to use when precompile is not on.
   */
  private static String generateModuleRecompileJs(ModuleDef module, TreeLogger compileLogger)
      throws UnableToCompleteException {

    String outputModuleName = module.getName();
    try {
      String templateJs = Resources.toString(
          Resources.getResource(Recompiler.class, "recompile_template.js"), Charsets.UTF_8);
      String propertyProviders = PropertiesUtil.generatePropertiesSnippet(module, compileLogger);
      String libJs = Resources.toString(
          Resources.getResource(Recompiler.class, "recompile_lib.js"), Charsets.UTF_8);
      String recompileJs = Resources.toString(
          Resources.getResource(Recompiler.class, "recompile_main.js"), Charsets.UTF_8);
      templateJs = templateJs.replace("__MODULE_NAME__", "'" + outputModuleName + "'");
      templateJs = templateJs.replace("__PROPERTY_PROVIDERS__", propertyProviders);
      templateJs = templateJs.replace("__LIB_JS__", libJs);
      templateJs = templateJs.replace("__MAIN__", recompileJs);

      return templateJs;

    } catch (IOException e) {
      compileLogger.log(Type.ERROR, "Can not generate + " + outputModuleName + " recompile js", e);
      throw new UnableToCompleteException();
    }
  }

  synchronized String getRecompileJs(TreeLogger logger) throws UnableToCompleteException {
    ModuleDef loadModule = loadModule(logger);
    return generateModuleRecompileJs(loadModule, logger);
  }

  private boolean doCompile(TreeLogger compileLogger, CompileDir compileDir, Job job)
      throws UnableToCompleteException {

    job.onProgress("Loading modules");

    CompilerOptions loadOptions = new CompilerOptionsImpl(compileDir, inputModuleName, options);
    compilerContext = compilerContextBuilder.options(loadOptions).build();

    ModuleDef module = loadModule(compileLogger);

    // We need to generate the stub before restricting permutations
    String recompileJs = generateModuleRecompileJs(module, compileLogger);

    Map<String, String> bindingProperties =
        restrictPermutations(compileLogger, module, job.getBindingProperties());

    // Propagates module rename.
    String newModuleName = module.getName();
    outputModuleName.set(newModuleName);

    // Check if the module definition + job specific binding property restrictions expanded to more
    // than permutation.
    PropertyCombinations propertyCombinations =
        new PropertyCombinations(module.getProperties(), module.getActiveLinkerNames());
    List<PropertyCombinations> permutationPropertySets = propertyCombinations.collapseProperties();
    if (options.isIncrementalCompileEnabled() && permutationPropertySets.size() > 1) {
      compileLogger.log(Type.INFO,
          "Current binding properties are expanding to more than one permutation "
          + "but incremental compilation requires that each compile operate on only "
          + "one permutation.");
      job.setCompileStrategy(CompileStrategy.SKIPPED);
      return true;
    }
    PropertyCombinations permutationPropertySet = permutationPropertySets.get(0);
    PermutationDescription permutationDescription =
        permutationPropertySet.getPermutationDescription(0);

    // Check if we can skip the compile altogether.
    InputSummary inputSummary = new InputSummary(bindingProperties, module);
    if (inputSummary.equals(previousInputSummary)) {
      compileLogger.log(Type.INFO, "skipped compile because no input files have changed");
      job.setCompileStrategy(CompileStrategy.SKIPPED);
      return true;
    }
    // Force a recompile if we don't succeed.
    forceNextRecompile();

    job.onProgress("Compiling");

    CompilerOptions runOptions = new CompilerOptionsImpl(compileDir, newModuleName, options);
    compilerContext = compilerContextBuilder.options(runOptions).build();

    MinimalRebuildCache minimalRebuildCache = new NullRebuildCache();
    if (options.isIncrementalCompileEnabled()) {
      // Returns a copy of the intended cache, which is safe to modify in this compile.
      minimalRebuildCache =
          minimalRebuildCacheManager.getCache(inputModuleName, permutationDescription);
    }
    job.setCompileStrategy(minimalRebuildCache.isPopulated() ? CompileStrategy.INCREMENTAL
        : CompileStrategy.FULL);

    boolean success = Compiler.compile(compileLogger, runOptions, minimalRebuildCache, module);
    if (success) {
      publishedCompileDir = compileDir;
      previousInputSummary = inputSummary;
      if (options.isIncrementalCompileEnabled()) {
        minimalRebuildCacheManager.putCache(inputModuleName, permutationDescription,
            minimalRebuildCache);
      }
      String moduleName = outputModuleName.get();
      writeRecompileNoCacheJs(new File(publishedCompileDir.getWarDir(), moduleName), moduleName,
          recompileJs, compileLogger);
      if (launcherDir != null) {
        launcherDir.update(module, compileDir, compileLogger);
      }
    }

    return success;
  }

  private static void writeRecompileNoCacheJs(File outputDir, String moduleName, String content,
      TreeLogger compileLogger) throws UnableToCompleteException {
    try {
      Files.write(content,
          new File(outputDir.getCanonicalPath() + "/" + moduleName + ".recompile.nocache.js"),
          Charsets.UTF_8);
    } catch (IOException e) {
      compileLogger.log(Type.ERROR, "Can not write recompile.nocache.js", e);
      throw new UnableToCompleteException();
    }
  }

  /**
   * Returns the log from the last compile. (It may be a failed build.)
   */
  File getLastLog() {
    return lastBuild.get().getLogFile();
  }

  /**
   * The module name that the recompiler passes as input to the GWT compiler (before renaming).
   */
  public String getInputModuleName() {
    return inputModuleName;
  }

  /**
   * The module name that the GWT compiler uses in compiled output (after renaming).
   */
  String getOutputModuleName() {
    return outputModuleName.get();
  }

  ResourceLoader getResourceLoader() {
    return resourceLoader.get();
  }

  private TreeLogger makeCompileLogger(CompileDir compileDir, TreeLogger parent)
      throws UnableToCompleteException {
    try {
      PrintWriterTreeLogger fileLogger =
          new PrintWriterTreeLogger(compileDir.getLogFile());
      fileLogger.setMaxDetail(options.getLogLevel());
      return new CompositeTreeLogger(parent, fileLogger);
    } catch (IOException e) {
      parent.log(TreeLogger.ERROR, "unable to open log file: " + compileDir.getLogFile(), e);
      throw new UnableToCompleteException();
    }
  }

  /**
   * Loads the module and configures it for SuperDevMode. (Does not restrict permutations.)
   */
  private ModuleDef loadModule(TreeLogger logger) throws UnableToCompleteException {

    // make sure we get the latest version of any modified jar
    ZipFileClassPathEntry.clearCache();
    ResourceOracleImpl.clearCache();

    ResourceLoader resources = ResourceLoaders.fromContextClassLoader();
    resources = ResourceLoaders.forPathAndFallback(options.getSourcePath(), resources);
    this.resourceLoader.set(resources);

    // ModuleDefLoader.loadFromResources() checks for modified .gwt.xml files.
    ModuleDef moduleDef = ModuleDefLoader.loadFromResources(
        logger, inputModuleName, resources, true);
    compilerContext = compilerContextBuilder.module(moduleDef).build();

    // Undo all permutation restriction customizations from previous compiles.
    for (BindingProperty bindingProperty : moduleDef.getProperties().getBindingProperties()) {
      bindingProperty.resetGeneratedValues();
    }

    // A snapshot of the module's configuration before we modified it.
    ConfigurationProperties config = new ConfigurationProperties(moduleDef);

    // We need a cross-site linker. Automatically replace the default linker.
    if (IFrameLinker.class.isAssignableFrom(moduleDef.getActivePrimaryLinker())) {
      moduleDef.addLinker("xsiframe");
    }

    // Check that we have a compatible linker.
    Class<? extends Linker> linker = moduleDef.getActivePrimaryLinker();
    if (!CrossSiteIframeLinker.class.isAssignableFrom(linker)) {
      logger.log(TreeLogger.ERROR,
          "linkers other than CrossSiteIFrameLinker aren't supported. Found: " + linker.getName());
      throw new UnableToCompleteException();
    }

    // Deactivate precompress linker.
    if (moduleDef.deactivateLinker("precompress")) {
      logger.log(TreeLogger.WARN, "Deactivated PrecompressLinker");
    }

    // Print a nice error if the superdevmode hook isn't present
    if (config.getStrings("devModeRedirectEnabled").isEmpty()) {
      throw new RuntimeException("devModeRedirectEnabled isn't set for module: " +
          moduleDef.getName());
    }

    // Disable the redirect hook here to make sure we don't have an infinite loop.
    // (There is another check in the JavaScript, but just in case.)
    overrideConfig(moduleDef, "devModeRedirectEnabled", "false");

    // Turn off "installCode" if it's on because it makes debugging harder.
    // (If it's already off, don't change anything.)
    if (config.getBoolean("installCode", true)) {
      overrideConfig(moduleDef, "installCode", "false");
      // Make sure installScriptJs is set to the default for compiling without installCode.
      overrideConfig(moduleDef, "installScriptJs",
          "com/google/gwt/core/ext/linker/impl/installScriptDirect.js");
    }

    // override computeScriptBase.js to enable the "Compile" button
    overrideConfig(moduleDef, "computeScriptBaseJs",
        "com/google/gwt/dev/codeserver/computeScriptBase.js");
    // Fix bug with SDM and Chrome 24+ where //@ sourceURL directives cause X-SourceMap header to be ignored
    // Frustratingly, Chrome won't canonicalize a relative URL
    overrideConfig(moduleDef, "includeSourceMapUrl", "http://" + serverPrefix +
        SourceHandler.sourceMapLocationTemplate(moduleDef.getName()));

    // If present, set some config properties back to defaults.
    // (Needed for Google's server-side linker.)
    maybeOverrideConfig(moduleDef, "includeBootstrapInPrimaryFragment", "false");
    maybeOverrideConfig(moduleDef, "permutationsJs",
        "com/google/gwt/core/ext/linker/impl/permutations.js");
    maybeOverrideConfig(moduleDef, "propertiesJs",
        "com/google/gwt/core/ext/linker/impl/properties.js");

    if (options.isIncrementalCompileEnabled()) {
      // CSSResourceGenerator needs to produce stable, unique naming for its input.
      // Currently on default settings CssResourceGenerator's obfuscation depends on
      // whole world knowledge and thus will produce collision in obfuscated mode, since in
      // incremental compiles that information is not available.
      //
      // TODO(dankurka): Once we do proper stable hashing of classes in CssResourceGenerator, we
      // can probably replace / remove this.
      maybeOverrideConfig(moduleDef, "CssResource.style", "stable");
    }

    overrideBinding(moduleDef, "compiler.useSourceMaps", "true");
    overrideBinding(moduleDef, "compiler.useSymbolMaps", "false");
    overrideBinding(moduleDef, "superdevmode", "on");

    return moduleDef;
  }

  /**
   * Restricts the compiled permutations by applying the given binding properties, if possible.
   * In some cases, a different binding may be chosen instead.
   */
  private Map<String, String> restrictPermutations(TreeLogger logger, ModuleDef moduleDef,
      Map<String, String> bindingProperties) {

    Map<String, String> chosenProps = Maps.newHashMap();

    for (Map.Entry<String, String> entry : bindingProperties.entrySet()) {
      String propName = entry.getKey();
      String propValue = entry.getValue();
      String actual = maybeSetBinding(logger, moduleDef, propName, propValue);
      if (actual != null) {
        chosenProps.put(propName, actual);
      }
    }

    return chosenProps;
  }

  /**
   * Attempts to set a binding property to the given value.
   * If the value is not allowed, see if we can find a value that will work.
   * There is a special case for "locale".
   * @return the value actually set, or null if unable to set the property
   */
  private static String maybeSetBinding(TreeLogger logger, ModuleDef module, String propName,
      String newValue) {

    logger = logger.branch(TreeLogger.Type.INFO, "binding: " + propName + "=" + newValue);

    BindingProperty binding = module.getProperties().findBindingProp(propName);
    if (binding == null) {
      logger.log(TreeLogger.Type.WARN, "undefined property: '" + propName + "'");
      return null;
    }

    if (!binding.isAllowedValue(newValue)) {

      String[] allowedValues = binding.getAllowedValues(binding.getRootCondition());
      logger.log(TreeLogger.Type.WARN, "property '" + propName +
          "' cannot be set to '" + newValue + "'");
      logger.log(TreeLogger.Type.INFO, "allowed values: " +
          Joiner.on(", ").join(allowedValues));

      // See if we can fall back on a reasonable default.
      if (allowedValues.length == 1) {
        // There is only one possibility, so use it.
        newValue = allowedValues[0];
      } else if (binding.getName().equals("locale")) {
        // TODO: come up with a more general solution. Perhaps fail
        // the compile and give the user a way to override the property?
        newValue = chooseDefault(binding, "default", "en", "en_US");
      } else {
        // There is more than one. Continue and possibly compile multiple permutations.
        logger.log(TreeLogger.Type.INFO, "continuing without " + propName +
            ". Sourcemaps may not work.");
        return null;
      }

      logger.log(TreeLogger.Type.INFO, "recovered with " + propName + "=" + newValue);
    }

    binding.setRootGeneratedValues(newValue);
    return newValue;
  }

  private static String chooseDefault(BindingProperty property, String... candidates) {
    for (String candidate : candidates) {
      if (property.isAllowedValue(candidate)) {
        return candidate;
      }
    }
    return property.getFirstAllowedValue();
  }

  /**
   * Sets a binding even if it's set to a different value in the GWT application.
   */
  private static void overrideBinding(ModuleDef module, String propName, String newValue) {
    BindingProperty binding = module.getProperties().findBindingProp(propName);
    if (binding != null) {
      // This sets both allowed and generated values, which is needed since the module
      // might have explicitly disallowed the value.
      // It persists over multiple compiles but that's okay since we set it the same way
      // every time.
      binding.setValues(binding.getRootCondition(), newValue);
    }
  }

  private static boolean maybeOverrideConfig(ModuleDef module, String propName, String newValue) {
    ConfigurationProperty config = module.getProperties().findConfigProp(propName);
    if (config != null) {
      config.setValue(newValue);
      return true;
    }
    return false;
  }

  private static void overrideConfig(ModuleDef module, String propName, String newValue) {
    if (!maybeOverrideConfig(module, propName, newValue)) {
      throw new RuntimeException("not found: " + propName);
    }
  }

  /**
   * Summarizes the inputs to a GWT compile. (Immutable.)
   * Two summaries should be equal if the compiler's inputs are equal (with high probability).
   */
  private static class InputSummary {
    private final ImmutableMap<String, String> bindingProperties;
    private final long moduleLastModified;
    private final long resourcesLastModified;
    private final String filenameHash;

    InputSummary(Map<String, String> bindingProperties, ModuleDef module) {
      this.bindingProperties = ImmutableMap.copyOf(bindingProperties);
      this.moduleLastModified = module.lastModified();
      this.resourcesLastModified = module.getResourceLastModified();
      this.filenameHash = module.getInputFilenameHash();
    }

    @Override
    public boolean equals(Object obj) {
      if (obj instanceof InputSummary) {
        InputSummary other = (InputSummary) obj;
        return bindingProperties.equals(other.bindingProperties) &&
            moduleLastModified == other.moduleLastModified &&
            resourcesLastModified == other.resourcesLastModified &&
            filenameHash.equals(other.filenameHash);
      }
      return false;
    }

    @Override
    public int hashCode() {
      return Objects.hashCode(bindingProperties, moduleLastModified, resourcesLastModified,
          filenameHash);
    }
  }
}