Expose structField callable methods of skylark objects to dir() and str() calls

RELNOTES: None.
PiperOrigin-RevId: 184498836
diff --git a/src/main/java/com/google/devtools/build/lib/packages/Info.java b/src/main/java/com/google/devtools/build/lib/packages/Info.java
index 50fcb15..33b8c95 100644
--- a/src/main/java/com/google/devtools/build/lib/packages/Info.java
+++ b/src/main/java/com/google/devtools/build/lib/packages/Info.java
@@ -13,6 +13,7 @@
 // limitations under the License.
 package com.google.devtools.build.lib.packages;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Joiner;
 import com.google.common.base.Objects;
 import com.google.common.base.Preconditions;
@@ -108,14 +109,17 @@
   public abstract boolean hasField(String name);
 
   /**
-   * {@inheritDoc}
-   *
-   * <p>Overrides {@link ClassObject#getValue(String)}, but does not allow {@link EvalException} to
-   * be thrown.
+   * <p>Wraps {@link ClassObject#getValue(String)}, returning null in cases where
+   * {@link EvalException} would have been thrown.
    */
-  @Nullable
-  @Override
-  public abstract Object getValue(String name);
+  @VisibleForTesting
+  public Object getValueOrNull(String name) {
+    try {
+      return getValue(name);
+    } catch (EvalException e) {
+      return null;
+    }
+  }
 
   /**
    * Returns the result of {@link #getValue(String)}, cast as the given type, throwing {@link
@@ -172,7 +176,7 @@
       return false;
     }
     for (String field : getFieldNames()) {
-      if (!this.getValue(field).equals(other.getValue(field))) {
+      if (!Objects.equal(this.getValueOrNull(field), other.getValueOrNull(field))) {
         return false;
       }
     }
@@ -187,7 +191,7 @@
     objectsToHash.add(provider);
     for (String field : fields) {
       objectsToHash.add(field);
-      objectsToHash.add(getValue(field));
+      objectsToHash.add(getValueOrNull(field));
     }
     return Objects.hashCode(objectsToHash.toArray());
   }
@@ -208,7 +212,7 @@
       first = false;
       printer.append(fieldName);
       printer.append(" = ");
-      printer.repr(getValue(fieldName));
+      printer.repr(getValueOrNull(fieldName));
     }
     printer.append(")");
   }
diff --git a/src/main/java/com/google/devtools/build/lib/packages/NativeInfo.java b/src/main/java/com/google/devtools/build/lib/packages/NativeInfo.java
index e1035c8..58ea8d3 100644
--- a/src/main/java/com/google/devtools/build/lib/packages/NativeInfo.java
+++ b/src/main/java/com/google/devtools/build/lib/packages/NativeInfo.java
@@ -15,7 +15,11 @@
 
 import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.devtools.build.lib.events.Location;
+import com.google.devtools.build.lib.syntax.EvalException;
+import com.google.devtools.build.lib.syntax.FuncallExpression;
+import com.google.devtools.build.lib.syntax.FuncallExpression.MethodDescriptor;
 import java.util.Map;
 
 /** Base class for native implementations of {@link Info}. */
