Add dot expressions to type checker

Currently the only type that permits field retrieval is `Any`. The resulting type of the retrieval is also `Any`.

To support type checking more complex assignment targets, including subscript expressions and tuple assignment in follow-up CLs, the `visit(AssignmentStatement)` override is factored out into an `assign(Expression lhs, StarlarkType rhsType)` helper.

Added an `assertContainsErrors()` helper to the unit test, to allow checking for more than one error in a single test case code snippet. This gets more use in tests introduced in follow-up CLs.

Work toward #28037.

PiperOrigin-RevId: 847446752
Change-Id: I01f033e14aa2126b3df99311fb5b4c72d5082280
diff --git a/src/main/java/net/starlark/java/syntax/TypeChecker.java b/src/main/java/net/starlark/java/syntax/TypeChecker.java
index cfe1a34..70e288b 100644
--- a/src/main/java/net/starlark/java/syntax/TypeChecker.java
+++ b/src/main/java/net/starlark/java/syntax/TypeChecker.java
@@ -92,15 +92,64 @@
       case FLOAT_LITERAL -> {
         return Types.FLOAT;
       }
+      case DOT -> {
+        // TODO: #27370 - Add support for field retrieval on types besides Any.
+        var dot = (DotExpression) expr;
+        StarlarkType objType = infer(dot.getObject());
+        if (objType.equals(Types.ANY)) {
+          return Types.ANY;
+        } else {
+          errorf(
+              dot.getDotLocation(),
+              "'%s' of type '%s' does not have field '%s'",
+              dot.getObject(),
+              objType,
+              dot.getField().getName());
+          return Types.ANY;
+        }
+      }
       default -> {
-        // TODO: #28037 - support binaryop, call, cast, comprehension, conditional, dict_expr, dot,
-        // index, lambda, list, and unaryop expressions.
+        // TODO: #28037 - support binaryop, call, cast, comprehension, conditional, dict_expr,
+        // index, lambda, list, slice, and unaryop expressions.
         throw new UnsupportedOperationException(
             String.format("cannot typecheck %s expression", expr.kind()));
       }
     }
   }
 
+  /**
+   * Recursively typechecks the assignment of type {@code rhsType} to the target expression {@code
+   * lhs}.
+   *
+   * <p>The asymmetry of the parameter types comes from the fact that this helper recursively
+   * decomposes the LHS syntactically, whereas the RHS has already been fully evaluated to a type.
+   * For instance, {@code x, y = (1, 2)} and {@code x, y = my_pair} both trigger the same behavior
+   * in this method. Decomposing the LHS syntactically rather than by type is what allows {@code (x,
+   * y) = [1, 2]} to succeed, even though assignment of a list to a tuple type is illegal (as in
+   * {@code t : Tuple[int, int] = [1, 2]}).
+   */
+  private void assign(Expression lhs, StarlarkType rhsType) {
+    // infer() handles Identifier and DotExpression. The type for evaluating these expressions in a
+    // read context is the same as its type for assignment purposes.
+    StarlarkType lhsType = infer(lhs);
+
+    if (lhs.kind() == Expression.Kind.LIST_EXPR) {
+      // TODO: #28037 - support LHSs containing multiple targets (list expression), field
+      // assignments, and subscript assignments.
+      throw new UnsupportedOperationException(
+          "cannot typecheck assignment statements with multiple targets on the LHS");
+    }
+
+    if (!StarlarkType.assignableFrom(lhsType, rhsType)) {
+      errorf(
+          lhs.getStartLocation(),
+          "cannot assign type '%s' to '%s' of type '%s'",
+          rhsType,
+          lhs,
+          lhsType);
+    }
+  }
+
   // Expressions should only be visited via infer(), not the visit() dispatch mechanism.
   // Override visit(Identifier) as a poison pill.
   @Override
@@ -116,21 +165,13 @@
       // TODO: #28037 - support this by validating that `lhs <op> rhs` would type check
       throw new UnsupportedOperationException("cannot typecheck augmented assignment statements");
     }
