Skip to content

Commit 8d9546b

Browse files
joke1196sonartech
authored andcommitted
SONARPY-3680 Added type matcher for any match in a union type (#792)
GitOrigin-RevId: d3df08f82830007ba7246f978b2b0dee6a9e707a
1 parent b34a7c9 commit 8d9546b

10 files changed

Lines changed: 281 additions & 10 deletions

File tree

.claude/skills/type-matchers/SKILL.md

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,49 @@ Checks if the type has a specific TypeSource (e.g., EXACT, INFERRED, STUBBED).
202202
TypeMatchers.hasTypeSource(TypeSource.EXACT)
203203
```
204204

205+
### Union Type Matching
206+
207+
#### `isAnyTypeInUnionSatisfying(TypeMatcher matcher)`
208+
Checks if **any** candidate in a UnionType satisfies the given matcher. Returns FALSE if the type is not a UnionType.
209+
210+
**Important:** This matcher has **different semantics** than regular TypeMatchers for unions:
211+
- Regular matchers require **ALL** union candidates to match
212+
- This matcher requires **ANY** union candidate to match (OR logic)
213+
214+
```java
215+
// Check if any type in Union[int, str, list] is a string
216+
InternalTypeMatchers.isAnyTypeInUnionSatisfying(
217+
TypeMatchers.isType("builtins.str")
218+
) // Returns TRUE
219+
```
220+
221+
**Entry Point:** `org.sonar.python.types.v2.matchers.InternalTypeMatchers`
222+
223+
**Behavior:**
224+
- Returns `TRUE` if at least one candidate satisfies the matcher
225+
- Returns `FALSE` if all candidates fail to satisfy the matcher
226+
- Returns `UNKNOWN` if no candidates match but at least one returns UNKNOWN
227+
- Returns `FALSE` for non-union types
228+
- Returns `UNKNOWN` for UnknownType
229+
230+
**Use Case:** This is useful when you need to check if a union contains a specific type, such as checking if `Union[int, str, None]` contains `None`.
231+
232+
**Example:**
233+
```java
234+
import org.sonar.python.types.v2.matchers.InternalTypeMatchers;
235+
236+
// Check if a union contains None
237+
private static final TypeMatcher CONTAINS_NONE =
238+
InternalTypeMatchers.isAnyTypeInUnionSatisfying(
239+
TypeMatchers.isType("builtins.NoneType")
240+
);
241+
242+
// Usage
243+
if (CONTAINS_NONE.isTrueFor(expression, ctx)) {
244+
// The type is a union that includes None
245+
}
246+
```
247+
205248
## Common Patterns
206249

207250
### Checking for List or Tuple of Specific Type
@@ -238,6 +281,17 @@ TypeMatchers.all(
238281
)
239282
```
240283

284+
### Checking if a Union Contains a Specific Type
285+
286+
```java
287+
import org.sonar.python.types.v2.matchers.InternalTypeMatchers;
288+
289+
// Check if Union[int, str, None] contains None
290+
InternalTypeMatchers.isAnyTypeInUnionSatisfying(
291+
TypeMatchers.isType("builtins.NoneType")
292+
)
293+
```
294+
241295
## TypeMatcher Interface
242296

243297
The `TypeMatcher` interface provides two methods for evaluating expressions:
@@ -351,4 +405,4 @@ if (matcher.isTrueFor(expression, ctx)) {
351405

352406
## Summary
353407

354-
The TypeMatchers API provides a robust, composable way to match types in the Python plugin. Always prefer it over deprecated APIs, use type equality over FQN matching, and remember to explicitly handle ObjectTypes with `isObjectSatisfying()`.
408+
The TypeMatchers API provides a robust, composable way to match types in the Python plugin. Always prefer it over deprecated APIs, use type equality over FQN matching, and remember to explicitly handle ObjectTypes with `isObjectSatisfying()`.

python-frontend/src/main/java/org/sonar/plugins/python/api/types/v2/matchers/TypeMatcher.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
import org.sonar.plugins.python.api.tree.Expression;
2323

2424
@Beta
25-
public sealed interface TypeMatcher permits TypeMatcherImpl {
25+
public interface TypeMatcher {
2626
@Beta
2727
TriBool evaluateFor(Expression expr, SubscriptionContext ctx);
2828

python-frontend/src/main/java/org/sonar/plugins/python/api/types/v2/matchers/TypeMatchers.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import org.sonar.python.types.v2.matchers.IsObjectSatisfyingPredicate;
3131
import org.sonar.python.types.v2.matchers.IsTypeOrSuperTypeSatisfyingPredicate;
3232
import org.sonar.python.types.v2.matchers.IsTypePredicate;
33+
import org.sonar.python.types.v2.matchers.TypeMatcherImpl;
3334
import org.sonar.python.types.v2.matchers.TypePredicate;
3435
import org.sonar.python.types.v2.matchers.TypeSourcePredicate;
3536

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2025 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
package org.sonar.python.types.v2.matchers;
18+
19+
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher;
20+
21+
22+
public final class InternalTypeMatchers {
23+
24+
private InternalTypeMatchers() {
25+
}
26+
27+
public static TypeMatcher isAnyTypeInUnionSatisfying(TypeMatcher matcher) {
28+
TypePredicate predicate = getTypePredicate(matcher);
29+
return new TypeMatcherImpl(new IsAnyTypeInUnionSatisfying(predicate));
30+
}
31+
32+
private static TypePredicate getTypePredicate(TypeMatcher matcher) {
33+
if (matcher instanceof TypeMatcherImpl typeMatcherImpl) {
34+
return typeMatcherImpl.predicate();
35+
}
36+
throw new IllegalArgumentException("Unsupported type matcher: " + matcher.getClass().getName());
37+
}
38+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2025 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
package org.sonar.python.types.v2.matchers;
18+
19+
import org.sonar.plugins.python.api.TriBool;
20+
import org.sonar.plugins.python.api.types.v2.PythonType;
21+
import org.sonar.plugins.python.api.types.v2.UnionType;
22+
import org.sonar.plugins.python.api.types.v2.UnknownType;
23+
24+
public class IsAnyTypeInUnionSatisfying implements TypePredicate {
25+
private final TypePredicate wrappedPredicate;
26+
27+
public IsAnyTypeInUnionSatisfying(TypePredicate wrappedPredicate) {
28+
this.wrappedPredicate = wrappedPredicate;
29+
}
30+
31+
@Override
32+
public TriBool check(PythonType type, TypePredicateContext ctx) {
33+
if (type instanceof UnknownType) {
34+
return TriBool.UNKNOWN;
35+
}
36+
37+
if (!(type instanceof UnionType unionType)) {
38+
return TriBool.FALSE;
39+
}
40+
41+
boolean hasUnknown = false;
42+
for (PythonType candidate : unionType.candidates()) {
43+
TriBool result = wrappedPredicate.check(candidate, ctx);
44+
if (result == TriBool.TRUE) {
45+
return TriBool.TRUE;
46+
}
47+
if (result == TriBool.UNKNOWN) {
48+
hasUnknown = true;
49+
}
50+
}
51+
52+
return hasUnknown ? TriBool.UNKNOWN : TriBool.FALSE;
53+
}
54+
}

python-frontend/src/main/java/org/sonar/plugins/python/api/types/v2/matchers/TypeMatcherImpl.java renamed to python-frontend/src/main/java/org/sonar/python/types/v2/matchers/TypeMatcherImpl.java

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,17 @@
1414
* You should have received a copy of the Sonar Source-Available License
1515
* along with this program; if not, see https://sonarsource.com/license/ssal/
1616
*/
17-
package org.sonar.plugins.python.api.types.v2.matchers;
17+
package org.sonar.python.types.v2.matchers;
1818

1919
import org.sonar.api.Beta;
2020
import org.sonar.plugins.python.api.SubscriptionContext;
2121
import org.sonar.plugins.python.api.TriBool;
2222
import org.sonar.plugins.python.api.tree.Expression;
2323
import org.sonar.plugins.python.api.types.v2.PythonType;
24-
import org.sonar.python.types.v2.matchers.TypePredicate;
25-
import org.sonar.python.types.v2.matchers.TypePredicateContext;
26-
import org.sonar.python.types.v2.matchers.TypePredicateUtils;
24+
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher;
2725

28-
// This record is package-private
2926
@Beta
30-
record TypeMatcherImpl(TypePredicate predicate) implements TypeMatcher {
27+
public record TypeMatcherImpl(TypePredicate predicate) implements TypeMatcher {
3128

3229
@Override
3330
public TriBool evaluateFor(Expression expr, SubscriptionContext ctx) {
@@ -41,4 +38,4 @@ public boolean isTrueFor(Expression expr, SubscriptionContext ctx) {
4138
return evaluateFor(expr, ctx).isTrue();
4239
}
4340

44-
}
41+
}

python-frontend/src/main/java/org/sonar/python/types/v2/matchers/TypePredicateUtils.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ private TypePredicateUtils() {
2626
}
2727

2828
public static TriBool evaluate(TypePredicate predicate, PythonType type, TypePredicateContext ctx) {
29+
if(predicate.check(type, ctx).isTrue()){
30+
return TriBool.TRUE;
31+
}
2932
Set<PythonType> candidates = extractCandidates(type);
3033

3134
TriBool result = TriBool.TRUE;

python-frontend/src/test/java/org/sonar/plugins/python/api/types/v2/matchers/MatchersTestUtils.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import org.sonar.plugins.python.api.TriBool;
2121
import org.sonar.plugins.python.api.types.v2.PythonType;
2222
import org.sonar.python.types.v2.matchers.TypePredicate;
23+
import org.sonar.python.types.v2.matchers.TypeMatcherImpl;
2324
import org.sonar.python.types.v2.matchers.TypePredicateContext;
2425

2526
import static org.mockito.ArgumentMatchers.any;

python-frontend/src/test/java/org/sonar/plugins/python/api/types/v2/matchers/TypeMatcherImplTest.java

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import org.sonar.plugins.python.api.types.v2.UnknownType;
3333
import org.sonar.python.semantic.v2.TestProject;
3434
import org.sonar.python.semantic.v2.typetable.TypeTable;
35+
import org.sonar.python.types.v2.matchers.InternalTypeMatchers;
3536
import org.sonar.python.types.v2.matchers.TypePredicate;
3637
import org.sonar.python.types.v2.matchers.TypePredicateContext;
3738

@@ -94,6 +95,7 @@ void prepare() {
9495
unionOfFunctionExpr = Mockito.mock(Expression.class);
9596
unionOfObjectExpr = Mockito.mock(Expression.class);
9697

98+
9799
Mockito.when(unknownExpr.typeV2()).thenReturn(unknownType);
98100
Mockito.when(objectExpr.typeV2()).thenReturn(objectType);
99101
Mockito.when(functionExpr.typeV2()).thenReturn(functionType);
@@ -202,5 +204,26 @@ def __init__(self):
202204
assertThat(TypeMatchers.isObjectOfType("my_file.A").evaluateFor(unknownTypeExpression, ctx)).isEqualTo(TriBool.UNKNOWN);
203205
assertThat(TypeMatchers.isObjectOfType("my_file.B").evaluateFor(knownTypeExpression, ctx)).isEqualTo(TriBool.UNKNOWN);
204206
}
205-
}
206207

208+
@Test
209+
void testIsAnyTypeInUnionSatisfying() {
210+
var project = new TestProject();
211+
project.addModule("my_file.py", """
212+
class A :
213+
def __init__(self):
214+
pass
215+
""");
216+
Expression unionTypeExpression = project.lastExpression("""
217+
from my_file import A
218+
def foo(param: int | A | str):
219+
param
220+
""");
221+
222+
SubscriptionContext ctx = Mockito.mock(SubscriptionContext.class);
223+
Mockito.when(ctx.typeTable()).thenReturn(project.projectLevelTypeTable());
224+
225+
assertThat(unionTypeExpression.typeV2()).isInstanceOf(UnionType.class);
226+
assertThat(InternalTypeMatchers.isAnyTypeInUnionSatisfying(TypeMatchers.isObjectOfType("my_file.A")).isTrueFor(unionTypeExpression, ctx)).isTrue();
227+
228+
}
229+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2025 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
package org.sonar.python.types.v2.matchers;
18+
19+
import java.util.stream.Stream;
20+
import org.junit.jupiter.api.Test;
21+
import org.junit.jupiter.params.ParameterizedTest;
22+
import org.junit.jupiter.params.provider.Arguments;
23+
import org.junit.jupiter.params.provider.MethodSource;
24+
import org.mockito.Mockito;
25+
import org.sonar.plugins.python.api.TriBool;
26+
import org.sonar.plugins.python.api.types.v2.PythonType;
27+
import org.sonar.plugins.python.api.types.v2.UnionType;
28+
import org.sonar.plugins.python.api.types.v2.UnknownType;
29+
import org.sonar.plugins.python.api.types.v2.matchers.MatchersTestUtils;
30+
import org.sonar.python.semantic.v2.typetable.TypeTable;
31+
32+
import static org.assertj.core.api.Assertions.assertThat;
33+
34+
class IsAnyTypeInUnionSatisfyingTest {
35+
36+
@Test
37+
void testUnionTypeWithOneMatchingCandidate() {
38+
PythonType candidate1 = Mockito.mock(PythonType.class);
39+
PythonType candidate2 = Mockito.mock(PythonType.class);
40+
UnionType unionType = (UnionType) UnionType.or(candidate1, candidate2);
41+
42+
TypePredicate wrappedPredicate = MatchersTestUtils.mockPredicateReturning(candidate1, TriBool.TRUE);
43+
Mockito.when(wrappedPredicate.check(Mockito.eq(candidate2), Mockito.any())).thenReturn(TriBool.FALSE);
44+
45+
TriBool result = checkType(unionType, wrappedPredicate);
46+
assertThat(result).isEqualTo(TriBool.TRUE);
47+
}
48+
49+
@Test
50+
void testUnionTypeWithAllNonMatchingCandidates() {
51+
PythonType candidate1 = Mockito.mock(PythonType.class);
52+
PythonType candidate2 = Mockito.mock(PythonType.class);
53+
UnionType unionType = (UnionType) UnionType.or(candidate1, candidate2);
54+
55+
TypePredicate wrappedPredicate = MatchersTestUtils.mockPredicateReturning(candidate1, TriBool.FALSE);
56+
Mockito.when(wrappedPredicate.check(Mockito.eq(candidate2), Mockito.any())).thenReturn(TriBool.FALSE);
57+
58+
TriBool result = checkType(unionType, wrappedPredicate);
59+
assertThat(result).isEqualTo(TriBool.FALSE);
60+
}
61+
62+
@Test
63+
void testUnionTypeWithUnknownCandidate() {
64+
PythonType candidate1 = Mockito.mock(PythonType.class);
65+
PythonType candidate2 = Mockito.mock(PythonType.class);
66+
UnionType unionType = (UnionType) UnionType.or(candidate1, candidate2);
67+
68+
TypePredicate wrappedPredicate = MatchersTestUtils.mockPredicateReturning(candidate1, TriBool.FALSE);
69+
Mockito.when(wrappedPredicate.check(Mockito.eq(candidate2), Mockito.any())).thenReturn(TriBool.UNKNOWN);
70+
71+
TriBool result = checkType(unionType, wrappedPredicate);
72+
assertThat(result).isEqualTo(TriBool.UNKNOWN);
73+
}
74+
75+
76+
@ParameterizedTest
77+
@MethodSource("nonUnionTypesWithExpectedResults")
78+
void testNonUnionTypeDoesNotDelegateToWrappedPredicate(PythonType nonUnionType, TriBool expectedResult) {
79+
TypePredicate wrappedPredicate = Mockito.mock(TypePredicate.class);
80+
81+
TriBool result = checkType(nonUnionType, wrappedPredicate);
82+
83+
assertThat(result).isEqualTo(expectedResult);
84+
Mockito.verifyNoInteractions(wrappedPredicate);
85+
}
86+
87+
static Stream<Arguments> nonUnionTypesWithExpectedResults() {
88+
return Stream.of(
89+
Arguments.of(PythonType.UNKNOWN, TriBool.UNKNOWN),
90+
Arguments.of(new UnknownType.UnresolvedImportType("some.module"), TriBool.UNKNOWN),
91+
Arguments.of(Mockito.mock(PythonType.class), TriBool.FALSE)
92+
);
93+
}
94+
95+
private static TriBool checkType(PythonType type, TypePredicate wrappedPredicate) {
96+
IsAnyTypeInUnionSatisfying predicate = new IsAnyTypeInUnionSatisfying(wrappedPredicate);
97+
TypePredicateContext ctx = TypePredicateContext.of(Mockito.mock(TypeTable.class));
98+
return predicate.check(type, ctx);
99+
}
100+
}

0 commit comments

Comments
 (0)