Add debugging helper to Environment.Extension to say why something's unequal

RELNOTES: None
PiperOrigin-RevId: 184649483
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/Environment.java b/src/main/java/com/google/devtools/build/lib/syntax/Environment.java
index 038961f..cafa8a0 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/Environment.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/Environment.java
@@ -14,8 +14,10 @@
 
 package com.google.devtools.build.lib.syntax;
 
+import com.google.common.base.Joiner;
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Sets;
 import com.google.devtools.build.lib.cmdline.Label;
 import com.google.devtools.build.lib.concurrent.ThreadSafety.Immutable;
 import com.google.devtools.build.lib.events.Event;
@@ -27,6 +29,7 @@
 import com.google.devtools.build.lib.util.Fingerprint;
 import com.google.devtools.build.lib.util.Pair;
 import com.google.devtools.build.lib.util.SpellChecker;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -351,6 +354,8 @@
 
     /**
      * Cached hash code for the transitive content of this {@code Extension} and its dependencies.
+     *
+     * <p>Note that "content" refers to the AST content, not the evaluated bindings.
      */
     private final String transitiveContentHashCode;
 
@@ -389,6 +394,61 @@
           && bindings.equals(other.getBindings());
     }
 
+    /**
+     * Throws {@link IllegalStateException} if this {@link Extension} is not equal to {@code obj}.
+     *
+     * <p>The exception explains the reason for the inequality, including all unequal bindings.
+     */
+    public void checkStateEquals(Object obj) {
+      if (this == obj) {
+        return;
+      }
+      if (!(obj instanceof Extension)) {
+        throw new IllegalStateException(String.format(
+            "Expected an equal Extension, but got a %s instead of an Extension",
+            obj == null ? "null" : obj.getClass().getName()));
+      }
+      Extension other = (Extension) obj;
+      ImmutableMap<String, Object> otherBindings = other.getBindings();
+
+      Set<String> names = bindings.keySet();
+      Set<String> otherNames = otherBindings.keySet();
+      if (!names.equals(otherNames)) {
+        throw new IllegalStateException(String.format(
+            "Expected Extensions to be equal, but they don't define the same bindings: "
+                + "in this one but not given one: [%s]; in given one but not this one: [%s]",
+            Joiner.on(", ").join(Sets.difference(names, otherNames)),
+            Joiner.on(", ").join(Sets.difference(otherNames, names))));
+      }
+
+      ArrayList<String> badEntries = new ArrayList<>();
+      for (String name : names) {
+        Object value = bindings.get(name);
+        Object otherValue = otherBindings.get(name);
+        if (!value.equals(otherValue)) {
+          badEntries.add(String.format(
+              "%s: this one has %s (class %s), but given one has %s (class %s)",
+              name,
+              Printer.repr(value),
+              value.getClass().getName(),
+              Printer.repr(otherValue),
+              otherValue.getClass().getName()));
+        }
+      }
+      if (!badEntries.isEmpty()) {
+        throw new IllegalStateException(
+            "Expected Extensions to be equal, but the following bindings are unequal: "
+                + Joiner.on("; ").join(badEntries));
+      }
+
+      if (!transitiveContentHashCode.equals(other.getTransitiveContentHashCode())) {
+        throw new IllegalStateException(String.format(
+            "Expected Extensions to be equal, but transitive content hashes don't match: %s != %s",
+            transitiveContentHashCode,
+            other.getTransitiveContentHashCode()));
+      }
+    }
+
     @Override
     public int hashCode() {
       return Objects.hash(bindings, transitiveContentHashCode);