[Skylark] Allow tuples as first argument of str.{starts,ends}with

Closes #5307

Closes #5455.

PiperOrigin-RevId: 202567483
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/StringModule.java b/src/main/java/com/google/devtools/build/lib/syntax/StringModule.java
index e18c833..7d4cbb6 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/StringModule.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/StringModule.java
@@ -19,6 +19,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.devtools.build.lib.events.Location;
 import com.google.devtools.build.lib.skylarkinterface.Param;
+import com.google.devtools.build.lib.skylarkinterface.ParamType;
 import com.google.devtools.build.lib.skylarkinterface.SkylarkCallable;
 import com.google.devtools.build.lib.skylarkinterface.SkylarkModule;
 import com.google.devtools.build.lib.skylarkinterface.SkylarkModuleCategory;
@@ -893,7 +894,13 @@
               + "and <code>end</code> being exclusive.",
       parameters = {
         @Param(name = "self", type = String.class, doc = "This string."),
-        @Param(name = "sub", type = String.class, legacyNamed = true,
+        @Param(
+            name = "sub",
+            allowedTypes = {
+              @ParamType(type = String.class),
+              @ParamType(type = Tuple.class, generic1 = String.class),
+            },
+            legacyNamed = true,
             doc = "The substring to check."),
         @Param(
             name = "start",
@@ -909,9 +916,21 @@
             defaultValue = "None",
             doc = "optional position at which to stop comparing.")
       })
-  public Boolean endsWith(String self, String sub, Integer start, Object end)
-      throws ConversionException {
-    return pythonSubstring(self, start, end, "'end' operand of 'endswith'").endsWith(sub);
+  public Boolean endsWith(String self, Object sub, Integer start, Object end)
+      throws ConversionException, EvalException {
+    String str = pythonSubstring(self, start, end, "'end' operand of 'endswith'");
+    if (sub instanceof String) {
+      return str.endsWith((String) sub);
+    }
+
+    @SuppressWarnings("unchecked")
+    Tuple<Object> subs = (Tuple<Object>) sub;
+    for (String s : subs.getContents(String.class, "string")) {
+      if (str.endsWith(s)) {
+        return true;
+      }
+    }
+    return false;
   }
 
   // In Python, formatting is very complex.
@@ -966,8 +985,14 @@
               + "<code>end</code> being exclusive.",
       parameters = {
         @Param(name = "self", type = String.class, doc = "This string."),
-        @Param(name = "sub", type = String.class, legacyNamed = true,
-            doc = "The substring to check."),
+        @Param(
+            name = "sub",
+            allowedTypes = {
+              @ParamType(type = String.class),
+              @ParamType(type = Tuple.class, generic1 = String.class),
+            },
+            legacyNamed = true,
+            doc = "The substring(s) to check."),
         @Param(
             name = "start",
             type = Integer.class,
@@ -982,9 +1007,21 @@
             defaultValue = "None",
             doc = "Stop comparing at this position.")
       })
