GROOVY-11261: Provide a custom type checker for format strings (additional tests)
diff --git a/subprojects/groovy-typecheckers/src/main/groovy/groovy/typecheckers/FormatStringChecker.groovy b/subprojects/groovy-typecheckers/src/main/groovy/groovy/typecheckers/FormatStringChecker.groovy
index f7ecb10..0e08631 100644
--- a/subprojects/groovy-typecheckers/src/main/groovy/groovy/typecheckers/FormatStringChecker.groovy
+++ b/subprojects/groovy-typecheckers/src/main/groovy/groovy/typecheckers/FormatStringChecker.groovy
@@ -147,9 +147,28 @@
 
             void checkFormatStringTypes(Expression expression, List<Expression> args, Expression target) {
                 int next = 0
+                int prevIndex = -1
                 expression.value.eachMatch(formatSpecifier) { spec ->
                     def (all, argIndex, flags, width, precision, tgroup, conversion) = spec
-                    def arg = args[argIndex ?: next]
+                    def flagList = flags?.toList()
+                    if (argIndex) {
+                        argIndex -= '$'
+                        argIndex = argIndex.toInteger()
+                    }
+                    int indexToUse = argIndex ?: next
+                    if (flagList.contains('<')) {
+                        if (prevIndex == -1) {
+                            addStaticTypeError("MissingFormatArgument: Format specifier '$all'", target)
+                            return
+                        } else {
+                            indexToUse = prevIndex
+                        }
+                    }
+                    if (indexToUse >= args.size()) {
+                        addStaticTypeError("MissingFormatArgument: Format specifier '$all'", target)
+                        return
+                    }
+                    def arg = args[indexToUse]
                     Object type = getWrapper(getType(arg)).typeClass
                     if (tgroup) {
                         if (!'HIklMSLNpzZsQBbhAaCYyjmdeRTrDFc'.contains(conversion)) {
@@ -161,13 +180,11 @@
                         if (!([Long, Calendar, Date, TemporalAccessor].any { it.isAssignableFrom(type) })) {
                             addStaticTypeError("IllegalFormatConversion: $conversion != $type.name", target)
                         }
-                        def flagList = flags?.toList()
                         checkBadFlags(flagList, conversion, target, '#+ 0,(')
                         if (flagList.contains('-') && !width) {
                             addStaticTypeError("MissingFormatWidth: $all", target)
                         }
                     } else {
-                        def flagList = flags?.toList()
                         def dupFlag = flagList.countBy().find { flag, count -> count > 1 }?.key
                         if (dupFlag) {
                             addStaticTypeError("DuplicateFormatFlags: Flags = '$dupFlag'", target)
@@ -224,12 +241,10 @@
                                 addStaticTypeError("UnknownFormatConversion: Conversion = '${conversion}'", target)
                         }
                     }
-                    if ((argIndex ?: next) > args.size()) {
-                        addStaticTypeError("Bad index", target)
-                    } else {
-                        println 'Inferred type: ' + type
+                    prevIndex = indexToUse
+                    if (!argIndex && !flagList.contains('<')) {
+                        next++
                     }
-                    next++ // TODO look for <
                 }
             }
 
diff --git a/subprojects/groovy-typecheckers/src/spec/doc/typecheckers.adoc b/subprojects/groovy-typecheckers/src/spec/doc/typecheckers.adoc
index eef34b2..a43e7be 100644
--- a/subprojects/groovy-typecheckers/src/spec/doc/typecheckers.adoc
+++ b/subprojects/groovy-typecheckers/src/spec/doc/typecheckers.adoc
@@ -40,7 +40,7 @@
 simplify application of such checkers, e.g. make it apply globally
 using a compiler configuration script, as just one example.
 
-== Checking Regex literals
+== Checking Regular Expressions
 
 Consider the following code, which contains 3 regular expressions:
 
@@ -109,7 +109,7 @@
 
 These methods have the following characteristics:
 
-* The first argument must be of type api:java.util.Locale[] or api:java.lang.String[].
+* The first argument must be of type `java.util.Locale` or `java.lang.String`.
 * If the first argument is of type `Locale`, the second argument must be of type `String`.
 * The `String` argument is treated as a format string containing zero or more embedded format specifiers as well as plain text. The format specifiers determine how the remaining arguments will be used within the resulting output.
 * The `FormatStringChecker` ensures that the format string is valid and the remaining arguments are compatible with the embedded format specifiers.
@@ -152,7 +152,7 @@
 ----
 |===
 
-Here is an example of correctly using some of these methods:
+Here is an example of correctly using some of these methods with type checking in place:
 
 [source,groovy]
 ----
@@ -170,6 +170,13 @@
 You can use the `FormatMethod` annotation provided in the `groovy-typecheckers`
 module or the https://checkerframework.org/api/org/checkerframework/checker/formatter/qual/FormatMethod.html[similarly named] annotation from the Java-based https://checkerframework.org/[checker framework].
 
+The format string checker looks for a constant literal
+format string. For arguments, it also looks for constant
+literals or otherwise makes checks based on inferred type.
+When looking for constants, the checker will find inline
+literals, local variables with a constant initializer,
+and fields with a constant initializer.
+
 === Errors detected
 
 Here are some examples of the kinds of errors detected.
@@ -226,3 +233,4 @@
 * unknown format conversions
 * illegal format conversions
 * format flags conversion mismatches
+* missing arguments
diff --git a/subprojects/groovy-typecheckers/src/test/groovy/groovy/typecheckers/FormatStringCheckerTest.groovy b/subprojects/groovy-typecheckers/src/test/groovy/groovy/typecheckers/FormatStringCheckerTest.groovy
index f0a83f8..51f9d09 100644
--- a/subprojects/groovy-typecheckers/src/test/groovy/groovy/typecheckers/FormatStringCheckerTest.groovy
+++ b/subprojects/groovy-typecheckers/src/test/groovy/groovy/typecheckers/FormatStringCheckerTest.groovy
@@ -24,7 +24,9 @@
 import org.junit.Test
 
 import static groovy.test.GroovyAssert.assertScript
+import static groovy.test.GroovyAssert.isAtLeastJdk
 import static groovy.test.GroovyAssert.shouldFail
+import static org.junit.Assume.assumeTrue
 
 final class FormatStringCheckerTest {
 
@@ -256,6 +258,38 @@
     }
 
     @Test
+    void testMissingFormatArgumentForConstantArgs() {
+        def err = shouldFail shell, '''
+            String.format('%<s', 'some string')
+        '''
+        assert err =~ /MissingFormatArgument: Format specifier '%<s'/
+        err = shouldFail shell, '''
+            String.format('%s %s', 'some string')
+        '''
+        assert err =~ "MissingFormatArgument: Format specifier '%s'"
+        err = shouldFail shell, '''
+            String.format('%2$s', 'some string')
+        '''
+        assert err =~ /MissingFormatArgument: Format specifier '%\d[$]s'/
+    }
+
+    @Test
+    void testMissingFormatArgumentForInferredArgs() {
+        def err = shouldFail shell, '''
+            String.format('%<s', 'some string' + '')
+        '''
+        assert err =~ /MissingFormatArgument: Format specifier '%<s'/
+        err = shouldFail shell, '''
+            String.format('%s %s', 'some string' + '')
+        '''
+        assert err =~ "MissingFormatArgument: Format specifier '%s'"
+        err = shouldFail shell, '''
+            String.format('%2$s', 'some string' + '')
+        '''
+        assert err =~ /MissingFormatArgument: Format specifier '%\d[$]s'/
+    }
+
+    @Test
     void testValidFormatStringsConstantArgs() {
         assertScript shell, '''
         static int x = 254
@@ -275,8 +309,20 @@
             assert sprintf('%3s', 'abcde') == 'abcde'
             assert sprintf('%6s', 'abcde') == ' abcde'
             assert sprintf('%-6s', 'abcde') == 'abcde '
+            assert String.format('%2$s %1$s', 'bar', 'foo') == 'foo bar'
+            assert String.format('%1$s %1$s', 'bar', 'foo') == 'bar bar'
+            assert String.format('%s %<s', 'bar', 'foo') == 'bar bar'
+            assert String.format('%2$s %<s', 'bar', 'foo') == 'foo foo'
+            assert String.format('%2$s %2$s', 'bar', 'foo') == 'foo foo'
         }
         '''
     }
 
+    @Test
+    void testValidFormattedCall() {
+        assumeTrue(isAtLeastJdk('15.0'))
+        assertScript shell, '''
+        assert '%x'.formatted(16) == '10'
+        '''
+    }
 }