@@ -23,19 +27,35 @@
 public class NativeInfo extends Info {
   protected final ImmutableMap<String, Object> values;
 
+  // Initialized lazily.
+  private ImmutableSet<String> fieldNames;
+
   @Override
-  public Object getValue(String name) {
-    return values.get(name);
+  public Object getValue(String name) throws EvalException {
+    if (values.containsKey(name)) {
+      return values.get(name);
+    } else if (hasField(name)) {
+      MethodDescriptor methodDescriptor = FuncallExpression.getStructField(this.getClass(), name);
+      return FuncallExpression.invokeStructField(methodDescriptor, name, this);
+    } else {
+      return null;
+    }
   }
 
   @Override
   public boolean hasField(String name) {
-    return values.containsKey(name);
+    return getFieldNames().contains(name);
   }
 
   @Override
   public ImmutableCollection<String> getFieldNames() {
-    return values.keySet();
+    if (fieldNames == null) {
+      fieldNames = ImmutableSet.<String>builder()
+          .addAll(values.keySet())
+          .addAll(FuncallExpression.getStructFieldNames(this.getClass()))
+          .build();
+    }
+    return fieldNames;
   }
 
   public NativeInfo(NativeProvider<?> provider) {
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/FuncallExpression.java b/src/main/java/com/google/devtools/build/lib/syntax/FuncallExpression.java
index 833b20a..241789d 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/FuncallExpression.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/FuncallExpression.java
@@ -42,12 +42,14 @@
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
+import java.util.stream.Collectors;
 import javax.annotation.Nullable;
 
 /**
@@ -134,6 +136,48 @@
                 }
               });
 
+  private static final LoadingCache<Class<?>, Map<String, MethodDescriptor>> fieldCache =
+      CacheBuilder.newBuilder()
+          .initialCapacity(10)
+          .maximumSize(100)
+          .build(
+              new CacheLoader<Class<?>, Map<String, MethodDescriptor>>() {
+
+                @Override
+                public Map<String, MethodDescriptor> load(Class<?> key) throws Exception {
+                  ImmutableMap.Builder<String, MethodDescriptor> fieldMap = ImmutableMap.builder();
+                  HashSet<String> fieldNamesForCollisions = new HashSet<>();
+                  List<MethodDescriptor> fieldMethods =
+                      methodCache
+                          .get(key)
+                          .values()
+                          .stream()
+                          .flatMap(List::stream)
+                          .filter(
+                              methodDescriptor -> methodDescriptor.getAnnotation().structField())
+                          .collect(Collectors.toList());
+
+                  for (MethodDescriptor fieldMethod : fieldMethods) {
+                    SkylarkCallable callable = fieldMethod.getAnnotation();
+                    String name = callable.name();
+                    if (name.isEmpty()) {
+                      name =
+                          StringUtilities.toPythonStyleFunctionName(
+                              fieldMethod.getMethod().getName());
+                    }
+                    // TODO(b/72113542): Validate with annotation processor instead of at runtime.
+                    if (!fieldNamesForCollisions.add(name)) {
+                      throw new IllegalArgumentException(
+                          String.format(
+                              "Class %s has two structField methods named %s defined",
+                              key.getName(), name));
+                    }
+                    fieldMap.put(name, fieldMethod);
+                  }
+                  return fieldMap.build();
+                }
+              });
+
   /**
    * Returns a map of methods and corresponding SkylarkCallable annotations of the methods of the
    * classObj class reachable from Skylark.
@@ -260,15 +304,30 @@
     return printer.toString();
   }
 
-  /**
-   * Returns the list of Skylark callable Methods of objClass with the given name and argument
-   * number.
-   */
+  /** Returns the Skylark callable Method of objClass with structField=true and the given name. */
+  public static MethodDescriptor getStructField(Class<?> objClass, String methodName) {
+    try {
+      return fieldCache.get(objClass).get(methodName);
+    } catch (ExecutionException e) {
+      throw new IllegalStateException("Method loading failed: " + e);
+    }
+  }
+
+  /** Returns the list of names of Skylark callable Methods of objClass with structField=true. */
+  public static Set<String> getStructFieldNames(Class<?> objClass) {
+    try {
+      return fieldCache.get(objClass).keySet();
+    } catch (ExecutionException e) {
+      throw new IllegalStateException("Method loading failed: " + e);
+    }
+  }
+
+  /** Returns the list of Skylark callable Methods of objClass with the given name. */
   public static List<MethodDescriptor> getMethods(Class<?> objClass, String methodName) {
     try {
       return methodCache.get(objClass).get(methodName);
     } catch (ExecutionException e) {
-      throw new IllegalStateException("method invocation failed: " + e);
+      throw new IllegalStateException("Method loading failed: " + e);
     }
   }
 
@@ -280,10 +339,25 @@
     try {
       return methodCache.get(objClass).keySet();
     } catch (ExecutionException e) {
-      throw new IllegalStateException("method invocation failed: " + e);
+      throw new IllegalStateException("Method loading failed: " + e);
     }
   }
 
+  /**
+   * Invokes the given structField=true method and returns the result.
+   *
+   * @param methodDescriptor the descriptor of the method to invoke
+   * @param fieldName the name of the struct field
+   * @param obj the object on which to invoke the method
+   * @return the method return value
+   * @throws EvalException if there was an issue evaluating the method
+   */
+  public static Object invokeStructField(
+      MethodDescriptor methodDescriptor, String fieldName, Object obj) throws EvalException {
+    Preconditions.checkArgument(methodDescriptor.getAnnotation().structField());
+    return callMethod(methodDescriptor, fieldName, obj, new Object[0], Location.BUILTIN, null);
+  }
+
   static Object callMethod(MethodDescriptor methodDescriptor, String methodName, Object obj,
       Object[] args, Location loc, Environment env) throws EvalException {
     try {