From 392a8e4e509adfd922f81045d58db50acb972f62 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Tue, 2 Dec 2025 19:58:45 +0000 Subject: [PATCH 1/4] [ty] Improve diagnostics for unsupported comparison operations (#21737) --- crates/ty/docs/rules.md | 148 ++++++++-------- .../resources/mdtest/bidirectional.md | 2 +- .../resources/mdtest/binary/instances.md | 2 +- .../comparison/instances/membership_test.md | 12 +- .../comparison/instances/rich_comparison.md | 36 +++- .../resources/mdtest/comparison/integers.md | 2 +- .../mdtest/comparison/intersections.md | 6 +- .../resources/mdtest/comparison/tuples.md | 29 ++-- .../resources/mdtest/comparison/unions.md | 19 +- .../mdtest/comparison/unsupported.md | 16 +- ...nsupported_operator…_(27f95f68d1c826ec).snap | 79 +++++++++ ...parison_Unsupport…_(966dd82bd3668d0e).snap | 157 +++++++++++++++++ ...upported_operatio…_(e15acf820f65e3e4).snap | 123 +++++++++++++ ...arison___Unsuppor…_(c13dd5902282489a).snap | 162 ++++++++++++++++++ .../src/types/diagnostic.rs | 112 +++++++++++- .../ty_python_semantic/src/types/display.rs | 11 +- crates/ty_python_semantic/src/types/infer.rs | 1 + .../src/types/infer/builder.rs | 148 ++++++++-------- 18 files changed, 873 insertions(+), 192 deletions(-) create mode 100644 crates/ty_python_semantic/resources/mdtest/snapshots/intersections.md_-_Comparison___Intersec…_-_Diagnostics_-_Unsupported_operator…_(27f95f68d1c826ec).snap create mode 100644 crates/ty_python_semantic/resources/mdtest/snapshots/tuples.md_-_Comparison___Tuples_-_Heterogeneous_-_Value_Comparisons_-_Comparison_Unsupport…_(966dd82bd3668d0e).snap create mode 100644 crates/ty_python_semantic/resources/mdtest/snapshots/unions.md_-_Comparison___Unions_-_Unsupported_operatio…_(e15acf820f65e3e4).snap create mode 100644 crates/ty_python_semantic/resources/mdtest/snapshots/unsupported.md_-_Comparison___Unsuppor…_(c13dd5902282489a).snap diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index 5ac36c4fb9..12eb74d15a 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -39,7 +39,7 @@ def test(): -> "int": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -63,7 +63,7 @@ Calling a non-callable object will raise a `TypeError` at runtime. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -95,7 +95,7 @@ f(int) # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -126,7 +126,7 @@ a = 1 Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -158,7 +158,7 @@ class C(A, B): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -190,7 +190,7 @@ class B(A): ... Default level: error · Preview (since 1.0.0) · Related issues · -View source +View source @@ -218,7 +218,7 @@ type B = A Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -245,7 +245,7 @@ class B(A, A): ... Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source @@ -357,7 +357,7 @@ def test(): -> "Literal[5]": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -387,7 +387,7 @@ class C(A, B): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -413,7 +413,7 @@ t[3] # IndexError: tuple index out of range Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source @@ -502,7 +502,7 @@ an atypical memory layout. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -529,7 +529,7 @@ func("foo") # error: [invalid-argument-type] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -557,7 +557,7 @@ a: int = '' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -591,7 +591,7 @@ C.instance_var = 3 # error: Cannot assign to instance variable Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -627,7 +627,7 @@ asyncio.run(main()) Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -651,7 +651,7 @@ class A(42): ... # error: [invalid-base] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -678,7 +678,7 @@ with 1: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -707,7 +707,7 @@ a: str Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -751,7 +751,7 @@ except ZeroDivisionError: Default level: error · Added in 0.0.1-alpha.28 · Related issues · -View source +View source @@ -793,7 +793,7 @@ class D(A): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -826,7 +826,7 @@ class C[U](Generic[T]): ... Default level: error · Added in 0.0.1-alpha.17 · Related issues · -View source +View source @@ -865,7 +865,7 @@ carol = Person(name="Carol", age=25) # typo! Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -900,7 +900,7 @@ def f(t: TypeVar("U")): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -934,7 +934,7 @@ class B(metaclass=f): ... Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -1041,7 +1041,7 @@ Correct use of `@override` is enforced by ty's `invalid-explicit-override` rule. Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -1095,7 +1095,7 @@ AttributeError: Cannot overwrite NamedTuple attribute _asdict Default level: error · Preview (since 1.0.0) · Related issues · -View source +View source @@ -1125,7 +1125,7 @@ Baz = NewType("Baz", int | str) # error: invalid base for `typing.NewType` Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1175,7 +1175,7 @@ def foo(x: int) -> int: ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1201,7 +1201,7 @@ def f(a: int = ''): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1232,7 +1232,7 @@ P2 = ParamSpec("S2") # error: ParamSpec name must match the variable it's assig Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1266,7 +1266,7 @@ TypeError: Protocols can only inherit from other protocols, got Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1315,7 +1315,7 @@ def g(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1340,7 +1340,7 @@ def func() -> int: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1398,7 +1398,7 @@ TODO #14889 Default level: error · Added in 0.0.1-alpha.6 · Related issues · -View source +View source @@ -1425,7 +1425,7 @@ NewAlias = TypeAliasType(get_name(), int) # error: TypeAliasType name mus Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -1472,7 +1472,7 @@ Bar[int] # error: too few arguments Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1502,7 +1502,7 @@ TYPE_CHECKING = '' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1532,7 +1532,7 @@ b: Annotated[int] # `Annotated` expects at least two arguments Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -1566,7 +1566,7 @@ f(10) # Error Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -1600,7 +1600,7 @@ class C: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1635,7 +1635,7 @@ T = TypeVar('T', bound=str) # valid bound TypeVar Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1660,7 +1660,7 @@ func() # TypeError: func() missing 1 required positional argument: 'x' Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -1693,7 +1693,7 @@ alice["age"] # KeyError Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1722,7 +1722,7 @@ func("string") # error: [no-matching-overload] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1746,7 +1746,7 @@ Subscripting an object that does not support it will raise a `TypeError` at runt Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1772,7 +1772,7 @@ for i in 34: # TypeError: 'int' object is not iterable Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -1805,7 +1805,7 @@ class B(A): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1832,7 +1832,7 @@ f(1, x=2) # Error raised here Default level: error · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -1890,7 +1890,7 @@ def test(): -> "int": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1920,7 +1920,7 @@ static_assert(int(2.0 * 3.0) == 6) # error: does not have a statically known tr Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1949,7 +1949,7 @@ class B(A): ... # Error raised here Default level: error · Preview (since 0.0.1-alpha.30) · Related issues · -View source +View source @@ -1983,7 +1983,7 @@ class F(NamedTuple): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2010,7 +2010,7 @@ f("foo") # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2038,7 +2038,7 @@ def _(x: int): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2084,7 +2084,7 @@ class A: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2111,7 +2111,7 @@ f(x=1, y=2) # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2139,7 +2139,7 @@ A().foo # AttributeError: 'A' object has no attribute 'foo' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2164,7 +2164,7 @@ import foo # ModuleNotFoundError: No module named 'foo' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2189,7 +2189,7 @@ print(x) # NameError: name 'x' is not defined Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2226,7 +2226,7 @@ b1 < b2 < b1 # exception raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2254,7 +2254,7 @@ A() + A() # TypeError: unsupported operand type(s) for +: 'A' and 'A' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2279,7 +2279,7 @@ l[1:10:0] # ValueError: slice step cannot be zero Default level: warn · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -2320,7 +2320,7 @@ class SubProto(BaseProto, Protocol): Default level: warn · Added in 0.0.1-alpha.16 · Related issues · -View source +View source @@ -2408,7 +2408,7 @@ a = 20 / 0 # type: ignore Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2436,7 +2436,7 @@ A.c # AttributeError: type object 'A' has no attribute 'c' Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2468,7 +2468,7 @@ A()[0] # TypeError: 'A' object is not subscriptable Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2500,7 +2500,7 @@ from module import a # ImportError: cannot import name 'a' from 'module' Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2527,7 +2527,7 @@ cast(int, f()) # Redundant Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2551,7 +2551,7 @@ reveal_type(1) # NameError: name 'reveal_type' is not defined Default level: warn · Added in 0.0.1-alpha.15 · Related issues · -View source +View source @@ -2609,7 +2609,7 @@ def g(): Default level: warn · Added in 0.0.1-alpha.7 · Related issues · -View source +View source @@ -2648,7 +2648,7 @@ class D(C): ... # error: [unsupported-base] Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2711,7 +2711,7 @@ def foo(x: int | str) -> int | str: Default level: ignore · Preview (since 0.0.1-alpha.1) · Related issues · -View source +View source @@ -2735,7 +2735,7 @@ Dividing by zero raises a `ZeroDivisionError` at runtime. Default level: ignore · Added in 0.0.1-alpha.1 · Related issues · -View source +View source diff --git a/crates/ty_python_semantic/resources/mdtest/bidirectional.md b/crates/ty_python_semantic/resources/mdtest/bidirectional.md index 20f28ef72a..bae848c4a7 100644 --- a/crates/ty_python_semantic/resources/mdtest/bidirectional.md +++ b/crates/ty_python_semantic/resources/mdtest/bidirectional.md @@ -340,7 +340,7 @@ def _(a: object, b: object, flag: bool): else: x = g - # error: [unsupported-operator] "Operator `>` is not supported for types `object` and `object`" + # error: [unsupported-operator] "Operator `>` is not supported between two objects of type `object`" x(f"{'a' if a > b else 'b'}") ``` diff --git a/crates/ty_python_semantic/resources/mdtest/binary/instances.md b/crates/ty_python_semantic/resources/mdtest/binary/instances.md index dab5751d66..c981a570b0 100644 --- a/crates/ty_python_semantic/resources/mdtest/binary/instances.md +++ b/crates/ty_python_semantic/resources/mdtest/binary/instances.md @@ -392,7 +392,7 @@ reveal_type(A - B) # revealed: Unknown reveal_type(A < B) # revealed: bool reveal_type(A > B) # revealed: bool -# error: [unsupported-operator] "Operator `<=` is not supported for types `` and ``" +# error: [unsupported-operator] "Operator `<=` is not supported between objects of type `` and ``" reveal_type(A <= B) # revealed: Unknown reveal_type(A[0]) # revealed: str diff --git a/crates/ty_python_semantic/resources/mdtest/comparison/instances/membership_test.md b/crates/ty_python_semantic/resources/mdtest/comparison/instances/membership_test.md index b28d0d04fa..a53aaef3bb 100644 --- a/crates/ty_python_semantic/resources/mdtest/comparison/instances/membership_test.md +++ b/crates/ty_python_semantic/resources/mdtest/comparison/instances/membership_test.md @@ -21,9 +21,9 @@ class A: reveal_type("hello" in A()) # revealed: bool reveal_type("hello" not in A()) # revealed: bool -# error: [unsupported-operator] "Operator `in` is not supported for types `int` and `A`, in comparing `Literal[42]` with `A`" +# error: [unsupported-operator] "Operator `in` is not supported between objects of type `Literal[42]` and `A`" reveal_type(42 in A()) # revealed: bool -# error: [unsupported-operator] "Operator `not in` is not supported for types `int` and `A`, in comparing `Literal[42]` with `A`" +# error: [unsupported-operator] "Operator `not in` is not supported between objects of type `Literal[42]` and `A`" reveal_type(42 not in A()) # revealed: bool ``` @@ -127,9 +127,9 @@ class A: reveal_type(CheckContains() in A()) # revealed: bool -# error: [unsupported-operator] "Operator `in` is not supported for types `CheckIter` and `A`" +# error: [unsupported-operator] "Operator `in` is not supported between objects of type `CheckIter` and `A`" reveal_type(CheckIter() in A()) # revealed: bool -# error: [unsupported-operator] "Operator `in` is not supported for types `CheckGetItem` and `A`" +# error: [unsupported-operator] "Operator `in` is not supported between objects of type `CheckGetItem` and `A`" reveal_type(CheckGetItem() in A()) # revealed: bool class B: @@ -155,9 +155,9 @@ class A: def __getitem__(self, key: str) -> str: return "foo" -# error: [unsupported-operator] "Operator `in` is not supported for types `int` and `A`, in comparing `Literal[42]` with `A`" +# error: [unsupported-operator] "Operator `in` is not supported between objects of type `Literal[42]` and `A`" reveal_type(42 in A()) # revealed: bool -# error: [unsupported-operator] "Operator `in` is not supported for types `str` and `A`, in comparing `Literal["hello"]` with `A`" +# error: [unsupported-operator] "Operator `in` is not supported between objects of type `Literal["hello"]` and `A`" reveal_type("hello" in A()) # revealed: bool ``` diff --git a/crates/ty_python_semantic/resources/mdtest/comparison/instances/rich_comparison.md b/crates/ty_python_semantic/resources/mdtest/comparison/instances/rich_comparison.md index f32d65d244..85f88ab181 100644 --- a/crates/ty_python_semantic/resources/mdtest/comparison/instances/rich_comparison.md +++ b/crates/ty_python_semantic/resources/mdtest/comparison/instances/rich_comparison.md @@ -309,7 +309,7 @@ reveal_type(A() != object()) # revealed: bool reveal_type(object() == A()) # revealed: bool reveal_type(object() != A()) # revealed: bool -# error: [unsupported-operator] "Operator `<` is not supported for types `A` and `object`" +# error: [unsupported-operator] "Operator `<` is not supported between objects of type `A` and `object`" # revealed: Unknown reveal_type(A() < object()) ``` @@ -327,13 +327,13 @@ reveal_type(1 >= 1.0) # revealed: bool reveal_type(1 == 2j) # revealed: bool reveal_type(1 != 2j) # revealed: bool -# error: [unsupported-operator] "Operator `<` is not supported for types `int` and `complex`, in comparing `Literal[1]` with `complex`" +# error: [unsupported-operator] "Operator `<` is not supported between objects of type `Literal[1]` and `complex`" reveal_type(1 < 2j) # revealed: Unknown -# error: [unsupported-operator] "Operator `<=` is not supported for types `int` and `complex`, in comparing `Literal[1]` with `complex`" +# error: [unsupported-operator] "Operator `<=` is not supported between objects of type `Literal[1]` and `complex`" reveal_type(1 <= 2j) # revealed: Unknown -# error: [unsupported-operator] "Operator `>` is not supported for types `int` and `complex`, in comparing `Literal[1]` with `complex`" +# error: [unsupported-operator] "Operator `>` is not supported between objects of type `Literal[1]` and `complex`" reveal_type(1 > 2j) # revealed: Unknown -# error: [unsupported-operator] "Operator `>=` is not supported for types `int` and `complex`, in comparing `Literal[1]` with `complex`" +# error: [unsupported-operator] "Operator `>=` is not supported between objects of type `Literal[1]` and `complex`" reveal_type(1 >= 2j) # revealed: Unknown def f(x: bool, y: int): @@ -386,3 +386,29 @@ reveal_type(A() == A()) # revealed: Literal[True] reveal_type(A() < A()) # revealed: Literal[True] reveal_type(A() > A()) # revealed: Literal[True] ``` + +## Diagnostics where classes have the same name + +We use the fully qualified names of classes to disambiguate them where necessary: + +`a.py`: + +```py +class Foo: ... +``` + +`b.py`: + +```py +class Foo: ... +``` + +`main.py`: + +```py +import a +import b + +# error: [unsupported-operator] "Operator `<` is not supported between objects of type `a.Foo` and `b.Foo`" +a.Foo() < b.Foo() +``` diff --git a/crates/ty_python_semantic/resources/mdtest/comparison/integers.md b/crates/ty_python_semantic/resources/mdtest/comparison/integers.md index eeccd6a60a..95eddb2e36 100644 --- a/crates/ty_python_semantic/resources/mdtest/comparison/integers.md +++ b/crates/ty_python_semantic/resources/mdtest/comparison/integers.md @@ -12,7 +12,7 @@ reveal_type(1 is 1) # revealed: bool reveal_type(1 is not 1) # revealed: bool reveal_type(1 is 2) # revealed: Literal[False] reveal_type(1 is not 7) # revealed: Literal[True] -# error: [unsupported-operator] "Operator `<=` is not supported for types `int` and `str`, in comparing `Literal[1]` with `Literal[""]`" +# error: [unsupported-operator] "Operator `<=` is not supported between objects of type `Literal[1]` and `Literal[""]`" reveal_type(1 <= "" and 0 < 1) # revealed: (Unknown & ~AlwaysTruthy) | Literal[True] ``` diff --git a/crates/ty_python_semantic/resources/mdtest/comparison/intersections.md b/crates/ty_python_semantic/resources/mdtest/comparison/intersections.md index 35d7203c98..6c6d7bc827 100644 --- a/crates/ty_python_semantic/resources/mdtest/comparison/intersections.md +++ b/crates/ty_python_semantic/resources/mdtest/comparison/intersections.md @@ -109,6 +109,8 @@ def _(o: object): ### Unsupported operators for positive contributions + + Raise an error if the given operator is unsupported for all positive contributions to the intersection type: @@ -121,7 +123,7 @@ def _(x: object): if isinstance(x, NonContainer2): reveal_type(x) # revealed: NonContainer1 & NonContainer2 - # error: [unsupported-operator] "Operator `in` is not supported for types `int` and `NonContainer1`" + # error: [unsupported-operator] "Operator `in` is not supported between objects of type `Literal[2]` and `NonContainer1 & NonContainer2`" reveal_type(2 in x) # revealed: bool ``` @@ -149,7 +151,7 @@ def _(x: object): if not isinstance(x, NonContainer1): reveal_type(x) # revealed: ~NonContainer1 - # error: [unsupported-operator] "Operator `in` is not supported for types `int` and `object`, in comparing `Literal[2]` with `~NonContainer1`" + # error: [unsupported-operator] "Operator `in` is not supported between objects of type `Literal[2]` and `~NonContainer1`" reveal_type(2 in x) # revealed: bool reveal_type(2 is x) # revealed: bool diff --git a/crates/ty_python_semantic/resources/mdtest/comparison/tuples.md b/crates/ty_python_semantic/resources/mdtest/comparison/tuples.md index 2c99df929e..832029c881 100644 --- a/crates/ty_python_semantic/resources/mdtest/comparison/tuples.md +++ b/crates/ty_python_semantic/resources/mdtest/comparison/tuples.md @@ -79,6 +79,8 @@ def _(x: bool, y: int): #### Comparison Unsupported + + If two tuples contain types that do not support comparison, the result may be `Unknown`. However, `==` and `!=` are exceptions and can still provide definite results. @@ -92,14 +94,17 @@ reveal_type(a == b) # revealed: bool # TODO: should be Literal[True], once we implement (in)equality for mismatched literals reveal_type(a != b) # revealed: bool -# error: [unsupported-operator] "Operator `<` is not supported for types `int` and `str`, in comparing `tuple[Literal[1], Literal[2]]` with `tuple[Literal[1], Literal["hello"]]`" +# error: [unsupported-operator] "Operator `<` is not supported between objects of type `tuple[Literal[1], Literal[2]]` and `tuple[Literal[1], Literal["hello"]]`" reveal_type(a < b) # revealed: Unknown -# error: [unsupported-operator] "Operator `<=` is not supported for types `int` and `str`, in comparing `tuple[Literal[1], Literal[2]]` with `tuple[Literal[1], Literal["hello"]]`" +# error: [unsupported-operator] "Operator `<=` is not supported between objects of type `tuple[Literal[1], Literal[2]]` and `tuple[Literal[1], Literal["hello"]]`" reveal_type(a <= b) # revealed: Unknown -# error: [unsupported-operator] "Operator `>` is not supported for types `int` and `str`, in comparing `tuple[Literal[1], Literal[2]]` with `tuple[Literal[1], Literal["hello"]]`" +# error: [unsupported-operator] "Operator `>` is not supported between objects of type `tuple[Literal[1], Literal[2]]` and `tuple[Literal[1], Literal["hello"]]`" reveal_type(a > b) # revealed: Unknown -# error: [unsupported-operator] "Operator `>=` is not supported for types `int` and `str`, in comparing `tuple[Literal[1], Literal[2]]` with `tuple[Literal[1], Literal["hello"]]`" +# error: [unsupported-operator] "Operator `>=` is not supported between objects of type `tuple[Literal[1], Literal[2]]` and `tuple[Literal[1], Literal["hello"]]`" reveal_type(a >= b) # revealed: Unknown +# error: [unsupported-operator] +# error: [unsupported-operator] +reveal_type((object(),) < (object(),) < (object(),)) # revealed: Unknown ``` However, if the lexicographic comparison completes without reaching a point where str and int are @@ -257,24 +262,24 @@ comparison can clearly conclude before encountering an error, the error should n ```py def _(n: int, s: str): class A: ... - # error: [unsupported-operator] "Operator `<` is not supported for types `A` and `A`" + # error: [unsupported-operator] "Operator `<` is not supported between two objects of type `A`" A() < A() - # error: [unsupported-operator] "Operator `<=` is not supported for types `A` and `A`" + # error: [unsupported-operator] "Operator `<=` is not supported between two objects of type `A`" A() <= A() - # error: [unsupported-operator] "Operator `>` is not supported for types `A` and `A`" + # error: [unsupported-operator] "Operator `>` is not supported between two objects of type `A`" A() > A() - # error: [unsupported-operator] "Operator `>=` is not supported for types `A` and `A`" + # error: [unsupported-operator] "Operator `>=` is not supported between two objects of type `A`" A() >= A() a = (0, n, A()) - # error: [unsupported-operator] "Operator `<` is not supported for types `A` and `A`, in comparing `tuple[Literal[0], int, A]` with `tuple[Literal[0], int, A]`" + # error: [unsupported-operator] "Operator `<` is not supported between two objects of type `tuple[Literal[0], int, A]`" reveal_type(a < a) # revealed: Unknown - # error: [unsupported-operator] "Operator `<=` is not supported for types `A` and `A`, in comparing `tuple[Literal[0], int, A]` with `tuple[Literal[0], int, A]`" + # error: [unsupported-operator] "Operator `<=` is not supported between two objects of type `tuple[Literal[0], int, A]`" reveal_type(a <= a) # revealed: Unknown - # error: [unsupported-operator] "Operator `>` is not supported for types `A` and `A`, in comparing `tuple[Literal[0], int, A]` with `tuple[Literal[0], int, A]`" + # error: [unsupported-operator] "Operator `>` is not supported between two objects of type `tuple[Literal[0], int, A]`" reveal_type(a > a) # revealed: Unknown - # error: [unsupported-operator] "Operator `>=` is not supported for types `A` and `A`, in comparing `tuple[Literal[0], int, A]` with `tuple[Literal[0], int, A]`" + # error: [unsupported-operator] "Operator `>=` is not supported between two objects of type `tuple[Literal[0], int, A]`" reveal_type(a >= a) # revealed: Unknown # Comparison between `a` and `b` should only involve the first elements, `Literal[0]` and `Literal[99999]`, diff --git a/crates/ty_python_semantic/resources/mdtest/comparison/unions.md b/crates/ty_python_semantic/resources/mdtest/comparison/unions.md index 56924ecc7f..6a7feea646 100644 --- a/crates/ty_python_semantic/resources/mdtest/comparison/unions.md +++ b/crates/ty_python_semantic/resources/mdtest/comparison/unions.md @@ -66,14 +66,29 @@ def _(flag_s: bool, flag_l: bool): ## Unsupported operations + + Make sure we emit a diagnostic if *any* of the possible comparisons is unsupported. For now, we fall back to `bool` for the result type instead of trying to infer something more precise from the other (supported) variants: ```py -def _(flag: bool): - x = [1, 2] if flag else 1 +from typing import Literal +def _( + x: list[int] | Literal[1], + y: list[int] | Literal[1], + aa: tuple[int], + bb: tuple[int] | tuple[int, int], + cc: tuple[str] | tuple[str, str], +): result = 1 in x # error: "Operator `in` is not supported" reveal_type(result) # revealed: bool + + result2 = y in x # error: [unsupported-operator] + reveal_type(result) # revealed: bool + + result3 = aa < cc # error: [unsupported-operator] + result4 = cc < aa # error: [unsupported-operator] + result5 = bb < cc # error: [unsupported-operator] ``` diff --git a/crates/ty_python_semantic/resources/mdtest/comparison/unsupported.md b/crates/ty_python_semantic/resources/mdtest/comparison/unsupported.md index f3e57c886d..c74fd57928 100644 --- a/crates/ty_python_semantic/resources/mdtest/comparison/unsupported.md +++ b/crates/ty_python_semantic/resources/mdtest/comparison/unsupported.md @@ -1,32 +1,34 @@ # Comparison: Unsupported operators + + ```py def _(flag: bool, flag1: bool, flag2: bool): class A: ... - a = 1 in 7 # error: "Operator `in` is not supported for types `Literal[1]` and `Literal[7]`" + a = 1 in 7 # error: "Operator `in` is not supported between objects of type `Literal[1]` and `Literal[7]`" reveal_type(a) # revealed: bool - b = 0 not in 10 # error: "Operator `not in` is not supported for types `Literal[0]` and `Literal[10]`" + b = 0 not in 10 # error: "Operator `not in` is not supported between objects of type `Literal[0]` and `Literal[10]`" reveal_type(b) # revealed: bool - # error: [unsupported-operator] "Operator `<` is not supported for types `object` and `int`, in comparing `object` with `Literal[5]`" + # error: [unsupported-operator] "Operator `<` is not supported between objects of type `object` and `Literal[5]`" c = object() < 5 reveal_type(c) # revealed: Unknown - # error: [unsupported-operator] "Operator `<` is not supported for types `int` and `object`, in comparing `Literal[5]` with `object`" + # error: [unsupported-operator] "Operator `<` is not supported between objects of type `Literal[5]` and `object`" d = 5 < object() reveal_type(d) # revealed: Unknown int_literal_or_str_literal = 1 if flag else "foo" - # error: "Operator `in` is not supported for types `Literal[42]` and `Literal[1]`, in comparing `Literal[42]` with `Literal[1, "foo"]`" + # error: "Operator `in` is not supported between objects of type `Literal[42]` and `Literal[1, "foo"]`" e = 42 in int_literal_or_str_literal reveal_type(e) # revealed: bool - # error: [unsupported-operator] "Operator `<` is not supported for types `int` and `str`, in comparing `tuple[Literal[1], Literal[2]]` with `tuple[Literal[1], Literal["hello"]]`" + # error: [unsupported-operator] "Operator `<` is not supported between objects of type `tuple[Literal[1], Literal[2]]` and `tuple[Literal[1], Literal["hello"]]`" f = (1, 2) < (1, "hello") reveal_type(f) # revealed: Unknown - # error: [unsupported-operator] "Operator `<` is not supported for types `A` and `A`, in comparing `tuple[bool, A]` with `tuple[bool, A]`" + # error: [unsupported-operator] "Operator `<` is not supported between two objects of type `tuple[bool, A]`" g = (flag1, A()) < (flag2, A()) reveal_type(g) # revealed: Unknown ``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/intersections.md_-_Comparison___Intersec…_-_Diagnostics_-_Unsupported_operator…_(27f95f68d1c826ec).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/intersections.md_-_Comparison___Intersec…_-_Diagnostics_-_Unsupported_operator…_(27f95f68d1c826ec).snap new file mode 100644 index 0000000000..2034ded4e3 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/intersections.md_-_Comparison___Intersec…_-_Diagnostics_-_Unsupported_operator…_(27f95f68d1c826ec).snap @@ -0,0 +1,79 @@ +--- +source: crates/ty_test/src/lib.rs +expression: snapshot +--- +--- +mdtest name: intersections.md - Comparison: Intersections - Diagnostics - Unsupported operators for positive contributions +mdtest path: crates/ty_python_semantic/resources/mdtest/comparison/intersections.md +--- + +# Python source files + +## mdtest_snippet.py + +``` + 1 | class NonContainer1: ... + 2 | class NonContainer2: ... + 3 | + 4 | def _(x: object): + 5 | if isinstance(x, NonContainer1): + 6 | if isinstance(x, NonContainer2): + 7 | reveal_type(x) # revealed: NonContainer1 & NonContainer2 + 8 | + 9 | # error: [unsupported-operator] "Operator `in` is not supported between objects of type `Literal[2]` and `NonContainer1 & NonContainer2`" +10 | reveal_type(2 in x) # revealed: bool +11 | class Container: +12 | def __contains__(self, x) -> bool: +13 | return False +14 | +15 | def _(x: object): +16 | if isinstance(x, NonContainer1): +17 | if isinstance(x, Container): +18 | if isinstance(x, NonContainer2): +19 | reveal_type(x) # revealed: NonContainer1 & Container & NonContainer2 +20 | reveal_type(2 in x) # revealed: bool +21 | def _(x: object): +22 | if not isinstance(x, NonContainer1): +23 | reveal_type(x) # revealed: ~NonContainer1 +24 | +25 | # error: [unsupported-operator] "Operator `in` is not supported between objects of type `Literal[2]` and `~NonContainer1`" +26 | reveal_type(2 in x) # revealed: bool +27 | +28 | reveal_type(2 is x) # revealed: bool +``` + +# Diagnostics + +``` +error[unsupported-operator]: Unsupported `in` operation + --> src/mdtest_snippet.py:10:25 + | + 9 | # error: [unsupported-operator] "Operator `in` is not supported between objects of type `Literal[2]` and `NonContainer1 & … +10 | reveal_type(2 in x) # revealed: bool + | -^^^^- + | | | + | | Has type `NonContainer1 & NonContainer2` + | Has type `Literal[2]` +11 | class Container: +12 | def __contains__(self, x) -> bool: + | +info: rule `unsupported-operator` is enabled by default + +``` + +``` +error[unsupported-operator]: Unsupported `in` operation + --> src/mdtest_snippet.py:26:21 + | +25 | # error: [unsupported-operator] "Operator `in` is not supported between objects of type `Literal[2]` and `~NonContainer1`" +26 | reveal_type(2 in x) # revealed: bool + | -^^^^- + | | | + | | Has type `~NonContainer1` + | Has type `Literal[2]` +27 | +28 | reveal_type(2 is x) # revealed: bool + | +info: rule `unsupported-operator` is enabled by default + +``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/tuples.md_-_Comparison___Tuples_-_Heterogeneous_-_Value_Comparisons_-_Comparison_Unsupport…_(966dd82bd3668d0e).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/tuples.md_-_Comparison___Tuples_-_Heterogeneous_-_Value_Comparisons_-_Comparison_Unsupport…_(966dd82bd3668d0e).snap new file mode 100644 index 0000000000..32b12ff278 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/tuples.md_-_Comparison___Tuples_-_Heterogeneous_-_Value_Comparisons_-_Comparison_Unsupport…_(966dd82bd3668d0e).snap @@ -0,0 +1,157 @@ +--- +source: crates/ty_test/src/lib.rs +expression: snapshot +--- +--- +mdtest name: tuples.md - Comparison: Tuples - Heterogeneous - Value Comparisons - Comparison Unsupported +mdtest path: crates/ty_python_semantic/resources/mdtest/comparison/tuples.md +--- + +# Python source files + +## mdtest_snippet.py + +``` + 1 | a = (1, 2) + 2 | b = (1, "hello") + 3 | + 4 | # TODO: should be Literal[False], once we implement (in)equality for mismatched literals + 5 | reveal_type(a == b) # revealed: bool + 6 | + 7 | # TODO: should be Literal[True], once we implement (in)equality for mismatched literals + 8 | reveal_type(a != b) # revealed: bool + 9 | +10 | # error: [unsupported-operator] "Operator `<` is not supported between objects of type `tuple[Literal[1], Literal[2]]` and `tuple[Literal[1], Literal["hello"]]`" +11 | reveal_type(a < b) # revealed: Unknown +12 | # error: [unsupported-operator] "Operator `<=` is not supported between objects of type `tuple[Literal[1], Literal[2]]` and `tuple[Literal[1], Literal["hello"]]`" +13 | reveal_type(a <= b) # revealed: Unknown +14 | # error: [unsupported-operator] "Operator `>` is not supported between objects of type `tuple[Literal[1], Literal[2]]` and `tuple[Literal[1], Literal["hello"]]`" +15 | reveal_type(a > b) # revealed: Unknown +16 | # error: [unsupported-operator] "Operator `>=` is not supported between objects of type `tuple[Literal[1], Literal[2]]` and `tuple[Literal[1], Literal["hello"]]`" +17 | reveal_type(a >= b) # revealed: Unknown +18 | # error: [unsupported-operator] +19 | # error: [unsupported-operator] +20 | reveal_type((object(),) < (object(),) < (object(),)) # revealed: Unknown +21 | a = (1, 2) +22 | b = (999999, "hello") +23 | +24 | reveal_type(a == b) # revealed: Literal[False] +25 | reveal_type(a != b) # revealed: Literal[True] +26 | reveal_type(a < b) # revealed: Literal[True] +27 | reveal_type(a <= b) # revealed: Literal[True] +28 | reveal_type(a > b) # revealed: Literal[False] +29 | reveal_type(a >= b) # revealed: Literal[False] +``` + +# Diagnostics + +``` +error[unsupported-operator]: Unsupported `<` operation + --> src/mdtest_snippet.py:11:13 + | +10 | # error: [unsupported-operator] "Operator `<` is not supported between objects of type `tuple[Literal[1], Literal[2]]` and `tuple[Lite… +11 | reveal_type(a < b) # revealed: Unknown + | -^^^- + | | | + | | Has type `tuple[Literal[1], Literal["hello"]]` + | Has type `tuple[Literal[1], Literal[2]]` +12 | # error: [unsupported-operator] "Operator `<=` is not supported between objects of type `tuple[Literal[1], Literal[2]]` and `tuple[Lit… +13 | reveal_type(a <= b) # revealed: Unknown + | +info: Operation fails because operator `<` is not supported between the tuple elements at index 2 (of type `Literal[2]` and `Literal["hello"]`) +info: rule `unsupported-operator` is enabled by default + +``` + +``` +error[unsupported-operator]: Unsupported `<=` operation + --> src/mdtest_snippet.py:13:13 + | +11 | reveal_type(a < b) # revealed: Unknown +12 | # error: [unsupported-operator] "Operator `<=` is not supported between objects of type `tuple[Literal[1], Literal[2]]` and `tuple[Lit… +13 | reveal_type(a <= b) # revealed: Unknown + | -^^^^- + | | | + | | Has type `tuple[Literal[1], Literal["hello"]]` + | Has type `tuple[Literal[1], Literal[2]]` +14 | # error: [unsupported-operator] "Operator `>` is not supported between objects of type `tuple[Literal[1], Literal[2]]` and `tuple[Lite… +15 | reveal_type(a > b) # revealed: Unknown + | +info: Operation fails because operator `<=` is not supported between the tuple elements at index 2 (of type `Literal[2]` and `Literal["hello"]`) +info: rule `unsupported-operator` is enabled by default + +``` + +``` +error[unsupported-operator]: Unsupported `>` operation + --> src/mdtest_snippet.py:15:13 + | +13 | reveal_type(a <= b) # revealed: Unknown +14 | # error: [unsupported-operator] "Operator `>` is not supported between objects of type `tuple[Literal[1], Literal[2]]` and `tuple[Lite… +15 | reveal_type(a > b) # revealed: Unknown + | -^^^- + | | | + | | Has type `tuple[Literal[1], Literal["hello"]]` + | Has type `tuple[Literal[1], Literal[2]]` +16 | # error: [unsupported-operator] "Operator `>=` is not supported between objects of type `tuple[Literal[1], Literal[2]]` and `tuple[Lit… +17 | reveal_type(a >= b) # revealed: Unknown + | +info: Operation fails because operator `>` is not supported between the tuple elements at index 2 (of type `Literal[2]` and `Literal["hello"]`) +info: rule `unsupported-operator` is enabled by default + +``` + +``` +error[unsupported-operator]: Unsupported `>=` operation + --> src/mdtest_snippet.py:17:13 + | +15 | reveal_type(a > b) # revealed: Unknown +16 | # error: [unsupported-operator] "Operator `>=` is not supported between objects of type `tuple[Literal[1], Literal[2]]` and `tuple[Lit… +17 | reveal_type(a >= b) # revealed: Unknown + | -^^^^- + | | | + | | Has type `tuple[Literal[1], Literal["hello"]]` + | Has type `tuple[Literal[1], Literal[2]]` +18 | # error: [unsupported-operator] +19 | # error: [unsupported-operator] + | +info: Operation fails because operator `>=` is not supported between the tuple elements at index 2 (of type `Literal[2]` and `Literal["hello"]`) +info: rule `unsupported-operator` is enabled by default + +``` + +``` +error[unsupported-operator]: Unsupported `<` operation + --> src/mdtest_snippet.py:20:13 + | +18 | # error: [unsupported-operator] +19 | # error: [unsupported-operator] +20 | reveal_type((object(),) < (object(),) < (object(),)) # revealed: Unknown + | -----------^^^----------- + | | + | Both operands have type `tuple[object]` +21 | a = (1, 2) +22 | b = (999999, "hello") + | +info: Operation fails because operator `<` is not supported between the tuple elements at index 1 (both of type `object`) +info: rule `unsupported-operator` is enabled by default + +``` + +``` +error[unsupported-operator]: Unsupported `<` operation + --> src/mdtest_snippet.py:20:27 + | +18 | # error: [unsupported-operator] +19 | # error: [unsupported-operator] +20 | reveal_type((object(),) < (object(),) < (object(),)) # revealed: Unknown + | -----------^^^----------- + | | + | Both operands have type `tuple[object]` +21 | a = (1, 2) +22 | b = (999999, "hello") + | +info: Operation fails because operator `<` is not supported between the tuple elements at index 1 (both of type `object`) +info: rule `unsupported-operator` is enabled by default + +``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/unions.md_-_Comparison___Unions_-_Unsupported_operatio…_(e15acf820f65e3e4).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/unions.md_-_Comparison___Unions_-_Unsupported_operatio…_(e15acf820f65e3e4).snap new file mode 100644 index 0000000000..219bb20795 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/unions.md_-_Comparison___Unions_-_Unsupported_operatio…_(e15acf820f65e3e4).snap @@ -0,0 +1,123 @@ +--- +source: crates/ty_test/src/lib.rs +expression: snapshot +--- +--- +mdtest name: unions.md - Comparison: Unions - Unsupported operations +mdtest path: crates/ty_python_semantic/resources/mdtest/comparison/unions.md +--- + +# Python source files + +## mdtest_snippet.py + +``` + 1 | from typing import Literal + 2 | + 3 | def _( + 4 | x: list[int] | Literal[1], + 5 | y: list[int] | Literal[1], + 6 | aa: tuple[int], + 7 | bb: tuple[int] | tuple[int, int], + 8 | cc: tuple[str] | tuple[str, str], + 9 | ): +10 | result = 1 in x # error: "Operator `in` is not supported" +11 | reveal_type(result) # revealed: bool +12 | +13 | result2 = y in x # error: [unsupported-operator] +14 | reveal_type(result) # revealed: bool +15 | +16 | result3 = aa < cc # error: [unsupported-operator] +17 | result4 = cc < aa # error: [unsupported-operator] +18 | result5 = bb < cc # error: [unsupported-operator] +``` + +# Diagnostics + +``` +error[unsupported-operator]: Unsupported `in` operation + --> src/mdtest_snippet.py:10:14 + | + 8 | cc: tuple[str] | tuple[str, str], + 9 | ): +10 | result = 1 in x # error: "Operator `in` is not supported" + | -^^^^- + | | | + | | Has type `list[int] | Literal[1]` + | Has type `Literal[1]` +11 | reveal_type(result) # revealed: bool + | +info: Operation fails because operator `in` is not supported between two objects of type `Literal[1]` +info: rule `unsupported-operator` is enabled by default + +``` + +``` +error[unsupported-operator]: Unsupported `in` operation + --> src/mdtest_snippet.py:13:15 + | +11 | reveal_type(result) # revealed: bool +12 | +13 | result2 = y in x # error: [unsupported-operator] + | -^^^^- + | | + | Both operands have type `list[int] | Literal[1]` +14 | reveal_type(result) # revealed: bool + | +info: Operation fails because operator `in` is not supported between objects of type `list[int]` and `Literal[1]` +info: rule `unsupported-operator` is enabled by default + +``` + +``` +error[unsupported-operator]: Unsupported `<` operation + --> src/mdtest_snippet.py:16:15 + | +14 | reveal_type(result) # revealed: bool +15 | +16 | result3 = aa < cc # error: [unsupported-operator] + | --^^^-- + | | | + | | Has type `tuple[str] | tuple[str, str]` + | Has type `tuple[int]` +17 | result4 = cc < aa # error: [unsupported-operator] +18 | result5 = bb < cc # error: [unsupported-operator] + | +info: Operation fails because operator `<` is not supported between objects of type `int` and `str` +info: rule `unsupported-operator` is enabled by default + +``` + +``` +error[unsupported-operator]: Unsupported `<` operation + --> src/mdtest_snippet.py:17:15 + | +16 | result3 = aa < cc # error: [unsupported-operator] +17 | result4 = cc < aa # error: [unsupported-operator] + | --^^^-- + | | | + | | Has type `tuple[int]` + | Has type `tuple[str] | tuple[str, str]` +18 | result5 = bb < cc # error: [unsupported-operator] + | +info: Operation fails because operator `<` is not supported between objects of type `str` and `int` +info: rule `unsupported-operator` is enabled by default + +``` + +``` +error[unsupported-operator]: Unsupported `<` operation + --> src/mdtest_snippet.py:18:15 + | +16 | result3 = aa < cc # error: [unsupported-operator] +17 | result4 = cc < aa # error: [unsupported-operator] +18 | result5 = bb < cc # error: [unsupported-operator] + | --^^^-- + | | | + | | Has type `tuple[str] | tuple[str, str]` + | Has type `tuple[int] | tuple[int, int]` + | +info: Operation fails because operator `<` is not supported between objects of type `int` and `str` +info: rule `unsupported-operator` is enabled by default + +``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/unsupported.md_-_Comparison___Unsuppor…_(c13dd5902282489a).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/unsupported.md_-_Comparison___Unsuppor…_(c13dd5902282489a).snap new file mode 100644 index 0000000000..c1593bfd0a --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/unsupported.md_-_Comparison___Unsuppor…_(c13dd5902282489a).snap @@ -0,0 +1,162 @@ +--- +source: crates/ty_test/src/lib.rs +expression: snapshot +--- +--- +mdtest name: unsupported.md - Comparison: Unsupported operators +mdtest path: crates/ty_python_semantic/resources/mdtest/comparison/unsupported.md +--- + +# Python source files + +## mdtest_snippet.py + +``` + 1 | def _(flag: bool, flag1: bool, flag2: bool): + 2 | class A: ... + 3 | a = 1 in 7 # error: "Operator `in` is not supported between objects of type `Literal[1]` and `Literal[7]`" + 4 | reveal_type(a) # revealed: bool + 5 | + 6 | b = 0 not in 10 # error: "Operator `not in` is not supported between objects of type `Literal[0]` and `Literal[10]`" + 7 | reveal_type(b) # revealed: bool + 8 | + 9 | # error: [unsupported-operator] "Operator `<` is not supported between objects of type `object` and `Literal[5]`" +10 | c = object() < 5 +11 | reveal_type(c) # revealed: Unknown +12 | +13 | # error: [unsupported-operator] "Operator `<` is not supported between objects of type `Literal[5]` and `object`" +14 | d = 5 < object() +15 | reveal_type(d) # revealed: Unknown +16 | +17 | int_literal_or_str_literal = 1 if flag else "foo" +18 | # error: "Operator `in` is not supported between objects of type `Literal[42]` and `Literal[1, "foo"]`" +19 | e = 42 in int_literal_or_str_literal +20 | reveal_type(e) # revealed: bool +21 | +22 | # error: [unsupported-operator] "Operator `<` is not supported between objects of type `tuple[Literal[1], Literal[2]]` and `tuple[Literal[1], Literal["hello"]]`" +23 | f = (1, 2) < (1, "hello") +24 | reveal_type(f) # revealed: Unknown +25 | +26 | # error: [unsupported-operator] "Operator `<` is not supported between two objects of type `tuple[bool, A]`" +27 | g = (flag1, A()) < (flag2, A()) +28 | reveal_type(g) # revealed: Unknown +``` + +# Diagnostics + +``` +error[unsupported-operator]: Unsupported `in` operation + --> src/mdtest_snippet.py:3:9 + | +1 | def _(flag: bool, flag1: bool, flag2: bool): +2 | class A: ... +3 | a = 1 in 7 # error: "Operator `in` is not supported between objects of type `Literal[1]` and `Literal[7]`" + | -^^^^- + | | | + | | Has type `Literal[7]` + | Has type `Literal[1]` +4 | reveal_type(a) # revealed: bool + | +info: rule `unsupported-operator` is enabled by default + +``` + +``` +error[unsupported-operator]: Unsupported `not in` operation + --> src/mdtest_snippet.py:6:9 + | +4 | reveal_type(a) # revealed: bool +5 | +6 | b = 0 not in 10 # error: "Operator `not in` is not supported between objects of type `Literal[0]` and `Literal[10]`" + | -^^^^^^^^-- + | | | + | | Has type `Literal[10]` + | Has type `Literal[0]` +7 | reveal_type(b) # revealed: bool + | +info: rule `unsupported-operator` is enabled by default + +``` + +``` +error[unsupported-operator]: Unsupported `<` operation + --> src/mdtest_snippet.py:10:9 + | + 9 | # error: [unsupported-operator] "Operator `<` is not supported between objects of type `object` and `Literal[5]`" +10 | c = object() < 5 + | --------^^^- + | | | + | | Has type `Literal[5]` + | Has type `object` +11 | reveal_type(c) # revealed: Unknown + | +info: rule `unsupported-operator` is enabled by default + +``` + +``` +error[unsupported-operator]: Unsupported `<` operation + --> src/mdtest_snippet.py:14:9 + | +13 | # error: [unsupported-operator] "Operator `<` is not supported between objects of type `Literal[5]` and `object`" +14 | d = 5 < object() + | -^^^-------- + | | | + | | Has type `object` + | Has type `Literal[5]` +15 | reveal_type(d) # revealed: Unknown + | +info: rule `unsupported-operator` is enabled by default + +``` + +``` +error[unsupported-operator]: Unsupported `in` operation + --> src/mdtest_snippet.py:19:9 + | +17 | int_literal_or_str_literal = 1 if flag else "foo" +18 | # error: "Operator `in` is not supported between objects of type `Literal[42]` and `Literal[1, "foo"]`" +19 | e = 42 in int_literal_or_str_literal + | --^^^^-------------------------- + | | | + | | Has type `Literal[1, "foo"]` + | Has type `Literal[42]` +20 | reveal_type(e) # revealed: bool + | +info: Operation fails because operator `in` is not supported between objects of type `Literal[42]` and `Literal[1]` +info: rule `unsupported-operator` is enabled by default + +``` + +``` +error[unsupported-operator]: Unsupported `<` operation + --> src/mdtest_snippet.py:23:9 + | +22 | # error: [unsupported-operator] "Operator `<` is not supported between objects of type `tuple[Literal[1], Literal[2]]` and `tuple[… +23 | f = (1, 2) < (1, "hello") + | ------^^^------------ + | | | + | | Has type `tuple[Literal[1], Literal["hello"]]` + | Has type `tuple[Literal[1], Literal[2]]` +24 | reveal_type(f) # revealed: Unknown + | +info: Operation fails because operator `<` is not supported between the tuple elements at index 2 (of type `Literal[2]` and `Literal["hello"]`) +info: rule `unsupported-operator` is enabled by default + +``` + +``` +error[unsupported-operator]: Unsupported `<` operation + --> src/mdtest_snippet.py:27:9 + | +26 | # error: [unsupported-operator] "Operator `<` is not supported between two objects of type `tuple[bool, A]`" +27 | g = (flag1, A()) < (flag2, A()) + | ------------^^^------------ + | | + | Both operands have type `tuple[bool, A]` +28 | reveal_type(g) # revealed: Unknown + | +info: Operation fails because operator `<` is not supported between the tuple elements at index 2 (both of type `A`) +info: rule `unsupported-operator` is enabled by default + +``` diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index 5a5c990ab1..8b20466b51 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -18,12 +18,14 @@ use crate::types::class::{ CodeGeneratorKind, DisjointBase, DisjointBaseKind, Field, MethodDecorator, }; use crate::types::function::{FunctionDecorators, FunctionType, KnownFunction, OverloadLiteral}; +use crate::types::infer::UnsupportedComparisonError; use crate::types::overrides::MethodKind; use crate::types::string_annotation::{ BYTE_STRING_TYPE_ANNOTATION, ESCAPE_CHARACTER_IN_FORWARD_ANNOTATION, FSTRING_TYPE_ANNOTATION, IMPLICIT_CONCATENATED_STRING_TYPE_ANNOTATION, INVALID_SYNTAX_IN_FORWARD_ANNOTATION, RAW_STRING_TYPE_ANNOTATION, }; +use crate::types::tuple::TupleSpec; use crate::types::{ BoundTypeVarInstance, ClassType, DynamicType, LintDiagnosticGuard, Protocol, ProtocolInstanceType, SpecialFormType, SubclassOfInner, Type, TypeContext, binding_type, @@ -2410,7 +2412,7 @@ pub(super) fn report_invalid_assignment<'db>( } let settings = - DisplaySettings::from_possibly_ambiguous_type_pair(context.db(), target_ty, value_ty); + DisplaySettings::from_possibly_ambiguous_types(context.db(), [target_ty, value_ty]); let diagnostic_range = if let Some(value_node) = value_node { // Expand the range to include parentheses around the value, if any. This allows @@ -2548,7 +2550,7 @@ pub(super) fn report_invalid_return_type( }; let settings = - DisplaySettings::from_possibly_ambiguous_type_pair(context.db(), expected_ty, actual_ty); + DisplaySettings::from_possibly_ambiguous_types(context.db(), [expected_ty, actual_ty]); let return_type_span = context.span(return_type_range); let mut diag = builder.into_diagnostic("Return type does not match returned value"); @@ -4054,6 +4056,112 @@ pub(super) fn report_overridden_final_method<'db>( } } +pub(super) fn report_unsupported_comparison<'db>( + context: &InferContext<'db, '_>, + error: &UnsupportedComparisonError<'db>, + range: TextRange, + left: &ast::Expr, + right: &ast::Expr, + left_ty: Type<'db>, + right_ty: Type<'db>, +) { + let db = context.db(); + + let Some(diagnostic_builder) = context.report_lint(&UNSUPPORTED_OPERATOR, range) else { + return; + }; + + let display_settings = DisplaySettings::from_possibly_ambiguous_types( + db, + [error.left_ty, error.right_ty, left_ty, right_ty], + ); + + let mut diagnostic = + diagnostic_builder.into_diagnostic(format_args!("Unsupported `{}` operation", error.op)); + + if left_ty == right_ty { + diagnostic.set_primary_message(format_args!( + "Both operands have type `{}`", + left_ty.display_with(db, display_settings.clone()) + )); + diagnostic.annotate(context.secondary(left)); + diagnostic.annotate(context.secondary(right)); + diagnostic.set_concise_message(format_args!( + "Operator `{}` is not supported between two objects of type `{}`", + error.op, + left_ty.display_with(db, display_settings.clone()) + )); + } else { + for (ty, expr) in [(left_ty, left), (right_ty, right)] { + diagnostic.annotate(context.secondary(expr).message(format_args!( + "Has type `{}`", + ty.display_with(db, display_settings.clone()) + ))); + } + diagnostic.set_concise_message(format_args!( + "Operator `{}` is not supported between objects of type `{}` and `{}`", + error.op, + left_ty.display_with(db, display_settings.clone()), + right_ty.display_with(db, display_settings.clone()) + )); + } + + // For non-atomic types like unions and tuples, we now provide context + // on the underlying elements that caused the error. + // If we're emitting a diagnostic for something like `(1, "foo") < (2, 3)`: + // + // - `left_ty` is `tuple[Literal[1], Literal["foo"]]` + // - `right_ty` is `tuple[Literal[2], Literal[3]] + // - `error.left_ty` is `Literal["foo"]` + // - `error.right_ty` is `Literal[3]` + if (error.left_ty, error.right_ty) != (left_ty, right_ty) { + if let Some(TupleSpec::Fixed(lhs_spec)) = left_ty.tuple_instance_spec(db).as_deref() + && let Some(TupleSpec::Fixed(rhs_spec)) = right_ty.tuple_instance_spec(db).as_deref() + && lhs_spec.len() == rhs_spec.len() + && let Some(position) = lhs_spec + .elements() + .zip(rhs_spec.elements()) + .position(|tup| tup == (&error.left_ty, &error.right_ty)) + { + if error.left_ty == error.right_ty { + diagnostic.info(format_args!( + "Operation fails because operator `{}` is not supported between \ + the tuple elements at index {} (both of type `{}`)", + error.op, + position + 1, + error.left_ty.display_with(db, display_settings), + )); + } else { + diagnostic.info(format_args!( + "Operation fails because operator `{}` is not supported between \ + the tuple elements at index {} (of type `{}` and `{}`)", + error.op, + position + 1, + error.left_ty.display_with(db, display_settings.clone()), + error.right_ty.display_with(db, display_settings), + )); + } + } else { + if error.left_ty == error.right_ty { + diagnostic.info(format_args!( + "Operation fails because operator `{}` is not supported \ + between two objects of type `{}`", + error.op, + error.left_ty.display_with(db, display_settings), + )); + } else { + diagnostic.info(format_args!( + "Operation fails because operator `{}` is not supported \ + between objects of type `{}` and `{}`", + error.op, + error.left_ty.display_with(db, display_settings.clone()), + error.right_ty.display_with(db, display_settings) + )); + } + } + } +} + /// This function receives an unresolved `from foo import bar` import, /// where `foo` can be resolved to a module but that module does not /// have a `bar` member or submodule. diff --git a/crates/ty_python_semantic/src/types/display.rs b/crates/ty_python_semantic/src/types/display.rs index e248845034..cadd54eef1 100644 --- a/crates/ty_python_semantic/src/types/display.rs +++ b/crates/ty_python_semantic/src/types/display.rs @@ -72,14 +72,15 @@ impl<'db> DisplaySettings<'db> { } #[must_use] - pub fn from_possibly_ambiguous_type_pair( + pub fn from_possibly_ambiguous_types( db: &'db dyn Db, - type_1: Type<'db>, - type_2: Type<'db>, + types: impl IntoIterator>, ) -> Self { let collector = AmbiguousClassCollector::default(); - collector.visit_type(db, type_1); - collector.visit_type(db, type_2); + + for ty in types { + collector.visit_type(db, ty); + } Self { qualified: Rc::new( diff --git a/crates/ty_python_semantic/src/types/infer.rs b/crates/ty_python_semantic/src/types/infer.rs index fe39deb36a..b9adc93eb2 100644 --- a/crates/ty_python_semantic/src/types/infer.rs +++ b/crates/ty_python_semantic/src/types/infer.rs @@ -57,6 +57,7 @@ use crate::types::{ }; use crate::unpack::Unpack; use builder::TypeInferenceBuilder; +pub(super) use builder::UnsupportedComparisonError; mod builder; #[cfg(test)] diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index f5870c9b08..d3a65e88b2 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -79,7 +79,7 @@ use crate::types::diagnostic::{ report_invalid_type_checking_constant, report_named_tuple_field_with_leading_underscore, report_namedtuple_field_without_default_after_field_with_default, report_non_subscriptable, report_possibly_missing_attribute, report_possibly_unresolved_reference, - report_rebound_typevar, report_slice_step_size_zero, + report_rebound_typevar, report_slice_step_size_zero, report_unsupported_comparison, }; use crate::types::function::{ FunctionDecorators, FunctionLiteral, FunctionType, KnownFunction, OverloadLiteral, @@ -158,7 +158,7 @@ impl<'db> DeclaredAndInferredType<'db> { type BinaryComparisonVisitor<'db> = CycleDetector< ast::CmpOp, (Type<'db>, ast::CmpOp, Type<'db>), - Result, CompareUnsupportedError<'db>>, + Result, UnsupportedComparisonError<'db>>, >; /// We currently store one dataclass field-specifiers inline, because that covers standard @@ -10056,26 +10056,15 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { &BinaryComparisonVisitor::new(Ok(Type::BooleanLiteral(true))), ) .unwrap_or_else(|error| { - if let Some(diagnostic_builder) = - builder.context.report_lint(&UNSUPPORTED_OPERATOR, range) - { - // Handle unsupported operators (diagnostic, `bool`/`Unknown` outcome) - diagnostic_builder.into_diagnostic(format_args!( - "Operator `{}` is not supported for types `{}` and `{}`{}", - error.op, - error.left_ty.display(builder.db()), - error.right_ty.display(builder.db()), - if (left_ty, right_ty) == (error.left_ty, error.right_ty) { - String::new() - } else { - format!( - ", in comparing `{}` with `{}`", - left_ty.display(builder.db()), - right_ty.display(builder.db()) - ) - } - )); - } + report_unsupported_comparison( + &builder.context, + &error, + range, + left, + right, + left_ty, + right_ty, + ); match op { // `in, not in, is, is not` always return bool instances @@ -10101,13 +10090,13 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { intersection_on: IntersectionOn, range: TextRange, visitor: &BinaryComparisonVisitor<'db>, - ) -> Result, CompareUnsupportedError<'db>> { + ) -> Result, UnsupportedComparisonError<'db>> { enum State<'db> { // We have not seen any positive elements (yet) NoPositiveElements, // The operator was unsupported on all elements that we have seen so far. // Contains the first error we encountered. - UnsupportedOnAllElements(CompareUnsupportedError<'db>), + UnsupportedOnAllElements(UnsupportedComparisonError<'db>), // The operator was supported on at least one positive element. Supported, } @@ -10263,7 +10252,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { right: Type<'db>, range: TextRange, visitor: &BinaryComparisonVisitor<'db>, - ) -> Result, CompareUnsupportedError<'db>> { + ) -> Result, UnsupportedComparisonError<'db>> { // Note: identity (is, is not) for equal builtin types is unreliable and not part of the // language spec. // - `[ast::CompOp::Is]`: return `false` if unequal, `bool` if equal @@ -10335,7 +10324,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { IntersectionOn::Left, range, visitor, - )) + ).map_err(|err|UnsupportedComparisonError { op, left_ty: left, right_ty: err.right_ty })) } (left, Type::Intersection(intersection)) => { Some(self.infer_binary_intersection_type_comparison( @@ -10345,7 +10334,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { IntersectionOn::Right, range, visitor, - )) + ).map_err(|err|UnsupportedComparisonError { op, left_ty: err.left_ty, right_ty: right })) } (Type::TypeAlias(alias), right) => Some( @@ -10392,7 +10381,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } // Undefined for (int, int) - ast::CmpOp::In | ast::CmpOp::NotIn => Err(CompareUnsupportedError { + ast::CmpOp::In | ast::CmpOp::NotIn => Err(UnsupportedComparisonError { op, left_ty: left, right_ty: right, @@ -10405,7 +10394,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { right, range, visitor, - )) + ).map_err(|_| UnsupportedComparisonError {op, left_ty: left, right_ty: right})) } (Type::NominalInstance(_), Type::IntLiteral(_)) => { Some(self.infer_binary_type_comparison( @@ -10414,7 +10403,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { KnownClass::Int.to_instance(self.db()), range, visitor, - )) + ).map_err(|_|UnsupportedComparisonError { op, left_ty: left, right_ty: right })) } // Booleans are coded as integers (False = 0, True = 1) @@ -10425,7 +10414,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { Type::IntLiteral(i64::from(b)), range, visitor, - )) + ).map_err(|_|UnsupportedComparisonError {op, left_ty: left, right_ty: right})) } (Type::BooleanLiteral(b), Type::IntLiteral(m)) => { Some(self.infer_binary_type_comparison( @@ -10434,7 +10423,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { Type::IntLiteral(m), range, visitor, - )) + ).map_err(|_|UnsupportedComparisonError {op, left_ty: left, right_ty: right})) } (Type::BooleanLiteral(a), Type::BooleanLiteral(b)) => { Some(self.infer_binary_type_comparison( @@ -10443,37 +10432,37 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { Type::IntLiteral(i64::from(b)), range, visitor, - )) + ).map_err(|_|UnsupportedComparisonError {op, left_ty: left, right_ty: right})) } (Type::StringLiteral(salsa_s1), Type::StringLiteral(salsa_s2)) => { let s1 = salsa_s1.value(self.db()); let s2 = salsa_s2.value(self.db()); let result = match op { - ast::CmpOp::Eq => Ok(Type::BooleanLiteral(s1 == s2)), - ast::CmpOp::NotEq => Ok(Type::BooleanLiteral(s1 != s2)), - ast::CmpOp::Lt => Ok(Type::BooleanLiteral(s1 < s2)), - ast::CmpOp::LtE => Ok(Type::BooleanLiteral(s1 <= s2)), - ast::CmpOp::Gt => Ok(Type::BooleanLiteral(s1 > s2)), - ast::CmpOp::GtE => Ok(Type::BooleanLiteral(s1 >= s2)), - ast::CmpOp::In => Ok(Type::BooleanLiteral(s2.contains(s1))), - ast::CmpOp::NotIn => Ok(Type::BooleanLiteral(!s2.contains(s1))), + ast::CmpOp::Eq => Type::BooleanLiteral(s1 == s2), + ast::CmpOp::NotEq => Type::BooleanLiteral(s1 != s2), + ast::CmpOp::Lt => Type::BooleanLiteral(s1 < s2), + ast::CmpOp::LtE => Type::BooleanLiteral(s1 <= s2), + ast::CmpOp::Gt => Type::BooleanLiteral(s1 > s2), + ast::CmpOp::GtE => Type::BooleanLiteral(s1 >= s2), + ast::CmpOp::In => Type::BooleanLiteral(s2.contains(s1)), + ast::CmpOp::NotIn => Type::BooleanLiteral(!s2.contains(s1)), ast::CmpOp::Is => { if s1 == s2 { - Ok(KnownClass::Bool.to_instance(self.db())) + KnownClass::Bool.to_instance(self.db()) } else { - Ok(Type::BooleanLiteral(false)) + Type::BooleanLiteral(false) } } ast::CmpOp::IsNot => { if s1 == s2 { - Ok(KnownClass::Bool.to_instance(self.db())) + KnownClass::Bool.to_instance(self.db()) } else { - Ok(Type::BooleanLiteral(true)) + Type::BooleanLiteral(true) } } }; - Some(result) + Some(Ok(result)) } (Type::StringLiteral(_), _) => Some(self.infer_binary_type_comparison( KnownClass::Str.to_instance(self.db()), @@ -10481,14 +10470,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { right, range, visitor, - )), + ).map_err(|err|UnsupportedComparisonError {op, left_ty: left, right_ty: err.right_ty})), (_, Type::StringLiteral(_)) => Some(self.infer_binary_type_comparison( left, op, KnownClass::Str.to_instance(self.db()), range, visitor, - )), + ).map_err(|err|UnsupportedComparisonError {op, left_ty: err.left_ty, right_ty: right})), (Type::LiteralString, _) => Some(self.infer_binary_type_comparison( KnownClass::Str.to_instance(self.db()), @@ -10496,47 +10485,47 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { right, range, visitor, - )), + ).map_err(|err|UnsupportedComparisonError {op, left_ty: left, right_ty: err.right_ty})), (_, Type::LiteralString) => Some(self.infer_binary_type_comparison( left, op, KnownClass::Str.to_instance(self.db()), range, visitor, - )), + ).map_err(|err|UnsupportedComparisonError {op, left_ty: err.left_ty, right_ty: right})), (Type::BytesLiteral(salsa_b1), Type::BytesLiteral(salsa_b2)) => { let b1 = salsa_b1.value(self.db()); let b2 = salsa_b2.value(self.db()); let result = match op { - ast::CmpOp::Eq => Ok(Type::BooleanLiteral(b1 == b2)), - ast::CmpOp::NotEq => Ok(Type::BooleanLiteral(b1 != b2)), - ast::CmpOp::Lt => Ok(Type::BooleanLiteral(b1 < b2)), - ast::CmpOp::LtE => Ok(Type::BooleanLiteral(b1 <= b2)), - ast::CmpOp::Gt => Ok(Type::BooleanLiteral(b1 > b2)), - ast::CmpOp::GtE => Ok(Type::BooleanLiteral(b1 >= b2)), + ast::CmpOp::Eq => Type::BooleanLiteral(b1 == b2), + ast::CmpOp::NotEq => Type::BooleanLiteral(b1 != b2), + ast::CmpOp::Lt => Type::BooleanLiteral(b1 < b2), + ast::CmpOp::LtE => Type::BooleanLiteral(b1 <= b2), + ast::CmpOp::Gt => Type::BooleanLiteral(b1 > b2), + ast::CmpOp::GtE => Type::BooleanLiteral(b1 >= b2), ast::CmpOp::In => { - Ok(Type::BooleanLiteral(memchr::memmem::find(b2, b1).is_some())) + Type::BooleanLiteral(memchr::memmem::find(b2, b1).is_some()) } ast::CmpOp::NotIn => { - Ok(Type::BooleanLiteral(memchr::memmem::find(b2, b1).is_none())) + Type::BooleanLiteral(memchr::memmem::find(b2, b1).is_none()) } ast::CmpOp::Is => { if b1 == b2 { - Ok(KnownClass::Bool.to_instance(self.db())) + KnownClass::Bool.to_instance(self.db()) } else { - Ok(Type::BooleanLiteral(false)) + Type::BooleanLiteral(false) } } ast::CmpOp::IsNot => { if b1 == b2 { - Ok(KnownClass::Bool.to_instance(self.db())) + KnownClass::Bool.to_instance(self.db()) } else { - Ok(Type::BooleanLiteral(true)) + Type::BooleanLiteral(true) } } }; - Some(result) + Some(Ok(result)) } (Type::BytesLiteral(_), _) => Some(self.infer_binary_type_comparison( KnownClass::Bytes.to_instance(self.db()), @@ -10544,14 +10533,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { right, range, visitor, - )), + ).map_err(|err| UnsupportedComparisonError { op, left_ty: left, right_ty: err.right_ty })), (_, Type::BytesLiteral(_)) => Some(self.infer_binary_type_comparison( left, op, KnownClass::Bytes.to_instance(self.db()), range, visitor, - )), + ).map_err(|err| UnsupportedComparisonError { op, left_ty: err.left_ty, right_ty: right })), (Type::EnumLiteral(literal_1), Type::EnumLiteral(literal_2)) if op == ast::CmpOp::Eq => @@ -10683,7 +10672,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { right: Type<'db>, op: RichCompareOperator, policy: MemberLookupPolicy, - ) -> Result, CompareUnsupportedError<'db>> { + ) -> Result, UnsupportedComparisonError<'db>> { let db = self.db(); // The following resource has details about the rich comparison algorithm: // https://snarky.ca/unravelling-rich-comparison-operators/ @@ -10719,7 +10708,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { None } }) - .ok_or_else(|| CompareUnsupportedError { + .ok_or_else(|| UnsupportedComparisonError { op: op.into(), left_ty: left, right_ty: right, @@ -10736,7 +10725,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { right: Type<'db>, op: MembershipTestCompareOperator, range: TextRange, - ) -> Result, CompareUnsupportedError<'db>> { + ) -> Result, UnsupportedComparisonError<'db>> { let db = self.db(); let contains_dunder = right.class_member(db, "__contains__".into()).place; @@ -10773,7 +10762,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { MembershipTestCompareOperator::NotIn => truthiness.negate().into_type(db), } }) - .ok_or_else(|| CompareUnsupportedError { + .ok_or_else(|| UnsupportedComparisonError { op: op.into(), left_ty: left, right_ty: right, @@ -10792,7 +10781,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { right: &TupleSpec<'db>, range: TextRange, visitor: &BinaryComparisonVisitor<'db>, - ) -> Result, CompareUnsupportedError<'db>> { + ) -> Result, UnsupportedComparisonError<'db>> { // If either tuple is variable length, we can make no assumptions about the relative // lengths of the tuples, and therefore neither about how they compare lexicographically. // TODO: Consider comparing the prefixes of the tuples, since that could give a comparison @@ -12382,11 +12371,22 @@ impl From for ast::CmpOp { } } +/// Context for a failed comparison operation. +/// +/// `left_ty` and `right_ty` are the "low-level" types +/// that cannot be compared using `op`. For example, +/// when evaluating `(1, "foo") < (2, 3)`, the "high-level" +/// types of the operands are `tuple[Literal[1], Literal["foo"]]` +/// and `tuple[Literal[2], Literal[3]]`. Those aren't captured +/// in this struct, but the "low-level" types that mean that +/// the high-level types cannot be compared *are* captured in +/// this struct. In this case, those would be `Literal["foo"]` +/// and `Literal[3]`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct CompareUnsupportedError<'db> { - op: ast::CmpOp, - left_ty: Type<'db>, - right_ty: Type<'db>, +pub(crate) struct UnsupportedComparisonError<'db> { + pub(crate) op: ast::CmpOp, + pub(crate) left_ty: Type<'db>, + pub(crate) right_ty: Type<'db>, } fn format_import_from_module(level: u32, module: Option<&str>) -> String { From 2250fa6f98f4fba4aaf17ff0a5e4f0be24419986 Mon Sep 17 00:00:00 2001 From: Brent Westbrook <36778786+ntBre@users.noreply.github.com> Date: Tue, 2 Dec 2025 16:02:02 -0500 Subject: [PATCH 2/4] Fix syntax error false positives for `await` outside functions (#21763) ## Summary Fixes #21750 and a related bug in `PLE1142`. We were not properly considering generators to be valid `await` contexts, which caused the `F704` issue. One of the tests I added for this also uncovered an issue in `PLE1142` for comprehensions nested within async generators because we were only checking the current scope rather than traversing the nested context. ## Test Plan Both of these rules are implemented as semantic syntax errors, so I added tests (and fixes) in both Ruff and ty. --- .../resources/test/fixtures/pyflakes/F704.py | 21 +++++ .../await_outside_async_function.py | 2 + crates/ruff_linter/src/checkers/ast/mod.rs | 23 +++-- ..._rules__pyflakes__tests__F704_F704.py.snap | 85 +++++++++++++++++++ ...ests__await_outside_async_function.py.snap | 2 + .../ruff_python_parser/src/semantic_errors.rs | 6 +- crates/ruff_python_parser/tests/fixtures.rs | 2 +- .../diagnostics/semantic_syntax_errors.md | 4 + .../src/semantic_index/builder.rs | 18 ++-- 9 files changed, 147 insertions(+), 16 deletions(-) diff --git a/crates/ruff_linter/resources/test/fixtures/pyflakes/F704.py b/crates/ruff_linter/resources/test/fixtures/pyflakes/F704.py index 70a1272d42..819c59c4b9 100644 --- a/crates/ruff_linter/resources/test/fixtures/pyflakes/F704.py +++ b/crates/ruff_linter/resources/test/fixtures/pyflakes/F704.py @@ -17,3 +17,24 @@ def _(): # Valid yield scope yield 3 + + +# await is valid in any generator, sync or async +(await cor async for cor in f()) # ok +(await cor for cor in f()) # ok + +# but not in comprehensions +[await cor async for cor in f()] # F704 +{await cor async for cor in f()} # F704 +{await cor: 1 async for cor in f()} # F704 +[await cor for cor in f()] # F704 +{await cor for cor in f()} # F704 +{await cor: 1 for cor in f()} # F704 + +# or in the iterator of an async generator, which is evaluated in the parent +# scope +(cor async for cor in await f()) # F704 +(await cor async for cor in [await c for c in f()]) # F704 + +# this is also okay because the comprehension is within the generator scope +([await c for c in cor] async for cor in f()) # ok diff --git a/crates/ruff_linter/resources/test/fixtures/syntax_errors/await_outside_async_function.py b/crates/ruff_linter/resources/test/fixtures/syntax_errors/await_outside_async_function.py index dd49cb05f9..6285677609 100644 --- a/crates/ruff_linter/resources/test/fixtures/syntax_errors/await_outside_async_function.py +++ b/crates/ruff_linter/resources/test/fixtures/syntax_errors/await_outside_async_function.py @@ -3,3 +3,5 @@ def func(): # Top-level await await 1 + +([await c for c in cor] async for cor in func()) # ok diff --git a/crates/ruff_linter/src/checkers/ast/mod.rs b/crates/ruff_linter/src/checkers/ast/mod.rs index 8c360448a4..c1d98b6d50 100644 --- a/crates/ruff_linter/src/checkers/ast/mod.rs +++ b/crates/ruff_linter/src/checkers/ast/mod.rs @@ -780,6 +780,10 @@ impl SemanticSyntaxContext for Checker<'_> { match scope.kind { ScopeKind::Class(_) => return false, ScopeKind::Function(_) | ScopeKind::Lambda(_) => return true, + ScopeKind::Generator { + kind: GeneratorKind::Generator, + .. + } => return true, ScopeKind::Generator { .. } | ScopeKind::Module | ScopeKind::Type @@ -829,14 +833,19 @@ impl SemanticSyntaxContext for Checker<'_> { self.source_type.is_ipynb() } - fn in_generator_scope(&self) -> bool { - matches!( - &self.semantic.current_scope().kind, - ScopeKind::Generator { - kind: GeneratorKind::Generator, - .. + fn in_generator_context(&self) -> bool { + for scope in self.semantic.current_scopes() { + if matches!( + scope.kind, + ScopeKind::Generator { + kind: GeneratorKind::Generator, + .. + } + ) { + return true; } - ) + } + false } fn in_loop_context(&self) -> bool { diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F704_F704.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F704_F704.py.snap index 25a0fe321a..8d3bff4284 100644 --- a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F704_F704.py.snap +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F704_F704.py.snap @@ -37,3 +37,88 @@ F704 `await` statement outside of a function 12 | 13 | def _(): | + +F704 `await` statement outside of a function + --> F704.py:27:2 + | +26 | # but not in comprehensions +27 | [await cor async for cor in f()] # F704 + | ^^^^^^^^^ +28 | {await cor async for cor in f()} # F704 +29 | {await cor: 1 async for cor in f()} # F704 + | + +F704 `await` statement outside of a function + --> F704.py:28:2 + | +26 | # but not in comprehensions +27 | [await cor async for cor in f()] # F704 +28 | {await cor async for cor in f()} # F704 + | ^^^^^^^^^ +29 | {await cor: 1 async for cor in f()} # F704 +30 | [await cor for cor in f()] # F704 + | + +F704 `await` statement outside of a function + --> F704.py:29:2 + | +27 | [await cor async for cor in f()] # F704 +28 | {await cor async for cor in f()} # F704 +29 | {await cor: 1 async for cor in f()} # F704 + | ^^^^^^^^^ +30 | [await cor for cor in f()] # F704 +31 | {await cor for cor in f()} # F704 + | + +F704 `await` statement outside of a function + --> F704.py:30:2 + | +28 | {await cor async for cor in f()} # F704 +29 | {await cor: 1 async for cor in f()} # F704 +30 | [await cor for cor in f()] # F704 + | ^^^^^^^^^ +31 | {await cor for cor in f()} # F704 +32 | {await cor: 1 for cor in f()} # F704 + | + +F704 `await` statement outside of a function + --> F704.py:31:2 + | +29 | {await cor: 1 async for cor in f()} # F704 +30 | [await cor for cor in f()] # F704 +31 | {await cor for cor in f()} # F704 + | ^^^^^^^^^ +32 | {await cor: 1 for cor in f()} # F704 + | + +F704 `await` statement outside of a function + --> F704.py:32:2 + | +30 | [await cor for cor in f()] # F704 +31 | {await cor for cor in f()} # F704 +32 | {await cor: 1 for cor in f()} # F704 + | ^^^^^^^^^ +33 | +34 | # or in the iterator of an async generator, which is evaluated in the parent + | + +F704 `await` statement outside of a function + --> F704.py:36:23 + | +34 | # or in the iterator of an async generator, which is evaluated in the parent +35 | # scope +36 | (cor async for cor in await f()) # F704 + | ^^^^^^^^^ +37 | (await cor async for cor in [await c for c in f()]) # F704 + | + +F704 `await` statement outside of a function + --> F704.py:37:30 + | +35 | # scope +36 | (cor async for cor in await f()) # F704 +37 | (await cor async for cor in [await c for c in f()]) # F704 + | ^^^^^^^ +38 | +39 | # this is also okay because the comprehension is within the generator scope + | diff --git a/crates/ruff_linter/src/snapshots/ruff_linter__linter__tests__await_outside_async_function.py.snap b/crates/ruff_linter/src/snapshots/ruff_linter__linter__tests__await_outside_async_function.py.snap index fbab466fc5..e6e38743fa 100644 --- a/crates/ruff_linter/src/snapshots/ruff_linter__linter__tests__await_outside_async_function.py.snap +++ b/crates/ruff_linter/src/snapshots/ruff_linter__linter__tests__await_outside_async_function.py.snap @@ -17,4 +17,6 @@ PLE1142 `await` should be used within an async function 4 | # Top-level await 5 | await 1 | ^^^^^^^ +6 | +7 | ([await c for c in cor] async for cor in func()) # ok | diff --git a/crates/ruff_python_parser/src/semantic_errors.rs b/crates/ruff_python_parser/src/semantic_errors.rs index a9bc9c9101..cd7335bdbe 100644 --- a/crates/ruff_python_parser/src/semantic_errors.rs +++ b/crates/ruff_python_parser/src/semantic_errors.rs @@ -896,7 +896,7 @@ impl SemanticSyntaxChecker { // This check is required in addition to avoiding calling this function in `visit_expr` // because the generator scope applies to nested parts of the `Expr::Generator` that are // visited separately. - if ctx.in_generator_scope() { + if ctx.in_generator_context() { return; } Self::add_error( @@ -2096,11 +2096,11 @@ pub trait SemanticSyntaxContext { /// Returns `true` if the visitor is in a function scope. fn in_function_scope(&self) -> bool; - /// Returns `true` if the visitor is in a generator scope. + /// Returns `true` if the visitor is within a generator scope. /// /// Note that this refers to an `Expr::Generator` precisely, not to comprehensions more /// generally. - fn in_generator_scope(&self) -> bool; + fn in_generator_context(&self) -> bool; /// Returns `true` if the source file is a Jupyter notebook. fn in_notebook(&self) -> bool; diff --git a/crates/ruff_python_parser/tests/fixtures.rs b/crates/ruff_python_parser/tests/fixtures.rs index 8f9a2994db..2c89ba7aad 100644 --- a/crates/ruff_python_parser/tests/fixtures.rs +++ b/crates/ruff_python_parser/tests/fixtures.rs @@ -573,7 +573,7 @@ impl SemanticSyntaxContext for SemanticSyntaxCheckerVisitor<'_> { true } - fn in_generator_scope(&self) -> bool { + fn in_generator_context(&self) -> bool { true } diff --git a/crates/ty_python_semantic/resources/mdtest/diagnostics/semantic_syntax_errors.md b/crates/ty_python_semantic/resources/mdtest/diagnostics/semantic_syntax_errors.md index f1c043478b..b4e0b1ae24 100644 --- a/crates/ty_python_semantic/resources/mdtest/diagnostics/semantic_syntax_errors.md +++ b/crates/ty_python_semantic/resources/mdtest/diagnostics/semantic_syntax_errors.md @@ -143,6 +143,10 @@ await C() def f(): # error: [invalid-syntax] "`await` outside of an asynchronous function" await C() + +(await cor async for cor in f()) # ok +(await cor for cor in f()) # ok +([await c for c in cor] async for cor in f()) # ok ``` Generators are evaluated lazily, so `await` is allowed, even outside of a function. diff --git a/crates/ty_python_semantic/src/semantic_index/builder.rs b/crates/ty_python_semantic/src/semantic_index/builder.rs index b729862f2b..66a0f6f428 100644 --- a/crates/ty_python_semantic/src/semantic_index/builder.rs +++ b/crates/ty_python_semantic/src/semantic_index/builder.rs @@ -2845,6 +2845,11 @@ impl SemanticSyntaxContext for SemanticIndexBuilder<'_, '_> { match scope.kind() { ScopeKind::Class => return false, ScopeKind::Function | ScopeKind::Lambda => return true, + ScopeKind::Comprehension + if matches!(scope.node(), NodeWithScopeKind::GeneratorExpression(_)) => + { + return true; + } ScopeKind::Comprehension | ScopeKind::Module | ScopeKind::TypeAlias @@ -2894,11 +2899,14 @@ impl SemanticSyntaxContext for SemanticIndexBuilder<'_, '_> { matches!(kind, ScopeKind::Function | ScopeKind::Lambda) } - fn in_generator_scope(&self) -> bool { - matches!( - self.scopes[self.current_scope()].node(), - NodeWithScopeKind::GeneratorExpression(_) - ) + fn in_generator_context(&self) -> bool { + for scope_info in &self.scope_stack { + let scope = &self.scopes[scope_info.file_scope_id]; + if matches!(scope.node(), NodeWithScopeKind::GeneratorExpression(_)) { + return true; + } + } + false } fn in_notebook(&self) -> bool { From 7b0aab1696c252b43da75102c92e7c180a8d34f6 Mon Sep 17 00:00:00 2001 From: Ibraheem Ahmed Date: Tue, 2 Dec 2025 18:25:09 -0500 Subject: [PATCH 3/4] [ty] `type[T]` is assignable to an inferable typevar (#21766) ## Summary Resolves https://github.com/astral-sh/ty/issues/1712. --- .../resources/mdtest/type_of/generics.md | 11 ++ crates/ty_python_semantic/src/types.rs | 125 ++++++++++-------- 2 files changed, 84 insertions(+), 52 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/type_of/generics.md b/crates/ty_python_semantic/resources/mdtest/type_of/generics.md index ee998bbb1b..3c8f157dad 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_of/generics.md +++ b/crates/ty_python_semantic/resources/mdtest/type_of/generics.md @@ -227,6 +227,17 @@ def _[T: (int | str, int)](_: T): static_assert(not is_disjoint_from(type[int], type[T])) ``` +```py +class X[T]: + value: T + + def get(self) -> T: + return self.value + +def _[T](x: X[type[T]]): + reveal_type(x.get()) # revealed: type[T@_] +``` + ## Generic Type Inference ```py diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 7c65a4715e..e64d3802d5 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -2105,33 +2105,52 @@ impl<'db> Type<'db> { }) .is_never_satisfied(db) => { - // TODO: The repetition here isn't great, but we really need the fallthrough logic, - // where this arm only engages if it returns true. - let this_instance = Type::TypeVar(subclass_of.into_type_var().unwrap()); - target.to_instance(db).when_some_and(|other_instance| { - this_instance.has_relation_to_impl( - db, - other_instance, - inferable, - relation, - relation_visitor, - disjointness_visitor, - ) - }) + // TODO: The repetition here isn't great, but we need the fallthrough logic. + subclass_of + .into_type_var() + .zip(target.to_instance(db)) + .when_some_and(|(this_instance, other_instance)| { + Type::TypeVar(this_instance).has_relation_to_impl( + db, + other_instance, + inferable, + relation, + relation_visitor, + disjointness_visitor, + ) + }) } - (_, Type::SubclassOf(subclass_of)) if subclass_of.is_type_var() => { - let other_instance = Type::TypeVar(subclass_of.into_type_var().unwrap()); - self.to_instance(db).when_some_and(|this_instance| { - this_instance.has_relation_to_impl( - db, - other_instance, - inferable, - relation, - relation_visitor, - disjointness_visitor, - ) - }) + (_, Type::SubclassOf(subclass_of)) + if !subclass_of + .into_type_var() + .zip(self.to_instance(db)) + .when_some_and(|(other_instance, this_instance)| { + this_instance.has_relation_to_impl( + db, + Type::TypeVar(other_instance), + inferable, + relation, + relation_visitor, + disjointness_visitor, + ) + }) + .is_never_satisfied(db) => + { + // TODO: The repetition here isn't great, but we need the fallthrough logic. + subclass_of + .into_type_var() + .zip(self.to_instance(db)) + .when_some_and(|(other_instance, this_instance)| { + this_instance.has_relation_to_impl( + db, + Type::TypeVar(other_instance), + inferable, + relation, + relation_visitor, + disjointness_visitor, + ) + }) } // A fully static typevar is a subtype of its upper bound, and to something similar to @@ -2656,7 +2675,9 @@ impl<'db> Type<'db> { disjointness_visitor, ), - (Type::SubclassOf(subclass_of), _) if subclass_of.is_type_var() => { + (Type::SubclassOf(subclass_of), _) | (_, Type::SubclassOf(subclass_of)) + if subclass_of.is_type_var() => + { ConstraintSet::from(false) } @@ -3116,33 +3137,33 @@ impl<'db> Type<'db> { // `type[T]` is disjoint from a class object `A` if every instance of `T` is disjoint from an instance of `A`. (Type::SubclassOf(subclass_of), other) | (other, Type::SubclassOf(subclass_of)) - if subclass_of.is_type_var() - && (other.to_instance(db).is_some() - || other.as_typevar().is_some_and(|type_var| { - type_var.typevar(db).bound_or_constraints(db).is_none() - })) => + if !subclass_of + .into_type_var() + .zip(other.to_instance(db)) + .when_none_or(|(this_instance, other_instance)| { + Type::TypeVar(this_instance).is_disjoint_from_impl( + db, + other_instance, + inferable, + disjointness_visitor, + relation_visitor, + ) + }) + .is_always_satisfied(db) => { - let this_instance = Type::TypeVar(subclass_of.into_type_var().unwrap()); - let other_instance = match other { - // An unbounded typevar `U` may have instances of type `object` if specialized to - // an instance of `type`. - Type::TypeVar(typevar) - if typevar.typevar(db).bound_or_constraints(db).is_none() => - { - Some(Type::object()) - } - _ => other.to_instance(db), - }; - - other_instance.when_none_or(|other_instance| { - this_instance.is_disjoint_from_impl( - db, - other_instance, - inferable, - disjointness_visitor, - relation_visitor, - ) - }) + // TODO: The repetition here isn't great, but we need the fallthrough logic. + subclass_of + .into_type_var() + .zip(other.to_instance(db)) + .when_none_or(|(this_instance, other_instance)| { + Type::TypeVar(this_instance).is_disjoint_from_impl( + db, + other_instance, + inferable, + disjointness_visitor, + relation_visitor, + ) + }) } // A typevar is never disjoint from itself, since all occurrences of the typevar must From abaa49f55296b1b895dd7e52fcfc51d2bf36dfd2 Mon Sep 17 00:00:00 2001 From: Amethyst Reese Date: Tue, 2 Dec 2025 15:39:59 -0800 Subject: [PATCH 4/4] new module for parsing ranged suppressions (#21441) This adds a new `suppression` module to the `ruff_linter` crate, similar to the suppression module for ty, to parse comments for ruff suppression directives, such as `# ruff: disable[CODE]`. --- Cargo.lock | 1 + crates/ruff_linter/Cargo.toml | 1 + crates/ruff_linter/src/lib.rs | 1 + crates/ruff_linter/src/suppression.rs | 1531 +++++++++++++++++++++++++ 4 files changed, 1534 insertions(+) create mode 100644 crates/ruff_linter/src/suppression.rs diff --git a/Cargo.lock b/Cargo.lock index fa2b9e4252..2dc5a258c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3124,6 +3124,7 @@ dependencies = [ "bitflags 2.10.0", "clap", "colored 3.0.0", + "compact_str", "fern", "glob", "globset", diff --git a/crates/ruff_linter/Cargo.toml b/crates/ruff_linter/Cargo.toml index 1f5f42a524..786007b016 100644 --- a/crates/ruff_linter/Cargo.toml +++ b/crates/ruff_linter/Cargo.toml @@ -35,6 +35,7 @@ anyhow = { workspace = true } bitflags = { workspace = true } clap = { workspace = true, features = ["derive", "string"], optional = true } colored = { workspace = true } +compact_str = { workspace = true } fern = { workspace = true } glob = { workspace = true } globset = { workspace = true } diff --git a/crates/ruff_linter/src/lib.rs b/crates/ruff_linter/src/lib.rs index eaafd7a526..24f0808ee4 100644 --- a/crates/ruff_linter/src/lib.rs +++ b/crates/ruff_linter/src/lib.rs @@ -46,6 +46,7 @@ pub mod rule_selector; pub mod rules; pub mod settings; pub mod source_kind; +pub mod suppression; mod text_helpers; pub mod upstream_categories; mod violation; diff --git a/crates/ruff_linter/src/suppression.rs b/crates/ruff_linter/src/suppression.rs new file mode 100644 index 0000000000..66ad98d25e --- /dev/null +++ b/crates/ruff_linter/src/suppression.rs @@ -0,0 +1,1531 @@ +use compact_str::CompactString; +use core::fmt; +use ruff_python_ast::token::{TokenKind, Tokens}; +use ruff_python_ast::whitespace::indentation; +use std::{error::Error, fmt::Formatter}; +use thiserror::Error; + +use ruff_python_trivia::Cursor; +use ruff_text_size::{Ranged, TextLen, TextRange, TextSize, TextSlice}; +use smallvec::{SmallVec, smallvec}; + +#[allow(unused)] +#[derive(Clone, Debug, Eq, PartialEq)] +enum SuppressionAction { + Disable, + Enable, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct SuppressionComment { + /// Range containing the entire suppression comment + range: TextRange, + + /// The action directive + action: SuppressionAction, + + /// Ranges containing the lint codes being suppressed + codes: SmallVec<[TextRange; 2]>, + + /// Range containing the reason for the suppression + reason: TextRange, +} + +#[allow(unused)] +impl SuppressionComment { + /// Return the suppressed codes as strings + fn codes_as_str<'src>(&self, source: &'src str) -> impl Iterator { + self.codes.iter().map(|range| source.slice(range)) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PendingSuppressionComment<'a> { + /// How indented an own-line comment is, or None for trailing comments + indent: &'a str, + + /// The suppression comment + comment: SuppressionComment, +} + +#[allow(unused)] +impl PendingSuppressionComment<'_> { + /// Whether the comment "matches" another comment, based on indentation and suppressed codes + /// Expects a "forward search" for matches, ie, will only match if the current comment is a + /// "disable" comment and other is the matching "enable" comment. + fn matches(&self, other: &PendingSuppressionComment, source: &str) -> bool { + self.comment.action == SuppressionAction::Disable + && other.comment.action == SuppressionAction::Enable + && self.indent == other.indent + && self + .comment + .codes_as_str(source) + .eq(other.comment.codes_as_str(source)) + } +} + +#[allow(unused)] +#[derive(Clone, Debug)] +pub(crate) struct Suppression { + /// The lint code being suppressed + code: CompactString, + + /// Range for which the suppression applies + range: TextRange, + + /// Any comments associated with the suppression + comments: SmallVec<[SuppressionComment; 2]>, +} + +#[allow(unused)] +#[derive(Copy, Clone, Debug)] +pub(crate) enum InvalidSuppressionKind { + /// Trailing suppression not supported + Trailing, + + /// No matching enable or disable suppression found + Unmatched, + + /// Suppression does not match surrounding indentation + Indentation, +} + +#[allow(unused)] +#[derive(Clone, Debug)] +pub(crate) struct InvalidSuppression { + kind: InvalidSuppressionKind, + comment: SuppressionComment, +} + +#[allow(unused)] +#[derive(Debug)] +pub(crate) struct Suppressions { + /// Valid suppression ranges with associated comments + valid: Vec, + + /// Invalid suppression comments + invalid: Vec, + + /// Parse errors from suppression comments + errors: Vec, +} + +#[allow(unused)] +impl Suppressions { + pub(crate) fn from_tokens(source: &str, tokens: &Tokens) -> Suppressions { + let builder = SuppressionsBuilder::new(source); + builder.load_from_tokens(tokens) + } +} + +#[derive(Default)] +pub(crate) struct SuppressionsBuilder<'a> { + source: &'a str, + + valid: Vec, + invalid: Vec, + errors: Vec, + + pending: Vec>, +} + +impl<'a> SuppressionsBuilder<'a> { + pub(crate) fn new(source: &'a str) -> Self { + Self { + source, + ..Default::default() + } + } + + pub(crate) fn load_from_tokens(mut self, tokens: &Tokens) -> Suppressions { + let default_indent = ""; + let mut indents: Vec<&str> = vec![]; + + // Iterate through tokens, tracking indentation, filtering trailing comments, and then + // looking for matching comments from the previous block when reaching a dedent token. + for (token_index, token) in tokens.iter().enumerate() { + match token.kind() { + TokenKind::Indent => { + indents.push(self.source.slice(token)); + } + TokenKind::Dedent => { + self.match_comments(indents.last().copied().unwrap_or_default(), token.range()); + indents.pop(); + } + TokenKind::Comment => { + let mut parser = SuppressionParser::new(self.source, token.range()); + match parser.parse_comment() { + Ok(comment) => { + let indent = indentation(self.source, &comment.range); + + let Some(indent) = indent else { + // trailing suppressions are not supported + self.invalid.push(InvalidSuppression { + kind: InvalidSuppressionKind::Trailing, + comment, + }); + continue; + }; + + // comment matches current block's indentation, or precedes an indent/dedent token + if indent == indents.last().copied().unwrap_or_default() + || tokens[token_index..] + .iter() + .find(|t| !t.kind().is_trivia()) + .is_some_and(|t| { + matches!(t.kind(), TokenKind::Dedent | TokenKind::Indent) + }) + { + self.pending + .push(PendingSuppressionComment { indent, comment }); + } else { + // weirdly indented? ¯\_(ツ)_/¯ + self.invalid.push(InvalidSuppression { + kind: InvalidSuppressionKind::Indentation, + comment, + }); + } + } + Err(ParseError { + kind: ParseErrorKind::NotASuppression, + .. + }) => {} + Err(error) => { + self.errors.push(error); + } + } + } + _ => {} + } + } + + self.match_comments(default_indent, TextRange::up_to(self.source.text_len())); + + Suppressions { + valid: self.valid, + invalid: self.invalid, + errors: self.errors, + } + } + + fn match_comments(&mut self, current_indent: &str, dedent_range: TextRange) { + let mut comment_index = 0; + + // for each pending comment, search for matching comments at the same indentation level, + // generate range suppressions for any matches, and then discard any unmatched comments + // from the outgoing indentation block + while comment_index < self.pending.len() { + let comment = &self.pending[comment_index]; + + // skip comments from an outer indentation level + if comment.indent.text_len() < current_indent.text_len() { + comment_index += 1; + continue; + } + + // find the first matching comment + if let Some(other_index) = self.pending[comment_index + 1..] + .iter() + .position(|other| comment.matches(other, self.source)) + { + // offset from current candidate + let other_index = comment_index + 1 + other_index; + let other = &self.pending[other_index]; + + // record a combined range suppression from the matching comments + let combined_range = + TextRange::new(comment.comment.range.start(), other.comment.range.end()); + for code in comment.comment.codes_as_str(self.source) { + self.valid.push(Suppression { + code: code.into(), + range: combined_range, + comments: smallvec![comment.comment.clone(), other.comment.clone()], + }); + } + + // remove both comments from further consideration + self.pending.remove(other_index); + self.pending.remove(comment_index); + } else if matches!(comment.comment.action, SuppressionAction::Disable) { + // treat "disable" comments without a matching "enable" as *implicitly* matched + // to the end of the current indentation level + let implicit_range = + TextRange::new(comment.comment.range.start(), dedent_range.end()); + for code in comment.comment.codes_as_str(self.source) { + self.valid.push(Suppression { + code: code.into(), + range: implicit_range, + comments: smallvec![comment.comment.clone()], + }); + } + self.pending.remove(comment_index); + } else { + self.invalid.push(InvalidSuppression { + kind: InvalidSuppressionKind::Unmatched, + comment: self.pending.remove(comment_index).comment.clone(), + }); + } + } + } +} + +#[derive(Copy, Clone, Debug, Eq, Error, PartialEq)] +enum ParseErrorKind { + #[error("not a suppression comment")] + NotASuppression, + + #[error("comment doesn't start with `#`")] + CommentWithoutHash, + + #[error("unknown ruff directive")] + UnknownAction, + + #[error("missing suppression codes")] + MissingCodes, + + #[error("missing closing bracket")] + MissingBracket, + + #[error("missing comma between codes")] + MissingComma, + + #[error("invalid error code")] + InvalidCode, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ParseError { + kind: ParseErrorKind, + range: TextRange, +} + +impl ParseError { + fn new(kind: ParseErrorKind, range: TextRange) -> Self { + Self { kind, range } + } +} + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + self.kind.fmt(f) + } +} + +impl Error for ParseError {} + +struct SuppressionParser<'src> { + cursor: Cursor<'src>, + range: TextRange, +} + +impl<'src> SuppressionParser<'src> { + fn new(source: &'src str, range: TextRange) -> Self { + let cursor = Cursor::new(&source[range]); + Self { cursor, range } + } + + fn parse_comment(&mut self) -> Result { + self.cursor.start_token(); + + if !self.cursor.eat_char('#') { + return self.error(ParseErrorKind::CommentWithoutHash); + } + + self.eat_whitespace(); + + let action = self.eat_action()?; + let codes = self.eat_codes()?; + if codes.is_empty() { + return Err(ParseError::new(ParseErrorKind::MissingCodes, self.range)); + } + + self.eat_whitespace(); + let reason = TextRange::new(self.offset(), self.range.end()); + + Ok(SuppressionComment { + range: self.range, + action, + codes, + reason, + }) + } + + fn eat_action(&mut self) -> Result { + if !self.cursor.as_str().starts_with("ruff") { + return self.error(ParseErrorKind::NotASuppression); + } + + self.cursor.skip_bytes("ruff".len()); + self.eat_whitespace(); + + if !self.cursor.eat_char(':') { + return self.error(ParseErrorKind::NotASuppression); + } + self.eat_whitespace(); + + if self.cursor.as_str().starts_with("disable") { + self.cursor.skip_bytes("disable".len()); + Ok(SuppressionAction::Disable) + } else if self.cursor.as_str().starts_with("enable") { + self.cursor.skip_bytes("enable".len()); + Ok(SuppressionAction::Enable) + } else if self.cursor.as_str().starts_with("noqa") { + // file-level "noqa" variant, ignore for now + self.error(ParseErrorKind::NotASuppression) + } else { + self.error(ParseErrorKind::UnknownAction) + } + } + + fn eat_codes(&mut self) -> Result, ParseError> { + self.eat_whitespace(); + if !self.cursor.eat_char('[') { + return self.error(ParseErrorKind::MissingCodes); + } + + let mut codes: SmallVec<[TextRange; 2]> = smallvec![]; + + loop { + if self.cursor.is_eof() { + return self.error(ParseErrorKind::MissingBracket); + } + + self.eat_whitespace(); + + if self.cursor.eat_char(']') { + break Ok(codes); + } + + let code_start = self.offset(); + if !self.eat_word() { + return self.error(ParseErrorKind::InvalidCode); + } + + codes.push(TextRange::new(code_start, self.offset())); + + self.eat_whitespace(); + if !self.cursor.eat_char(',') { + if self.cursor.eat_char(']') { + break Ok(codes); + } + + return if self.cursor.is_eof() { + self.error(ParseErrorKind::MissingBracket) + } else { + self.error(ParseErrorKind::MissingComma) + }; + } + } + } + + fn eat_whitespace(&mut self) -> bool { + if self.cursor.eat_if(char::is_whitespace) { + self.cursor.eat_while(char::is_whitespace); + true + } else { + false + } + } + + fn eat_word(&mut self) -> bool { + if self.cursor.eat_if(char::is_alphabetic) { + // Allow `:` for better error recovery when someone uses `lint:code` instead of just `code`. + self.cursor + .eat_while(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | ':')); + true + } else { + false + } + } + + fn offset(&self) -> TextSize { + self.range.start() + self.range.len() - self.cursor.text_len() + } + + fn error(&self, kind: ParseErrorKind) -> Result { + Err(ParseError::new(kind, self.range)) + } +} + +#[cfg(test)] +mod tests { + use std::fmt::{self, Formatter}; + + use insta::assert_debug_snapshot; + use itertools::Itertools; + use ruff_python_parser::{Mode, ParseOptions, parse}; + use ruff_text_size::{TextRange, TextSize}; + use similar::DiffableStr; + + use crate::suppression::{ + InvalidSuppression, ParseError, Suppression, SuppressionAction, SuppressionComment, + SuppressionParser, Suppressions, + }; + + #[test] + fn no_suppression() { + let source = " +# this is a comment +print('hello') +"; + assert_debug_snapshot!( + Suppressions::debug(source), + @r" + Suppressions { + valid: [], + invalid: [], + errors: [], + } + ", + ); + } + + #[test] + fn file_level_suppression() { + let source = " +# ruff: noqa F401 +print('hello') +"; + assert_debug_snapshot!( + Suppressions::debug(source), + @r" + Suppressions { + valid: [], + invalid: [], + errors: [], + } + ", + ); + } + + #[test] + fn single_range_suppression() { + let source = " +# ruff: disable[foo] +print('hello') +# ruff: enable[foo] +"; + assert_debug_snapshot!( + Suppressions::debug(source), + @r##" + Suppressions { + valid: [ + Suppression { + covered_source: "# ruff: disable[foo]\nprint('hello')\n# ruff: enable[foo]", + code: "foo", + comments: [ + SuppressionComment { + text: "# ruff: disable[foo]", + action: Disable, + codes: [ + "foo", + ], + reason: "", + }, + SuppressionComment { + text: "# ruff: enable[foo]", + action: Enable, + codes: [ + "foo", + ], + reason: "", + }, + ], + }, + ], + invalid: [], + errors: [], + } + "##, + ); + } + + #[test] + fn single_range_suppression_implicit_match() { + let source = " +# ruff: disable[foo] +print('hello') + +def foo(): + # ruff: disable[bar] + print('hello') + +"; + assert_debug_snapshot!( + Suppressions::debug(source), + @r##" + Suppressions { + valid: [ + Suppression { + covered_source: "# ruff: disable[bar]\n print('hello')\n\n", + code: "bar", + comments: [ + SuppressionComment { + text: "# ruff: disable[bar]", + action: Disable, + codes: [ + "bar", + ], + reason: "", + }, + ], + }, + Suppression { + covered_source: "# ruff: disable[foo]\nprint('hello')\n\ndef foo():\n # ruff: disable[bar]\n print('hello')\n\n", + code: "foo", + comments: [ + SuppressionComment { + text: "# ruff: disable[foo]", + action: Disable, + codes: [ + "foo", + ], + reason: "", + }, + ], + }, + ], + invalid: [], + errors: [], + } + "##, + ); + } + + #[test] + fn nested_range_suppressions() { + let source = " +class Foo: + # ruff: disable[foo] + def bar(self): + # ruff: disable[bar] + print('hello') + # ruff: enable[bar] + # ruff: enable[foo] +"; + assert_debug_snapshot!( + Suppressions::debug(source), + @r##" + Suppressions { + valid: [ + Suppression { + covered_source: "# ruff: disable[bar]\n print('hello')\n # ruff: enable[bar]", + code: "bar", + comments: [ + SuppressionComment { + text: "# ruff: disable[bar]", + action: Disable, + codes: [ + "bar", + ], + reason: "", + }, + SuppressionComment { + text: "# ruff: enable[bar]", + action: Enable, + codes: [ + "bar", + ], + reason: "", + }, + ], + }, + Suppression { + covered_source: "# ruff: disable[foo]\n def bar(self):\n # ruff: disable[bar]\n print('hello')\n # ruff: enable[bar]\n # ruff: enable[foo]", + code: "foo", + comments: [ + SuppressionComment { + text: "# ruff: disable[foo]", + action: Disable, + codes: [ + "foo", + ], + reason: "", + }, + SuppressionComment { + text: "# ruff: enable[foo]", + action: Enable, + codes: [ + "foo", + ], + reason: "", + }, + ], + }, + ], + invalid: [], + errors: [], + } + "##, + ); + } + + #[test] + fn interleaved_range_suppressions() { + let source = " +def foo(): + # ruff: disable[foo] + print('hello') + # ruff: disable[bar] + print('hello') + # ruff: enable[foo] + print('hello') + # ruff: enable[bar] +"; + assert_debug_snapshot!( + Suppressions::debug(source), + @r##" + Suppressions { + valid: [ + Suppression { + covered_source: "# ruff: disable[foo]\n print('hello')\n # ruff: disable[bar]\n print('hello')\n # ruff: enable[foo]", + code: "foo", + comments: [ + SuppressionComment { + text: "# ruff: disable[foo]", + action: Disable, + codes: [ + "foo", + ], + reason: "", + }, + SuppressionComment { + text: "# ruff: enable[foo]", + action: Enable, + codes: [ + "foo", + ], + reason: "", + }, + ], + }, + Suppression { + covered_source: "# ruff: disable[bar]\n print('hello')\n # ruff: enable[foo]\n print('hello')\n # ruff: enable[bar]", + code: "bar", + comments: [ + SuppressionComment { + text: "# ruff: disable[bar]", + action: Disable, + codes: [ + "bar", + ], + reason: "", + }, + SuppressionComment { + text: "# ruff: enable[bar]", + action: Enable, + codes: [ + "bar", + ], + reason: "", + }, + ], + }, + ], + invalid: [], + errors: [], + } + "##, + ); + } + + #[test] + fn range_suppression_two_codes() { + let source = " +# ruff: disable[foo, bar] +print('hello') +# ruff: enable[foo, bar] +"; + assert_debug_snapshot!( + Suppressions::debug(source), + @r##" + Suppressions { + valid: [ + Suppression { + covered_source: "# ruff: disable[foo, bar]\nprint('hello')\n# ruff: enable[foo, bar]", + code: "foo", + comments: [ + SuppressionComment { + text: "# ruff: disable[foo, bar]", + action: Disable, + codes: [ + "foo", + "bar", + ], + reason: "", + }, + SuppressionComment { + text: "# ruff: enable[foo, bar]", + action: Enable, + codes: [ + "foo", + "bar", + ], + reason: "", + }, + ], + }, + Suppression { + covered_source: "# ruff: disable[foo, bar]\nprint('hello')\n# ruff: enable[foo, bar]", + code: "bar", + comments: [ + SuppressionComment { + text: "# ruff: disable[foo, bar]", + action: Disable, + codes: [ + "foo", + "bar", + ], + reason: "", + }, + SuppressionComment { + text: "# ruff: enable[foo, bar]", + action: Enable, + codes: [ + "foo", + "bar", + ], + reason: "", + }, + ], + }, + ], + invalid: [], + errors: [], + } + "##, + ); + } + + #[test] + fn range_suppression_unmatched() { + let source = " +# ruff: disable[foo] +print('hello') +# ruff: enable[bar] +print('world') +"; + assert_debug_snapshot!( + Suppressions::debug(source), + @r##" + Suppressions { + valid: [ + Suppression { + covered_source: "# ruff: disable[foo]\nprint('hello')\n# ruff: enable[bar]\nprint('world')\n", + code: "foo", + comments: [ + SuppressionComment { + text: "# ruff: disable[foo]", + action: Disable, + codes: [ + "foo", + ], + reason: "", + }, + ], + }, + ], + invalid: [ + InvalidSuppression { + kind: Unmatched, + comment: SuppressionComment { + text: "# ruff: enable[bar]", + action: Enable, + codes: [ + "bar", + ], + reason: "", + }, + }, + ], + errors: [], + } + "##, + ); + } + + #[test] + fn range_suppression_unordered() { + let source = " +# ruff: disable[foo, bar] +print('hello') +# ruff: enable[bar, foo] +"; + assert_debug_snapshot!( + Suppressions::debug(source), + @r##" + Suppressions { + valid: [ + Suppression { + covered_source: "# ruff: disable[foo, bar]\nprint('hello')\n# ruff: enable[bar, foo]\n", + code: "foo", + comments: [ + SuppressionComment { + text: "# ruff: disable[foo, bar]", + action: Disable, + codes: [ + "foo", + "bar", + ], + reason: "", + }, + ], + }, + Suppression { + covered_source: "# ruff: disable[foo, bar]\nprint('hello')\n# ruff: enable[bar, foo]\n", + code: "bar", + comments: [ + SuppressionComment { + text: "# ruff: disable[foo, bar]", + action: Disable, + codes: [ + "foo", + "bar", + ], + reason: "", + }, + ], + }, + ], + invalid: [ + InvalidSuppression { + kind: Unmatched, + comment: SuppressionComment { + text: "# ruff: enable[bar, foo]", + action: Enable, + codes: [ + "bar", + "foo", + ], + reason: "", + }, + }, + ], + errors: [], + } + "##, + ); + } + + #[test] + fn range_suppression_extra_disable() { + let source = " +# ruff: disable[foo] first +print('hello') +# ruff: disable[foo] second +print('hello') +# ruff: enable[foo] +"; + assert_debug_snapshot!( + Suppressions::debug(source), + @r##" + Suppressions { + valid: [ + Suppression { + covered_source: "# ruff: disable[foo] first\nprint('hello')\n# ruff: disable[foo] second\nprint('hello')\n# ruff: enable[foo]", + code: "foo", + comments: [ + SuppressionComment { + text: "# ruff: disable[foo] first", + action: Disable, + codes: [ + "foo", + ], + reason: "first", + }, + SuppressionComment { + text: "# ruff: enable[foo]", + action: Enable, + codes: [ + "foo", + ], + reason: "", + }, + ], + }, + Suppression { + covered_source: "# ruff: disable[foo] second\nprint('hello')\n# ruff: enable[foo]\n", + code: "foo", + comments: [ + SuppressionComment { + text: "# ruff: disable[foo] second", + action: Disable, + codes: [ + "foo", + ], + reason: "second", + }, + ], + }, + ], + invalid: [], + errors: [], + } + "##, + ); + } + + #[test] + fn combined_range_suppressions() { + let source = " +# ruff: noqa # ignored + +# comment here +print('hello') # ruff: disable[phi] trailing + +# ruff: disable[alpha] +def foo(): + # ruff: disable[beta,gamma] + if True: + # ruff: disable[delta] unmatched + pass + # ruff: enable[beta,gamma] +# ruff: enable[alpha] + +# ruff: disable # parse error! +def bar(): + # ruff: disable[zeta] unmatched + pass +# ruff: enable[zeta] underindented + pass +"; + assert_debug_snapshot!( + Suppressions::debug(source), + @r##" + Suppressions { + valid: [ + Suppression { + covered_source: "# ruff: disable[delta] unmatched\n pass\n # ruff: enable[beta,gamma]\n# ruff: enable[alpha]\n\n# ruff: disable # parse error!\n", + code: "delta", + comments: [ + SuppressionComment { + text: "# ruff: disable[delta] unmatched", + action: Disable, + codes: [ + "delta", + ], + reason: "unmatched", + }, + ], + }, + Suppression { + covered_source: "# ruff: disable[beta,gamma]\n if True:\n # ruff: disable[delta] unmatched\n pass\n # ruff: enable[beta,gamma]", + code: "beta", + comments: [ + SuppressionComment { + text: "# ruff: disable[beta,gamma]", + action: Disable, + codes: [ + "beta", + "gamma", + ], + reason: "", + }, + SuppressionComment { + text: "# ruff: enable[beta,gamma]", + action: Enable, + codes: [ + "beta", + "gamma", + ], + reason: "", + }, + ], + }, + Suppression { + covered_source: "# ruff: disable[beta,gamma]\n if True:\n # ruff: disable[delta] unmatched\n pass\n # ruff: enable[beta,gamma]", + code: "gamma", + comments: [ + SuppressionComment { + text: "# ruff: disable[beta,gamma]", + action: Disable, + codes: [ + "beta", + "gamma", + ], + reason: "", + }, + SuppressionComment { + text: "# ruff: enable[beta,gamma]", + action: Enable, + codes: [ + "beta", + "gamma", + ], + reason: "", + }, + ], + }, + Suppression { + covered_source: "# ruff: disable[zeta] unmatched\n pass\n# ruff: enable[zeta] underindented\n pass\n", + code: "zeta", + comments: [ + SuppressionComment { + text: "# ruff: disable[zeta] unmatched", + action: Disable, + codes: [ + "zeta", + ], + reason: "unmatched", + }, + ], + }, + Suppression { + covered_source: "# ruff: disable[alpha]\ndef foo():\n # ruff: disable[beta,gamma]\n if True:\n # ruff: disable[delta] unmatched\n pass\n # ruff: enable[beta,gamma]\n# ruff: enable[alpha]", + code: "alpha", + comments: [ + SuppressionComment { + text: "# ruff: disable[alpha]", + action: Disable, + codes: [ + "alpha", + ], + reason: "", + }, + SuppressionComment { + text: "# ruff: enable[alpha]", + action: Enable, + codes: [ + "alpha", + ], + reason: "", + }, + ], + }, + ], + invalid: [ + InvalidSuppression { + kind: Trailing, + comment: SuppressionComment { + text: "# ruff: disable[phi] trailing", + action: Disable, + codes: [ + "phi", + ], + reason: "trailing", + }, + }, + InvalidSuppression { + kind: Indentation, + comment: SuppressionComment { + text: "# ruff: enable[zeta] underindented", + action: Enable, + codes: [ + "zeta", + ], + reason: "underindented", + }, + }, + ], + errors: [ + ParseError { + text: "# ruff: disable # parse error!", + kind: MissingCodes, + }, + ], + } + "##, + ); + } + + #[test] + fn parse_unrelated_comment() { + assert_debug_snapshot!( + parse_suppression_comment("# hello world"), + @r" + Err( + ParseError { + kind: NotASuppression, + range: 0..13, + }, + ) + ", + ); + } + + #[test] + fn parse_invalid_action() { + assert_debug_snapshot!( + parse_suppression_comment("# ruff: lol[hi]"), + @r" + Err( + ParseError { + kind: UnknownAction, + range: 0..15, + }, + ) + ", + ); + } + + #[test] + fn parse_missing_codes() { + assert_debug_snapshot!( + parse_suppression_comment("# ruff: disable"), + @r" + Err( + ParseError { + kind: MissingCodes, + range: 0..15, + }, + ) + ", + ); + } + + #[test] + fn parse_empty_codes() { + assert_debug_snapshot!( + parse_suppression_comment("# ruff: disable[]"), + @r" + Err( + ParseError { + kind: MissingCodes, + range: 0..17, + }, + ) + ", + ); + } + + #[test] + fn parse_missing_bracket() { + assert_debug_snapshot!( + parse_suppression_comment("# ruff: disable[foo"), + @r" + Err( + ParseError { + kind: MissingBracket, + range: 0..19, + }, + ) + ", + ); + } + + #[test] + fn parse_missing_comma() { + assert_debug_snapshot!( + parse_suppression_comment("# ruff: disable[foo bar]"), + @r" + Err( + ParseError { + kind: MissingComma, + range: 0..24, + }, + ) + ", + ); + } + + #[test] + fn disable_single_code() { + assert_debug_snapshot!( + parse_suppression_comment("# ruff: disable[foo]"), + @r##" + Ok( + SuppressionComment { + text: "# ruff: disable[foo]", + action: Disable, + codes: [ + "foo", + ], + reason: "", + }, + ) + "##, + ); + } + + #[test] + fn disable_single_code_with_reason() { + assert_debug_snapshot!( + parse_suppression_comment("# ruff: disable[foo] I like bar better"), + @r##" + Ok( + SuppressionComment { + text: "# ruff: disable[foo] I like bar better", + action: Disable, + codes: [ + "foo", + ], + reason: "I like bar better", + }, + ) + "##, + ); + } + + #[test] + fn disable_multiple_codes() { + assert_debug_snapshot!( + parse_suppression_comment("# ruff: disable[foo, bar]"), + @r##" + Ok( + SuppressionComment { + text: "# ruff: disable[foo, bar]", + action: Disable, + codes: [ + "foo", + "bar", + ], + reason: "", + }, + ) + "##, + ); + } + + #[test] + fn enable_single_code() { + assert_debug_snapshot!( + parse_suppression_comment("# ruff: enable[some-thing]"), + @r##" + Ok( + SuppressionComment { + text: "# ruff: enable[some-thing]", + action: Enable, + codes: [ + "some-thing", + ], + reason: "", + }, + ) + "##, + ); + } + + #[test] + fn trailing_comment() { + let source = "print('hello world') # ruff: enable[some-thing]"; + let comment = parse_suppression_comment(source); + assert_debug_snapshot!( + comment, + @r##" + Ok( + SuppressionComment { + text: "# ruff: enable[some-thing]", + action: Enable, + codes: [ + "some-thing", + ], + reason: "", + }, + ) + "##, + ); + } + + #[test] + fn indented_comment() { + let source = " # ruff: enable[some-thing]"; + let comment = parse_suppression_comment(source); + assert_debug_snapshot!( + comment, + @r##" + Ok( + SuppressionComment { + text: "# ruff: enable[some-thing]", + action: Enable, + codes: [ + "some-thing", + ], + reason: "", + }, + ) + "##, + ); + } + + #[test] + fn comment_attributes() { + let source = "# ruff: disable[foo, bar] hello world"; + let mut parser = SuppressionParser::new( + source, + TextRange::new(0.into(), TextSize::try_from(source.len()).unwrap()), + ); + let comment = parser.parse_comment().unwrap(); + assert_eq!(comment.action, SuppressionAction::Disable); + assert_eq!( + comment + .codes + .into_iter() + .map(|range| { source.slice(range.into()) }) + .collect::>(), + ["foo", "bar"] + ); + assert_eq!(source.slice(comment.reason.into()), "hello world"); + } + + /// Parse a single suppression comment for testing + fn parse_suppression_comment( + source: &'_ str, + ) -> Result, ParseError> { + let offset = TextSize::new(source.find('#').unwrap_or(0).try_into().unwrap()); + let mut parser = SuppressionParser::new( + source, + TextRange::new(offset, TextSize::try_from(source.len()).unwrap()), + ); + match parser.parse_comment() { + Ok(comment) => Ok(DebugSuppressionComment { source, comment }), + Err(error) => Err(error), + } + } + + impl Suppressions { + /// Parse all suppressions and errors in a module for testing + fn debug(source: &'_ str) -> DebugSuppressions<'_> { + let parsed = parse(source, ParseOptions::from(Mode::Module)).unwrap(); + let suppressions = Suppressions::from_tokens(source, parsed.tokens()); + DebugSuppressions { + source, + suppressions, + } + } + } + + struct DebugSuppressions<'a> { + source: &'a str, + suppressions: Suppressions, + } + + impl fmt::Debug for DebugSuppressions<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("Suppressions") + .field( + "valid", + &self + .suppressions + .valid + .iter() + .map(|suppression| DebugSuppression { + source: self.source, + suppression, + }) + .collect_vec(), + ) + .field( + "invalid", + &self + .suppressions + .invalid + .iter() + .map(|invalid| DebugInvalidSuppression { + source: self.source, + invalid, + }) + .collect_vec(), + ) + .field( + "errors", + &self + .suppressions + .errors + .iter() + .map(|error| DebugParseError { + source: self.source, + error, + }) + .collect_vec(), + ) + .finish() + } + } + + struct DebugSuppression<'a> { + source: &'a str, + suppression: &'a Suppression, + } + + impl fmt::Debug for DebugSuppression<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("Suppression") + .field("covered_source", &&self.source[self.suppression.range]) + .field("code", &self.suppression.code) + .field( + "comments", + &self + .suppression + .comments + .iter() + .map(|comment| DebugSuppressionComment { + source: self.source, + comment: comment.clone(), + }) + .collect_vec(), + ) + .finish() + } + } + + struct DebugInvalidSuppression<'a> { + source: &'a str, + invalid: &'a InvalidSuppression, + } + + impl fmt::Debug for DebugInvalidSuppression<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("InvalidSuppression") + .field("kind", &self.invalid.kind) + .field( + "comment", + &DebugSuppressionComment { + source: self.source, + comment: self.invalid.comment.clone(), + }, + ) + .finish() + } + } + + struct DebugParseError<'a> { + source: &'a str, + error: &'a ParseError, + } + + impl fmt::Debug for DebugParseError<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("ParseError") + .field("text", &&self.source[self.error.range]) + .field("kind", &self.error.kind) + .finish() + } + } + + struct DebugSuppressionComment<'a> { + source: &'a str, + comment: SuppressionComment, + } + + impl fmt::Debug for DebugSuppressionComment<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("SuppressionComment") + .field("text", &&self.source[self.comment.range]) + .field("action", &self.comment.action) + .field( + "codes", + &DebugCodes { + source: self.source, + codes: &self.comment.codes, + }, + ) + .field("reason", &&self.source[self.comment.reason]) + .finish() + } + } + + struct DebugCodes<'a> { + source: &'a str, + codes: &'a [TextRange], + } + + impl fmt::Debug for DebugCodes<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + let mut f = f.debug_list(); + for code in self.codes { + f.entry(&&self.source[*code]); + } + f.finish() + } + } +}