-  public Boolean startsWith(String self, String sub, Integer start, Object end)
-      throws ConversionException {
-    return pythonSubstring(self, start, end, "'end' operand of 'startswith'").startsWith(sub);
+  public Boolean startsWith(String self, Object sub, Integer start, Object end)
+      throws ConversionException, EvalException {
+    String str = pythonSubstring(self, start, end, "'end' operand of 'startswith'");
+    if (sub instanceof String) {
+      return str.startsWith((String) sub);
+    }
+
+    @SuppressWarnings("unchecked")
+    Tuple<Object> subs = (Tuple<Object>) sub;
+    for (String s : subs.getContents(String.class, "string")) {
+      if (str.startsWith(s)) {
+        return true;
+      }
+    }
+    return false;
   }
 
   public static final StringModule INSTANCE = new StringModule();
diff --git a/src/test/java/com/google/devtools/build/lib/syntax/MethodLibraryTest.java b/src/test/java/com/google/devtools/build/lib/syntax/MethodLibraryTest.java
index 7420cde..072b9c8 100644
--- a/src/test/java/com/google/devtools/build/lib/syntax/MethodLibraryTest.java
+++ b/src/test/java/com/google/devtools/build/lib/syntax/MethodLibraryTest.java
@@ -149,7 +149,7 @@
     new BothModesTest()
         .testIfErrorContains("substring \"z\" not found in \"abc\"", "'abc'.index('z')")
         .testIfErrorContains(
-            "expected value of type 'string' for parameter 'sub', "
+            "expected value of type 'string or tuple of strings' for parameter 'sub', "
                 + "in method call startswith(int) of 'string'",
             "'test'.startswith(1)")
         .testIfErrorContains(
diff --git a/src/test/skylark/testdata/string_misc.sky b/src/test/skylark/testdata/string_misc.sky
index 901d7f3..87708f9 100644
--- a/src/test/skylark/testdata/string_misc.sky
+++ b/src/test/skylark/testdata/string_misc.sky
@@ -62,6 +62,26 @@
 assert_eq('abcd'.endswith('c', -2, -1), True)
 assert_eq('abcd'.endswith('c', 1, 8), False)
 assert_eq('abcd'.endswith('d', 1, 8), True)
+assert_eq('Apricot'.endswith(('cot', 'toc')), True)
+assert_eq('Apricot'.endswith(('toc', 'cot')), True)
+assert_eq('a'.endswith(('', '')), True)
+assert_eq('a'.endswith(('', 'a')), True)
+assert_eq('a'.endswith(('a', 'a')), True)
+assert_eq(''.endswith(('a', '')), True)
+assert_eq(''.endswith(('', '')), True)
+assert_eq(''.endswith(('a', 'a')), False)
+assert_eq('a'.endswith(('a')), True)
+assert_eq('a'.endswith(('a',)), True)
+assert_eq('a'.endswith(('b',)), False)
+assert_eq('a'.endswith(()), False)
+assert_eq(''.endswith(()), False)
+---
+'a'.endswith(['a']) ### expected value of type 'string or tuple of strings' for parameter 'sub', in method call endswith(list) of 'string'
+---
+'1'.endswith((1,)) ### expected type 'string' for 'string' element but got type 'int' instead
+---
+'a'.endswith(('1', 1)) ### expected type 'string' for 'string' element but got type 'int' instead
+---
 
 # startswith
 assert_eq('Apricot'.startswith('Apr'), True)
@@ -70,6 +90,26 @@
 assert_eq('Apricot'.startswith('z'), False)
 assert_eq(''.startswith(''), True)
 assert_eq(''.startswith('a'), False)
+assert_eq('Apricot'.startswith(('Apr', 'rpA')), True)
+assert_eq('Apricot'.startswith(('rpA', 'Apr')), True)
+assert_eq('a'.startswith(('', '')), True)
+assert_eq('a'.startswith(('', 'a')), True)
+assert_eq('a'.startswith(('a', 'a')), True)
+assert_eq(''.startswith(('a', '')), True)
+assert_eq(''.startswith(('', '')), True)
+assert_eq(''.startswith(('a', 'a')), False)
+assert_eq('a'.startswith(('a')), True)
+assert_eq('a'.startswith(('a',)), True)
+assert_eq('a'.startswith(('b',)), False)
+assert_eq('a'.startswith(()), False)
+assert_eq(''.startswith(()), False)
+---
+'a'.startswith(['a']) ### expected value of type 'string or tuple of strings' for parameter 'sub', in method call startswith(list) of 'string'
+---
+'1'.startswith((1,)) ### expected type 'string' for 'string' element but got type 'int' instead
+---
+'a'.startswith(('1', 1)) ### expected type 'string' for 'string' element but got type 'int' instead
+---
 
 # substring
 assert_eq('012345678'[0:-1], "01234567")