-    if (!(assignment.getLHS() instanceof Identifier id)) {
-      // TODO: #28037 - support LHSs containing multiple targets (list expression), field
-      // assignments, and subscript assignments.
-      throw new UnsupportedOperationException(
-          "cannot typecheck assignment statements with anything besides a single identifier on the"
-              + " LHS");
-    }
-    var lhsType = getType(id);
+
     // TODO: #27370 - Do bidirectional inference, passing down information about the expected type
     // from the LHS to the infer() call here, e.g. to construct the type of `[1, 2, 3]` as list[int]
     // instead of list[object].
     var rhsType = infer(assignment.getRHS());
-    if (!StarlarkType.assignableFrom(lhsType, rhsType)) {
-      errorf(assignment, "cannot assign type '%s' to '%s'", rhsType, lhsType);
-    }
+
+    assign(assignment.getLHS(), rhsType);
   }
 
   @Override
diff --git a/src/test/java/net/starlark/java/syntax/TypeCheckerTest.java b/src/test/java/net/starlark/java/syntax/TypeCheckerTest.java
index e285285..5ac0a83 100644
--- a/src/test/java/net/starlark/java/syntax/TypeCheckerTest.java
+++ b/src/test/java/net/starlark/java/syntax/TypeCheckerTest.java
@@ -78,6 +78,13 @@
     assertContainsError(file.errors(), expectedError);
   }
 
+  /** Asserts that the given file has at least the specified errors. */
+  private static void assertContainsErrors(StarlarkFile file, String... errors) throws Exception {
+    for (var err : errors) {
+      assertContainsError(file.errors(), err);
+    }
+  }
+
   @Test
   public void assignment_simple() throws Exception {
     assertValid(
@@ -87,7 +94,7 @@
         """);
 
     assertInvalid(
-        ":2:1: cannot assign type 'str' to 'int'",
+        ":2:1: cannot assign type 'str' to 'x' of type 'int'",
         """
         x: int
         x = "abc"
@@ -97,7 +104,7 @@
   @Test
   public void canLookupIdentifierTypeOnRhs() throws Exception {
     assertInvalid(
-        ":3:1: cannot assign type 'bool' to 'int'",
+        ":3:1: cannot assign type 'bool' to 'x' of type 'int'",
         """
         x: int
         y: bool
@@ -107,21 +114,18 @@
 
   @Test
   public void primitiveTypesAreInferredFromLiteralsAndAreIncompatible() throws Exception {
-    assertInvalid(
-        ":1:1: cannot assign type 'str' to 'bool'",
-        """
-        x: bool = 'abc'
-        """);
-    assertInvalid(
-        ":1:1: cannot assign type 'int' to 'float'",
-        """
-        x: float = 123
-        """);
-    assertInvalid(
-        ":1:1: cannot assign type 'float' to 'None'",
-        """
-        x: None = 1.0
-        """);
+    var file =
+        typecheckFilePossiblyFailing(
+            """
+            x: bool = 'abc'
+            y: float = 123
+            z: None = 1.0
+            """);
+    assertContainsErrors(
+        file,
+        ":1:1: cannot assign type 'str' to 'x' of type 'bool'",
+        ":2:1: cannot assign type 'int' to 'y' of type 'float'",
+        ":3:1: cannot assign type 'float' to 'z' of type 'None'");
   }
 
   // TODO: #27728 - We should add a test that the types of universals, and in particular the
@@ -173,4 +177,38 @@
         """);
     // TODO: #28037 - Check break/continue, once we support for and def statements
   }
+
+  @Test
+  public void dotExpression() throws Exception {
+    // TODO: #27370 - Make this more interesting when we support struct types.
+    assertValid(
+        """
+        o: Any
+        x: int
+        x = o.f  # Any.f ==> Any
+        """);
+
+    assertInvalid(
+        ":2:2: 'x' of type 'int' does not have field 'f'",
+        """
+        x: int
+        x.f
+        """);
+  }
+
+  @Test
+  public void assignment_dot() throws Exception {
+    assertValid(
+        """
+        x: Any
+        x.f = 123
+        """);
+
+    assertInvalid(
+        ":2:2: 'x' of type 'str' does not have field 'f'",
+        """
+        x: str
+        x.f = 123
+        """);
+  }
 }