Skip to content

Commit 7f877b3

Browse files
committed
GROOVY-12289: Switch expressions with duplicate case labels compile under @TypeChecked but fail under @CompileStatic
1 parent c906639 commit 7f877b3

6 files changed

Lines changed: 202 additions & 25 deletions

File tree

src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesSwitchExpressionWriter.java

Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@
3131
import org.codehaus.groovy.classgen.asm.OperandStack;
3232
import org.codehaus.groovy.classgen.asm.SwitchExpressionWriter;
3333
import org.codehaus.groovy.classgen.asm.VariableSlotLoader;
34-
import org.codehaus.groovy.syntax.SyntaxException;
3534
import org.codehaus.groovy.transform.stc.StaticTypesMarker;
3635
import org.objectweb.asm.Label;
3736
import org.objectweb.asm.MethodVisitor;
@@ -167,7 +166,6 @@ private boolean writeIntSwitch(final SwitchExpression expression,
167166
Label defaultTarget = new Label();
168167
ArmGroup<Integer> group = groupArms(expression.getCaseStatements(),
169168
cs -> intConstant(cs.getExpression()), defaultTarget);
170-
if (group.error) return true;
171169
if (group.keys == null) return false;
172170

173171
OperandStack operandStack = controller.getOperandStack();
@@ -239,7 +237,6 @@ private boolean writeStringSwitch(final SwitchExpression expression,
239237
Label defaultTarget = new Label();
240238
ArmGroup<String> group = groupArms(expression.getCaseStatements(),
241239
cs -> stringConstant(cs.getExpression()), defaultTarget);
242-
if (group.error) return true;
243240
if (group.keys == null) return false;
244241

245242
jumpIfNull(selectorIndex, selectorType, defaultTarget);
@@ -261,7 +258,6 @@ private boolean writeEnumSwitch(final SwitchExpression expression,
261258
Label defaultTarget = new Label();
262259
ArmGroup<String> group = groupArms(expression.getCaseStatements(),
263260
cs -> enumConstantName(cs.getExpression(), enumType), defaultTarget);
264-
if (group.error) return true;
265261
if (group.keys == null) return false;
266262

267263
MethodVisitor mv = controller.getMethodVisitor();
@@ -339,6 +335,10 @@ private void emitStringHashDispatch(final int stringLocal, final List<String> ke
339335
* Forward pass extracts every constant key (or skips the optimizer).
340336
* Backward pass gives empty colon prefixes the following body's label,
341337
* or {@code defaultTarget} when the empty suffix falls into default.
338+
* Duplicate keys also skip the optimizer: the type checker reports them
339+
* as an error (GROOVY-12289), so reaching here with one means checking
340+
* was bypassed ({@code TypeCheckingMode.SKIP} or an extension), where
341+
* sequential first-match-wins dispatch preserves dynamic semantics.
342342
*/
343343
private <K> ArmGroup<K> groupArms(final List<CaseStatement> caseStatements,
344344
final Function<CaseStatement, K> keyFn, final Label defaultTarget) {
@@ -349,11 +349,7 @@ private <K> ArmGroup<K> groupArms(final List<CaseStatement> caseStatements,
349349
Set<K> seen = new HashSet<>();
350350
for (CaseStatement caseStatement : caseStatements) {
351351
K key = keyFn.apply(caseStatement);
352-
if (key == null) return ArmGroup.skip();
353-
if (!seen.add(key)) {
354-
addError("Duplicate case label: " + key, caseStatement);
355-
return ArmGroup.error();
356-
}
352+
if (key == null || !seen.add(key)) return ArmGroup.skip();
357353
keys.add(key);
358354
}
359355

@@ -396,35 +392,25 @@ private void jumpIfNull(final int selectorIndex, final ClassNode selectorType, f
396392
controller.getMethodVisitor().visitJumpInsn(IFNULL, target);
397393
}
398394

399-
private void addError(final String message, final CaseStatement caseStatement) {
400-
controller.getSourceUnit().addError(new SyntaxException(message, caseStatement));
401-
}
402-
403395
/**
404-
* {@code keys == null && !error} means "not a constant switch, try the next
405-
* optimizer". {@code error} means a compile error was already reported.
396+
* {@code keys == null} means "not an optimizable constant switch, try the
397+
* next optimizer (or fall back to sequential dispatch)".
406398
*/
407399
private static final class ArmGroup<K> {
408400
final List<K> keys;
409401
final List<Label> targets;
410-
final boolean error;
411402

412-
private ArmGroup(final List<K> keys, final List<Label> targets, final boolean error) {
403+
private ArmGroup(final List<K> keys, final List<Label> targets) {
413404
this.keys = keys;
414405
this.targets = targets;
415-
this.error = error;
416406
}
417407

418408
static <K> ArmGroup<K> skip() {
419-
return new ArmGroup<>(null, null, false);
420-
}
421-
422-
static <K> ArmGroup<K> error() {
423-
return new ArmGroup<>(null, null, true);
409+
return new ArmGroup<>(null, null);
424410
}
425411

426412
static <K> ArmGroup<K> of(final List<K> keys, final List<Label> targets) {
427-
return new ArmGroup<>(keys, targets, false);
413+
return new ArmGroup<>(keys, targets);
428414
}
429415
}
430416
}

src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,8 +154,12 @@
154154

155155
import static org.apache.groovy.ast.tools.ClassNodeUtils.getNestHost;
156156
import static org.apache.groovy.ast.tools.MethodNodeUtils.withDefaultArgumentMethods;
157+
import static org.apache.groovy.ast.tools.SwitchExpressionUtils.enumConstantName;
158+
import static org.apache.groovy.ast.tools.SwitchExpressionUtils.intConstant;
157159
import static org.apache.groovy.ast.tools.SwitchExpressionUtils.isIntegralType;
158160
import static org.apache.groovy.ast.tools.SwitchExpressionUtils.isOptimizedIntSwitch;
161+
import static org.apache.groovy.ast.tools.SwitchExpressionUtils.stringConstant;
162+
import static org.apache.groovy.ast.tools.SwitchExpressionUtils.unwrapEnumType;
159163
import static org.apache.groovy.util.BeanUtils.capitalize;
160164
import static org.apache.groovy.util.BeanUtils.decapitalize;
161165
import static org.codehaus.groovy.ast.ClassHelper.AUTOCLOSEABLE_TYPE;
@@ -4928,6 +4932,7 @@ public void visitSwitchExpression(final SwitchExpression expression) {
49284932
expression.setType(resultType);
49294933

49304934
typeCheckSwitchExpressionIsCase(expression);
4935+
checkSwitchExpressionDuplicateLabels(expression);
49314936
checkSwitchExpressionExhaustiveness(expression);
49324937
} finally {
49334938
typeCheckingContext.popTemporaryTypeInfo();
@@ -4981,6 +4986,38 @@ && isOptimizedIntSwitch(selectorType, expression.getCaseStatements())) {
49814986
}
49824987
}
49834988

4989+
/**
4990+
* Reports a repeated constant case label in a switch expression. Sequential
4991+
* {@code isCase} semantics make the second arm dead code, and the optimized
4992+
* {@code tableswitch}/{@code lookupswitch} forms cannot represent it at all,
4993+
* so it is rejected here, uniformly for type-checked and statically-compiled
4994+
* code (GROOVY-12289). Labels compared are the same ones the optimizers key
4995+
* on: int-family, String and enum constants; anything else (GStrings, calls,
4996+
* regex or collection labels) cannot be proven duplicated statically and is
4997+
* left to sequential first-match-wins dispatch.
4998+
*
4999+
* @since 6.0.0
5000+
*/
5001+
private void checkSwitchExpressionDuplicateLabels(final SwitchExpression expression) {
5002+
ClassNode enumType = unwrapEnumType(getType(expression.getExpression()));
5003+
Set<Object> seen = new HashSet<>();
5004+
for (CaseStatement caseStatement : expression.getCaseStatements()) {
5005+
Expression label = caseStatement.getExpression();
5006+
Object key = null;
5007+
if (enumType != null && enumType.isEnum()) {
5008+
String name = enumConstantName(label, enumType);
5009+
// keyed by type and name so an enum constant never collides with
5010+
// a string label of the same spelling (a distinct, legal label)
5011+
if (name != null) key = Map.entry(enumType, name);
5012+
}
5013+
if (key == null) key = intConstant(label);
5014+
if (key == null) key = stringConstant(label);
5015+
if (key != null && !seen.add(key)) {
5016+
addStaticTypeError("Duplicate case label: " + label.getText(), label);
5017+
}
5018+
}
5019+
}
5020+
49845021
/**
49855022
* {@inheritDoc}
49865023
*/
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
// GROOVY-12289: a DSL that wants dynamic first-match-wins semantics for
20+
// duplicate constant case labels can opt affected methods out of type
21+
// checking; they then compile and dispatch as dynamic Groovy.
22+
beforeVisitMethod { mn ->
23+
if (mn.name.startsWith('dsl')) {
24+
handled = true
25+
}
26+
}

src/test/groovy/groovy/transform/stc/TypeCheckingExtensionsTest.groovy

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,4 +656,27 @@ final class TypeCheckingExtensionsTest extends StaticTypeCheckingTestCase {
656656
assert m(1) == 10
657657
'''
658658
}
659+
660+
@Test // GROOVY-12289
661+
void testSwitchExpressionDuplicateLabelEscapeHatch() {
662+
String dsl = '''
663+
def dsl(int x) {
664+
def r = switch (x) {
665+
case 1 -> 'a'
666+
case 1 -> 'b'
667+
default -> 'c'
668+
}
669+
r
670+
}
671+
'''
672+
shouldFailWithMessages dsl, 'Duplicate case label: 1'
673+
674+
// an extension that opts DSL-style methods out of type checking
675+
// restores dynamic first-match-wins semantics for them
676+
extension = 'groovy/transform/stc/Groovy12289Extension.groovy'
677+
assertScript dsl + '''
678+
assert dsl(1) == 'a'
679+
assert dsl(9) == 'c'
680+
'''
681+
}
659682
}

src/test/groovy/org/codehaus/groovy/classgen/Jep361SwitchExpressionTest.groovy

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,4 +601,85 @@ final class Jep361SwitchExpressionTest {
601601
}
602602
''', 'cannot be used together'
603603
}
604+
605+
//--------------------------------------------------------------------------
606+
// Duplicate case labels (GROOVY-12289)
607+
608+
@Test
609+
void duplicateConstantLabelDynamicFirstMatchWins() {
610+
assertScript '''
611+
def m(x) { def r = switch (x) { case 1 -> 'a'; case 1 -> 'b'; default -> 'c' }; r }
612+
assert m(1) == 'a'
613+
assert m(9) == 'c'
614+
'''
615+
}
616+
617+
@Test
618+
void duplicateConstantLabelIsTypeCheckingError() {
619+
for (mode in ['@groovy.transform.TypeChecked', '@groovy.transform.CompileStatic']) {
620+
for (dup in [
621+
[decl: 'int x', arms: "case 1 -> 'a'; case 1 -> 'b'", label: '1'],
622+
[decl: 'String x', arms: 'case "a" -> 1; case "a" -> 2', label: 'a'],
623+
[decl: 'int x', arms: "case 1 -> 'a'; case 1 -> 'b'; case f() -> 'd'", label: '1']]) {
624+
def err = shouldFail MultipleCompilationErrorsException, """
625+
class C {
626+
def f() { 5 }
627+
$mode
628+
def m(${dup.decl}) { def r = switch (x) { ${dup.arms}; default -> 'z' }; r }
629+
}
630+
"""
631+
assert err.message.contains('[Static type checking] - Duplicate case label: ' + dup.label)
632+
}
633+
}
634+
}
635+
636+
@Test
637+
void duplicateEnumLabelIsTypeCheckingError() {
638+
for (mode in ['@groovy.transform.TypeChecked', '@groovy.transform.CompileStatic']) {
639+
def err = shouldFail MultipleCompilationErrorsException, """
640+
enum E { X, Y }
641+
class C {
642+
$mode
643+
def m(E e) { def r = switch (e) { case E.X -> 1; case E.X -> 2; default -> 0 }; r }
644+
}
645+
"""
646+
assert err.message.contains('Duplicate case label: E.X')
647+
}
648+
}
649+
650+
@Test
651+
void duplicateNonConstantLabelIsNotAnError() {
652+
assertBoth '''
653+
def m(String x, String y) {
654+
def r = switch (x) { case "${y}" -> 1; case "${y}" -> 2; default -> 0 }
655+
r
656+
}
657+
assert m('a', 'a') == 1
658+
assert m('b', 'a') == 0
659+
'''
660+
}
661+
662+
// an enum constant and a string label with the same spelling are distinct
663+
// labels, not duplicates (the string arm simply never matches an enum)
664+
@Test
665+
void enumAndStringLabelsWithSameNameAreNotDuplicates() {
666+
assertBoth '''
667+
enum E { X, Y }
668+
def m(E e) {
669+
def r = switch (e) { case E.X -> 1; case 'X' -> 2; default -> 0 }
670+
r
671+
}
672+
assert m(E.X) == 1
673+
assert m(E.Y) == 0
674+
'''
675+
}
676+
677+
@Test
678+
void duplicateLabelInSwitchStatementIsNotAnError() {
679+
assertBoth '''
680+
def m(int x) { switch (x) { case 1: return 'a'; case 1: return 'b'; default: return 'c' } }
681+
assert m(1) == 'a'
682+
assert m(9) == 'c'
683+
'''
684+
}
604685
}

src/test/groovy/org/codehaus/groovy/classgen/asm/sc/SwitchExpressionStaticCompileTest.groovy

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,30 @@ final class SwitchExpressionStaticCompileTest extends AbstractBytecodeTestCase {
581581
}
582582
}
583583
'''
584-
assert err.message.contains('Duplicate case label')
584+
// GROOVY-12289: reported by the type checker, so @TypeChecked fails identically
585+
assert err.message.contains('[Static type checking] - Duplicate case label: 1')
586+
}
587+
588+
// GROOVY-12289: with type checking bypassed, the optimizers cannot represent
589+
// the duplicate key, so the writer falls back to sequential dispatch with
590+
// dynamic first-match-wins semantics instead of reporting a late error.
591+
@Test
592+
void staticSkipModeDuplicateCaseFallsBackToSequentialDispatch() {
593+
assertScript '''
594+
@groovy.transform.CompileStatic
595+
class C {
596+
@groovy.transform.CompileStatic(groovy.transform.TypeCheckingMode.SKIP)
597+
def m(int x) {
598+
def r = switch (x) {
599+
case 1 -> 'a'
600+
case 1 -> 'b'
601+
default -> 'c'
602+
}
603+
r
604+
}
605+
}
606+
assert new C().m(1) == 'a'
607+
assert new C().m(9) == 'c'
608+
'''
585609
}
586610
}

0 commit comments

Comments
 (0)