Scott Murphy Heiberg created GROOVY-12696:
---------------------------------------------

             Summary: FormatStringChecker treats explicit argument index as 
zero-based when arguments are not constants
                 Key: GROOVY-12696
                 URL: https://issues.apache.org/jira/browse/GROOVY-12696
             Project: Groovy
          Issue Type: Bug
    Affects Versions: 6.0.0
            Reporter: Scott Murphy Heiberg


*Environment:* macOS 26.5, JDK 21.0.12 (BellSoft Liberica), Groovy 6.0.0, 
6.0.1-SNAPSHOT (GROOVY_6_0_X at 7c0dcf17dd) and master (7.0.0-SNAPSHOT at 
d86ea7eb85)

{{groovy.typecheckers.FormatStringChecker}} has two paths. When every argument 
of a format call is a constant it runs {{java.util.Formatter}} itself and 
reports whatever {{Formatter}} throws. When at least one argument is not a 
constant it walks the format specifiers and checks each argument's static type. 
On that second path an explicit argument index ({{%1$s}}, {{%2$d}}, ...) is 
used directly as a zero-based position in the argument list, while 
{{Formatter}} numbers explicit indices from 1. The checker therefore looks at 
the argument after the one {{Formatter}} will use: it rejects valid calls and 
accepts calls that throw at run time.

h3. Reproducer

{code:groovy}
import groovy.transform.TypeChecked

@TypeChecked(extensions = 'groovy.typecheckers.FormatStringChecker')
class Repro {
    static String run(String s, Integer i) {
        String.format('%1$s', s)
    }
}
println '[' + Repro.run('x', 1) + ']'
{code}

Actual output ({{groovy Repro.groovy}}):

{noformat}
org.codehaus.groovy.control.MultipleCompilationErrorsException: startup failed:
Repro.groovy: 6: [Static type checking] - MissingFormatArgument: Format 
specifier '%1$s'
 @ line 6, column 9.
           String.format('%1$s', s)
           ^

1 error
{noformat}

Expected: the script compiles and prints {{[x]}}. {{String.format('%1$s', 
'x')}} is valid and returns {{x}}; with the constant argument {{'x'}} in place 
of {{s}} the checker accepts the call.

The opposite case, with the same class but

{code:groovy}
        String.format('%1$d', s, i)
{code}

compiles without a type error and then fails at run time:

{noformat}
Caught: java.util.IllegalFormatConversionException: d != java.lang.String
{noformat}

Expected: a compile-time error {{IllegalFormatConversion: d != 
java.lang.String}}, which is what the checker reports for the same call with 
constant arguments, {{String.format('%1$d', 'x', 1)}}.

More calls with {{String s}} and {{Integer i}} parameters, run on all three 
versions listed above:

|| call || Formatter at run time || FormatStringChecker ||
| {{String.format('%1$s', s)}} | {{x}} | MissingFormatArgument: Format 
specifier '%1$s' |
| {{String.format('%2$s', s, i)}} | {{1}} | MissingFormatArgument: Format 
specifier '%2$s' |
| {{String.format('%2$d %1$s', s, i)}} | {{1 x}} | MissingFormatArgument: 
Format specifier '%2$d' |
| {{String.format('%1$s %2$d', s, i)}} | {{x 1}} | MissingFormatArgument: 
Format specifier '%2$d' |
| {{String.format('%2$s %s %<s %1$s', s, i)}} | {{1 x x x}} | 
MissingFormatArgument: Format specifier '%2$s' |
| {{String.format('%1$d', i, s)}} | {{1}} | IllegalFormatConversion: d != 
java.lang.String (checks {{s}} instead of {{i}}) |
| {{String.format('%1$d', s, i)}} | IllegalFormatConversionException: d != 
java.lang.String | accepted |
| {{String.format('%3$s', s, i)}} | MissingFormatArgumentException | 
MissingFormatArgument: Format specifier '%3$s' (correct only because 3 and 2 
both exceed the last index) |
| {{String.format('%1$s', 'x')}} (constants) | {{x}} | accepted |
| {{String.format('%1$d', 'x', 1)}} (constants) | 
IllegalFormatConversionException | IllegalFormatConversion: d != 
java.lang.String |

h3. Cause

In {{FormatStringChecker.checkFormatStringTypes}} 
(subprojects/groovy-typecheckers/src/main/groovy/groovy/typecheckers/FormatStringChecker.groovy,
 line 184 on master):

{code:groovy}
int indexToUse = argIndex ?: next
{code}

{{argIndex}} is the integer parsed from the {{n$}} prefix and is used as is to 
index {{args}}, which is zero-based. The ordinary counter {{next}} and the 
index remembered for {{%<}} are zero-based, so an explicit index is off by one 
relative to them and to {{Formatter}}, whose {{format}} loop uses {{index - 1}} 
for an explicit index. The bounds check {{indexToUse >= args.size()}} then 
rejects {{%n$}} whenever {{n}} equals the argument count, and the type check 
otherwise inspects argument {{n + 1}} instead of {{n}}. The constant-argument 
path ({{checkFormatStringConstantArgs}}) is not affected.

The fix is to subtract one from an explicit index before using it: {{int 
indexToUse = argIndex ? argIndex - 1 : next}}. With that change every call in 
the table above is checked the same way {{Formatter}} resolves it. A pull 
request with regression tests follows.

h3. Versions

Reproduces on Groovy 6.0.0, 6.0.1-SNAPSHOT (GROOVY_6_0_X at 7c0dcf17dd) and 
master (d86ea7eb85). The line has been unchanged since the checker was added 
for GROOVY-11261 (5.0.0-alpha-5), so 5.0.x is presumably affected as well, 
though that was not run.



--
This message was sent by Atlassian Jira
(v10.3.26#10030026)

Reply via email to