Compare commits

...

68 Commits

Author SHA1 Message Date
Jack O'Connor
c7a241e112 add an I_KNOW_TY_IS_PRE_RELEASE env var to turn off the pre-release warning 2025-07-15 18:15:12 -07:00
Jack O'Connor
e73a8ba571 lint on the global keyword if there's no explicit definition in the global scope 2025-07-15 16:56:54 -07:00
David Peter
a1edb69ea5 [ty] Enum literal types (#19328)
## Summary

Add a new `Type::EnumLiteral(…)` variant and infer this type for member
accesses on enums.

**Example**: No more `@Todo` types here:
```py
from enum import Enum

class Answer(Enum):
    YES = 1
    NO = 2

    def is_yes(self) -> bool:
        return self == Answer.YES

reveal_type(Answer.YES)  # revealed: Literal[Answer.YES]
reveal_type(Answer.YES == Answer.NO)  # revealed: Literal[False]
reveal_type(Answer.YES.is_yes())  # revealed: bool
```

## Test Plan

* Many new Markdown tests for the new type variant
* Added enum literal types to property tests, ran property tests

## Ecosystem analysis

Summary:

Lots of false positives removed. All of the new diagnostics are
either new true positives (the majority) or known problems. Click for
detailed analysis</summary>

Details:

```diff
AutoSplit (https://github.com/Toufool/AutoSplit)
+ error[call-non-callable] src/capture_method/__init__.py:137:9: Method `__getitem__` of type `bound method CaptureMethodDict.__getitem__(key: Never, /) -> type[CaptureMethodBase]` is not callable on object of type `CaptureMethodDict`
+ error[call-non-callable] src/capture_method/__init__.py:147:9: Method `__getitem__` of type `bound method CaptureMethodDict.__getitem__(key: Never, /) -> type[CaptureMethodBase]` is not callable on object of type `CaptureMethodDict`
+ error[call-non-callable] src/capture_method/__init__.py:148:1: Method `__getitem__` of type `bound method CaptureMethodDict.__getitem__(key: Never, /) -> type[CaptureMethodBase]` is not callable on object of type `CaptureMethodDict`
```

New true positives. That `__getitem__` method is apparently annotated
with `Never` to prevent developers from using it.


```diff
dd-trace-py (https://github.com/DataDog/dd-trace-py)
+ error[invalid-assignment] ddtrace/vendor/psutil/_common.py:29:5: Object of type `None` is not assignable to `Literal[AddressFamily.AF_INET6]`
+ error[invalid-assignment] ddtrace/vendor/psutil/_common.py:33:5: Object of type `None` is not assignable to `Literal[AddressFamily.AF_UNIX]`
```

Arguably true positives:
e0a772c28b/ddtrace/vendor/psutil/_common.py (L29)

```diff
ignite (https://github.com/pytorch/ignite)
+ error[invalid-argument-type] tests/ignite/engine/test_custom_events.py:190:34: Argument to bound method `__call__` is incorrect: Expected `((...) -> Unknown) | None`, found `Literal["123"]`
+ error[invalid-argument-type] tests/ignite/engine/test_custom_events.py:220:37: Argument to function `default_event_filter` is incorrect: Expected `Engine`, found `None`
+ error[invalid-argument-type] tests/ignite/engine/test_custom_events.py:220:43: Argument to function `default_event_filter` is incorrect: Expected `int`, found `None`
+ error[call-non-callable] tests/ignite/engine/test_custom_events.py:561:9: Object of type `CustomEvents` is not callable
+ error[invalid-argument-type] tests/ignite/metrics/test_frequency.py:50:38: Argument to bound method `attach` is incorrect: Expected `Events`, found `CallableEventWithFilter`
```

All true positives. Some of them are inside `pytest.raises(TypeError,
…)` blocks 🙃

```diff
meson (https://github.com/mesonbuild/meson)
+ error[invalid-argument-type] unittests/internaltests.py:243:51: Argument to bound method `__init__` is incorrect: Expected `bool`, found `Literal[MachineChoice.HOST]`
+ error[invalid-argument-type] unittests/internaltests.py:271:51: Argument to bound method `__init__` is incorrect: Expected `bool`, found `Literal[MachineChoice.HOST]`
```

New true positives. Enum literals can not be assigned to `bool`, even if
their value types are `0` and `1`.

```diff
poetry (https://github.com/python-poetry/poetry)
+ error[invalid-assignment] src/poetry/console/exceptions.py:101:5: Object of type `Literal[""]` is not assignable to `InitVar[str]`
```

New false positive, missing support for `InitVar`.

```diff
prefect (https://github.com/PrefectHQ/prefect)
+ error[invalid-argument-type] src/integrations/prefect-dask/tests/test_task_runners.py:193:17: Argument is incorrect: Expected `StateType`, found `Literal[StateType.COMPLETED]`
```

This is confusing. There are two definitions
([one](74d8cd93ee/src/prefect/client/schemas/objects.py (L89-L100)),
[two](https://github.com/PrefectHQ/prefect/blob/main/src/prefect/server/schemas/states.py#L40))
of the `StateType` enum. Here, we're trying to assign one to the other.
I don't think that should be allowed, so this is a true positive (?).

```diff
python-htmlgen (https://github.com/srittau/python-htmlgen)
+ error[invalid-assignment] test_htmlgen/form.py:51:9: Object of type `str` is not assignable to attribute `autocomplete` of type `Autocomplete | None`
+ error[invalid-assignment] test_htmlgen/video.py:38:9: Object of type `str` is not assignable to attribute `preload` of type `Preload | None`
```

True positives. [The stubs are
wrong](01e3b911ac/htmlgen/form.pyi (L8-L10)).
These should not contain type annotations, but rather just `OFF = ...`.

```diff
rotki (https://github.com/rotki/rotki)
+ error[invalid-argument-type] rotkehlchen/tests/unit/test_serialization.py:62:30: Argument to bound method `deserialize` is incorrect: Expected `str`, found `Literal[15]`
```

New true positive.

```diff
vision (https://github.com/pytorch/vision)
+ error[unresolved-attribute] test/test_extended_models.py:302:17: Type `type[WeightsEnum]` has no attribute `DEFAULT`
+ error[unresolved-attribute] test/test_extended_models.py:302:58: Type `type[WeightsEnum]` has no attribute `DEFAULT`
```

Also new true positives. No `DEFAULT` member exists on `WeightsEnum`.
2025-07-15 21:31:53 +02:00
github-actions[bot]
a0d4e1f854 [ty] Sync vendored typeshed stubs (#19368)
Co-authored-by: typeshedbot <>
Co-authored-by: Alex Waygood <alex.waygood@gmail.com>
2025-07-15 18:14:46 +00:00
Alex Waygood
c0d04f2d56 Fix typeshed-sync workflow (#19367) 2025-07-15 19:07:38 +01:00
Alex Waygood
8d7d02193e Rework typeshed-sync workflow to also add docstrings for Windows- and MacOS-specific APIs (#19360) 2025-07-15 18:14:32 +01:00
Zanie Blue
78dfc8af0f [ty] Allow -qq for silent output mode (#19366)
This matches uv's behavior.

Briefly discussed at
https://github.com/astral-sh/ruff/pull/19233#discussion_r2197930360

I think the most useful case is to avoid piping to `/dev/null` which
hard to do properly in a cross-platform script.
2025-07-15 17:08:19 +00:00
Zanie Blue
0c84652cc5 [ty] Allow -q short alias for --quiet (#19364) 2025-07-15 12:00:07 -05:00
Alex Waygood
560ae04346 Add shellcheck to pre-commit (#19361) 2025-07-15 16:49:13 +00:00
Jack O'Connor
a357a68fc9 distinguish references from definitions in infer_nonlocal
The initial implementation of `infer_nonlocal` landed in
https://github.com/astral-sh/ruff/pull/19112 fails to report an error
for this example:

```py
x = 1
def f():
    # This is only a usage of `x`, not a definition. It shouldn't be
    # enough to make the `nonlocal` statement below allowed.
    print(x)
    def g():
        nonlocal x
```

Fix this by continuing to walk enclosing scopes when the place we've
found isn't bound, declared, or `nonlocal`.
2025-07-15 07:55:40 -07:00
Dylan
00e7d1ffd6 [pycodestyle] Handle brace escapes for t-strings in logical lines (#19358)
Tracks both f and t-strings in the logical line rules for `pycodestyle`.

Progress towards #15506
2025-07-15 14:48:48 +00:00
Douglas Creager
f4d0273532 [ty] Combine CallArguments and CallArgumentTypes (#19337)
We previously had separate `CallArguments` and `CallArgumentTypes` types
in support of our two-phase call binding logic. `CallArguments` would
store only the arity/kind of each argument (positional, keyword,
variadic, etc). We then performed parameter matching using only this
arity/kind information, and then infered the type of each argument,
placing the result of this second phase into a new `CallArgumentTypes`.

In #18996, we will need to infer the types of splatted arguments
_before_ performing parameter matching, since we need to know the
argument type to accurately infer its length, which informs how many
parameters the splatted argument is matched against.

That makes this separation of Rust types no longer useful. This PR
merges everything back into a single `CallArguments`. In the case where
we are performing two-phase call binding, the types will be initialized
to `None`, and updated to the actual argument type during the second
`check_types` phase.

_[This is a refactoring in support of fixing the merge conflicts on
#18996. I've pulled this out into a separate PR to make it easier to
review in isolation.]_
2025-07-15 10:20:58 -04:00
Brent Westbrook
e9cac3684a Move Pylint rendering to ruff_db (#19340)
Summary
--

This is a very simple output format, the only decision is what to do if
the file
is missing from the diagnostic. For now, I opted to `unwrap_or_default`
both the
path and the `OneIndexed` row number, giving `:1: main diagnostic
message` in
the test without a file.

Another quirk here is that the path is relativized. I just pasted in the
`relativize_path` and `get_cwd` implementations from `ruff_linter::fs`
for now,
but maybe there's a better place for them.

I didn't see any details about why this needs to be relativized in the
original
[issue](https://github.com/astral-sh/ruff/issues/1953),
[PR](https://github.com/astral-sh/ruff/pull/1995), or in the pylint

[docs](https://flake8.pycqa.org/en/latest/internal/formatters.html#pylint-formatter),
but it did change the results of the CLI integration test when I tried
deleting
it. I haven't been able to reproduce that in the CLI, though, so it may
only
happen with `Command::current_dir`.

Test Plan
--

Tests ported from `ruff_linter` and a new test for the case with no file

---------

Co-authored-by: Micha Reiser <micha@reiser.io>
2025-07-15 10:14:49 -04:00
Dylan
92a302e291 [pylint] Extend invalid string character rules to include t-strings (#19355)
Handle t-strings in PLE2510-15

Progress towards #15506
2025-07-15 07:59:51 -05:00
Alex Waygood
7b8161e80d Make TC010 docs example more realistic (#19356) 2025-07-15 13:52:21 +01:00
Brent Westbrook
e9b0c33703 Move RDJSON rendering to ruff_db (#19293)
## Summary

Another output format like #19133. This is the
[reviewdog](https://github.com/reviewdog/reviewdog) output format, which
is somewhat similar to regular JSON. Like #19270, in the first commit I
converted from using `json!` to `Serialize` structs, then in the second
commit I moved the module to `ruff_db`.

The reviewdog
[schema](320a8e73a9/proto/rdf/jsonschema/DiagnosticResult.json)
seems a bit more flexible than our JSON schema, so I'm not sure if we
need any preview checks here. I'll flag the places I wasn't sure about
as review comments.

## Test Plan

New tests in `rdjson.rs`, ported from the old `rjdson.rs` module, as
well as the new CLI output tests.

---------

Co-authored-by: Micha Reiser <micha@reiser.io>
2025-07-15 12:39:21 +00:00
Dylan
82391b5675 [flake8-use-pathlib] Skip single dots for invalid-pathlib-with-suffix (PTH210) on versions >= 3.14 (#19331)
Skips [invalid-pathlib-with-suffix
(PTH210)](https://docs.astral.sh/ruff/rules/invalid-pathlib-with-suffix/#invalid-pathlib-with-suffix-pth210)
for `.with_suffix(".")` on Python versions 3.14 and greater, as per [the
docs](https://docs.python.org/3.14/library/pathlib.html#pathlib.PurePath.with_suffix).

Progress towards #15506
2025-07-15 07:05:00 -05:00
Dylan
464144f1c6 [ruff] Allow strict kwarg when checking for starmap-zip (RUF058) in Python 3.14+ (#19333)
In Python 3.14 the keyword-argument `strict` was [added to
`map`](https://docs.python.org/3.14/library/functions.html#map). This PR
adds support for this when replacing a starmap-zip call with map in
[starmap-zip
(RUF058)](https://docs.astral.sh/ruff/rules/starmap-zip/#starmap-zip-ruf058).

Progress towards #15506
2025-07-15 07:04:23 -05:00
Alex Waygood
002f9057db [ty] Reduce false positives for TypedDict types (#19354) 2025-07-15 12:47:19 +01:00
Dhruv Manilawala
f3a27406c9 [ty] Remove ConnectionInitializer (#19353)
## Summary

This PR removes the `ConnectionInitializer` and inlines the
`initialize_start` and `initialize_finish` calls.

The main benefit of this is that it will allow us to use
[`Connection::memory`](https://docs.rs/lsp-server/latest/lsp_server/struct.Connection.html#method.memory)
in the mock server. That method returns two `Connection` where one of
them will represent the client side connection and the other will be
sent to the `Server::new` call to be used by the server. This way the
mock client can send notifications and requests to mimic the editor.

## Test Plan

I tested out the initialization process and checked that the initialized
result contains the server capabilities and server info.
2025-07-15 17:02:44 +05:30
Alex Waygood
2c9da80985 [ty] Use Type::string_literal() more (#19352) 2025-07-15 11:09:07 +00:00
David Peter
8e61da740a [ty] Add ecosystem-report workflow (#19349)
## Summary

Adds a new workflow that generates an ecosystem report of all
diagnostics and publishes it to Cloudflare pages.

## Test Plan

Not yet tested.
2025-07-15 12:29:44 +02:00
Micha Reiser
e506296cec [ty] Make use of salsa Lookup when interning values (#19347) 2025-07-15 09:54:43 +02:00
github-actions[bot]
966cc9d6e9 [ty] Sync vendored typeshed stubs (#19345)
Close and reopen this PR to trigger CI

Co-authored-by: typeshedbot <>
2025-07-15 11:46:59 +05:30
GiGaGon
7b27fe966e [pylint] Make example error out-of-the-box (PLE2502) (#19272)
<!--
Thank you for contributing to Ruff/ty! To help us out with reviewing,
please consider the following:

- Does this pull request include a summary of the change? (See below.)
- Does this pull request include a descriptive title? (Please prefix
with `[ty]` for ty pull
  requests.)
- Does this pull request include references to any relevant issues?
-->

## Summary

<!-- What's the purpose of the change? What does it do, and why? -->

Part of #18972
Fixes #14346

This PR makes [bidirectional-unicode
(PLE2502)](https://docs.astral.sh/ruff/rules/bidirectional-unicode/#bidirectional-unicode-ple2502)'s
example error out-of-the-box, by converting it to use one of the test
cases. The documentation in general is also updated to replace
"bidirectional unicode character" with "bidirectional formatting
character", as those are the only ones checked for, and the "unicode"
suffix is redundant. The new example section looks like this:
<img width="1074" height="264" alt="image"
src="https://github.com/user-attachments/assets/cc1d2cb4-b590-4f20-a4d2-15b744872cdd"
/>

The "References" section link is also updated to reflect the rule's
actual behavior.

## Test Plan

<!-- How was it tested? -->

N/A, no functionality/tests affected
2025-07-14 14:46:23 -04:00
GiGaGon
966fd6f57a [pydoclint] Fix SyntaxError from fixes with line continuations (D201, D202) (#19246)
<!--
Thank you for contributing to Ruff/ty! To help us out with reviewing,
please consider the following:

- Does this pull request include a summary of the change? (See below.)
- Does this pull request include a descriptive title? (Please prefix
with `[ty]` for ty pull
  requests.)
- Does this pull request include references to any relevant issues?
-->

## Summary

<!-- What's the purpose of the change? What does it do, and why? -->

This PR fixes #7172 by suppressing the fixes for
[docstring-missing-returns
(DOC201)](https://docs.astral.sh/ruff/rules/docstring-missing-returns/#docstring-missing-returns-doc201)
/ [docstring-extraneous-returns
(DOC202)](https://docs.astral.sh/ruff/rules/docstring-extraneous-returns/#docstring-extraneous-returns-doc202)
if there is a surrounding line continuation character `\` that would
make the fix cause a syntax error.

To do this, the lints are changed from `AlwaysFixableViolation` to
`Violation` with `FixAvailability::Sometimes`.

In the case of `DOC201`, the fix is not given if the non-break line ends
in a line continuation character `\`. Note that lines are iterated in
reverse from the docstring to the function definition.

In the case of `DOC202`, the fix is not given if the docstring ends with
a line continuation character `\`.

## Test Plan

<!-- How was it tested? -->

Added a test case.
2025-07-14 13:31:36 -04:00
github-actions[bot]
4f60f0e925 [ty] Sync vendored typeshed stubs (#19334)
Co-authored-by: typeshedbot <>
Co-authored-by: Alex Waygood <alex.waygood@gmail.com>
2025-07-14 17:34:09 +01:00
GiGaGon
059e90a98f [refurb] Make example error out-of-the-box (FURB122) (#19297)
## Summary

Part of #18972

This PR makes [for-loop-writes
(FURB122)](https://docs.astral.sh/ruff/rules/for-loop-writes/#for-loop-writes-furb122)'s
example error out-of-the-box. I also had to re-name the second case's
variables to get both to raise at the same time, I suspect because of
limitations in ruff's current semantic model. New names subject to
bikeshedding, I just went with the least effort `_b` for binary suffix.

[Old example](https://play.ruff.rs/19e8e47a-8058-4013-aef5-e9b5eab65962)
```py
with Path("file").open("w") as f:
    for line in lines:
        f.write(line)

with Path("file").open("wb") as f:
    for line in lines:
        f.write(line.encode())
```

[New example](https://play.ruff.rs/e96b00e5-3c63-47c3-996d-dace420dd711)
```py
from pathlib import Path

with Path("file").open("w") as f:
    for line in lines:
        f.write(line)

with Path("file").open("wb") as f_b:
    for line_b in lines_b:
        f_b.write(line_b.encode())
```

The "Use instead" section was also modified similarly.

## Test Plan

<!-- How was it tested? -->

N/A, no functionality/tests affected
2025-07-14 11:24:16 -05:00
Juriah
a4562ac673 [refurb] Make example error out-of-the-box (FURB177) (#19309)
<!--
Thank you for contributing to Ruff/ty! To help us out with reviewing,
please consider the following:

- Does this pull request include a summary of the change? (See below.)
- Does this pull request include a descriptive title? (Please prefix
with `[ty]` for ty pull
  requests.)
- Does this pull request include references to any relevant issues?
-->

## Summary

Part of #18972
This PR makes
[implicit-cwd(FURB177)](https://docs.astral.sh/ruff/rules/implicit-cwd/)'s
example error out-of-the-box.

[Old example](https://play.ruff.rs/a0bef229-9626-426f-867f-55cb95ee64d8)
```python
cwd = Path().resolve()
```
[New example](https://play.ruff.rs/bdbea4af-e276-4603-a1b6-88757dfaa399)
```python
from pathlib import Path

cwd = Path().resolve()
```
<!-- What's the purpose of the change? What does it do, and why? -->


## Test Plan

<!-- How was it tested? -->
N/A, no functionality/tests affected
2025-07-14 11:23:02 -05:00
Alex Waygood
021a70d30c [ty] ignore errors when reformatting codemodded typeshed (#19332) 2025-07-14 16:14:01 +00:00
Alex Waygood
fddf2f33d2 [ty] Provide docstrings for stdlib APIs when hovering over them in an IDE (#19311) 2025-07-14 17:00:45 +01:00
Dhruv Manilawala
b4c42eb83b [ty] Add virtual files to the only project database (#19322)
## Summary

Previously, the virtual files were being added to the default database
that's present on the session. This is wrong because the default
database is for any files that don't belong to any project i.e., they're
outside of any projects managed by the server. Virtual files are neither
part of the project nor it is outside the projects. This was not the
intention as in the initial version, virtual files were being added to
the only project database managed by the server.

This PR fixes this by reverting back to the original behavior where
virtual files will be added to the only project database present. When
support for multiple workspace and project is added, this will require
updating (https://github.com/astral-sh/ty/issues/794).

This is required for #19264 because workspace diagnostics doesn't check
the default project database yet. Ideally, the default db should be
checked as well.

The implementation of this PR means that virtual files are now being
included for workspace diagnostics but it doesn't work completely e.g.,
if I save an untitled file the diagnostics disappears but it doesn't
appear back for the (now) saved file on disk as shown in the following
video demonstration:



https://github.com/user-attachments/assets/123e8d20-1e95-4c7d-b7eb-eb65be8c476e
2025-07-14 20:17:51 +05:30
Dylan
2a2cc37158 Add t-string fixtures for rules that do not need to be modified (#19146)
I used a script to attempt to identify those rules with the following
property: changing f-strings to t-strings in the corresponding fixture
altered the number of lint errors emitted. In other words, those rules
for which f-strings and t-strings are not treated the same in the
current implementation.

This PR documents the subset of such rules where this is fine and no
changes need to be made to the implementation of the rule. Mostly these
are the rules where it is relevant that an f-string evaluates to type
`str` at runtime whereas t-strings do not.

In theory many of these fixtures are not super necessary - it's unlikely
t-strings would be used for most of these. However, the internal
handling of t-strings is tightly coupled with that of f-strings, and may
become even more so as we implement the upcoming changes due to
https://github.com/python/cpython/pull/135996 . So I'd like to keep
these around as regression tests.

Note: The `flake8-bandit` fixtures were already added during the
original t-string implementation.

| Rule(s) | Reason |
| --- | --- |
| [`unused-method-argument`
(`ARG002`)](https://docs.astral.sh/ruff/rules/unused-method-argument/#unused-method-argument-arg002)
| f-strings exempted for msg in `NotImplementedError` not relevant for
t-strings |
| [`logging-f-string`
(`G004`)](https://docs.astral.sh/ruff/rules/logging-f-string/#logging-f-string-g004)
| t-strings cannot be used here |
| [`f-string-in-get-text-func-call`
(`INT001`)](https://docs.astral.sh/ruff/rules/f-string-in-get-text-func-call/#f-string-in-get-text-func-call-int001)
| rule justified by eager evaluation of interpolations |
| [`flake8-bandit`](https://docs.astral.sh/ruff/rules/#flake8-bandit-s)|
rules justified by eager evaluation of interpolations |
| [`single-string-slots`
(`PLC0205`)](https://docs.astral.sh/ruff/rules/single-string-slots/#single-string-slots-plc0205)
| t-strings cannot be slots in general |
| [`unnecessary-encode-utf8`
(`UP012`)](https://docs.astral.sh/ruff/rules/unnecessary-encode-utf8/#unnecessary-encode-utf8-up012)
| cannot encode t-strings |
| [`no-self-use`
(`PLR6301`)](https://docs.astral.sh/ruff/rules/no-self-use/#no-self-use-plr6301)
| f-strings exempted for msg in NotImplementedError not relevant for
t-strings |
| [`pytest-raises-too-broad`
(`PT011`)](https://docs.astral.sh/ruff/rules/pytest-raises-too-broad/) /
[`pytest-fail-without-message`
(`PT016`)](https://docs.astral.sh/ruff/rules/pytest-fail-without-message/#pytest-fail-without-message-pt016)
/ [`pytest-warns-too-broad`
(`PT030`)](https://docs.astral.sh/ruff/rules/pytest-warns-too-broad/#pytest-warns-too-broad-pt030)
| t-strings cannot be empty or used as messages |
| [`assert-on-string-literal`
(`PLW0129`)](https://docs.astral.sh/ruff/rules/assert-on-string-literal/#assert-on-string-literal-plw0129)
| t-strings are not strings and cannot be empty |
| [`native-literals`
(`UP018`)](https://docs.astral.sh/ruff/rules/native-literals/#native-literals-up018)
| t-strings are not native literals |
2025-07-14 09:46:31 -05:00
Dhruv Manilawala
8a217e5920 [ty] Remove FileLookupError (#19323)
## Summary

This PR removes the `FileLookupError` as it's not really required. The
original intention was that this would be returned from the `.file`
lookup to the different handlers but we've since moved the logic of
"lookup file and add trace message if file unavailable with the reason"
under the `file_ok` method which all of the handlers use.
2025-07-14 13:35:14 +00:00
Andrew Gallant
f7973ac870 [ty] Fix handling of metaclasses in object.<CURSOR> completions
Basically, we weren't quite using `Type::member` in every case
correctly. Specifically, this example from @sharkdp:

```
class Meta(type):
    @property
    def meta_attr(self) -> int:
        return 0

class C(metaclass=Meta): ...

C.<CURSOR>
```

While we would return `C.meta_attr` here, we were claiming its type was
`property`. But its type should be `int`.

Ref https://github.com/astral-sh/ruff/pull/19216#discussion_r2197065241
2025-07-14 08:24:23 -04:00
Micha Reiser
3560f86450 [ty] Use an interval map for scopes by expression (#19025) 2025-07-14 13:50:58 +02:00
David Peter
f22da352db [ty] List all enum members (#19283)
## Summary

Adds a way to list all members of an `Enum` and implements almost all of
the mechanisms by which members are distinguished from non-members
([spec](https://typing.python.org/en/latest/spec/enums.html#defining-members)).
This has no effect on actual enums, so far.

## Test Plan

New Markdown tests using `ty_extensions.enum_members`.
2025-07-14 13:18:17 +02:00
Micha Reiser
cb530a0216 [ty] Handle configuration errors in LSP more gracefully (#19262) 2025-07-14 12:27:52 +02:00
Micha Reiser
90026047f9 [ty] Use python version and path from Python extension (#19012) 2025-07-14 09:47:27 +00:00
w0nder1ng
26f736bc46 [pep8_naming] Avoid false positives on standard library functions with uppercase names (N802) (#18907)
Co-authored-by: Micha Reiser <micha@reiser.io>
2025-07-14 08:26:57 +00:00
renovate[bot]
c9f95e8714 Update Rust crate toml to 0.9.0 (#19320)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [toml](https://redirect.github.com/toml-rs/toml) |
workspace.dependencies | minor | `0.8.11` -> `0.9.0` |

---

> [!WARNING]
> Some dependencies could not be looked up. Check the Dependency
Dashboard for more information.

---

### Release Notes

<details>
<summary>toml-rs/toml (toml)</summary>

###
[`v0.9.2`](https://redirect.github.com/toml-rs/toml/compare/toml-v0.9.1...toml-v0.9.2)

[Compare
Source](https://redirect.github.com/toml-rs/toml/compare/toml-v0.9.1...toml-v0.9.2)

###
[`v0.9.1`](https://redirect.github.com/toml-rs/toml/compare/toml-v0.9.0...toml-v0.9.1)

[Compare
Source](https://redirect.github.com/toml-rs/toml/compare/toml-v0.9.0...toml-v0.9.1)

###
[`v0.9.0`](https://redirect.github.com/toml-rs/toml/compare/toml-v0.8.23...toml-v0.9.0)

[Compare
Source](https://redirect.github.com/toml-rs/toml/compare/toml-v0.8.23...toml-v0.9.0)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - "before 4am on Monday" (UTC),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/astral-sh/ruff).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS4yMy4yIiwidXBkYXRlZEluVmVyIjoiNDEuMjMuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiaW50ZXJuYWwiXX0=-->

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Dhruv Manilawala <dhruvmanila@gmail.com>
2025-07-14 13:11:10 +05:30
Micha Reiser
3da8b51dc1 [ty] Fix server version (#19284) 2025-07-14 09:06:34 +02:00
renovate[bot]
3cbf2fe82e Update NPM Development dependencies (#19319)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-14 08:45:59 +02:00
renovate[bot]
221f3258d4 Update taiki-e/install-action action to v2.56.13 (#19317)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-14 08:40:43 +02:00
renovate[bot]
3fae3f248c Update Rust crate clap to v4.5.41 (#19315)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-14 08:40:26 +02:00
renovate[bot]
6c73837bc4 Update Rust crate get-size2 to v0.5.2 (#19316)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-14 08:40:03 +02:00
renovate[bot]
6f8f38cb68 Update CodSpeedHQ/action action to v3.7.0 (#19318)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-14 08:35:06 +02:00
renovate[bot]
4e2d6f5e45 Update pre-commit hook astral-sh/ruff-pre-commit to v0.12.3 (#19314)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
|
[astral-sh/ruff-pre-commit](https://redirect.github.com/astral-sh/ruff-pre-commit)
| repository | patch | `v0.12.2` -> `v0.12.3` |

---

> [!WARNING]
> Some dependencies could not be looked up. Check the Dependency
Dashboard for more information.

Note: The `pre-commit` manager in Renovate is not supported by the
`pre-commit` maintainers or community. Please do not report any problems
there, instead [create a Discussion in the Renovate
repository](https://redirect.github.com/renovatebot/renovate/discussions/new)
if you have any questions.

---

### Release Notes

<details>
<summary>astral-sh/ruff-pre-commit (astral-sh/ruff-pre-commit)</summary>

###
[`v0.12.3`](https://redirect.github.com/astral-sh/ruff-pre-commit/releases/tag/v0.12.3)

[Compare
Source](https://redirect.github.com/astral-sh/ruff-pre-commit/compare/v0.12.2...v0.12.3)

See: https://github.com/astral-sh/ruff/releases/tag/0.12.3

</details>

---

### Configuration

📅 **Schedule**: Branch creation - "before 4am on Monday" (UTC),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/astral-sh/ruff).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS4yMy4yIiwidXBkYXRlZEluVmVyIjoiNDEuMjMuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiaW50ZXJuYWwiXX0=-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-14 11:52:42 +05:30
renovate[bot]
3ed3852c38 Update dependency ruff to v0.12.3 (#19313)
This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
| [ruff](https://docs.astral.sh/ruff)
([source](https://redirect.github.com/astral-sh/ruff),
[changelog](https://redirect.github.com/astral-sh/ruff/blob/main/CHANGELOG.md))
| `==0.12.2` -> `==0.12.3` |
[![age](https://developer.mend.io/api/mc/badges/age/pypi/ruff/0.12.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/ruff/0.12.2/0.12.3?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

---

> [!WARNING]
> Some dependencies could not be looked up. Check the Dependency
Dashboard for more information.

---

### Release Notes

<details>
<summary>astral-sh/ruff (ruff)</summary>

###
[`v0.12.3`](https://redirect.github.com/astral-sh/ruff/blob/HEAD/CHANGELOG.md#0123)

[Compare
Source](https://redirect.github.com/astral-sh/ruff/compare/0.12.2...0.12.3)

##### Preview features

- \[`flake8-bugbear`] Support non-context-manager calls in `B017`
([#&#8203;19063](https://redirect.github.com/astral-sh/ruff/pull/19063))
- \[`flake8-use-pathlib`] Add autofixes for `PTH100`, `PTH106`,
`PTH107`, `PTH108`, `PTH110`, `PTH111`, `PTH112`, `PTH113`, `PTH114`,
`PTH115`, `PTH117`, `PTH119`, `PTH120`
([#&#8203;19213](https://redirect.github.com/astral-sh/ruff/pull/19213))
- \[`flake8-use-pathlib`] Add autofixes for `PTH203`, `PTH204`, `PTH205`
([#&#8203;18922](https://redirect.github.com/astral-sh/ruff/pull/18922))

##### Bug fixes

- \[`flake8-return`] Fix false-positive for variables used inside nested
functions in `RET504`
([#&#8203;18433](https://redirect.github.com/astral-sh/ruff/pull/18433))
- Treat form feed as valid whitespace before a line continuation
([#&#8203;19220](https://redirect.github.com/astral-sh/ruff/pull/19220))
- \[`flake8-type-checking`] Fix syntax error introduced by fix (`TC008`)
([#&#8203;19150](https://redirect.github.com/astral-sh/ruff/pull/19150))
- \[`pyupgrade`] Keyword arguments in `super` should suppress the
`UP008` fix
([#&#8203;19131](https://redirect.github.com/astral-sh/ruff/pull/19131))

##### Documentation

- \[`flake8-pyi`] Make example error out-of-the-box (`PYI007`, `PYI008`)
([#&#8203;19103](https://redirect.github.com/astral-sh/ruff/pull/19103))
- \[`flake8-simplify`] Make example error out-of-the-box (`SIM116`)
([#&#8203;19111](https://redirect.github.com/astral-sh/ruff/pull/19111))
- \[`flake8-type-checking`] Make example error out-of-the-box (`TC001`)
([#&#8203;19151](https://redirect.github.com/astral-sh/ruff/pull/19151))
- \[`flake8-use-pathlib`] Make example error out-of-the-box (`PTH210`)
([#&#8203;19189](https://redirect.github.com/astral-sh/ruff/pull/19189))
- \[`pycodestyle`] Make example error out-of-the-box (`E272`)
([#&#8203;19191](https://redirect.github.com/astral-sh/ruff/pull/19191))
- \[`pycodestyle`] Make example not raise unnecessary `SyntaxError`
(`E114`)
([#&#8203;19190](https://redirect.github.com/astral-sh/ruff/pull/19190))
- \[`pydoclint`] Make example error out-of-the-box (`DOC501`)
([#&#8203;19218](https://redirect.github.com/astral-sh/ruff/pull/19218))
- \[`pylint`, `pyupgrade`] Fix syntax errors in examples (`PLW1501`,
`UP028`)
([#&#8203;19127](https://redirect.github.com/astral-sh/ruff/pull/19127))
- \[`pylint`] Update `missing-maxsplit-arg` docs and error to suggest
proper usage (`PLC0207`)
([#&#8203;18949](https://redirect.github.com/astral-sh/ruff/pull/18949))
- \[`flake8-bandit`] Make example error out-of-the-box (`S412`)
([#&#8203;19241](https://redirect.github.com/astral-sh/ruff/pull/19241))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - "before 4am on Monday" (UTC),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/astral-sh/ruff).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS4yMy4yIiwidXBkYXRlZEluVmVyIjoiNDEuMjMuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiaW50ZXJuYWwiXX0=-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-14 11:52:25 +05:30
GiGaGon
dca594f89f [pyupgrade] Make example error out-of-the-box (UP040) (#19296)
<!--
Thank you for contributing to Ruff/ty! To help us out with reviewing,
please consider the following:

- Does this pull request include a summary of the change? (See below.)
- Does this pull request include a descriptive title? (Please prefix
with `[ty]` for ty pull
  requests.)
- Does this pull request include references to any relevant issues?
-->

## Summary

<!-- What's the purpose of the change? What does it do, and why? -->

Part of #18972

This PR makes [non-pep695-type-alias
(UP040)](https://docs.astral.sh/ruff/rules/non-pep695-type-alias/#non-pep695-type-alias-up040)'s
example error out-of-the-box.

[Old example](https://play.ruff.rs/6beca1be-45cd-4e5a-aafa-6a0584c10d64)
```py
ListOfInt: TypeAlias = list[int]
PositiveInt = TypeAliasType("PositiveInt", Annotated[int, Gt(0)])
```

[New example](https://play.ruff.rs/bbad34da-bf07-44e6-9f34-53337e8f57d4)
```py
from typing import Annotated, TypeAlias, TypeAliasType
from annotated_types import Gt

ListOfInt: TypeAlias = list[int]
PositiveInt = TypeAliasType("PositiveInt", Annotated[int, Gt(0)])
```

Imports were also added to the "Use instead" section.

## Test Plan

<!-- How was it tested? -->

N/A, no functionality/tests affected
2025-07-12 18:39:25 +01:00
GiGaGon
4bc27133a9 [pyupgrade] Make example error out-of-the-box (UP046) (#19295) 2025-07-12 14:54:56 +01:00
GiGaGon
7154b64248 [pylint] Make example error out-of-the-box (PLE1507) (#19288)
## Summary

Part of #18972

This PR makes [invalid-envvar-value
(PLE1507)](https://docs.astral.sh/ruff/rules/invalid-envvar-value/#invalid-envvar-value-ple1507)'s
example error out-of-the-box.

[Old example](https://play.ruff.rs/a46a9bca-edd5-4474-b20d-e6b6d87291ca)
```py
os.getenv(1)
```

[New example](https://play.ruff.rs/8348d32d-71fa-422c-b228-e2bc343765b1)
```py
import os

os.getenv(1)
```

The "Use instead" section was also updated similarly.

## Test Plan

<!-- How was it tested? -->

N/A, no functionality/tests affected
2025-07-11 16:08:47 -05:00
GiGaGon
6d01c487a5 [pyupgrade] Make example error out-of-the-box (UP041) (#19292)
<!--
Thank you for contributing to Ruff/ty! To help us out with reviewing,
please consider the following:

- Does this pull request include a summary of the change? (See below.)
- Does this pull request include a descriptive title? (Please prefix
with `[ty]` for ty pull
  requests.)
- Does this pull request include references to any relevant issues?
-->

## Summary

<!-- What's the purpose of the change? What does it do, and why? -->

Part of #18972

This PR makes [timeout-error-alias
(UP041)](https://docs.astral.sh/ruff/rules/timeout-error-alias/#timeout-error-alias-up041)'s
example error out-of-the-box.

[Old example](https://play.ruff.rs/87e20352-d80a-46ec-98a2-6f6ea700438b)
```py
raise asyncio.TimeoutError
```

[New example](https://play.ruff.rs/d3b95557-46a2-4856-bd71-30d5f3f5ca44)
```py
import asyncio

raise asyncio.TimeoutError
```

## Test Plan

<!-- How was it tested? -->

N/A, no functionality/tests affected
2025-07-11 16:08:20 -05:00
GiGaGon
6660b11422 [pyupgrade] Make example error out-of-the-box (UP023) (#19291)
## Summary

Part of #18972

This PR makes [deprecated-c-element-tree
(UP023)](https://docs.astral.sh/ruff/rules/deprecated-c-element-tree/#deprecated-c-element-tree-up023)'s
example error out-of-the-box. I have no clue why the `import
xml.etree.cElementTree` and `from xml.etree import cElementTree` cases
are specifically carved out if they do not have an `as ...`, but the
tests explicitly call this out, and that's how it is in `pyupgrade`'s
source as well.


b5c5f710fc/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP023.py (L23-L31)

[Old example](https://play.ruff.rs/632b8ce1-393d-45e5-9504-5444ae71a0d8)
```py
from xml.etree import cElementTree
```

[New example](https://play.ruff.rs/fef4d378-8c54-41b2-8778-2d02bcbbd7d3)
```py
from xml.etree import cElementTree as ET
```

The "Use instead" section was also updated similarly.

## Test Plan

<!-- How was it tested? -->

N/A, no functionality/tests affected
2025-07-11 16:07:34 -05:00
Brent Westbrook
b5c5f710fc Render Azure, JSON, and JSON lines output with the new diagnostics (#19133)
## Summary

This was originally stacked on #19129, but some of the changes I made
for JSON also impacted the Azure format, so I went ahead and combined
them. The main changes here are:

- Implementing `FileResolver` for Ruff's `EmitterContext`
- Adding `FileResolver::notebook_index` and `FileResolver::is_notebook`
methods
- Adding a `DisplayDiagnostics` (with an "s") type for rendering a group
of diagnostics at once
- Adding `Azure`, `Json`, and `JsonLines` as new `DiagnosticFormat`s

I tried a couple of alternatives to the `FileResolver::notebook` methods
like passing down the `NotebookIndex` separately and trying to reparse a
`Notebook` from Ruff's `SourceFile`. The latter seemed promising, but
the `SourceFile` only stores the concatenated plain text of the
notebook, not the re-parsable JSON. I guess the current version is just
a variation on passing the `NotebookIndex`, but at least we can reuse
the existing `resolver` argument. I think a lot of this can be cleaned
up once Ruff has its own actual file resolver.

As suggested, I also tried deleting the corresponding `Emitter` files in
`ruff_linter`, but it doesn't look like git was able to follow this as a
rename. It did, however, track that the tests were moved, so the
snapshots should be easy to review.

## Test Plan

Existing Ruff tests ported to tests in `ruff_db`. I think some other
existing ruff tests also cover parts of this refactor.

---------

Co-authored-by: Micha Reiser <micha@reiser.io>
2025-07-11 15:04:46 -04:00
Dan Parizher
ee88abf77c [flake8_django] Fix DJ008 false positive for abstract models with type-annotated abstract field (#19221)
Co-authored-by: Micha Reiser <micha@reiser.io>
2025-07-11 16:50:59 +00:00
Jack O'Connor
78bd73f25a [ty] add support for nonlocal statements 2025-07-11 09:44:54 -07:00
Dan Parizher
110765154f [flake8-bugbear] Fix B017 false negatives for keyword exception arguments (#19217)
Co-authored-by: Micha Reiser <micha@reiser.io>
2025-07-11 16:43:09 +00:00
Dan Parizher
30ee44770d Fix I002 import insertion after docstring with multiple string statements (#19222) 2025-07-11 18:35:41 +02:00
Dhruv Manilawala
fd69533fe5 [ty] Make sure to always respond to client requests (#19277)
## Summary

This PR fixes a bug that didn't return a response to the client if the
document snapshotting failed.

This is resolved by making sure that the server always creates the
document snapshot and embed the any failures inside the snapshot.

Closes: astral-sh/ty#798

## Test Plan

Using the test case as described in the linked issue:



https://github.com/user-attachments/assets/f32833f8-03e5-4641-8c7f-2a536fe2e270
2025-07-11 14:27:27 +00:00
Zanie Blue
39c6364545 Only build tests in the msrv job (#19261)
Alternative to https://github.com/astral-sh/ruff/pull/19260
2025-07-11 09:16:12 -05:00
Andrew Gallant
100d765ddf [ty] Document path separator usage in VendoredFileSystem
Ref https://github.com/astral-sh/ruff/pull/19266#discussion_r2198530383
2025-07-11 10:06:35 -04:00
Andrew Gallant
6ea231e458 [ty] Add debug output with completion request timings
I had this in a branch somewhere but forgot to get it
merged. So I'm sneaking it in here.

This is useful for very ad hoc performance testing.
2025-07-11 10:06:35 -04:00
Alex Waygood
c9df4ddf6a [ty] Add completions for submodule imports
While we did previously support submodule completions via our
`all_members` API, that only works when submodules are attributes of
their parent module. For example, `os.path`. But that didn't work when
the submodule was not an attribute of its parent. For example,
`http.client`. To make the latter work, we read the directory of the
parent module to discover its submodules.
2025-07-11 10:06:35 -04:00
Andrew Gallant
948463aafa [ty] Move SystemOrVendoredPathRef
This moves the type and adds a few methods so that it can
be used elsewhere.
2025-07-11 10:06:35 -04:00
Andrew Gallant
729fa12575 [ty] Add "readdir" for vendored file systems
This is mostly just holding a zip file in the right way
to simulate reading a directory. We want this to be able
to discover sub-modules for completions.
2025-07-11 10:06:35 -04:00
Brent Westbrook
f14ee9edd5 Use structs for JSON serialization (#19270)
## Summary

See https://github.com/astral-sh/ruff/pull/19133#discussion_r2198413586
for recent discussion. This PR moves to using structs for the types in
our JSON output format instead of the `json!` macro.

I didn't rename any of the `message` references because that should be
handled when rebasing #19133 onto this.

My plan for handling the `preview` behavior with the new diagnostics is
to use a wrapper enum. Something like:

```rust
#[derive(Serialize)]
#[serde(untagged)]
pub(crate) enum JsonDiagnostic<'a> {
    Old(OldJsonDiagnostic<'a>),
}

#[derive(Serialize)]
pub(crate) struct OldJsonDiagnostic<'a> {
    // ...
}
```

Initially I thought I could use a `&dyn Serialize` for the affected
fields, but I see that `Serialize` isn't dyn-compatible in testing this
now.

## Test Plan

Existing tests. One quirk of the new types is that their fields are in
alphabetical order. I guess `json!` sorts the fields alphabetically? The
tests were failing before I sorted the struct fields.

## Other formats

It looks like the `rdjson`, `sarif`, and `gitlab` formats also use
`json!`, so if we decide to merge this, I can do something similar for
those before moving them to the new diagnostic format.
2025-07-11 09:37:44 -04:00
Alex Waygood
a67630f907 [ty] Filter out private type aliases from stub files when offering autocomplete suggestions (#19282) 2025-07-11 13:20:16 +00:00
895 changed files with 81236 additions and 10761 deletions

View File

@@ -240,11 +240,11 @@ jobs:
- name: "Install mold"
uses: rui314/setup-mold@85c79d00377f0d32cdbae595a46de6f7c2fa6599 # v1
- name: "Install cargo nextest"
uses: taiki-e/install-action@f3a27926ea13d7be3ee2f4cbb925883cf9442b56 # v2.56.7
uses: taiki-e/install-action@c07504cae06f832dc8de08911c9a9c5cddb0d2d3 # v2.56.13
with:
tool: cargo-nextest
- name: "Install cargo insta"
uses: taiki-e/install-action@f3a27926ea13d7be3ee2f4cbb925883cf9442b56 # v2.56.7
uses: taiki-e/install-action@c07504cae06f832dc8de08911c9a9c5cddb0d2d3 # v2.56.13
with:
tool: cargo-insta
- name: ty mdtests (GitHub annotations)
@@ -298,11 +298,11 @@ jobs:
- name: "Install mold"
uses: rui314/setup-mold@85c79d00377f0d32cdbae595a46de6f7c2fa6599 # v1
- name: "Install cargo nextest"
uses: taiki-e/install-action@f3a27926ea13d7be3ee2f4cbb925883cf9442b56 # v2.56.7
uses: taiki-e/install-action@c07504cae06f832dc8de08911c9a9c5cddb0d2d3 # v2.56.13
with:
tool: cargo-nextest
- name: "Install cargo insta"
uses: taiki-e/install-action@f3a27926ea13d7be3ee2f4cbb925883cf9442b56 # v2.56.7
uses: taiki-e/install-action@c07504cae06f832dc8de08911c9a9c5cddb0d2d3 # v2.56.13
with:
tool: cargo-insta
- name: "Run tests"
@@ -325,7 +325,7 @@ jobs:
- name: "Install Rust toolchain"
run: rustup show
- name: "Install cargo nextest"
uses: taiki-e/install-action@f3a27926ea13d7be3ee2f4cbb925883cf9442b56 # v2.56.7
uses: taiki-e/install-action@c07504cae06f832dc8de08911c9a9c5cddb0d2d3 # v2.56.13
with:
tool: cargo-nextest
- name: "Run tests"
@@ -407,20 +407,11 @@ jobs:
run: rustup default "${MSRV}"
- name: "Install mold"
uses: rui314/setup-mold@85c79d00377f0d32cdbae595a46de6f7c2fa6599 # v1
- name: "Install cargo nextest"
uses: taiki-e/install-action@f3a27926ea13d7be3ee2f4cbb925883cf9442b56 # v2.56.7
with:
tool: cargo-nextest
- name: "Install cargo insta"
uses: taiki-e/install-action@f3a27926ea13d7be3ee2f4cbb925883cf9442b56 # v2.56.7
with:
tool: cargo-insta
- name: "Run tests"
- name: "Build tests"
shell: bash
env:
NEXTEST_PROFILE: "ci"
MSRV: ${{ steps.msrv.outputs.value }}
run: cargo "+${MSRV}" insta test --all-features --unreferenced reject --test-runner nextest
run: cargo "+${MSRV}" test --no-run --all-features
cargo-fuzz-build:
name: "cargo fuzz build"
@@ -912,7 +903,7 @@ jobs:
run: rustup show
- name: "Install codspeed"
uses: taiki-e/install-action@f3a27926ea13d7be3ee2f4cbb925883cf9442b56 # v2.56.7
uses: taiki-e/install-action@c07504cae06f832dc8de08911c9a9c5cddb0d2d3 # v2.56.13
with:
tool: cargo-codspeed
@@ -920,7 +911,7 @@ jobs:
run: cargo codspeed build --features "codspeed,instrumented" --no-default-features -p ruff_benchmark
- name: "Run benchmarks"
uses: CodSpeedHQ/action@0010eb0ca6e89b80c88e8edaaa07cfe5f3e6664d # v3.5.0
uses: CodSpeedHQ/action@c28fe9fbe7d57a3da1b7834ae3761c1d8217612d # v3.7.0
with:
run: cargo codspeed run
token: ${{ secrets.CODSPEED_TOKEN }}
@@ -945,7 +936,7 @@ jobs:
run: rustup show
- name: "Install codspeed"
uses: taiki-e/install-action@f3a27926ea13d7be3ee2f4cbb925883cf9442b56 # v2.56.7
uses: taiki-e/install-action@c07504cae06f832dc8de08911c9a9c5cddb0d2d3 # v2.56.13
with:
tool: cargo-codspeed
@@ -953,7 +944,7 @@ jobs:
run: cargo codspeed build --features "codspeed,walltime" --no-default-features -p ruff_benchmark
- name: "Run benchmarks"
uses: CodSpeedHQ/action@0010eb0ca6e89b80c88e8edaaa07cfe5f3e6664d # v3.5.0
uses: CodSpeedHQ/action@c28fe9fbe7d57a3da1b7834ae3761c1d8217612d # v3.7.0
with:
run: cargo codspeed run
token: ${{ secrets.CODSPEED_TOKEN }}

View File

@@ -1,5 +1,25 @@
name: Sync typeshed
# How this works:
#
# 1. A Linux worker:
# a. Checks out Ruff and typeshed
# b. Deletes the vendored typeshed stdlib stubs from Ruff
# c. Copies the latest versions of the stubs from typeshed
# d. Uses docstring-adder to sync all docstrings available on Linux
# e. Creates a new branch on the upstream astral-sh/ruff repository
# f. Commits the changes it's made and pushes them to the new upstream branch
# 2. Once the Linux worker is done, a Windows worker:
# a. Checks out the branch created by the Linux worker
# b. Syncs all docstrings available on Windows that are not available on Linux
# c. Commits the changes and pushes them to the same upstream branch
# 3. Once the Windows worker is done, a MacOS worker:
# a. Checks out the branch created by the Linux worker
# b. Syncs all docstrings available on MacOS that are not available on Linux or Windows
# c. Commits the changes and pushes them to the same upstream branch
# d. Creates a PR against the `main` branch using the branch all three workers have pushed to
# 4. If any of steps 1-3 failed, an issue is created in the `astral-sh/ruff` repository
on:
workflow_dispatch:
schedule:
@@ -10,7 +30,13 @@ env:
FORCE_COLOR: 1
GH_TOKEN: ${{ github.token }}
# The name of the upstream branch that the first worker creates,
# and which all three workers push to.
UPSTREAM_BRANCH: typeshedbot/sync-typeshed
jobs:
# Sync typeshed stubs, and sync all docstrings available on Linux.
# Push the changes to a new branch on the upstream repository.
sync:
name: Sync typeshed
runs-on: ubuntu-latest
@@ -19,7 +45,6 @@ jobs:
if: ${{ github.repository == 'astral-sh/ruff' || github.event_name != 'schedule' }}
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
name: Checkout Ruff
@@ -36,37 +61,130 @@ jobs:
run: |
git config --global user.name typeshedbot
git config --global user.email '<>'
- name: Sync typeshed
id: sync
- uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6.3.1
- name: Sync typeshed stubs
run: |
rm -rf ruff/crates/ty_vendored/vendor/typeshed
mkdir ruff/crates/ty_vendored/vendor/typeshed
cp typeshed/README.md ruff/crates/ty_vendored/vendor/typeshed
cp typeshed/LICENSE ruff/crates/ty_vendored/vendor/typeshed
# The pyproject.toml file is needed by a later job for the black configuration.
# It's deleted before creating the PR.
cp typeshed/pyproject.toml ruff/crates/ty_vendored/vendor/typeshed
cp -r typeshed/stdlib ruff/crates/ty_vendored/vendor/typeshed/stdlib
rm -rf ruff/crates/ty_vendored/vendor/typeshed/stdlib/@tests
git -C typeshed rev-parse HEAD > ruff/crates/ty_vendored/vendor/typeshed/source_commit.txt
- name: Commit the changes
id: commit
if: ${{ steps.sync.outcome == 'success' }}
run: |
cd ruff
git checkout -b typeshedbot/sync-typeshed
git add .
git diff --staged --quiet || git commit -m "Sync typeshed. Source commit: https://github.com/python/typeshed/commit/$(git -C ../typeshed rev-parse HEAD)"
- name: Create a PR
if: ${{ steps.sync.outcome == 'success' && steps.commit.outcome == 'success' }}
git commit -m "Sync typeshed. Source commit: https://github.com/python/typeshed/commit/$(git -C ../typeshed rev-parse HEAD)" --allow-empty
- name: Sync Linux docstrings
if: ${{ success() }}
run: |
cd ruff
git push --force origin typeshedbot/sync-typeshed
./scripts/codemod_docstrings.sh
git commit -am "Sync Linux docstrings" --allow-empty
- name: Push the changes
id: commit
if: ${{ success() }}
run: git -C ruff push --force --set-upstream origin "${UPSTREAM_BRANCH}"
# Checkout the branch created by the sync job,
# and sync all docstrings available on Windows that are not available on Linux.
# Commit the changes and push them to the same branch.
docstrings-windows:
runs-on: windows-latest
timeout-minutes: 20
needs: [sync]
# Don't run the cron job on forks.
# The job will also be skipped if the sync job failed, because it's specified in `needs` above,
# and we haven't used `always()` in the `if` condition here
# (https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#example-requiring-successful-dependent-jobs)
if: ${{ github.repository == 'astral-sh/ruff' || github.event_name != 'schedule' }}
permissions:
contents: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
name: Checkout Ruff
with:
persist-credentials: true
ref: ${{ env.UPSTREAM_BRANCH}}
- uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6.3.1
- name: Setup git
run: |
git config --global user.name typeshedbot
git config --global user.email '<>'
- name: Sync Windows docstrings
id: docstrings
shell: bash
run: ./scripts/codemod_docstrings.sh
- name: Commit the changes
if: ${{ steps.docstrings.outcome == 'success' }}
run: |
git commit -am "Sync Windows docstrings" --allow-empty
git push
# Checkout the branch created by the sync job,
# and sync all docstrings available on macOS that are not available on Linux or Windows.
# Push the changes to the same branch and create a PR against the `main` branch using that branch.
docstrings-macos-and-pr:
runs-on: macos-latest
timeout-minutes: 20
needs: [sync, docstrings-windows]
# Don't run the cron job on forks.
# The job will also be skipped if the sync or docstrings-windows jobs failed,
# because they're specified in `needs` above and we haven't used an `always()` condition in the `if` here
# (https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#example-requiring-successful-dependent-jobs)
if: ${{ github.repository == 'astral-sh/ruff' || github.event_name != 'schedule' }}
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
name: Checkout Ruff
with:
persist-credentials: true
ref: ${{ env.UPSTREAM_BRANCH}}
- uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6.3.1
- name: Setup git
run: |
git config --global user.name typeshedbot
git config --global user.email '<>'
- name: Sync macOS docstrings
run: ./scripts/codemod_docstrings.sh
- name: Commit and push the changes
if: ${{ success() }}
run: |
git commit -am "Sync macOS docstrings" --allow-empty
# Here we just reformat the codemodded stubs so that they are
# consistent with the other typeshed stubs around them.
# Typeshed formats code using black in their CI, so we just invoke
# black on the stubs the same way that typeshed does.
uvx black crates/ty_vendored/vendor/typeshed/stdlib --config crates/ty_vendored/vendor/typeshed/pyproject.toml || true
git commit -am "Format codemodded docstrings" --allow-empty
rm crates/ty_vendored/vendor/typeshed/pyproject.toml
git commit -am "Remove pyproject.toml file"
git push
- name: Create a PR
if: ${{ success() }}
run: |
gh pr list --repo "$GITHUB_REPOSITORY" --head typeshedbot/sync-typeshed --json id --jq length | grep 1 && exit 0 # exit if there is existing pr
gh pr create --title "[ty] Sync vendored typeshed stubs" --body "Close and reopen this PR to trigger CI" --label "ty"
create-issue-on-failure:
name: Create an issue if the typeshed sync failed
runs-on: ubuntu-latest
needs: [sync]
if: ${{ github.repository == 'astral-sh/ruff' && always() && github.event_name == 'schedule' && needs.sync.result == 'failure' }}
needs: [sync, docstrings-windows, docstrings-macos-and-pr]
if: ${{ github.repository == 'astral-sh/ruff' && always() && github.event_name == 'schedule' && (needs.sync.result == 'failure' || needs.docstrings-windows.result == 'failure' || needs.docstrings-macos-and-pr.result == 'failure') }}
permissions:
issues: write
steps:

View File

@@ -0,0 +1,76 @@
name: ty ecosystem-report
permissions: {}
on:
workflow_dispatch:
schedule:
# Run every Wednesday at 5:00 UTC:
- cron: 0 5 * * 3
env:
CARGO_INCREMENTAL: 0
CARGO_NET_RETRY: 10
CARGO_TERM_COLOR: always
RUSTUP_MAX_RETRIES: 10
RUST_BACKTRACE: 1
CF_API_TOKEN_EXISTS: ${{ secrets.CF_API_TOKEN != '' }}
jobs:
ty-ecosystem-report:
name: Create ecosystem report
runs-on: depot-ubuntu-22.04-32
timeout-minutes: 20
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
path: ruff
fetch-depth: 0
persist-credentials: false
- name: Install the latest version of uv
uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6.3.1
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
with:
workspaces: "ruff"
- name: Install Rust toolchain
run: rustup show
- name: Create report
shell: bash
run: |
cd ruff
echo "Enabling configuration overloads (see .github/mypy-primer-ty.toml)"
mkdir -p ~/.config/ty
cp .github/mypy-primer-ty.toml ~/.config/ty/ty.toml
cd ..
uv tool install "git+https://github.com/astral-sh/ecosystem-analyzer@f0eec0e549684d8e1d7b8bc3e351202124b63bda"
ecosystem-analyzer \
--verbose \
--repository ruff \
analyze \
--projects ruff/crates/ty_python_semantic/resources/primer/good.txt \
--output ecosystem-diagnostics.json
mkdir dist
ecosystem-analyzer \
generate-report \
--max-diagnostics-per-project=1200 \
ecosystem-diagnostics.json \
--output dist/index.html
- name: "Deploy to Cloudflare Pages"
if: ${{ env.CF_API_TOKEN_EXISTS == 'true' }}
id: deploy
uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3.14.1
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
command: pages deploy dist --project-name=ty-ecosystem --branch main --commit-hash ${GITHUB_SHA}

1
.github/zizmor.yml vendored
View File

@@ -11,6 +11,7 @@ rules:
- build-docker.yml
- publish-playground.yml
- ty-ecosystem-analyzer.yaml
- ty-ecosystem-report.yaml
excessive-permissions:
# it's hard to test what the impact of removing these ignores would be
# without actually running the release workflow...

View File

@@ -81,7 +81,7 @@ repos:
pass_filenames: false # This makes it a lot faster
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.2
rev: v0.12.3
hooks:
- id: ruff-format
- id: ruff
@@ -128,5 +128,10 @@ repos:
# but the integration only works if shellcheck is installed
- "github.com/wasilibs/go-shellcheck/cmd/shellcheck@v0.10.0"
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: v0.10.0.1
hooks:
- id: shellcheck
ci:
skip: [cargo-fmt, dev-generate-all]

118
Cargo.lock generated
View File

@@ -396,9 +396,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.40"
version = "4.5.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f"
checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9"
dependencies = [
"clap_builder",
"clap_derive",
@@ -406,9 +406,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.40"
version = "4.5.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e"
checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d"
dependencies = [
"anstream",
"anstyle",
@@ -449,9 +449,9 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "4.5.40"
version = "4.5.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce"
checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491"
dependencies = [
"heck",
"proc-macro2",
@@ -591,7 +591,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [
"lazy_static",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -600,7 +600,7 @@ version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -933,7 +933,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.59.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -1013,7 +1013,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -1133,9 +1133,9 @@ dependencies = [
[[package]]
name = "get-size-derive2"
version = "0.5.1"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aac2af9f9a6a50e31b1e541d05b7925add83d3982c2793193fe9d4ee584323c"
checksum = "028f3cfad7c3e3b1d8d04ef0a1c03576f2d62800803fe1301a4cd262849f2dea"
dependencies = [
"attribute-derive",
"quote",
@@ -1144,9 +1144,9 @@ dependencies = [
[[package]]
name = "get-size2"
version = "0.5.1"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "624a0312efd19e1c45922dfcc2d6806d3ffc4bca261f89f31fcc4f63f438d885"
checksum = "3a09c2043819a3def7bfbb4927e7df96aab0da4cfd8824484b22d0c94e84458e"
dependencies = [
"compact_str",
"get-size-derive2",
@@ -1586,7 +1586,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9"
dependencies = [
"hermit-abi 0.5.1",
"libc",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -1650,7 +1650,7 @@ dependencies = [
"portable-atomic",
"portable-atomic-util",
"serde",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -2465,7 +2465,7 @@ dependencies = [
"pep508_rs",
"serde",
"thiserror 2.0.12",
"toml",
"toml 0.8.23",
]
[[package]]
@@ -2763,7 +2763,7 @@ dependencies = [
"test-case",
"thiserror 2.0.12",
"tikv-jemallocator",
"toml",
"toml 0.9.2",
"tracing",
"walkdir",
"wild",
@@ -2779,7 +2779,7 @@ dependencies = [
"ruff_annotate_snippets",
"serde",
"snapbox",
"toml",
"toml 0.9.2",
"tryfn",
"unicode-width 0.2.1",
]
@@ -2852,6 +2852,7 @@ dependencies = [
"salsa",
"schemars",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.12",
"tracing",
@@ -2894,7 +2895,7 @@ dependencies = [
"similar",
"strum",
"tempfile",
"toml",
"toml 0.9.2",
"tracing",
"tracing-indicatif",
"tracing-subscriber",
@@ -3015,7 +3016,7 @@ dependencies = [
"tempfile",
"test-case",
"thiserror 2.0.12",
"toml",
"toml 0.9.2",
"typed-arena",
"unicode-normalization",
"unicode-width 0.2.1",
@@ -3265,7 +3266,7 @@ dependencies = [
"serde_json",
"shellexpand",
"thiserror 2.0.12",
"toml",
"toml 0.9.2",
"tracing",
"tracing-log",
"tracing-subscriber",
@@ -3355,7 +3356,7 @@ dependencies = [
"shellexpand",
"strum",
"tempfile",
"toml",
"toml 0.9.2",
]
[[package]]
@@ -3390,7 +3391,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -3575,6 +3576,15 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_spanned"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83"
dependencies = [
"serde",
]
[[package]]
name = "serde_test"
version = "1.0.177"
@@ -3780,7 +3790,7 @@ dependencies = [
"getrandom 0.3.3",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -3980,11 +3990,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_edit",
]
[[package]]
name = "toml"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed0aee96c12fa71097902e0bb061a5e1ebd766a6636bb605ba401c45c1650eac"
dependencies = [
"indexmap",
"serde",
"serde_spanned 1.0.0",
"toml_datetime 0.7.0",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
@@ -3994,6 +4019,15 @@ dependencies = [
"serde",
]
[[package]]
name = "toml_datetime"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
@@ -4002,17 +4036,25 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
name = "toml_parser"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
checksum = "97200572db069e74c512a14117b296ba0a80a30123fbbb5aa1f4a348f639ca30"
dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64"
[[package]]
name = "tracing"
@@ -4135,7 +4177,7 @@ dependencies = [
"ruff_python_trivia",
"salsa",
"tempfile",
"toml",
"toml 0.9.2",
"tracing",
"tracing-flame",
"tracing-subscriber",
@@ -4196,7 +4238,7 @@ dependencies = [
"schemars",
"serde",
"thiserror 2.0.12",
"toml",
"toml 0.9.2",
"tracing",
"ty_ide",
"ty_python_semantic",
@@ -4265,6 +4307,7 @@ dependencies = [
"lsp-types",
"ruff_db",
"ruff_notebook",
"ruff_python_ast",
"ruff_source_file",
"ruff_text_size",
"rustc-hash",
@@ -4272,6 +4315,7 @@ dependencies = [
"serde",
"serde_json",
"shellexpand",
"thiserror 2.0.12",
"tracing",
"tracing-subscriber",
"ty_ide",
@@ -4312,7 +4356,7 @@ dependencies = [
"smallvec",
"tempfile",
"thiserror 2.0.12",
"toml",
"toml 0.9.2",
"tracing",
"ty_python_semantic",
"ty_static",
@@ -4806,7 +4850,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]

View File

@@ -165,7 +165,7 @@ tempfile = { version = "3.9.0" }
test-case = { version = "3.3.1" }
thiserror = { version = "2.0.0" }
tikv-jemallocator = { version = "0.6.0" }
toml = { version = "0.8.11" }
toml = { version = "0.9.0" }
tracing = { version = "0.1.40" }
tracing-flame = { version = "0.2.0" }
tracing-indicatif = { version = "0.3.11" }

View File

@@ -131,6 +131,7 @@ pub fn run(
}: Args,
) -> Result<ExitStatus> {
{
ruff_db::set_program_version(crate::version::version().to_string()).unwrap();
let default_panic_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
#[expect(clippy::print_stderr)]
@@ -439,7 +440,7 @@ pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result<Exi
if cli.statistics {
printer.write_statistics(&diagnostics, &mut summary_writer)?;
} else {
printer.write_once(&diagnostics, &mut summary_writer)?;
printer.write_once(&diagnostics, &mut summary_writer, preview)?;
}
if !cli.exit_zero {

View File

@@ -9,13 +9,14 @@ use itertools::{Itertools, iterate};
use ruff_linter::linter::FixTable;
use serde::Serialize;
use ruff_db::diagnostic::{Diagnostic, SecondaryCode};
use ruff_db::diagnostic::{
Diagnostic, DiagnosticFormat, DisplayDiagnosticConfig, DisplayDiagnostics, SecondaryCode,
};
use ruff_linter::fs::relativize_path;
use ruff_linter::logging::LogLevel;
use ruff_linter::message::{
AzureEmitter, Emitter, EmitterContext, GithubEmitter, GitlabEmitter, GroupedEmitter,
JsonEmitter, JsonLinesEmitter, JunitEmitter, PylintEmitter, RdjsonEmitter, SarifEmitter,
TextEmitter,
Emitter, EmitterContext, GithubEmitter, GitlabEmitter, GroupedEmitter, JunitEmitter,
SarifEmitter, TextEmitter,
};
use ruff_linter::notify_user;
use ruff_linter::settings::flags::{self};
@@ -202,6 +203,7 @@ impl Printer {
&self,
diagnostics: &Diagnostics,
writer: &mut dyn Write,
preview: bool,
) -> Result<()> {
if matches!(self.log_level, LogLevel::Silent) {
return Ok(());
@@ -229,13 +231,25 @@ impl Printer {
match self.format {
OutputFormat::Json => {
JsonEmitter.emit(writer, &diagnostics.inner, &context)?;
let config = DisplayDiagnosticConfig::default()
.format(DiagnosticFormat::Json)
.preview(preview);
let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner);
write!(writer, "{value}")?;
}
OutputFormat::Rdjson => {
RdjsonEmitter.emit(writer, &diagnostics.inner, &context)?;
let config = DisplayDiagnosticConfig::default()
.format(DiagnosticFormat::Rdjson)
.preview(preview);
let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner);
write!(writer, "{value}")?;
}
OutputFormat::JsonLines => {
JsonLinesEmitter.emit(writer, &diagnostics.inner, &context)?;
let config = DisplayDiagnosticConfig::default()
.format(DiagnosticFormat::JsonLines)
.preview(preview);
let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner);
write!(writer, "{value}")?;
}
OutputFormat::Junit => {
JunitEmitter.emit(writer, &diagnostics.inner, &context)?;
@@ -280,10 +294,18 @@ impl Printer {
GitlabEmitter::default().emit(writer, &diagnostics.inner, &context)?;
}
OutputFormat::Pylint => {
PylintEmitter.emit(writer, &diagnostics.inner, &context)?;
let config = DisplayDiagnosticConfig::default()
.format(DiagnosticFormat::Pylint)
.preview(preview);
let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner);
write!(writer, "{value}")?;
}
OutputFormat::Azure => {
AzureEmitter.emit(writer, &diagnostics.inner, &context)?;
let config = DisplayDiagnosticConfig::default()
.format(DiagnosticFormat::Azure)
.preview(preview);
let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner);
write!(writer, "{value}")?;
}
OutputFormat::Sarif => {
SarifEmitter.emit(writer, &diagnostics.inner, &context)?;

View File

@@ -120,7 +120,7 @@ fn nonexistent_config_file() {
#[test]
fn config_override_rejected_if_invalid_toml() {
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
.args(["format", "--config", "foo = bar", "."]), @r#"
.args(["format", "--config", "foo = bar", "."]), @r"
success: false
exit_code: 2
----- stdout -----
@@ -137,12 +137,11 @@ fn config_override_rejected_if_invalid_toml() {
TOML parse error at line 1, column 7
|
1 | foo = bar
| ^
invalid string
expected `"`, `'`
| ^^^
string values must be quoted, expected literal string
For more information, try '--help'.
"#);
");
}
#[test]

View File

@@ -2246,8 +2246,7 @@ fn pyproject_toml_stdin_syntax_error() {
success: false
exit_code: 1
----- stdout -----
pyproject.toml:1:9: RUF200 Failed to parse pyproject.toml: invalid table header
expected `.`, `]`
pyproject.toml:1:9: RUF200 Failed to parse pyproject.toml: unclosed table, expected `]`
|
1 | [project
| ^ RUF200

View File

@@ -534,7 +534,7 @@ fn nonexistent_config_file() {
fn config_override_rejected_if_invalid_toml() {
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
.args(STDIN_BASE_OPTIONS)
.args(["--config", "foo = bar", "."]), @r#"
.args(["--config", "foo = bar", "."]), @r"
success: false
exit_code: 2
----- stdout -----
@@ -551,12 +551,11 @@ fn config_override_rejected_if_invalid_toml() {
TOML parse error at line 1, column 7
|
1 | foo = bar
| ^
invalid string
expected `"`, `'`
| ^^^
string values must be quoted, expected literal string
For more information, try '--help'.
"#);
");
}
#[test]
@@ -733,9 +732,8 @@ select = [E501]
Cause: TOML parse error at line 3, column 11
|
3 | select = [E501]
| ^
invalid array
expected `]`
| ^^^^
string values must be quoted, expected literal string
");
});
@@ -876,7 +874,7 @@ fn each_toml_option_requires_a_new_flag_1() {
|
1 | extend-select=['F841'], line-length=90
| ^
expected newline, `#`
unexpected key or value, expected newline, `#`
For more information, try '--help'.
");
@@ -907,7 +905,7 @@ fn each_toml_option_requires_a_new_flag_2() {
|
1 | extend-select=['F841'] line-length=90
| ^
expected newline, `#`
unexpected key or value, expected newline, `#`
For more information, try '--help'.
");

View File

@@ -18,6 +18,6 @@ exit_code: 1
----- stdout -----
input.py:1: [F401] `os` imported but unused
input.py:2: [F821] Undefined name `y`
input.py:3: SyntaxError: Cannot use `match` statement on Python 3.9 (syntax was added in Python 3.10)
input.py:3: [invalid-syntax] SyntaxError: Cannot use `match` statement on Python 3.9 (syntax was added in Python 3.10)
----- stderr -----

View File

@@ -75,8 +75,7 @@ exit_code: 1
},
{
"code": {
"url": null,
"value": null
"value": "invalid-syntax"
},
"location": {
"path": "[TMP]/input.py",
@@ -94,7 +93,7 @@ exit_code: 1
"message": "SyntaxError: Cannot use `match` statement on Python 3.9 (syntax was added in Python 3.10)"
}
],
"severity": "warning",
"severity": "WARNING",
"source": {
"name": "ruff",
"url": "https://docs.astral.sh/ruff"

View File

@@ -60,7 +60,7 @@ fn config_option_ignored_but_validated() {
assert_cmd_snapshot!(
Command::new(get_cargo_bin(BIN_NAME))
.arg("version")
.args(["--config", "foo = bar"]), @r#"
.args(["--config", "foo = bar"]), @r"
success: false
exit_code: 2
----- stdout -----
@@ -77,12 +77,11 @@ fn config_option_ignored_but_validated() {
TOML parse error at line 1, column 7
|
1 | foo = bar
| ^
invalid string
expected `"`, `'`
| ^^^
string values must be quoted, expected literal string
For more information, try '--help'.
"#
"
);
});
}

View File

@@ -2,6 +2,7 @@
use ruff_benchmark::criterion;
use ruff_benchmark::real_world_projects::{InstalledProject, RealWorldProject};
use std::fmt::Write;
use std::ops::Range;
use criterion::{BatchSize, Criterion, criterion_group, criterion_main};
@@ -441,6 +442,37 @@ fn benchmark_complex_constrained_attributes_2(criterion: &mut Criterion) {
});
}
fn benchmark_many_enum_members(criterion: &mut Criterion) {
const NUM_ENUM_MEMBERS: usize = 512;
setup_rayon();
let mut code = String::new();
writeln!(&mut code, "from enum import Enum").ok();
writeln!(&mut code, "class E(Enum):").ok();
for i in 0..NUM_ENUM_MEMBERS {
writeln!(&mut code, " m{i} = {i}").ok();
}
writeln!(&mut code).ok();
for i in 0..NUM_ENUM_MEMBERS {
writeln!(&mut code, "print(E.m{i})").ok();
}
criterion.bench_function("ty_micro[many_enum_members]", |b| {
b.iter_batched_ref(
|| setup_micro_case(&code),
|case| {
let Case { db, .. } = case;
let result = db.check();
assert_eq!(result.len(), 0);
},
BatchSize::SmallInput,
);
});
}
struct ProjectBenchmark<'a> {
project: InstalledProject<'a>,
fs: MemoryFileSystem,
@@ -591,6 +623,7 @@ criterion_group!(
benchmark_many_tuple_assignments,
benchmark_complex_constrained_attributes_1,
benchmark_complex_constrained_attributes_2,
benchmark_many_enum_members,
);
criterion_group!(project, anyio, attrs, hydra, datetype);
criterion_main!(check_file, micro, project);

View File

@@ -38,6 +38,7 @@ rustc-hash = { workspace = true }
salsa = { workspace = true }
schemars = { workspace = true, optional = true }
serde = { workspace = true, optional = true }
serde_json = { workspace = true, optional = true }
thiserror = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true, optional = true }
@@ -56,6 +57,6 @@ tempfile = { workspace = true }
[features]
cache = ["ruff_cache"]
os = ["ignore", "dep:etcetera"]
serde = ["dep:serde", "camino/serde1"]
serde = ["camino/serde1", "dep:serde", "dep:serde_json", "ruff_diagnostics/serde"]
# Exposes testing utilities.
testing = ["tracing-subscriber"]

View File

@@ -1,13 +1,12 @@
use std::{fmt::Formatter, sync::Arc};
use std::{fmt::Formatter, path::Path, sync::Arc};
use render::{FileResolver, Input};
use ruff_diagnostics::Fix;
use ruff_source_file::{LineColumn, SourceCode, SourceFile};
use ruff_annotate_snippets::Level as AnnotateLevel;
use ruff_text_size::{Ranged, TextRange, TextSize};
pub use self::render::DisplayDiagnostic;
pub use self::render::{DisplayDiagnostic, DisplayDiagnostics, FileResolver, Input};
use crate::{Db, files::File};
mod render;
@@ -309,6 +308,10 @@ impl Diagnostic {
/// Set the fix for this diagnostic.
pub fn set_fix(&mut self, fix: Fix) {
debug_assert!(
self.primary_span().is_some(),
"Expected a source file for a diagnostic with a fix"
);
Arc::make_mut(&mut self.inner).fix = Some(fix);
}
@@ -380,7 +383,7 @@ impl Diagnostic {
}
/// Returns the URL for the rule documentation, if it exists.
pub fn to_url(&self) -> Option<String> {
pub fn to_ruff_url(&self) -> Option<String> {
if self.is_invalid_syntax() {
None
} else {
@@ -432,8 +435,9 @@ impl Diagnostic {
/// Returns the [`SourceFile`] which the message belongs to.
///
/// Panics if the diagnostic has no primary span, or if its file is not a `SourceFile`.
pub fn expect_ruff_source_file(&self) -> SourceFile {
self.expect_primary_span().expect_ruff_file().clone()
pub fn expect_ruff_source_file(&self) -> &SourceFile {
self.ruff_source_file()
.expect("Expected a ruff source file")
}
/// Returns the [`TextRange`] for the diagnostic.
@@ -1008,6 +1012,18 @@ impl UnifiedFile {
}
}
/// Return the file's path relative to the current working directory.
pub fn relative_path<'a>(&'a self, resolver: &'a dyn FileResolver) -> &'a Path {
let cwd = resolver.current_directory();
let path = Path::new(self.path(resolver));
if let Ok(path) = path.strip_prefix(cwd) {
return path;
}
path
}
fn diagnostic_source(&self, resolver: &dyn FileResolver) -> DiagnosticSource {
match self {
UnifiedFile::Ty(file) => DiagnosticSource::Ty(resolver.input(*file)),
@@ -1174,6 +1190,12 @@ pub struct DisplayDiagnosticConfig {
/// here for now as the most "sensible" place for it to live until
/// we had more concrete use cases. ---AG
context: usize,
/// Whether to use preview formatting for Ruff diagnostics.
#[allow(
dead_code,
reason = "This is currently only used for JSON but will be needed soon for other formats"
)]
preview: bool,
}
impl DisplayDiagnosticConfig {
@@ -1194,6 +1216,14 @@ impl DisplayDiagnosticConfig {
..self
}
}
/// Whether to enable preview behavior or not.
pub fn preview(self, yes: bool) -> DisplayDiagnosticConfig {
DisplayDiagnosticConfig {
preview: yes,
..self
}
}
}
impl Default for DisplayDiagnosticConfig {
@@ -1202,6 +1232,7 @@ impl Default for DisplayDiagnosticConfig {
format: DiagnosticFormat::default(),
color: false,
context: 2,
preview: false,
}
}
}
@@ -1229,6 +1260,28 @@ pub enum DiagnosticFormat {
///
/// This may use color when printing to a `tty`.
Concise,
/// Print diagnostics in the [Azure Pipelines] format.
///
/// [Azure Pipelines]: https://learn.microsoft.com/en-us/azure/devops/pipelines/scripts/logging-commands?view=azure-devops&tabs=bash#logissue-log-an-error-or-warning
Azure,
/// Print diagnostics in JSON format.
///
/// Unlike `json-lines`, this prints all of the diagnostics as a JSON array.
#[cfg(feature = "serde")]
Json,
/// Print diagnostics in JSON format, one per line.
///
/// This will print each diagnostic as a separate JSON object on its own line. See the `json`
/// format for an array of all diagnostics. See <https://jsonlines.org/> for more details.
#[cfg(feature = "serde")]
JsonLines,
/// Print diagnostics in the JSON format expected by [reviewdog].
///
/// [reviewdog]: https://github.com/reviewdog/reviewdog
#[cfg(feature = "serde")]
Rdjson,
/// Print diagnostics in the format emitted by Pylint.
Pylint,
}
/// A representation of the kinds of messages inside a diagnostic.

View File

@@ -1,9 +1,11 @@
use std::collections::BTreeMap;
use std::path::Path;
use ruff_annotate_snippets::{
Annotation as AnnotateAnnotation, Level as AnnotateLevel, Message as AnnotateMessage,
Renderer as AnnotateRenderer, Snippet as AnnotateSnippet,
};
use ruff_notebook::{Notebook, NotebookIndex};
use ruff_source_file::{LineIndex, OneIndexed, SourceCode};
use ruff_text_size::{TextRange, TextSize};
@@ -17,9 +19,21 @@ use crate::{
use super::{
Annotation, Diagnostic, DiagnosticFormat, DiagnosticSource, DisplayDiagnosticConfig, Severity,
SubDiagnostic,
SubDiagnostic, UnifiedFile,
};
use azure::AzureRenderer;
use pylint::PylintRenderer;
mod azure;
#[cfg(feature = "serde")]
mod json;
#[cfg(feature = "serde")]
mod json_lines;
mod pylint;
#[cfg(feature = "serde")]
mod rdjson;
/// A type that implements `std::fmt::Display` for diagnostic rendering.
///
/// It is created via [`Diagnostic::display`].
@@ -34,7 +48,6 @@ use super::{
pub struct DisplayDiagnostic<'a> {
config: &'a DisplayDiagnosticConfig,
resolver: &'a dyn FileResolver,
annotate_renderer: AnnotateRenderer,
diag: &'a Diagnostic,
}
@@ -44,16 +57,9 @@ impl<'a> DisplayDiagnostic<'a> {
config: &'a DisplayDiagnosticConfig,
diag: &'a Diagnostic,
) -> DisplayDiagnostic<'a> {
let annotate_renderer = if config.color {
AnnotateRenderer::styled()
} else {
AnnotateRenderer::plain()
};
DisplayDiagnostic {
config,
resolver,
annotate_renderer,
diag,
}
}
@@ -61,68 +67,138 @@ impl<'a> DisplayDiagnostic<'a> {
impl std::fmt::Display for DisplayDiagnostic<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
let stylesheet = if self.config.color {
DiagnosticStylesheet::styled()
} else {
DiagnosticStylesheet::plain()
};
DisplayDiagnostics::new(self.resolver, self.config, std::slice::from_ref(self.diag)).fmt(f)
}
}
if matches!(self.config.format, DiagnosticFormat::Concise) {
let (severity, severity_style) = match self.diag.severity() {
Severity::Info => ("info", stylesheet.info),
Severity::Warning => ("warning", stylesheet.warning),
Severity::Error => ("error", stylesheet.error),
Severity::Fatal => ("fatal", stylesheet.error),
};
/// A type that implements `std::fmt::Display` for rendering a collection of diagnostics.
///
/// It is intended for collections of diagnostics that need to be serialized together, as is the
/// case for JSON, for example.
///
/// See [`DisplayDiagnostic`] for rendering individual `Diagnostic`s and details about the lifetime
/// constraints.
pub struct DisplayDiagnostics<'a> {
config: &'a DisplayDiagnosticConfig,
resolver: &'a dyn FileResolver,
diagnostics: &'a [Diagnostic],
}
write!(
f,
"{severity}[{id}]",
severity = fmt_styled(severity, severity_style),
id = fmt_styled(self.diag.id(), stylesheet.emphasis)
)?;
impl<'a> DisplayDiagnostics<'a> {
pub fn new(
resolver: &'a dyn FileResolver,
config: &'a DisplayDiagnosticConfig,
diagnostics: &'a [Diagnostic],
) -> DisplayDiagnostics<'a> {
DisplayDiagnostics {
config,
resolver,
diagnostics,
}
}
}
if let Some(span) = self.diag.primary_span() {
write!(
f,
" {path}",
path = fmt_styled(span.file().path(self.resolver), stylesheet.emphasis)
)?;
if let Some(range) = span.range() {
let diagnostic_source = span.file().diagnostic_source(self.resolver);
let start = diagnostic_source
.as_source_code()
.line_column(range.start());
impl std::fmt::Display for DisplayDiagnostics<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self.config.format {
DiagnosticFormat::Concise => {
let stylesheet = if self.config.color {
DiagnosticStylesheet::styled()
} else {
DiagnosticStylesheet::plain()
};
for diag in self.diagnostics {
let (severity, severity_style) = match diag.severity() {
Severity::Info => ("info", stylesheet.info),
Severity::Warning => ("warning", stylesheet.warning),
Severity::Error => ("error", stylesheet.error),
Severity::Fatal => ("fatal", stylesheet.error),
};
write!(
f,
":{line}:{col}",
line = fmt_styled(start.line, stylesheet.emphasis),
col = fmt_styled(start.column, stylesheet.emphasis),
"{severity}[{id}]",
severity = fmt_styled(severity, severity_style),
id = fmt_styled(diag.id(), stylesheet.emphasis)
)?;
if let Some(span) = diag.primary_span() {
write!(
f,
" {path}",
path = fmt_styled(span.file().path(self.resolver), stylesheet.emphasis)
)?;
if let Some(range) = span.range() {
let diagnostic_source = span.file().diagnostic_source(self.resolver);
let start = diagnostic_source
.as_source_code()
.line_column(range.start());
write!(
f,
":{line}:{col}",
line = fmt_styled(start.line, stylesheet.emphasis),
col = fmt_styled(start.column, stylesheet.emphasis),
)?;
}
write!(f, ":")?;
}
writeln!(f, " {message}", message = diag.concise_message())?;
}
write!(f, ":")?;
}
return writeln!(f, " {message}", message = self.diag.concise_message());
DiagnosticFormat::Full => {
let stylesheet = if self.config.color {
DiagnosticStylesheet::styled()
} else {
DiagnosticStylesheet::plain()
};
let mut renderer = if self.config.color {
AnnotateRenderer::styled()
} else {
AnnotateRenderer::plain()
};
renderer = renderer
.error(stylesheet.error)
.warning(stylesheet.warning)
.info(stylesheet.info)
.note(stylesheet.note)
.help(stylesheet.help)
.line_no(stylesheet.line_no)
.emphasis(stylesheet.emphasis)
.none(stylesheet.none);
for diag in self.diagnostics {
let resolved = Resolved::new(self.resolver, diag);
let renderable = resolved.to_renderable(self.config.context);
for diag in renderable.diagnostics.iter() {
writeln!(f, "{}", renderer.render(diag.to_annotate()))?;
}
writeln!(f)?;
}
}
DiagnosticFormat::Azure => {
AzureRenderer::new(self.resolver).render(f, self.diagnostics)?;
}
#[cfg(feature = "serde")]
DiagnosticFormat::Json => {
json::JsonRenderer::new(self.resolver, self.config).render(f, self.diagnostics)?;
}
#[cfg(feature = "serde")]
DiagnosticFormat::JsonLines => {
json_lines::JsonLinesRenderer::new(self.resolver, self.config)
.render(f, self.diagnostics)?;
}
#[cfg(feature = "serde")]
DiagnosticFormat::Rdjson => {
rdjson::RdjsonRenderer::new(self.resolver).render(f, self.diagnostics)?;
}
DiagnosticFormat::Pylint => {
PylintRenderer::new(self.resolver).render(f, self.diagnostics)?;
}
}
let mut renderer = self.annotate_renderer.clone();
renderer = renderer
.error(stylesheet.error)
.warning(stylesheet.warning)
.info(stylesheet.info)
.note(stylesheet.note)
.help(stylesheet.help)
.line_no(stylesheet.line_no)
.emphasis(stylesheet.emphasis)
.none(stylesheet.none);
let resolved = Resolved::new(self.resolver, self.diag);
let renderable = resolved.to_renderable(self.config.context);
for diag in renderable.diagnostics.iter() {
writeln!(f, "{}", renderer.render(diag.to_annotate()))?;
}
writeln!(f)
Ok(())
}
}
@@ -635,6 +711,15 @@ pub trait FileResolver {
/// Returns the input contents associated with the file given.
fn input(&self, file: File) -> Input;
/// Returns the [`NotebookIndex`] associated with the file given, if it's a Jupyter notebook.
fn notebook_index(&self, file: &UnifiedFile) -> Option<NotebookIndex>;
/// Returns whether the file given is a Jupyter notebook.
fn is_notebook(&self, file: &UnifiedFile) -> bool;
/// Returns the current working directory.
fn current_directory(&self) -> &Path;
}
impl<T> FileResolver for T
@@ -651,6 +736,29 @@ where
line_index: line_index(self, file),
}
}
fn notebook_index(&self, file: &UnifiedFile) -> Option<NotebookIndex> {
match file {
UnifiedFile::Ty(file) => self
.input(*file)
.text
.as_notebook()
.map(Notebook::index)
.cloned(),
UnifiedFile::Ruff(_) => unimplemented!("Expected an interned ty file"),
}
}
fn is_notebook(&self, file: &UnifiedFile) -> bool {
match file {
UnifiedFile::Ty(file) => self.input(*file).text.as_notebook().is_some(),
UnifiedFile::Ruff(_) => unimplemented!("Expected an interned ty file"),
}
}
fn current_directory(&self) -> &Path {
self.system().current_directory().as_std_path()
}
}
impl FileResolver for &dyn Db {
@@ -664,6 +772,29 @@ impl FileResolver for &dyn Db {
line_index: line_index(*self, file),
}
}
fn notebook_index(&self, file: &UnifiedFile) -> Option<NotebookIndex> {
match file {
UnifiedFile::Ty(file) => self
.input(*file)
.text
.as_notebook()
.map(Notebook::index)
.cloned(),
UnifiedFile::Ruff(_) => unimplemented!("Expected an interned ty file"),
}
}
fn is_notebook(&self, file: &UnifiedFile) -> bool {
match file {
UnifiedFile::Ty(file) => self.input(*file).text.as_notebook().is_some(),
UnifiedFile::Ruff(_) => unimplemented!("Expected an interned ty file"),
}
}
fn current_directory(&self) -> &Path {
self.system().current_directory().as_std_path()
}
}
/// An abstraction over a unit of user input.
@@ -724,7 +855,9 @@ fn relativize_path<'p>(cwd: &SystemPath, path: &'p str) -> &'p str {
#[cfg(test)]
mod tests {
use crate::diagnostic::{Annotation, DiagnosticId, Severity, Span};
use ruff_diagnostics::{Edit, Fix};
use crate::diagnostic::{Annotation, DiagnosticId, SecondaryCode, Severity, Span};
use crate::files::system_path_to_file;
use crate::system::{DbWithWritableSystem, SystemPath};
use crate::tests::TestDb;
@@ -2121,7 +2254,7 @@ watermelon
/// A small harness for setting up an environment specifically for testing
/// diagnostic rendering.
struct TestEnvironment {
pub(super) struct TestEnvironment {
db: TestDb,
config: DisplayDiagnosticConfig,
}
@@ -2130,7 +2263,7 @@ watermelon
/// Create a new test harness.
///
/// This uses the default diagnostic rendering configuration.
fn new() -> TestEnvironment {
pub(super) fn new() -> TestEnvironment {
TestEnvironment {
db: TestDb::new(),
config: DisplayDiagnosticConfig::default(),
@@ -2149,8 +2282,26 @@ watermelon
self.config = config;
}
/// Set the output format to use in diagnostic rendering.
pub(super) fn format(&mut self, format: DiagnosticFormat) {
let mut config = std::mem::take(&mut self.config);
config = config.format(format);
self.config = config;
}
/// Enable preview functionality for diagnostic rendering.
#[allow(
dead_code,
reason = "This is currently only used for JSON but will be needed soon for other formats"
)]
pub(super) fn preview(&mut self, yes: bool) {
let mut config = std::mem::take(&mut self.config);
config = config.preview(yes);
self.config = config;
}
/// Add a file with the given path and contents to this environment.
fn add(&mut self, path: &str, contents: &str) {
pub(super) fn add(&mut self, path: &str, contents: &str) {
let path = SystemPath::new(path);
self.db.write_file(path, contents).unwrap();
}
@@ -2200,7 +2351,7 @@ watermelon
/// A convenience function for returning a builder for a diagnostic
/// with "error" severity and canned values for its identifier
/// and message.
fn err(&mut self) -> DiagnosticBuilder<'_> {
pub(super) fn err(&mut self) -> DiagnosticBuilder<'_> {
self.builder(
"test-diagnostic",
Severity::Error,
@@ -2226,6 +2377,12 @@ watermelon
DiagnosticBuilder { env: self, diag }
}
/// A convenience function for returning a builder for an invalid syntax diagnostic.
fn invalid_syntax(&mut self, message: &str) -> DiagnosticBuilder<'_> {
let diag = Diagnostic::new(DiagnosticId::InvalidSyntax, Severity::Error, message);
DiagnosticBuilder { env: self, diag }
}
/// Returns a builder for tersely constructing sub-diagnostics.
fn sub_builder(&mut self, severity: Severity, message: &str) -> SubDiagnosticBuilder<'_> {
let subdiag = SubDiagnostic::new(severity, message);
@@ -2235,9 +2392,18 @@ watermelon
/// Render the given diagnostic into a `String`.
///
/// (This will set the "printed" flag on `Diagnostic`.)
fn render(&self, diag: &Diagnostic) -> String {
pub(super) fn render(&self, diag: &Diagnostic) -> String {
diag.display(&self.db, &self.config).to_string()
}
/// Render the given diagnostics into a `String`.
///
/// See `render` for rendering a single diagnostic.
///
/// (This will set the "printed" flag on `Diagnostic`.)
pub(super) fn render_diagnostics(&self, diagnostics: &[Diagnostic]) -> String {
DisplayDiagnostics::new(&self.db, &self.config, diagnostics).to_string()
}
}
/// A helper builder for tersely populating a `Diagnostic`.
@@ -2246,14 +2412,14 @@ watermelon
/// supported by this builder, and this only needs to be done
/// infrequently, consider doing it more verbosely on `diag`
/// itself.
struct DiagnosticBuilder<'e> {
pub(super) struct DiagnosticBuilder<'e> {
env: &'e mut TestEnvironment,
diag: Diagnostic,
}
impl<'e> DiagnosticBuilder<'e> {
/// Return the built diagnostic.
fn build(self) -> Diagnostic {
pub(super) fn build(self) -> Diagnostic {
self.diag
}
@@ -2302,6 +2468,25 @@ watermelon
self.diag.annotate(ann);
self
}
/// Set the secondary code on the diagnostic.
fn secondary_code(mut self, secondary_code: &str) -> DiagnosticBuilder<'e> {
self.diag
.set_secondary_code(SecondaryCode::new(secondary_code.to_string()));
self
}
/// Set the fix on the diagnostic.
pub(super) fn fix(mut self, fix: Fix) -> DiagnosticBuilder<'e> {
self.diag.set_fix(fix);
self
}
/// Set the noqa offset on the diagnostic.
fn noqa_offset(mut self, noqa_offset: TextSize) -> DiagnosticBuilder<'e> {
self.diag.set_noqa_offset(noqa_offset);
self
}
}
/// A helper builder for tersely populating a `SubDiagnostic`.
@@ -2381,4 +2566,199 @@ watermelon
let offset = TextSize::from(offset.parse::<u32>().unwrap());
(line_number, Some(offset))
}
/// Create Ruff-style diagnostics for testing the various output formats.
pub(crate) fn create_diagnostics(
format: DiagnosticFormat,
) -> (TestEnvironment, Vec<Diagnostic>) {
let mut env = TestEnvironment::new();
env.add(
"fib.py",
r#"import os
def fibonacci(n):
"""Compute the nth number in the Fibonacci sequence."""
x = 1
if n == 0:
return 0
elif n == 1:
return 1
else:
return fibonacci(n - 1) + fibonacci(n - 2)
"#,
);
env.add("undef.py", r"if a == 1: pass");
env.format(format);
let diagnostics = vec![
env.builder("unused-import", Severity::Error, "`os` imported but unused")
.primary("fib.py", "1:7", "1:9", "Remove unused import: `os`")
.secondary_code("F401")
.fix(Fix::unsafe_edit(Edit::range_deletion(TextRange::new(
TextSize::from(0),
TextSize::from(10),
))))
.noqa_offset(TextSize::from(7))
.build(),
env.builder(
"unused-variable",
Severity::Error,
"Local variable `x` is assigned to but never used",
)
.primary(
"fib.py",
"6:4",
"6:5",
"Remove assignment to unused variable `x`",
)
.secondary_code("F841")
.fix(Fix::unsafe_edit(Edit::deletion(
TextSize::from(94),
TextSize::from(99),
)))
.noqa_offset(TextSize::from(94))
.build(),
env.builder("undefined-name", Severity::Error, "Undefined name `a`")
.primary("undef.py", "1:3", "1:4", "")
.secondary_code("F821")
.noqa_offset(TextSize::from(3))
.build(),
];
(env, diagnostics)
}
/// Create Ruff-style syntax error diagnostics for testing the various output formats.
pub(crate) fn create_syntax_error_diagnostics(
format: DiagnosticFormat,
) -> (TestEnvironment, Vec<Diagnostic>) {
let mut env = TestEnvironment::new();
env.add(
"syntax_errors.py",
r"from os import
if call(foo
def bar():
pass
",
);
env.format(format);
let diagnostics = vec![
env.invalid_syntax("SyntaxError: Expected one or more symbol names after import")
.primary("syntax_errors.py", "1:14", "1:15", "")
.build(),
env.invalid_syntax("SyntaxError: Expected ')', found newline")
.primary("syntax_errors.py", "3:11", "3:12", "")
.build(),
];
(env, diagnostics)
}
/// Create Ruff-style diagnostics for testing the various output formats for a notebook.
#[allow(
dead_code,
reason = "This is currently only used for JSON but will be needed soon for other formats"
)]
pub(crate) fn create_notebook_diagnostics(
format: DiagnosticFormat,
) -> (TestEnvironment, Vec<Diagnostic>) {
let mut env = TestEnvironment::new();
env.add(
"notebook.ipynb",
r##"
{
"cells": [
{
"cell_type": "code",
"metadata": {},
"outputs": [],
"source": [
"# cell 1\n",
"import os"
]
},
{
"cell_type": "code",
"metadata": {},
"outputs": [],
"source": [
"# cell 2\n",
"import math\n",
"\n",
"print('hello world')"
]
},
{
"cell_type": "code",
"metadata": {},
"outputs": [],
"source": [
"# cell 3\n",
"def foo():\n",
" print()\n",
" x = 1\n"
]
}
],
"metadata": {},
"nbformat": 4,
"nbformat_minor": 5
}
"##,
);
env.format(format);
let diagnostics = vec![
env.builder("unused-import", Severity::Error, "`os` imported but unused")
.primary("notebook.ipynb", "2:7", "2:9", "Remove unused import: `os`")
.secondary_code("F401")
.fix(Fix::safe_edit(Edit::range_deletion(TextRange::new(
TextSize::from(9),
TextSize::from(19),
))))
.noqa_offset(TextSize::from(16))
.build(),
env.builder(
"unused-import",
Severity::Error,
"`math` imported but unused",
)
.primary(
"notebook.ipynb",
"4:7",
"4:11",
"Remove unused import: `math`",
)
.secondary_code("F401")
.fix(Fix::safe_edit(Edit::range_deletion(TextRange::new(
TextSize::from(28),
TextSize::from(40),
))))
.noqa_offset(TextSize::from(35))
.build(),
env.builder(
"unused-variable",
Severity::Error,
"Local variable `x` is assigned to but never used",
)
.primary(
"notebook.ipynb",
"10:4",
"10:5",
"Remove assignment to unused variable `x`",
)
.secondary_code("F841")
.fix(Fix::unsafe_edit(Edit::range_deletion(TextRange::new(
TextSize::from(94),
TextSize::from(104),
))))
.noqa_offset(TextSize::from(98))
.build(),
];
(env, diagnostics)
}
}

View File

@@ -0,0 +1,83 @@
use ruff_source_file::LineColumn;
use crate::diagnostic::{Diagnostic, Severity};
use super::FileResolver;
pub(super) struct AzureRenderer<'a> {
resolver: &'a dyn FileResolver,
}
impl<'a> AzureRenderer<'a> {
pub(super) fn new(resolver: &'a dyn FileResolver) -> Self {
Self { resolver }
}
}
impl AzureRenderer<'_> {
pub(super) fn render(
&self,
f: &mut std::fmt::Formatter,
diagnostics: &[Diagnostic],
) -> std::fmt::Result {
for diag in diagnostics {
let severity = match diag.severity() {
Severity::Info | Severity::Warning => "warning",
Severity::Error | Severity::Fatal => "error",
};
write!(f, "##vso[task.logissue type={severity};")?;
if let Some(span) = diag.primary_span() {
let filename = span.file().path(self.resolver);
write!(f, "sourcepath={filename};")?;
if let Some(range) = span.range() {
let location = if self.resolver.notebook_index(span.file()).is_some() {
// We can't give a reasonable location for the structured formats,
// so we show one that's clearly a fallback
LineColumn::default()
} else {
span.file()
.diagnostic_source(self.resolver)
.as_source_code()
.line_column(range.start())
};
write!(
f,
"linenumber={line};columnnumber={col};",
line = location.line,
col = location.column,
)?;
}
}
writeln!(
f,
"{code}]{body}",
code = diag
.secondary_code()
.map_or_else(String::new, |code| format!("code={code};")),
body = diag.body(),
)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::diagnostic::{
DiagnosticFormat,
render::tests::{create_diagnostics, create_syntax_error_diagnostics},
};
#[test]
fn output() {
let (env, diagnostics) = create_diagnostics(DiagnosticFormat::Azure);
insta::assert_snapshot!(env.render_diagnostics(&diagnostics));
}
#[test]
fn syntax_errors() {
let (env, diagnostics) = create_syntax_error_diagnostics(DiagnosticFormat::Azure);
insta::assert_snapshot!(env.render_diagnostics(&diagnostics));
}
}

View File

@@ -0,0 +1,352 @@
use serde::{Serialize, Serializer, ser::SerializeSeq};
use serde_json::{Value, json};
use ruff_diagnostics::{Applicability, Edit};
use ruff_notebook::NotebookIndex;
use ruff_source_file::{LineColumn, OneIndexed};
use ruff_text_size::Ranged;
use crate::diagnostic::{Diagnostic, DiagnosticSource, DisplayDiagnosticConfig, SecondaryCode};
use super::FileResolver;
pub(super) struct JsonRenderer<'a> {
resolver: &'a dyn FileResolver,
config: &'a DisplayDiagnosticConfig,
}
impl<'a> JsonRenderer<'a> {
pub(super) fn new(resolver: &'a dyn FileResolver, config: &'a DisplayDiagnosticConfig) -> Self {
Self { resolver, config }
}
}
impl JsonRenderer<'_> {
pub(super) fn render(
&self,
f: &mut std::fmt::Formatter,
diagnostics: &[Diagnostic],
) -> std::fmt::Result {
write!(
f,
"{:#}",
diagnostics_to_json_value(diagnostics, self.resolver, self.config)
)
}
}
fn diagnostics_to_json_value<'a>(
diagnostics: impl IntoIterator<Item = &'a Diagnostic>,
resolver: &dyn FileResolver,
config: &DisplayDiagnosticConfig,
) -> Value {
let values: Vec<_> = diagnostics
.into_iter()
.map(|diag| diagnostic_to_json(diag, resolver, config))
.collect();
json!(values)
}
pub(super) fn diagnostic_to_json<'a>(
diagnostic: &'a Diagnostic,
resolver: &'a dyn FileResolver,
config: &'a DisplayDiagnosticConfig,
) -> JsonDiagnostic<'a> {
let span = diagnostic.primary_span_ref();
let filename = span.map(|span| span.file().path(resolver));
let range = span.and_then(|span| span.range());
let diagnostic_source = span.map(|span| span.file().diagnostic_source(resolver));
let source_code = diagnostic_source
.as_ref()
.map(|diagnostic_source| diagnostic_source.as_source_code());
let notebook_index = span.and_then(|span| resolver.notebook_index(span.file()));
let mut start_location = None;
let mut end_location = None;
let mut noqa_location = None;
let mut notebook_cell_index = None;
if let Some(source_code) = source_code {
noqa_location = diagnostic
.noqa_offset()
.map(|offset| source_code.line_column(offset));
if let Some(range) = range {
let mut start = source_code.line_column(range.start());
let mut end = source_code.line_column(range.end());
if let Some(notebook_index) = &notebook_index {
notebook_cell_index =
Some(notebook_index.cell(start.line).unwrap_or(OneIndexed::MIN));
start = notebook_index.translate_line_column(&start);
end = notebook_index.translate_line_column(&end);
noqa_location =
noqa_location.map(|location| notebook_index.translate_line_column(&location));
}
start_location = Some(start);
end_location = Some(end);
}
}
let fix = diagnostic.fix().map(|fix| JsonFix {
applicability: fix.applicability(),
message: diagnostic.suggestion(),
edits: ExpandedEdits {
edits: fix.edits(),
notebook_index,
config,
diagnostic_source,
},
});
// In preview, the locations and filename can be optional.
if config.preview {
JsonDiagnostic {
code: diagnostic.secondary_code(),
url: diagnostic.to_ruff_url(),
message: diagnostic.body(),
fix,
cell: notebook_cell_index,
location: start_location.map(JsonLocation::from),
end_location: end_location.map(JsonLocation::from),
filename,
noqa_row: noqa_location.map(|location| location.line),
}
} else {
JsonDiagnostic {
code: diagnostic.secondary_code(),
url: diagnostic.to_ruff_url(),
message: diagnostic.body(),
fix,
cell: notebook_cell_index,
location: Some(start_location.unwrap_or_default().into()),
end_location: Some(end_location.unwrap_or_default().into()),
filename: Some(filename.unwrap_or_default()),
noqa_row: noqa_location.map(|location| location.line),
}
}
}
struct ExpandedEdits<'a> {
edits: &'a [Edit],
notebook_index: Option<NotebookIndex>,
config: &'a DisplayDiagnosticConfig,
diagnostic_source: Option<DiagnosticSource>,
}
impl Serialize for ExpandedEdits<'_> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut s = serializer.serialize_seq(Some(self.edits.len()))?;
for edit in self.edits {
let (location, end_location) = if let Some(diagnostic_source) = &self.diagnostic_source
{
let source_code = diagnostic_source.as_source_code();
let mut location = source_code.line_column(edit.start());
let mut end_location = source_code.line_column(edit.end());
if let Some(notebook_index) = &self.notebook_index {
// There exists a newline between each cell's source code in the
// concatenated source code in Ruff. This newline doesn't actually
// exists in the JSON source field.
//
// Now, certain edits may try to remove this newline, which means
// the edit will spill over to the first character of the next cell.
// If it does, we need to translate the end location to the last
// character of the previous cell.
match (
notebook_index.cell(location.line),
notebook_index.cell(end_location.line),
) {
(Some(start_cell), Some(end_cell)) if start_cell != end_cell => {
debug_assert_eq!(end_location.column.get(), 1);
let prev_row = end_location.line.saturating_sub(1);
end_location = LineColumn {
line: notebook_index.cell_row(prev_row).unwrap_or(OneIndexed::MIN),
column: source_code
.line_column(source_code.line_end_exclusive(prev_row))
.column,
};
}
(Some(_), None) => {
debug_assert_eq!(end_location.column.get(), 1);
let prev_row = end_location.line.saturating_sub(1);
end_location = LineColumn {
line: notebook_index.cell_row(prev_row).unwrap_or(OneIndexed::MIN),
column: source_code
.line_column(source_code.line_end_exclusive(prev_row))
.column,
};
}
_ => {
end_location = notebook_index.translate_line_column(&end_location);
}
}
location = notebook_index.translate_line_column(&location);
}
(Some(location), Some(end_location))
} else {
(None, None)
};
// In preview, the locations can be optional.
let value = if self.config.preview {
JsonEdit {
content: edit.content().unwrap_or_default(),
location: location.map(JsonLocation::from),
end_location: end_location.map(JsonLocation::from),
}
} else {
JsonEdit {
content: edit.content().unwrap_or_default(),
location: Some(location.unwrap_or_default().into()),
end_location: Some(end_location.unwrap_or_default().into()),
}
};
s.serialize_element(&value)?;
}
s.end()
}
}
/// A serializable version of `Diagnostic`.
///
/// The `Old` variant only exists to preserve backwards compatibility. Both this and `JsonEdit`
/// should become structs with the `New` definitions in a future Ruff release.
#[derive(Serialize)]
pub(crate) struct JsonDiagnostic<'a> {
cell: Option<OneIndexed>,
code: Option<&'a SecondaryCode>,
end_location: Option<JsonLocation>,
filename: Option<&'a str>,
fix: Option<JsonFix<'a>>,
location: Option<JsonLocation>,
message: &'a str,
noqa_row: Option<OneIndexed>,
url: Option<String>,
}
#[derive(Serialize)]
struct JsonFix<'a> {
applicability: Applicability,
edits: ExpandedEdits<'a>,
message: Option<&'a str>,
}
#[derive(Serialize)]
struct JsonLocation {
column: OneIndexed,
row: OneIndexed,
}
impl From<LineColumn> for JsonLocation {
fn from(location: LineColumn) -> Self {
JsonLocation {
row: location.line,
column: location.column,
}
}
}
#[derive(Serialize)]
struct JsonEdit<'a> {
content: &'a str,
end_location: Option<JsonLocation>,
location: Option<JsonLocation>,
}
#[cfg(test)]
mod tests {
use crate::diagnostic::{
DiagnosticFormat,
render::tests::{
TestEnvironment, create_diagnostics, create_notebook_diagnostics,
create_syntax_error_diagnostics,
},
};
#[test]
fn output() {
let (env, diagnostics) = create_diagnostics(DiagnosticFormat::Json);
insta::assert_snapshot!(env.render_diagnostics(&diagnostics));
}
#[test]
fn syntax_errors() {
let (env, diagnostics) = create_syntax_error_diagnostics(DiagnosticFormat::Json);
insta::assert_snapshot!(env.render_diagnostics(&diagnostics));
}
#[test]
fn notebook_output() {
let (env, diagnostics) = create_notebook_diagnostics(DiagnosticFormat::Json);
insta::assert_snapshot!(env.render_diagnostics(&diagnostics));
}
#[test]
fn missing_file_stable() {
let mut env = TestEnvironment::new();
env.format(DiagnosticFormat::Json);
env.preview(false);
let diag = env.err().build();
insta::assert_snapshot!(
env.render(&diag),
@r#"
[
{
"cell": null,
"code": null,
"end_location": {
"column": 1,
"row": 1
},
"filename": "",
"fix": null,
"location": {
"column": 1,
"row": 1
},
"message": "main diagnostic message",
"noqa_row": null,
"url": "https://docs.astral.sh/ruff/rules/test-diagnostic"
}
]
"#,
);
}
#[test]
fn missing_file_preview() {
let mut env = TestEnvironment::new();
env.format(DiagnosticFormat::Json);
env.preview(true);
let diag = env.err().build();
insta::assert_snapshot!(
env.render(&diag),
@r#"
[
{
"cell": null,
"code": null,
"end_location": null,
"filename": null,
"fix": null,
"location": null,
"message": "main diagnostic message",
"noqa_row": null,
"url": "https://docs.astral.sh/ruff/rules/test-diagnostic"
}
]
"#,
);
}
}

View File

@@ -0,0 +1,59 @@
use crate::diagnostic::{Diagnostic, DisplayDiagnosticConfig, render::json::diagnostic_to_json};
use super::FileResolver;
pub(super) struct JsonLinesRenderer<'a> {
resolver: &'a dyn FileResolver,
config: &'a DisplayDiagnosticConfig,
}
impl<'a> JsonLinesRenderer<'a> {
pub(super) fn new(resolver: &'a dyn FileResolver, config: &'a DisplayDiagnosticConfig) -> Self {
Self { resolver, config }
}
}
impl JsonLinesRenderer<'_> {
pub(super) fn render(
&self,
f: &mut std::fmt::Formatter,
diagnostics: &[Diagnostic],
) -> std::fmt::Result {
for diag in diagnostics {
writeln!(
f,
"{}",
serde_json::json!(diagnostic_to_json(diag, self.resolver, self.config))
)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::diagnostic::{
DiagnosticFormat,
render::tests::{
create_diagnostics, create_notebook_diagnostics, create_syntax_error_diagnostics,
},
};
#[test]
fn output() {
let (env, diagnostics) = create_diagnostics(DiagnosticFormat::JsonLines);
insta::assert_snapshot!(env.render_diagnostics(&diagnostics));
}
#[test]
fn syntax_errors() {
let (env, diagnostics) = create_syntax_error_diagnostics(DiagnosticFormat::JsonLines);
insta::assert_snapshot!(env.render_diagnostics(&diagnostics));
}
#[test]
fn notebook_output() {
let (env, diagnostics) = create_notebook_diagnostics(DiagnosticFormat::JsonLines);
insta::assert_snapshot!(env.render_diagnostics(&diagnostics));
}
}

View File

@@ -0,0 +1,97 @@
use crate::diagnostic::{Diagnostic, SecondaryCode, render::FileResolver};
/// Generate violations in Pylint format.
///
/// The format is given by this string:
///
/// ```python
/// "%(path)s:%(row)d: [%(code)s] %(text)s"
/// ```
///
/// See: [Flake8 documentation](https://flake8.pycqa.org/en/latest/internal/formatters.html#pylint-formatter)
pub(super) struct PylintRenderer<'a> {
resolver: &'a dyn FileResolver,
}
impl<'a> PylintRenderer<'a> {
pub(super) fn new(resolver: &'a dyn FileResolver) -> Self {
Self { resolver }
}
}
impl PylintRenderer<'_> {
pub(super) fn render(
&self,
f: &mut std::fmt::Formatter,
diagnostics: &[Diagnostic],
) -> std::fmt::Result {
for diagnostic in diagnostics {
let (filename, row) = diagnostic
.primary_span_ref()
.map(|span| {
let file = span.file();
let row = span
.range()
.filter(|_| !self.resolver.is_notebook(file))
.map(|range| {
file.diagnostic_source(self.resolver)
.as_source_code()
.line_column(range.start())
.line
});
(file.relative_path(self.resolver).to_string_lossy(), row)
})
.unwrap_or_default();
let code = diagnostic
.secondary_code()
.map_or_else(|| diagnostic.name(), SecondaryCode::as_str);
let row = row.unwrap_or_default();
writeln!(
f,
"{path}:{row}: [{code}] {body}",
path = filename,
body = diagnostic.body()
)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::diagnostic::{
DiagnosticFormat,
render::tests::{TestEnvironment, create_diagnostics, create_syntax_error_diagnostics},
};
#[test]
fn output() {
let (env, diagnostics) = create_diagnostics(DiagnosticFormat::Pylint);
insta::assert_snapshot!(env.render_diagnostics(&diagnostics));
}
#[test]
fn syntax_errors() {
let (env, diagnostics) = create_syntax_error_diagnostics(DiagnosticFormat::Pylint);
insta::assert_snapshot!(env.render_diagnostics(&diagnostics));
}
#[test]
fn missing_file() {
let mut env = TestEnvironment::new();
env.format(DiagnosticFormat::Pylint);
let diag = env.err().build();
insta::assert_snapshot!(
env.render(&diag),
@":1: [test-diagnostic] main diagnostic message",
);
}
}

View File

@@ -0,0 +1,235 @@
use serde::ser::SerializeSeq;
use serde::{Serialize, Serializer};
use ruff_diagnostics::{Edit, Fix};
use ruff_source_file::{LineColumn, SourceCode};
use ruff_text_size::Ranged;
use crate::diagnostic::Diagnostic;
use super::FileResolver;
pub struct RdjsonRenderer<'a> {
resolver: &'a dyn FileResolver,
}
impl<'a> RdjsonRenderer<'a> {
pub(super) fn new(resolver: &'a dyn FileResolver) -> Self {
Self { resolver }
}
pub(super) fn render(
&self,
f: &mut std::fmt::Formatter,
diagnostics: &[Diagnostic],
) -> std::fmt::Result {
write!(
f,
"{:#}",
serde_json::json!(RdjsonDiagnostics::new(diagnostics, self.resolver))
)
}
}
struct ExpandedDiagnostics<'a> {
resolver: &'a dyn FileResolver,
diagnostics: &'a [Diagnostic],
}
impl Serialize for ExpandedDiagnostics<'_> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut s = serializer.serialize_seq(Some(self.diagnostics.len()))?;
for diagnostic in self.diagnostics {
let value = diagnostic_to_rdjson(diagnostic, self.resolver);
s.serialize_element(&value)?;
}
s.end()
}
}
fn diagnostic_to_rdjson<'a>(
diagnostic: &'a Diagnostic,
resolver: &'a dyn FileResolver,
) -> RdjsonDiagnostic<'a> {
let span = diagnostic.primary_span_ref();
let source_file = span.map(|span| {
let file = span.file();
(file.path(resolver), file.diagnostic_source(resolver))
});
let location = source_file.as_ref().map(|(path, source)| {
let range = diagnostic.range().map(|range| {
let source_code = source.as_source_code();
let start = source_code.line_column(range.start());
let end = source_code.line_column(range.end());
RdjsonRange::new(start, end)
});
RdjsonLocation { path, range }
});
let edits = diagnostic.fix().map(Fix::edits).unwrap_or_default();
RdjsonDiagnostic {
message: diagnostic.body(),
location,
code: RdjsonCode {
value: diagnostic
.secondary_code()
.map_or_else(|| diagnostic.name(), |code| code.as_str()),
url: diagnostic.to_ruff_url(),
},
suggestions: rdjson_suggestions(
edits,
source_file
.as_ref()
.map(|(_, source)| source.as_source_code()),
),
}
}
fn rdjson_suggestions<'a>(
edits: &'a [Edit],
source_code: Option<SourceCode>,
) -> Vec<RdjsonSuggestion<'a>> {
if edits.is_empty() {
return Vec::new();
}
let Some(source_code) = source_code else {
debug_assert!(false, "Expected a source file for a diagnostic with a fix");
return Vec::new();
};
edits
.iter()
.map(|edit| {
let start = source_code.line_column(edit.start());
let end = source_code.line_column(edit.end());
let range = RdjsonRange::new(start, end);
RdjsonSuggestion {
range,
text: edit.content().unwrap_or_default(),
}
})
.collect()
}
#[derive(Serialize)]
struct RdjsonDiagnostics<'a> {
diagnostics: ExpandedDiagnostics<'a>,
severity: &'static str,
source: RdjsonSource,
}
impl<'a> RdjsonDiagnostics<'a> {
fn new(diagnostics: &'a [Diagnostic], resolver: &'a dyn FileResolver) -> Self {
Self {
source: RdjsonSource {
name: "ruff",
url: env!("CARGO_PKG_HOMEPAGE"),
},
severity: "WARNING",
diagnostics: ExpandedDiagnostics {
diagnostics,
resolver,
},
}
}
}
#[derive(Serialize)]
struct RdjsonSource {
name: &'static str,
url: &'static str,
}
#[derive(Serialize)]
struct RdjsonDiagnostic<'a> {
code: RdjsonCode<'a>,
#[serde(skip_serializing_if = "Option::is_none")]
location: Option<RdjsonLocation<'a>>,
message: &'a str,
#[serde(skip_serializing_if = "Vec::is_empty")]
suggestions: Vec<RdjsonSuggestion<'a>>,
}
#[derive(Serialize)]
struct RdjsonLocation<'a> {
path: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
range: Option<RdjsonRange>,
}
#[derive(Default, Serialize)]
struct RdjsonRange {
end: LineColumn,
start: LineColumn,
}
impl RdjsonRange {
fn new(start: LineColumn, end: LineColumn) -> Self {
Self { start, end }
}
}
#[derive(Serialize)]
struct RdjsonCode<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
url: Option<String>,
value: &'a str,
}
#[derive(Serialize)]
struct RdjsonSuggestion<'a> {
range: RdjsonRange,
text: &'a str,
}
#[cfg(test)]
mod tests {
use crate::diagnostic::{
DiagnosticFormat,
render::tests::{TestEnvironment, create_diagnostics, create_syntax_error_diagnostics},
};
#[test]
fn output() {
let (env, diagnostics) = create_diagnostics(DiagnosticFormat::Rdjson);
insta::assert_snapshot!(env.render_diagnostics(&diagnostics));
}
#[test]
fn syntax_errors() {
let (env, diagnostics) = create_syntax_error_diagnostics(DiagnosticFormat::Rdjson);
insta::assert_snapshot!(env.render_diagnostics(&diagnostics));
}
#[test]
fn missing_file_stable() {
let mut env = TestEnvironment::new();
env.format(DiagnosticFormat::Rdjson);
env.preview(false);
let diag = env.err().build();
insta::assert_snapshot!(env.render(&diag));
}
#[test]
fn missing_file_preview() {
let mut env = TestEnvironment::new();
env.format(DiagnosticFormat::Rdjson);
env.preview(true);
let diag = env.err().build();
insta::assert_snapshot!(env.render(&diag));
}
}

View File

@@ -1,7 +1,6 @@
---
source: crates/ruff_linter/src/message/azure.rs
expression: content
snapshot_kind: text
source: crates/ruff_db/src/diagnostic/render/azure.rs
expression: env.render_diagnostics(&diagnostics)
---
##vso[task.logissue type=error;sourcepath=fib.py;linenumber=1;columnnumber=8;code=F401;]`os` imported but unused
##vso[task.logissue type=error;sourcepath=fib.py;linenumber=6;columnnumber=5;code=F841;]Local variable `x` is assigned to but never used

View File

@@ -1,7 +1,6 @@
---
source: crates/ruff_linter/src/message/azure.rs
expression: content
snapshot_kind: text
source: crates/ruff_db/src/diagnostic/render/azure.rs
expression: env.render_diagnostics(&diagnostics)
---
##vso[task.logissue type=error;sourcepath=syntax_errors.py;linenumber=1;columnnumber=15;]SyntaxError: Expected one or more symbol names after import
##vso[task.logissue type=error;sourcepath=syntax_errors.py;linenumber=3;columnnumber=12;]SyntaxError: Expected ')', found newline

View File

@@ -1,7 +1,6 @@
---
source: crates/ruff_linter/src/message/json.rs
expression: content
snapshot_kind: text
source: crates/ruff_db/src/diagnostic/render/json.rs
expression: env.render_diagnostics(&diagnostics)
---
[
{
@@ -84,8 +83,8 @@ snapshot_kind: text
{
"content": "",
"end_location": {
"column": 10,
"row": 4
"column": 1,
"row": 5
},
"location": {
"column": 1,

View File

@@ -1,7 +1,6 @@
---
source: crates/ruff_linter/src/message/json.rs
expression: content
snapshot_kind: text
source: crates/ruff_db/src/diagnostic/render/json.rs
expression: env.render_diagnostics(&diagnostics)
---
[
{

View File

@@ -1,7 +1,6 @@
---
source: crates/ruff_linter/src/message/json.rs
expression: content
snapshot_kind: text
source: crates/ruff_db/src/diagnostic/render/json.rs
expression: env.render_diagnostics(&diagnostics)
---
[
{

View File

@@ -1,8 +1,7 @@
---
source: crates/ruff_linter/src/message/json_lines.rs
expression: content
snapshot_kind: text
source: crates/ruff_db/src/diagnostic/render/json_lines.rs
expression: env.render_diagnostics(&diagnostics)
---
{"cell":1,"code":"F401","end_location":{"column":10,"row":2},"filename":"notebook.ipynb","fix":{"applicability":"safe","edits":[{"content":"","end_location":{"column":10,"row":2},"location":{"column":1,"row":2}}],"message":"Remove unused import: `os`"},"location":{"column":8,"row":2},"message":"`os` imported but unused","noqa_row":2,"url":"https://docs.astral.sh/ruff/rules/unused-import"}
{"cell":2,"code":"F401","end_location":{"column":12,"row":2},"filename":"notebook.ipynb","fix":{"applicability":"safe","edits":[{"content":"","end_location":{"column":1,"row":3},"location":{"column":1,"row":2}}],"message":"Remove unused import: `math`"},"location":{"column":8,"row":2},"message":"`math` imported but unused","noqa_row":2,"url":"https://docs.astral.sh/ruff/rules/unused-import"}
{"cell":3,"code":"F841","end_location":{"column":6,"row":4},"filename":"notebook.ipynb","fix":{"applicability":"unsafe","edits":[{"content":"","end_location":{"column":10,"row":4},"location":{"column":1,"row":4}}],"message":"Remove assignment to unused variable `x`"},"location":{"column":5,"row":4},"message":"Local variable `x` is assigned to but never used","noqa_row":4,"url":"https://docs.astral.sh/ruff/rules/unused-variable"}
{"cell":3,"code":"F841","end_location":{"column":6,"row":4},"filename":"notebook.ipynb","fix":{"applicability":"unsafe","edits":[{"content":"","end_location":{"column":1,"row":5},"location":{"column":1,"row":4}}],"message":"Remove assignment to unused variable `x`"},"location":{"column":5,"row":4},"message":"Local variable `x` is assigned to but never used","noqa_row":4,"url":"https://docs.astral.sh/ruff/rules/unused-variable"}

View File

@@ -1,7 +1,6 @@
---
source: crates/ruff_linter/src/message/json_lines.rs
expression: content
snapshot_kind: text
source: crates/ruff_db/src/diagnostic/render/json_lines.rs
expression: env.render_diagnostics(&diagnostics)
---
{"cell":null,"code":"F401","end_location":{"column":10,"row":1},"filename":"fib.py","fix":{"applicability":"unsafe","edits":[{"content":"","end_location":{"column":1,"row":2},"location":{"column":1,"row":1}}],"message":"Remove unused import: `os`"},"location":{"column":8,"row":1},"message":"`os` imported but unused","noqa_row":1,"url":"https://docs.astral.sh/ruff/rules/unused-import"}
{"cell":null,"code":"F841","end_location":{"column":6,"row":6},"filename":"fib.py","fix":{"applicability":"unsafe","edits":[{"content":"","end_location":{"column":10,"row":6},"location":{"column":5,"row":6}}],"message":"Remove assignment to unused variable `x`"},"location":{"column":5,"row":6},"message":"Local variable `x` is assigned to but never used","noqa_row":6,"url":"https://docs.astral.sh/ruff/rules/unused-variable"}

View File

@@ -1,7 +1,6 @@
---
source: crates/ruff_linter/src/message/json_lines.rs
expression: content
snapshot_kind: text
source: crates/ruff_db/src/diagnostic/render/json_lines.rs
expression: env.render_diagnostics(&diagnostics)
---
{"cell":null,"code":null,"end_location":{"column":1,"row":2},"filename":"syntax_errors.py","fix":null,"location":{"column":15,"row":1},"message":"SyntaxError: Expected one or more symbol names after import","noqa_row":null,"url":null}
{"cell":null,"code":null,"end_location":{"column":1,"row":4},"filename":"syntax_errors.py","fix":null,"location":{"column":12,"row":3},"message":"SyntaxError: Expected ')', found newline","noqa_row":null,"url":null}

View File

@@ -1,7 +1,6 @@
---
source: crates/ruff_linter/src/message/pylint.rs
expression: content
snapshot_kind: text
source: crates/ruff_db/src/diagnostic/render/pylint.rs
expression: env.render_diagnostics(&diagnostics)
---
fib.py:1: [F401] `os` imported but unused
fib.py:6: [F841] Local variable `x` is assigned to but never used

View File

@@ -0,0 +1,6 @@
---
source: crates/ruff_db/src/diagnostic/render/pylint.rs
expression: env.render_diagnostics(&diagnostics)
---
syntax_errors.py:1: [invalid-syntax] SyntaxError: Expected one or more symbol names after import
syntax_errors.py:3: [invalid-syntax] SyntaxError: Expected ')', found newline

View File

@@ -0,0 +1,20 @@
---
source: crates/ruff_db/src/diagnostic/render/rdjson.rs
expression: env.render(&diag)
---
{
"diagnostics": [
{
"code": {
"url": "https://docs.astral.sh/ruff/rules/test-diagnostic",
"value": "test-diagnostic"
},
"message": "main diagnostic message"
}
],
"severity": "WARNING",
"source": {
"name": "ruff",
"url": "https://docs.astral.sh/ruff"
}
}

View File

@@ -0,0 +1,20 @@
---
source: crates/ruff_db/src/diagnostic/render/rdjson.rs
expression: env.render(&diag)
---
{
"diagnostics": [
{
"code": {
"url": "https://docs.astral.sh/ruff/rules/test-diagnostic",
"value": "test-diagnostic"
},
"message": "main diagnostic message"
}
],
"severity": "WARNING",
"source": {
"name": "ruff",
"url": "https://docs.astral.sh/ruff"
}
}

View File

@@ -1,7 +1,6 @@
---
source: crates/ruff_linter/src/message/rdjson.rs
expression: content
snapshot_kind: text
source: crates/ruff_db/src/diagnostic/render/rdjson.rs
expression: env.render_diagnostics(&diagnostics)
---
{
"diagnostics": [
@@ -96,7 +95,7 @@ snapshot_kind: text
"message": "Undefined name `a`"
}
],
"severity": "warning",
"severity": "WARNING",
"source": {
"name": "ruff",
"url": "https://docs.astral.sh/ruff"

View File

@@ -1,14 +1,12 @@
---
source: crates/ruff_linter/src/message/rdjson.rs
expression: content
snapshot_kind: text
source: crates/ruff_db/src/diagnostic/render/rdjson.rs
expression: env.render_diagnostics(&diagnostics)
---
{
"diagnostics": [
{
"code": {
"url": null,
"value": null
"value": "invalid-syntax"
},
"location": {
"path": "syntax_errors.py",
@@ -27,8 +25,7 @@ snapshot_kind: text
},
{
"code": {
"url": null,
"value": null
"value": "invalid-syntax"
},
"location": {
"path": "syntax_errors.py",
@@ -46,7 +43,7 @@ snapshot_kind: text
"message": "SyntaxError: Expected ')', found newline"
}
],
"severity": "warning",
"severity": "WARNING",
"source": {
"name": "ruff",
"url": "https://docs.astral.sh/ruff"

View File

@@ -28,6 +28,21 @@ pub use web_time::{Instant, SystemTime, SystemTimeError};
pub type FxDashMap<K, V> = dashmap::DashMap<K, V, BuildHasherDefault<FxHasher>>;
pub type FxDashSet<K> = dashmap::DashSet<K, BuildHasherDefault<FxHasher>>;
static VERSION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
/// Returns the version of the executing program if set.
pub fn program_version() -> Option<&'static str> {
VERSION.get().map(|version| version.as_str())
}
/// Sets the version of the executing program.
///
/// ## Errors
/// If the version has already been initialized (can only be set once).
pub fn set_program_version(version: String) -> Result<(), String> {
VERSION.set(version)
}
/// Most basic database that gives access to files, the host system, source code, and parsed AST.
#[salsa::db]
pub trait Db: salsa::Database {

View File

@@ -21,6 +21,19 @@ type LockedZipArchive<'a> = MutexGuard<'a, VendoredZipArchive>;
///
/// "Files" in the `VendoredFileSystem` are read-only and immutable.
/// Directories are supported, but symlinks and hardlinks cannot exist.
///
/// # Path separators
///
/// At time of writing (2025-07-11), this implementation always uses `/` as a
/// path separator, even in Windows environments where `\` is traditionally
/// used as a file path separator. Namely, this is only currently used with zip
/// files built by `crates/ty_vendored/build.rs`.
///
/// Callers using this may provide paths that use a `\` as a separator. It will
/// be transparently normalized to `/`.
///
/// This is particularly important because the presence of a trailing separator
/// in a zip file is conventionally used to indicate a directory entry.
#[derive(Clone)]
pub struct VendoredFileSystem {
inner: Arc<Mutex<VendoredZipArchive>>,
@@ -115,6 +128,68 @@ impl VendoredFileSystem {
read_to_string(self, path.as_ref())
}
/// Read the direct children of the directory
/// identified by `path`.
///
/// If `path` is not a directory, then this will
/// return an empty `Vec`.
pub fn read_directory(&self, dir: impl AsRef<VendoredPath>) -> Vec<DirectoryEntry> {
// N.B. We specifically do not return an iterator here to avoid
// holding a lock for the lifetime of the iterator returned.
// That is, it seems like a footgun to keep the zip archive
// locked during iteration, since the unit of work for each
// item in the iterator could be arbitrarily long. Allocating
// up front and stuffing all entries into it is probably the
// simplest solution and what we do here. If this becomes
// a problem, there are other strategies we could pursue.
// (Amortizing allocs, using a different synchronization
// behavior or even exposing additional APIs.) ---AG
fn read_directory(fs: &VendoredFileSystem, dir: &VendoredPath) -> Vec<DirectoryEntry> {
let mut normalized = NormalizedVendoredPath::from(dir);
if !normalized.as_str().ends_with('/') {
normalized = normalized.with_trailing_slash();
}
let archive = fs.lock_archive();
let mut entries = vec![];
for name in archive.0.file_names() {
// Any entry that doesn't have the `path` (with a
// trailing slash) as a prefix cannot possibly be in
// the directory referenced by `path`.
let Some(without_dir_prefix) = name.strip_prefix(normalized.as_str()) else {
continue;
};
// Filter out an entry equivalent to the path given
// since we only want children of the directory.
if without_dir_prefix.is_empty() {
continue;
}
// We only want *direct* children. Files that are
// direct children cannot have any slashes (or else
// they are not direct children). Directories that
// are direct children can only have one slash and
// it must be at the end.
//
// (We do this manually ourselves to avoid doing a
// full file lookup and metadata retrieval via the
// `zip` crate.)
let file_type = FileType::from_zip_file_name(without_dir_prefix);
let slash_count = without_dir_prefix.matches('/').count();
match file_type {
FileType::File if slash_count > 0 => continue,
FileType::Directory if slash_count > 1 => continue,
_ => {}
}
entries.push(DirectoryEntry {
path: VendoredPathBuf::from(name),
file_type,
});
}
entries
}
read_directory(self, dir.as_ref())
}
/// Acquire a lock on the underlying zip archive.
/// The call will block until it is able to acquire the lock.
///
@@ -206,6 +281,14 @@ pub enum FileType {
}
impl FileType {
fn from_zip_file_name(name: &str) -> FileType {
if name.ends_with('/') {
FileType::Directory
} else {
FileType::File
}
}
pub const fn is_file(self) -> bool {
matches!(self, Self::File)
}
@@ -244,6 +327,30 @@ impl Metadata {
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct DirectoryEntry {
path: VendoredPathBuf,
file_type: FileType,
}
impl DirectoryEntry {
pub fn new(path: VendoredPathBuf, file_type: FileType) -> Self {
Self { path, file_type }
}
pub fn into_path(self) -> VendoredPathBuf {
self.path
}
pub fn path(&self) -> &VendoredPath {
&self.path
}
pub fn file_type(&self) -> FileType {
self.file_type
}
}
/// Newtype wrapper around a ZipArchive.
#[derive(Debug)]
struct VendoredZipArchive(ZipArchive<io::Cursor<Cow<'static, [u8]>>>);
@@ -498,6 +605,60 @@ pub(crate) mod tests {
test_directory("./stdlib/asyncio/../asyncio/")
}
fn readdir_snapshot(fs: &VendoredFileSystem, path: &str) -> String {
let mut paths = fs
.read_directory(VendoredPath::new(path))
.into_iter()
.map(|entry| entry.path().to_string())
.collect::<Vec<String>>();
paths.sort();
paths.join("\n")
}
#[test]
fn read_directory_stdlib() {
let mock_typeshed = mock_typeshed();
assert_snapshot!(readdir_snapshot(&mock_typeshed, "stdlib"), @r"
vendored://stdlib/asyncio/
vendored://stdlib/functools.pyi
");
assert_snapshot!(readdir_snapshot(&mock_typeshed, "stdlib/"), @r"
vendored://stdlib/asyncio/
vendored://stdlib/functools.pyi
");
assert_snapshot!(readdir_snapshot(&mock_typeshed, "./stdlib"), @r"
vendored://stdlib/asyncio/
vendored://stdlib/functools.pyi
");
assert_snapshot!(readdir_snapshot(&mock_typeshed, "./stdlib/"), @r"
vendored://stdlib/asyncio/
vendored://stdlib/functools.pyi
");
}
#[test]
fn read_directory_asyncio() {
let mock_typeshed = mock_typeshed();
assert_snapshot!(
readdir_snapshot(&mock_typeshed, "stdlib/asyncio"),
@"vendored://stdlib/asyncio/tasks.pyi",
);
assert_snapshot!(
readdir_snapshot(&mock_typeshed, "./stdlib/asyncio"),
@"vendored://stdlib/asyncio/tasks.pyi",
);
assert_snapshot!(
readdir_snapshot(&mock_typeshed, "stdlib/asyncio/"),
@"vendored://stdlib/asyncio/tasks.pyi",
);
assert_snapshot!(
readdir_snapshot(&mock_typeshed, "./stdlib/asyncio/"),
@"vendored://stdlib/asyncio/tasks.pyi",
);
}
fn test_nonexistent_path(path: &str) {
let mock_typeshed = mock_typeshed();
let path = VendoredPath::new(path);

View File

@@ -17,6 +17,10 @@ impl VendoredPath {
unsafe { &*(path as *const Utf8Path as *const VendoredPath) }
}
pub fn file_name(&self) -> Option<&str> {
self.0.file_name()
}
pub fn to_path_buf(&self) -> VendoredPathBuf {
VendoredPathBuf(self.0.to_path_buf())
}

View File

@@ -1,6 +1,6 @@
"""
Should emit:
B017 - on lines 24, 28, 46, 49, 52, and 58
B017 - on lines 24, 28, 46, 49, 52, 58, 62, 68, and 71
"""
import asyncio
import unittest
@@ -56,3 +56,17 @@ def test_pytest_raises():
with contextlib.nullcontext(), pytest.raises(Exception):
raise ValueError("Multiple context managers")
def test_pytest_raises_keyword():
with pytest.raises(expected_exception=Exception):
raise ValueError("Should be flagged")
def test_assert_raises_keyword():
class TestKwargs(unittest.TestCase):
def test_method(self):
with self.assertRaises(exception=Exception):
raise ValueError("Should be flagged")
with self.assertRaises(exception=BaseException):
raise ValueError("Should be flagged")

View File

@@ -181,3 +181,51 @@ class SubclassTestModel2(TestModel4):
# Subclass without __str__
class SubclassTestModel3(TestModel1):
pass
# Test cases for type-annotated abstract models - these should NOT trigger DJ008
from typing import ClassVar
from django_stubs_ext.db.models import TypedModelMeta
class TypeAnnotatedAbstractModel1(models.Model):
"""Model with type-annotated abstract = True - should not trigger DJ008"""
new_field = models.CharField(max_length=10)
class Meta(TypedModelMeta):
abstract: ClassVar[bool] = True
class TypeAnnotatedAbstractModel2(models.Model):
"""Model with type-annotated abstract = True using regular Meta - should not trigger DJ008"""
new_field = models.CharField(max_length=10)
class Meta:
abstract: ClassVar[bool] = True
class TypeAnnotatedAbstractModel3(models.Model):
"""Model with type-annotated abstract = True but without ClassVar - should not trigger DJ008"""
new_field = models.CharField(max_length=10)
class Meta:
abstract: bool = True
class TypeAnnotatedNonAbstractModel(models.Model):
"""Model with type-annotated abstract = False - should trigger DJ008"""
new_field = models.CharField(max_length=10)
class Meta:
abstract: ClassVar[bool] = False
class TypeAnnotatedAbstractModelWithStr(models.Model):
"""Model with type-annotated abstract = True and __str__ method - should not trigger DJ008"""
new_field = models.CharField(max_length=10)
class Meta(TypedModelMeta):
abstract: ClassVar[bool] = True
def __str__(self):
return self.new_field

View File

@@ -1 +1,4 @@
_(f"{'value'}")
# Don't trigger for t-strings
_(t"{'value'}")

View File

@@ -13,3 +13,7 @@ from logging import info
info(f"{name}")
info(f"{__name__}")
# Don't trigger for t-strings
info(t"{name}")
info(t"{__name__}")

View File

@@ -47,3 +47,7 @@ def test_error_match_is_empty():
with pytest.raises(ValueError, match=f""):
raise ValueError("Can't divide 1 by 0")
def test_ok_t_string_match():
with pytest.raises(ValueError, match=t""):
raise ValueError("Can't divide 1 by 0")

View File

@@ -23,3 +23,9 @@ def f():
pytest.fail(msg=f"")
pytest.fail(reason="")
pytest.fail(reason=f"")
# Skip for t-strings
def g():
pytest.fail(t"")
pytest.fail(msg=t"")
pytest.fail(reason=t"")

View File

@@ -32,3 +32,7 @@ def test_error_match_is_empty():
with pytest.warns(UserWarning, match=f""):
pass
def test_ok_match_t_string():
with pytest.warns(UserWarning, match=t""):
pass

View File

@@ -245,3 +245,14 @@ def f(bar: str):
class C:
def __init__(self, x) -> None:
print(locals())
###
# Should trigger for t-string here
# even though the corresponding f-string
# does not trigger (since it is common in stubs)
###
class C:
def f(self, x, y):
"""Docstring."""
msg = t"{x}..."
raise NotImplementedError(msg)

View File

@@ -0,0 +1,26 @@
from pathlib import (
Path,
PosixPath,
PurePath,
PurePosixPath,
PureWindowsPath,
WindowsPath,
)
import pathlib
path = Path()
posix_path: pathlib.PosixPath = PosixPath()
pure_path: PurePath = PurePath()
pure_posix_path = pathlib.PurePosixPath()
pure_windows_path: PureWindowsPath = pathlib.PureWindowsPath()
windows_path: pathlib.WindowsPath = pathlib.WindowsPath()
### No Errors
path.with_suffix(".")
posix_path.with_suffix(".")
pure_path.with_suffix(".")
pure_posix_path.with_suffix(".")
pure_windows_path.with_suffix(".")
windows_path.with_suffix(".")

View File

@@ -0,0 +1,5 @@
"""This is a docstring."""
"This is not a docstring."
"This is also not a docstring."
x = 1

View File

@@ -48,6 +48,39 @@ from typing import override, overload
def BAD_FUNC():
pass
@overload
def BAD_FUNC():
pass
import ast
from ast import NodeTransformer
class Visitor(ast.NodeVisitor):
def visit_Constant(self, node):
pass
def bad_Name(self):
pass
class ExtendsVisitor(Visitor):
def visit_Constant(self, node):
pass
class Transformer(NodeTransformer):
def visit_Constant(self, node):
pass
from http.server import BaseHTTPRequestHandler
class MyRequestHandler(BaseHTTPRequestHandler):
def do_GET(self):
pass
def dont_GET(self):
pass

View File

@@ -189,3 +189,18 @@ f"{ham[lower + 1 :, "columnname"]}"
#: Okay: https://github.com/astral-sh/ruff/issues/12023
f"{x = :.2f}"
f"{(x) = :.2f}"
# t-strings
t"{ {'a': 1} }"
t"{[ { {'a': 1} } ]}"
t"normal { {t"{ { [1, 2] } }" } } normal"
t"{x = :.2f}"
t"{(x) = :.2f}"
#: Okay
t"{ham[lower +1 :, "columnname"]}"
#: E203:1:13
t"{ham[lower + 1 :, "columnname"]}"

View File

@@ -142,3 +142,20 @@ class PEP696GoodWithEmptyBases[A: object="foo"[::-1], B: object =[[["foo", "bar"
class PEP696GoodWithNonEmptyBases[A: object="foo"[::-1], B: object =[[["foo", "bar"]]], C: object= bytes](object, something_dynamic[x::-1]):
pass
# E231
t"{(a,b)}"
# Okay because it's hard to differentiate between the usages of a colon in a t-string
t"{a:=1}"
t"{ {'a':1} }"
t"{a:.3f}"
t"{(a:=1)}"
t"{(lambda x:x)}"
t"normal{t"{a:.3f}"}normal"
#: Okay
snapshot.file_uri[len(t's3://{self.s3_bucket_name}/'):]
#: E231
{len(t's3://{self.s3_bucket_name}/'):1}

View File

@@ -722,3 +722,10 @@ def inconsistent_indent_byte_size():
    Returns:
"""
def line_continuation_chars():\
"""No fix should be offered for D201/D202 because of the line continuation chars."""\
...

View File

@@ -22,3 +22,10 @@ assert b"hello" # [assert-on-string-literal]
assert "", b"hi" # [assert-on-string-literal]
assert "WhyNotHere?", "HereIsOk" # [assert-on-string-literal]
assert 12, "ok here"
# t-strings are always True even when "empty"
# skip lint in this case
assert t""
assert t"hey"
assert t"{a}"

View File

@@ -140,3 +140,15 @@ class Foo:
def unused_message_2(self, x):
msg = ""
raise NotImplementedError(x)
class TPerson:
def developer_greeting(self, name): # [no-self-use]
print(t"Greetings {name}!")
def greeting_1(self):
print(t"Hello from {self.name} !")
def tstring(self, x):
msg = t"{x}"
raise NotImplementedError(msg)

View File

@@ -33,3 +33,11 @@ class Foo:
def __init__(self, bar):
self.bar = bar
# This is a type error, out of scope for the rule
class Foo:
__slots__ = t"bar{baz}"
def __init__(self, bar):
self.bar = bar

View File

@@ -84,3 +84,7 @@ def _match_ignore(line):
# Not a valid type annotation but this test shouldn't result in a panic.
# Refer: https://github.com/astral-sh/ruff/issues/11736
x: '"foo".encode("utf-8")'
# AttributeError for t-strings so skip lint
(t"foo{bar}").encode("utf-8")
(t"foo{bar}").encode(encoding="utf-8")

View File

@@ -90,3 +90,7 @@ bool(True)and None
int(1)and None
float(1.)and None
bool(True)and()
# t-strings are not native literals
str(t"hey")

View File

@@ -0,0 +1,16 @@
from itertools import starmap
import itertools
# Errors in Python 3.14+
starmap(func, zip(a, b, c, strict=True))
starmap(func, zip(a, b, c, strict=False))
starmap(func, zip(a, b, c, strict=strict))
# No errors
starmap(func)
starmap(func, zip(a, b, c, **kwargs))
starmap(func, zip(a, b, c), foo)
starmap(func, zip(a, b, c, lorem=ipsum))
starmap(func, zip(a, b, c), lorem=ipsum)

View File

@@ -670,7 +670,11 @@ impl SemanticSyntaxContext for Checker<'_> {
| SemanticSyntaxErrorKind::InvalidStarExpression
| SemanticSyntaxErrorKind::AsyncComprehensionInSyncComprehension(_)
| SemanticSyntaxErrorKind::DuplicateParameter(_)
| SemanticSyntaxErrorKind::NonlocalDeclarationAtModuleLevel => {
| SemanticSyntaxErrorKind::NonlocalDeclarationAtModuleLevel
| SemanticSyntaxErrorKind::LoadBeforeNonlocalDeclaration { .. }
| SemanticSyntaxErrorKind::NonlocalAndGlobal(_)
| SemanticSyntaxErrorKind::AnnotatedGlobal(_)
| SemanticSyntaxErrorKind::AnnotatedNonlocal(_) => {
self.semantic_errors.borrow_mut().push(error);
}
}

View File

@@ -275,19 +275,12 @@ impl<'a> Insertion<'a> {
}
}
/// Find the end of the last docstring.
/// Find the end of the docstring (first string statement).
fn match_docstring_end(body: &[Stmt]) -> Option<TextSize> {
let mut iter = body.iter();
let mut stmt = iter.next()?;
let stmt = body.first()?;
if !is_docstring_stmt(stmt) {
return None;
}
for next in iter {
if !is_docstring_stmt(next) {
break;
}
stmt = next;
}
Some(stmt.end())
}
@@ -367,7 +360,7 @@ mod tests {
.trim_start();
assert_eq!(
insert(contents)?,
Insertion::own_line("", TextSize::from(40), "\n")
Insertion::own_line("", TextSize::from(20), "\n")
);
let contents = r"

View File

@@ -1,71 +0,0 @@
use std::io::Write;
use ruff_db::diagnostic::Diagnostic;
use ruff_source_file::LineColumn;
use crate::message::{Emitter, EmitterContext};
/// Generate error logging commands for Azure Pipelines format.
/// See [documentation](https://learn.microsoft.com/en-us/azure/devops/pipelines/scripts/logging-commands?view=azure-devops&tabs=bash#logissue-log-an-error-or-warning)
#[derive(Default)]
pub struct AzureEmitter;
impl Emitter for AzureEmitter {
fn emit(
&mut self,
writer: &mut dyn Write,
diagnostics: &[Diagnostic],
context: &EmitterContext,
) -> anyhow::Result<()> {
for diagnostic in diagnostics {
let filename = diagnostic.expect_ruff_filename();
let location = if context.is_notebook(&filename) {
// We can't give a reasonable location for the structured formats,
// so we show one that's clearly a fallback
LineColumn::default()
} else {
diagnostic.expect_ruff_start_location()
};
writeln!(
writer,
"##vso[task.logissue type=error\
;sourcepath={filename};linenumber={line};columnnumber={col};{code}]{body}",
line = location.line,
col = location.column,
code = diagnostic
.secondary_code()
.map_or_else(String::new, |code| format!("code={code};")),
body = diagnostic.body(),
)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use insta::assert_snapshot;
use crate::message::AzureEmitter;
use crate::message::tests::{
capture_emitter_output, create_diagnostics, create_syntax_error_diagnostics,
};
#[test]
fn output() {
let mut emitter = AzureEmitter;
let content = capture_emitter_output(&mut emitter, &create_diagnostics());
assert_snapshot!(content);
}
#[test]
fn syntax_errors() {
let mut emitter = AzureEmitter;
let content = capture_emitter_output(&mut emitter, &create_syntax_error_diagnostics());
assert_snapshot!(content);
}
}

View File

@@ -21,7 +21,7 @@ use crate::{Applicability, Fix};
/// * Compute the diff from the [`Edit`] because diff calculation is expensive.
pub(super) struct Diff<'a> {
fix: &'a Fix,
source_code: SourceFile,
source_code: &'a SourceFile,
}
impl<'a> Diff<'a> {

View File

@@ -1,220 +0,0 @@
use std::io::Write;
use serde::ser::SerializeSeq;
use serde::{Serialize, Serializer};
use serde_json::{Value, json};
use ruff_db::diagnostic::Diagnostic;
use ruff_notebook::NotebookIndex;
use ruff_source_file::{LineColumn, OneIndexed, SourceCode};
use ruff_text_size::Ranged;
use crate::Edit;
use crate::message::{Emitter, EmitterContext};
#[derive(Default)]
pub struct JsonEmitter;
impl Emitter for JsonEmitter {
fn emit(
&mut self,
writer: &mut dyn Write,
diagnostics: &[Diagnostic],
context: &EmitterContext,
) -> anyhow::Result<()> {
serde_json::to_writer_pretty(
writer,
&ExpandedMessages {
diagnostics,
context,
},
)?;
Ok(())
}
}
struct ExpandedMessages<'a> {
diagnostics: &'a [Diagnostic],
context: &'a EmitterContext<'a>,
}
impl Serialize for ExpandedMessages<'_> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut s = serializer.serialize_seq(Some(self.diagnostics.len()))?;
for message in self.diagnostics {
let value = message_to_json_value(message, self.context);
s.serialize_element(&value)?;
}
s.end()
}
}
pub(crate) fn message_to_json_value(message: &Diagnostic, context: &EmitterContext) -> Value {
let source_file = message.expect_ruff_source_file();
let source_code = source_file.to_source_code();
let filename = message.expect_ruff_filename();
let notebook_index = context.notebook_index(&filename);
let fix = message.fix().map(|fix| {
json!({
"applicability": fix.applicability(),
"message": message.suggestion(),
"edits": &ExpandedEdits { edits: fix.edits(), source_code: &source_code, notebook_index },
})
});
let mut start_location = source_code.line_column(message.expect_range().start());
let mut end_location = source_code.line_column(message.expect_range().end());
let mut noqa_location = message
.noqa_offset()
.map(|offset| source_code.line_column(offset));
let mut notebook_cell_index = None;
if let Some(notebook_index) = notebook_index {
notebook_cell_index = Some(
notebook_index
.cell(start_location.line)
.unwrap_or(OneIndexed::MIN),
);
start_location = notebook_index.translate_line_column(&start_location);
end_location = notebook_index.translate_line_column(&end_location);
noqa_location =
noqa_location.map(|location| notebook_index.translate_line_column(&location));
}
json!({
"code": message.secondary_code(),
"url": message.to_url(),
"message": message.body(),
"fix": fix,
"cell": notebook_cell_index,
"location": location_to_json(start_location),
"end_location": location_to_json(end_location),
"filename": filename,
"noqa_row": noqa_location.map(|location| location.line)
})
}
fn location_to_json(location: LineColumn) -> serde_json::Value {
json!({
"row": location.line,
"column": location.column
})
}
struct ExpandedEdits<'a> {
edits: &'a [Edit],
source_code: &'a SourceCode<'a, 'a>,
notebook_index: Option<&'a NotebookIndex>,
}
impl Serialize for ExpandedEdits<'_> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut s = serializer.serialize_seq(Some(self.edits.len()))?;
for edit in self.edits {
let mut location = self.source_code.line_column(edit.start());
let mut end_location = self.source_code.line_column(edit.end());
if let Some(notebook_index) = self.notebook_index {
// There exists a newline between each cell's source code in the
// concatenated source code in Ruff. This newline doesn't actually
// exists in the JSON source field.
//
// Now, certain edits may try to remove this newline, which means
// the edit will spill over to the first character of the next cell.
// If it does, we need to translate the end location to the last
// character of the previous cell.
match (
notebook_index.cell(location.line),
notebook_index.cell(end_location.line),
) {
(Some(start_cell), Some(end_cell)) if start_cell != end_cell => {
debug_assert_eq!(end_location.column.get(), 1);
let prev_row = end_location.line.saturating_sub(1);
end_location = LineColumn {
line: notebook_index.cell_row(prev_row).unwrap_or(OneIndexed::MIN),
column: self
.source_code
.line_column(self.source_code.line_end_exclusive(prev_row))
.column,
};
}
(Some(_), None) => {
debug_assert_eq!(end_location.column.get(), 1);
let prev_row = end_location.line.saturating_sub(1);
end_location = LineColumn {
line: notebook_index.cell_row(prev_row).unwrap_or(OneIndexed::MIN),
column: self
.source_code
.line_column(self.source_code.line_end_exclusive(prev_row))
.column,
};
}
_ => {
end_location = notebook_index.translate_line_column(&end_location);
}
}
location = notebook_index.translate_line_column(&location);
}
let value = json!({
"content": edit.content().unwrap_or_default(),
"location": location_to_json(location),
"end_location": location_to_json(end_location)
});
s.serialize_element(&value)?;
}
s.end()
}
}
#[cfg(test)]
mod tests {
use insta::assert_snapshot;
use crate::message::JsonEmitter;
use crate::message::tests::{
capture_emitter_notebook_output, capture_emitter_output, create_diagnostics,
create_notebook_diagnostics, create_syntax_error_diagnostics,
};
#[test]
fn output() {
let mut emitter = JsonEmitter;
let content = capture_emitter_output(&mut emitter, &create_diagnostics());
assert_snapshot!(content);
}
#[test]
fn syntax_errors() {
let mut emitter = JsonEmitter;
let content = capture_emitter_output(&mut emitter, &create_syntax_error_diagnostics());
assert_snapshot!(content);
}
#[test]
fn notebook_output() {
let mut emitter = JsonEmitter;
let (diagnostics, notebook_indexes) = create_notebook_diagnostics();
let content =
capture_emitter_notebook_output(&mut emitter, &diagnostics, &notebook_indexes);
assert_snapshot!(content);
}
}

View File

@@ -1,60 +0,0 @@
use std::io::Write;
use ruff_db::diagnostic::Diagnostic;
use crate::message::json::message_to_json_value;
use crate::message::{Emitter, EmitterContext};
#[derive(Default)]
pub struct JsonLinesEmitter;
impl Emitter for JsonLinesEmitter {
fn emit(
&mut self,
writer: &mut dyn Write,
diagnostics: &[Diagnostic],
context: &EmitterContext,
) -> anyhow::Result<()> {
for diagnostic in diagnostics {
serde_json::to_writer(&mut *writer, &message_to_json_value(diagnostic, context))?;
writer.write_all(b"\n")?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use insta::assert_snapshot;
use crate::message::json_lines::JsonLinesEmitter;
use crate::message::tests::{
capture_emitter_notebook_output, capture_emitter_output, create_diagnostics,
create_notebook_diagnostics, create_syntax_error_diagnostics,
};
#[test]
fn output() {
let mut emitter = JsonLinesEmitter;
let content = capture_emitter_output(&mut emitter, &create_diagnostics());
assert_snapshot!(content);
}
#[test]
fn syntax_errors() {
let mut emitter = JsonLinesEmitter;
let content = capture_emitter_output(&mut emitter, &create_syntax_error_diagnostics());
assert_snapshot!(content);
}
#[test]
fn notebook_output() {
let mut emitter = JsonLinesEmitter;
let (messages, notebook_indexes) = create_notebook_diagnostics();
let content = capture_emitter_notebook_output(&mut emitter, &messages, &notebook_indexes);
assert_snapshot!(content);
}
}

View File

@@ -3,20 +3,18 @@ use std::fmt::Display;
use std::io::Write;
use std::ops::Deref;
use ruff_db::diagnostic::{
Annotation, Diagnostic, DiagnosticId, LintName, SecondaryCode, Severity, Span,
};
use rustc_hash::FxHashMap;
pub use azure::AzureEmitter;
use ruff_db::diagnostic::{
Annotation, Diagnostic, DiagnosticId, FileResolver, Input, LintName, SecondaryCode, Severity,
Span, UnifiedFile,
};
use ruff_db::files::File;
pub use github::GithubEmitter;
pub use gitlab::GitlabEmitter;
pub use grouped::GroupedEmitter;
pub use json::JsonEmitter;
pub use json_lines::JsonLinesEmitter;
pub use junit::JunitEmitter;
pub use pylint::PylintEmitter;
pub use rdjson::RdjsonEmitter;
use ruff_notebook::NotebookIndex;
use ruff_source_file::{LineColumn, SourceFile};
use ruff_text_size::{Ranged, TextRange, TextSize};
@@ -26,16 +24,11 @@ pub use text::TextEmitter;
use crate::Fix;
use crate::registry::Rule;
mod azure;
mod diff;
mod github;
mod gitlab;
mod grouped;
mod json;
mod json_lines;
mod junit;
mod pylint;
mod rdjson;
mod sarif;
mod text;
@@ -83,6 +76,13 @@ where
body,
);
let span = Span::from(file).with_range(range);
let mut annotation = Annotation::primary(span);
if let Some(suggestion) = suggestion {
annotation = annotation.message(suggestion);
}
diagnostic.annotate(annotation);
if let Some(fix) = fix {
diagnostic.set_fix(fix);
}
@@ -95,18 +95,43 @@ where
diagnostic.set_noqa_offset(noqa_offset);
}
let span = Span::from(file).with_range(range);
let mut annotation = Annotation::primary(span);
if let Some(suggestion) = suggestion {
annotation = annotation.message(suggestion);
}
diagnostic.annotate(annotation);
diagnostic.set_secondary_code(SecondaryCode::new(rule.noqa_code().to_string()));
diagnostic
}
impl FileResolver for EmitterContext<'_> {
fn path(&self, _file: File) -> &str {
unimplemented!("Expected a Ruff file for rendering a Ruff diagnostic");
}
fn input(&self, _file: File) -> Input {
unimplemented!("Expected a Ruff file for rendering a Ruff diagnostic");
}
fn notebook_index(&self, file: &UnifiedFile) -> Option<NotebookIndex> {
match file {
UnifiedFile::Ty(_) => {
unimplemented!("Expected a Ruff file for rendering a Ruff diagnostic")
}
UnifiedFile::Ruff(file) => self.notebook_indexes.get(file.name()).cloned(),
}
}
fn is_notebook(&self, file: &UnifiedFile) -> bool {
match file {
UnifiedFile::Ty(_) => {
unimplemented!("Expected a Ruff file for rendering a Ruff diagnostic")
}
UnifiedFile::Ruff(file) => self.notebook_indexes.get(file.name()).is_some(),
}
}
fn current_directory(&self) -> &std::path::Path {
crate::fs::get_cwd()
}
}
struct MessageWithLocation<'a> {
message: &'a Diagnostic,
start_location: LineColumn,

View File

@@ -1,72 +0,0 @@
use std::io::Write;
use ruff_db::diagnostic::Diagnostic;
use ruff_source_file::OneIndexed;
use crate::fs::relativize_path;
use crate::message::{Emitter, EmitterContext};
/// Generate violations in Pylint format.
/// See: [Flake8 documentation](https://flake8.pycqa.org/en/latest/internal/formatters.html#pylint-formatter)
#[derive(Default)]
pub struct PylintEmitter;
impl Emitter for PylintEmitter {
fn emit(
&mut self,
writer: &mut dyn Write,
diagnostics: &[Diagnostic],
context: &EmitterContext,
) -> anyhow::Result<()> {
for diagnostic in diagnostics {
let filename = diagnostic.expect_ruff_filename();
let row = if context.is_notebook(&filename) {
// We can't give a reasonable location for the structured formats,
// so we show one that's clearly a fallback
OneIndexed::from_zero_indexed(0)
} else {
diagnostic.expect_ruff_start_location().line
};
let body = if let Some(code) = diagnostic.secondary_code() {
format!("[{code}] {body}", body = diagnostic.body())
} else {
diagnostic.body().to_string()
};
writeln!(
writer,
"{path}:{row}: {body}",
path = relativize_path(&filename),
)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use insta::assert_snapshot;
use crate::message::PylintEmitter;
use crate::message::tests::{
capture_emitter_output, create_diagnostics, create_syntax_error_diagnostics,
};
#[test]
fn output() {
let mut emitter = PylintEmitter;
let content = capture_emitter_output(&mut emitter, &create_diagnostics());
assert_snapshot!(content);
}
#[test]
fn syntax_errors() {
let mut emitter = PylintEmitter;
let content = capture_emitter_output(&mut emitter, &create_syntax_error_diagnostics());
assert_snapshot!(content);
}
}

View File

@@ -1,143 +0,0 @@
use std::io::Write;
use serde::ser::SerializeSeq;
use serde::{Serialize, Serializer};
use serde_json::{Value, json};
use ruff_db::diagnostic::Diagnostic;
use ruff_source_file::SourceCode;
use ruff_text_size::Ranged;
use crate::Edit;
use crate::message::{Emitter, EmitterContext, LineColumn};
#[derive(Default)]
pub struct RdjsonEmitter;
impl Emitter for RdjsonEmitter {
fn emit(
&mut self,
writer: &mut dyn Write,
diagnostics: &[Diagnostic],
_context: &EmitterContext,
) -> anyhow::Result<()> {
serde_json::to_writer_pretty(
writer,
&json!({
"source": {
"name": "ruff",
"url": "https://docs.astral.sh/ruff",
},
"severity": "warning",
"diagnostics": &ExpandedMessages{ diagnostics }
}),
)?;
Ok(())
}
}
struct ExpandedMessages<'a> {
diagnostics: &'a [Diagnostic],
}
impl Serialize for ExpandedMessages<'_> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut s = serializer.serialize_seq(Some(self.diagnostics.len()))?;
for message in self.diagnostics {
let value = message_to_rdjson_value(message);
s.serialize_element(&value)?;
}
s.end()
}
}
fn message_to_rdjson_value(message: &Diagnostic) -> Value {
let source_file = message.expect_ruff_source_file();
let source_code = source_file.to_source_code();
let start_location = source_code.line_column(message.expect_range().start());
let end_location = source_code.line_column(message.expect_range().end());
if let Some(fix) = message.fix() {
json!({
"message": message.body(),
"location": {
"path": message.expect_ruff_filename(),
"range": rdjson_range(start_location, end_location),
},
"code": {
"value": message.secondary_code(),
"url": message.to_url(),
},
"suggestions": rdjson_suggestions(fix.edits(), &source_code),
})
} else {
json!({
"message": message.body(),
"location": {
"path": message.expect_ruff_filename(),
"range": rdjson_range(start_location, end_location),
},
"code": {
"value": message.secondary_code(),
"url": message.to_url(),
},
})
}
}
fn rdjson_suggestions(edits: &[Edit], source_code: &SourceCode) -> Value {
Value::Array(
edits
.iter()
.map(|edit| {
let location = source_code.line_column(edit.start());
let end_location = source_code.line_column(edit.end());
json!({
"range": rdjson_range(location, end_location),
"text": edit.content().unwrap_or_default(),
})
})
.collect(),
)
}
fn rdjson_range(start: LineColumn, end: LineColumn) -> Value {
json!({
"start": start,
"end": end,
})
}
#[cfg(test)]
mod tests {
use insta::assert_snapshot;
use crate::message::RdjsonEmitter;
use crate::message::tests::{
capture_emitter_output, create_diagnostics, create_syntax_error_diagnostics,
};
#[test]
fn output() {
let mut emitter = RdjsonEmitter;
let content = capture_emitter_output(&mut emitter, &create_diagnostics());
assert_snapshot!(content);
}
#[test]
fn syntax_errors() {
let mut emitter = RdjsonEmitter;
let content = capture_emitter_output(&mut emitter, &create_syntax_error_diagnostics());
assert_snapshot!(content);
}
}

View File

@@ -1,7 +0,0 @@
---
source: crates/ruff_linter/src/message/pylint.rs
expression: content
snapshot_kind: text
---
syntax_errors.py:1: SyntaxError: Expected one or more symbol names after import
syntax_errors.py:3: SyntaxError: Expected ')', found newline

View File

@@ -87,9 +87,14 @@ fn detect_blind_exception(
}
}
let first_arg = arguments.args.first()?;
let exception_argument_name = if is_pytest_raises {
"expected_exception"
} else {
"exception"
};
let builtin_symbol = semantic.resolve_builtin_symbol(first_arg)?;
let exception_expr = arguments.find_argument_value(exception_argument_name, 0)?;
let builtin_symbol = semantic.resolve_builtin_symbol(exception_expr)?;
match builtin_symbol {
"Exception" => Some(ExceptionKind::Exception),

View File

@@ -43,3 +43,29 @@ B017_0.py:57:36: B017 Do not assert blind exception: `Exception`
| ^^^^^^^^^^^^^^^^^^^^^^^^ B017
58 | raise ValueError("Multiple context managers")
|
B017_0.py:62:10: B017 Do not assert blind exception: `Exception`
|
61 | def test_pytest_raises_keyword():
62 | with pytest.raises(expected_exception=Exception):
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ B017
63 | raise ValueError("Should be flagged")
|
B017_0.py:68:18: B017 Do not assert blind exception: `Exception`
|
66 | class TestKwargs(unittest.TestCase):
67 | def test_method(self):
68 | with self.assertRaises(exception=Exception):
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ B017
69 | raise ValueError("Should be flagged")
|
B017_0.py:71:18: B017 Do not assert blind exception: `BaseException`
|
69 | raise ValueError("Should be flagged")
70 |
71 | with self.assertRaises(exception=BaseException):
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ B017
72 | raise ValueError("Should be flagged")
|

View File

@@ -1,6 +1,5 @@
---
source: crates/ruff_linter/src/rules/flake8_bugbear/mod.rs
snapshot_kind: text
---
B018.py:11:5: B018 Found useless expression. Either assign it to a variable or remove it.
|

View File

@@ -43,3 +43,29 @@ B017_0.py:57:36: B017 Do not assert blind exception: `Exception`
| ^^^^^^^^^^^^^^^^^^^^^^^^ B017
58 | raise ValueError("Multiple context managers")
|
B017_0.py:62:10: B017 Do not assert blind exception: `Exception`
|
61 | def test_pytest_raises_keyword():
62 | with pytest.raises(expected_exception=Exception):
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ B017
63 | raise ValueError("Should be flagged")
|
B017_0.py:68:18: B017 Do not assert blind exception: `Exception`
|
66 | class TestKwargs(unittest.TestCase):
67 | def test_method(self):
68 | with self.assertRaises(exception=Exception):
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ B017
69 | raise ValueError("Should be flagged")
|
B017_0.py:71:18: B017 Do not assert blind exception: `BaseException`
|
69 | raise ValueError("Should be flagged")
70 |
71 | with self.assertRaises(exception=BaseException):
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ B017
72 | raise ValueError("Should be flagged")
|

View File

@@ -96,22 +96,43 @@ fn is_model_abstract(class_def: &ast::StmtClassDef) -> bool {
continue;
}
for element in body {
let Stmt::Assign(ast::StmtAssign { targets, value, .. }) = element else {
continue;
};
for target in targets {
let Expr::Name(ast::ExprName { id, .. }) = target else {
continue;
};
if id != "abstract" {
continue;
match element {
Stmt::Assign(ast::StmtAssign { targets, value, .. }) => {
if targets
.iter()
.any(|target| is_abstract_true_assignment(target, Some(value)))
{
return true;
}
}
if !is_const_true(value) {
continue;
Stmt::AnnAssign(ast::StmtAnnAssign { target, value, .. }) => {
if is_abstract_true_assignment(target, value.as_deref()) {
return true;
}
}
return true;
_ => {}
}
}
}
false
}
fn is_abstract_true_assignment(target: &Expr, value: Option<&Expr>) -> bool {
let Expr::Name(ast::ExprName { id, .. }) = target else {
return false;
};
if id != "abstract" {
return false;
}
let Some(value) = value else {
return false;
};
if !is_const_true(value) {
return false;
}
true
}

View File

@@ -1,6 +1,5 @@
---
source: crates/ruff_linter/src/rules/flake8_django/mod.rs
snapshot_kind: text
---
DJ008.py:6:7: DJ008 Model does not define `__str__` method
|
@@ -31,3 +30,11 @@ DJ008.py:182:7: DJ008 Model does not define `__str__` method
| ^^^^^^^^^^^^^^^^^^ DJ008
183 | pass
|
DJ008.py:215:7: DJ008 Model does not define `__str__` method
|
215 | class TypeAnnotatedNonAbstractModel(models.Model):
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ DJ008
216 | """Model with type-annotated abstract = False - should trigger DJ008"""
217 | new_field = models.CharField(max_length=10)
|

View File

@@ -1,9 +1,10 @@
---
source: crates/ruff_linter/src/rules/flake8_gettext/mod.rs
snapshot_kind: text
---
INT001.py:1:3: INT001 f-string is resolved before function call; consider `_("string %s") % arg`
|
1 | _(f"{'value'}")
| ^^^^^^^^^^^^ INT001
2 |
3 | # Don't trigger for t-strings
|

View File

@@ -52,4 +52,6 @@ G004.py:15:6: G004 Logging statement uses f-string
14 | info(f"{name}")
15 | info(f"{__name__}")
| ^^^^^^^^^^^^^ G004
16 |
17 | # Don't trigger for t-strings
|

View File

@@ -1,6 +1,5 @@
---
source: crates/ruff_linter/src/rules/flake8_pytest_style/mod.rs
snapshot_kind: text
---
PT016.py:19:5: PT016 No message passed to `pytest.fail()`
|
@@ -67,4 +66,6 @@ PT016.py:25:5: PT016 No message passed to `pytest.fail()`
24 | pytest.fail(reason="")
25 | pytest.fail(reason=f"")
| ^^^^^^^^^^^ PT016
26 |
27 | # Skip for t-strings
|

View File

@@ -23,17 +23,28 @@ use crate::checkers::ast::Checker;
///
/// ## Example
/// ```python
/// var: str | "int"
/// var: "Foo" | None
///
///
/// class Foo: ...
/// ```
///
/// Use instead:
/// ```python
/// var: str | int
/// from __future__ import annotations
///
/// var: Foo | None
///
///
/// class Foo: ...
/// ```
///
/// Or, extend the quotes to include the entire union:
/// ```python
/// var: "str | int"
/// var: "Foo | None"
///
///
/// class Foo: ...
/// ```
///
/// ## References

View File

@@ -66,3 +66,13 @@ ARG.py:216:24: ARG002 Unused method argument: `x`
| ^ ARG002
217 | print("Hello, world!")
|
ARG.py:255:20: ARG002 Unused method argument: `y`
|
253 | ###
254 | class C:
255 | def f(self, x, y):
| ^ ARG002
256 | """Docstring."""
257 | msg = t"{x}..."
|

View File

@@ -8,6 +8,7 @@ mod tests {
use std::path::Path;
use anyhow::Result;
use ruff_python_ast::PythonVersion;
use test_case::test_case;
use crate::assert_diagnostics;
@@ -143,4 +144,22 @@ mod tests {
assert_diagnostics!(snapshot, diagnostics);
Ok(())
}
#[test_case(Rule::InvalidPathlibWithSuffix, Path::new("PTH210_2.py"))]
fn pathlib_with_suffix_py314(rule_code: Rule, path: &Path) -> Result<()> {
let snapshot = format!(
"py314__{}_{}",
rule_code.noqa_code(),
path.to_string_lossy()
);
let diagnostics = test_path(
Path::new("flake8_use_pathlib").join(path).as_path(),
&settings::LinterSettings {
unresolved_target_version: PythonVersion::PY314.into(),
..settings::LinterSettings::for_rule(rule_code)
},
)?;
assert_diagnostics!(snapshot, diagnostics);
Ok(())
}
}

View File

@@ -1,7 +1,7 @@
use crate::checkers::ast::Checker;
use crate::{Edit, Fix, FixAvailability, Violation};
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::{self as ast, StringFlags};
use ruff_python_ast::{self as ast, PythonVersion, StringFlags};
use ruff_python_semantic::SemanticModel;
use ruff_python_semantic::analyze::typing;
use ruff_text_size::Ranged;
@@ -9,12 +9,13 @@ use ruff_text_size::Ranged;
/// ## What it does
/// Checks for `pathlib.Path.with_suffix()` calls where
/// the given suffix does not have a leading dot
/// or the given suffix is a single dot `"."`.
/// or the given suffix is a single dot `"."` and the
/// Python version is less than 3.14.
///
/// ## Why is this bad?
/// `Path.with_suffix()` will raise an error at runtime
/// if the given suffix is not prefixed with a dot
/// or it is a single dot `"."`.
/// or, in versions prior to Python 3.14, if it is a single dot `"."`.
///
/// ## Example
///
@@ -57,9 +58,6 @@ use ruff_text_size::Ranged;
/// No fix is offered if the suffix `"."` is given, since the intent is unclear.
#[derive(ViolationMetadata)]
pub(crate) struct InvalidPathlibWithSuffix {
// TODO: Since "." is a correct suffix in Python 3.14,
// we will need to update this rule and documentation
// once Ruff supports Python 3.14.
single_dot: bool,
}
@@ -116,6 +114,13 @@ pub(crate) fn invalid_pathlib_with_suffix(checker: &Checker, call: &ast::ExprCal
};
let single_dot = string_value == ".";
// As of Python 3.14, a single dot is considered a valid suffix.
// https://docs.python.org/3.14/library/pathlib.html#pathlib.PurePath.with_suffix
if single_dot && checker.target_version() >= PythonVersion::PY314 {
return;
}
let mut diagnostic =
checker.report_diagnostic(InvalidPathlibWithSuffix { single_dot }, call.range);
if !single_dot {

View File

@@ -0,0 +1,4 @@
---
source: crates/ruff_linter/src/rules/flake8_use_pathlib/mod.rs
---

View File

@@ -912,6 +912,7 @@ mod tests {
#[test_case(Path::new("docstring.pyi"))]
#[test_case(Path::new("docstring_only.py"))]
#[test_case(Path::new("empty.py"))]
#[test_case(Path::new("multiple_strings.py"))]
fn required_imports(path: &Path) -> Result<()> {
let snapshot = format!("required_imports_{}", path.to_string_lossy());
let diagnostics = test_path(

View File

@@ -0,0 +1,18 @@
---
source: crates/ruff_linter/src/rules/isort/mod.rs
---
multiple_strings.py:1:1: I002 [*] Missing required import: `from __future__ import annotations`
Safe fix
1 1 | """This is a docstring."""
2 |+from __future__ import annotations
2 3 | "This is not a docstring."
3 4 | "This is also not a docstring."
4 5 |
multiple_strings.py:1:1: I002 [*] Missing required import: `from __future__ import generator_stop`
Safe fix
1 1 | """This is a docstring."""
2 |+from __future__ import generator_stop
2 3 | "This is not a docstring."
3 4 | "This is also not a docstring."
4 5 |

View File

@@ -1,9 +1,7 @@
use ruff_python_ast::{Decorator, Stmt};
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::identifier::Identifier;
use ruff_python_ast::{Decorator, Stmt, identifier::Identifier};
use ruff_python_semantic::SemanticModel;
use ruff_python_semantic::analyze::visibility;
use ruff_python_semantic::analyze::{class::any_base_class, visibility};
use ruff_python_stdlib::str;
use crate::Violation;
@@ -84,6 +82,41 @@ pub(crate) fn invalid_function_name(
return;
}
let parent_class = semantic
.current_statement_parent()
.and_then(|parent| parent.as_class_def_stmt());
// Ignore the visit_* methods of the ast.NodeVisitor and ast.NodeTransformer classes.
if name.starts_with("visit_")
&& parent_class.is_some_and(|class| {
any_base_class(class, semantic, &mut |superclass| {
let qualified = semantic.resolve_qualified_name(superclass);
qualified.is_some_and(|name| {
matches!(name.segments(), ["ast", "NodeVisitor" | "NodeTransformer"])
})
})
})
{
return;
}
// Ignore the do_* methods of the http.server.BaseHTTPRequestHandler class
if name.starts_with("do_")
&& parent_class.is_some_and(|class| {
any_base_class(class, semantic, &mut |superclass| {
let qualified = semantic.resolve_qualified_name(superclass);
qualified.is_some_and(|name| {
matches!(
name.segments(),
["http", "server", "BaseHTTPRequestHandler"]
)
})
})
})
{
return;
}
checker.report_diagnostic(
InvalidFunctionName {
name: name.to_string(),

View File

@@ -37,3 +37,21 @@ N802.py:40:9: N802 Function name `testTest` should be lowercase
| ^^^^^^^^ N802
41 | assert True
|
N802.py:65:9: N802 Function name `bad_Name` should be lowercase
|
63 | pass
64 |
65 | def bad_Name(self):
| ^^^^^^^^ N802
66 | pass
|
N802.py:84:9: N802 Function name `dont_GET` should be lowercase
|
82 | pass
83 |
84 | def dont_GET(self):
| ^^^^^^^^ N802
85 | pass
|

View File

@@ -126,7 +126,7 @@ impl AlwaysFixableViolation for WhitespaceBeforePunctuation {
/// E201, E202, E203
pub(crate) fn extraneous_whitespace(line: &LogicalLine, context: &LintContext) {
let mut fstrings = 0u32;
let mut interpolated_strings = 0u32;
let mut brackets = vec![];
let mut prev_token = None;
let mut iter = line.tokens().iter().peekable();
@@ -134,8 +134,10 @@ pub(crate) fn extraneous_whitespace(line: &LogicalLine, context: &LintContext) {
while let Some(token) = iter.next() {
let kind = token.kind();
match kind {
TokenKind::FStringStart => fstrings += 1,
TokenKind::FStringEnd => fstrings = fstrings.saturating_sub(1),
TokenKind::FStringStart | TokenKind::TStringStart => interpolated_strings += 1,
TokenKind::FStringEnd | TokenKind::TStringEnd => {
interpolated_strings = interpolated_strings.saturating_sub(1);
}
TokenKind::Lsqb => {
brackets.push(kind);
}
@@ -161,7 +163,9 @@ pub(crate) fn extraneous_whitespace(line: &LogicalLine, context: &LintContext) {
// Here, `{{` / `}} would be interpreted as a single raw `{` / `}`
// character.
match symbol {
BracketOrPunctuation::OpenBracket(symbol) if symbol != '{' || fstrings == 0 => {
BracketOrPunctuation::OpenBracket(symbol)
if symbol != '{' || interpolated_strings == 0 =>
{
let (trailing, trailing_len) = line.trailing_whitespace(token);
if !matches!(trailing, Whitespace::None) {
if let Some(mut diagnostic) = context.report_diagnostic_if_enabled(
@@ -173,7 +177,9 @@ pub(crate) fn extraneous_whitespace(line: &LogicalLine, context: &LintContext) {
}
}
}
BracketOrPunctuation::CloseBracket(symbol) if symbol != '}' || fstrings == 0 => {
BracketOrPunctuation::CloseBracket(symbol)
if symbol != '}' || interpolated_strings == 0 =>
{
if !matches!(prev_token, Some(TokenKind::Comma)) {
if let (Whitespace::Single | Whitespace::Many | Whitespace::Tab, offset) =
line.leading_whitespace(token)
@@ -286,7 +292,7 @@ pub(crate) fn extraneous_whitespace(line: &LogicalLine, context: &LintContext) {
}
}
} else {
if fstrings > 0
if interpolated_strings > 0
&& symbol == ':'
&& matches!(prev_token, Some(TokenKind::Equal))
{

View File

@@ -41,7 +41,7 @@ impl AlwaysFixableViolation for MissingWhitespace {
/// E231
pub(crate) fn missing_whitespace(line: &LogicalLine, context: &LintContext) {
let mut fstrings = 0u32;
let mut interpolated_strings = 0u32;
let mut definition_state = DefinitionState::from_tokens(line.tokens());
let mut brackets = Vec::new();
let mut iter = line.tokens().iter().peekable();
@@ -50,21 +50,23 @@ pub(crate) fn missing_whitespace(line: &LogicalLine, context: &LintContext) {
let kind = token.kind();
definition_state.visit_token_kind(kind);
match kind {
TokenKind::FStringStart => fstrings += 1,
TokenKind::FStringEnd => fstrings = fstrings.saturating_sub(1),
TokenKind::Lsqb if fstrings == 0 => {
TokenKind::FStringStart | TokenKind::TStringStart => interpolated_strings += 1,
TokenKind::FStringEnd | TokenKind::TStringEnd => {
interpolated_strings = interpolated_strings.saturating_sub(1);
}
TokenKind::Lsqb if interpolated_strings == 0 => {
brackets.push(kind);
}
TokenKind::Rsqb if fstrings == 0 => {
TokenKind::Rsqb if interpolated_strings == 0 => {
brackets.pop();
}
TokenKind::Lbrace if fstrings == 0 => {
TokenKind::Lbrace if interpolated_strings == 0 => {
brackets.push(kind);
}
TokenKind::Rbrace if fstrings == 0 => {
TokenKind::Rbrace if interpolated_strings == 0 => {
brackets.pop();
}
TokenKind::Colon if fstrings > 0 => {
TokenKind::Colon if interpolated_strings > 0 => {
// Colon in f-string, no space required. This will yield false
// negatives for cases like the following as it's hard to
// differentiate between the usage of a colon in a f-string.

View File

@@ -183,3 +183,23 @@ E20.py:145:5: E201 [*] Whitespace after '['
146 146 |
147 147 | #: Okay
148 148 | ham[lower + offset :: upper + offset]
E20.py:195:5: E201 [*] Whitespace after '['
|
193 | # t-strings
194 | t"{ {'a': 1} }"
195 | t"{[ { {'a': 1} } ]}"
| ^ E201
196 | t"normal { {t"{ { [1, 2] } }" } } normal"
|
= help: Remove whitespace before '['
Safe fix
192 192 |
193 193 | # t-strings
194 194 | t"{ {'a': 1} }"
195 |-t"{[ { {'a': 1} } ]}"
195 |+t"{[{ {'a': 1} } ]}"
196 196 | t"normal { {t"{ { [1, 2] } }" } } normal"
197 197 |
198 198 | t"{x = :.2f}"

View File

@@ -165,3 +165,23 @@ E20.py:172:12: E202 [*] Whitespace before ']'
173 173 |
174 174 | #: E203:1:10
175 175 | ham[upper :]
E20.py:195:18: E202 [*] Whitespace before ']'
|
193 | # t-strings
194 | t"{ {'a': 1} }"
195 | t"{[ { {'a': 1} } ]}"
| ^ E202
196 | t"normal { {t"{ { [1, 2] } }" } } normal"
|
= help: Remove whitespace before ']'
Safe fix
192 192 |
193 193 | # t-strings
194 194 | t"{ {'a': 1} }"
195 |-t"{[ { {'a': 1} } ]}"
195 |+t"{[ { {'a': 1} }]}"
196 196 | t"normal { {t"{ { [1, 2] } }" } } normal"
197 197 |
198 198 | t"{x = :.2f}"

View File

@@ -345,3 +345,19 @@ E20.py:187:17: E203 [*] Whitespace before ':'
188 188 |
189 189 | #: Okay: https://github.com/astral-sh/ruff/issues/12023
190 190 | f"{x = :.2f}"
E20.py:205:17: E203 [*] Whitespace before ':'
|
204 | #: E203:1:13
205 | t"{ham[lower + 1 :, "columnname"]}"
| ^^ E203
|
= help: Remove whitespace before ':'
Safe fix
202 202 | t"{ham[lower +1 :, "columnname"]}"
203 203 |
204 204 | #: E203:1:13
205 |-t"{ham[lower + 1 :, "columnname"]}"
205 |+t"{ham[lower + 1:, "columnname"]}"
206 206 |

View File

@@ -905,3 +905,38 @@ E23.py:126:99: E231 [*] Missing whitespace after ':'
127 127 | pass
128 128 |
129 129 | # Should be no E231 errors on any of these:
E23.py:147:6: E231 [*] Missing whitespace after ','
|
146 | # E231
147 | t"{(a,b)}"
| ^ E231
148 |
149 | # Okay because it's hard to differentiate between the usages of a colon in a t-string
|
= help: Add missing whitespace
Safe fix
144 144 | pass
145 145 |
146 146 | # E231
147 |-t"{(a,b)}"
147 |+t"{(a, b)}"
148 148 |
149 149 | # Okay because it's hard to differentiate between the usages of a colon in a t-string
150 150 | t"{a:=1}"
E23.py:161:37: E231 [*] Missing whitespace after ':'
|
160 | #: E231
161 | {len(t's3://{self.s3_bucket_name}/'):1}
| ^ E231
|
= help: Add missing whitespace
Safe fix
158 158 | snapshot.file_uri[len(t's3://{self.s3_bucket_name}/'):]
159 159 |
160 160 | #: E231
161 |-{len(t's3://{self.s3_bucket_name}/'):1}
161 |+{len(t's3://{self.s3_bucket_name}/'): 1}

View File

@@ -10,7 +10,7 @@ use ruff_text_size::TextRange;
use crate::checkers::ast::Checker;
use crate::docstrings::Docstring;
use crate::registry::Rule;
use crate::{AlwaysFixableViolation, Edit, Fix};
use crate::{Edit, Fix, FixAvailability, Violation};
/// ## What it does
/// Checks for docstrings on functions that are separated by one or more blank
@@ -42,15 +42,17 @@ pub(crate) struct BlankLineBeforeFunction {
num_lines: usize,
}
impl AlwaysFixableViolation for BlankLineBeforeFunction {
impl Violation for BlankLineBeforeFunction {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes;
#[derive_message_formats]
fn message(&self) -> String {
let BlankLineBeforeFunction { num_lines } = self;
format!("No blank lines allowed before function docstring (found {num_lines})")
}
fn fix_title(&self) -> String {
"Remove blank line(s) before function docstring".to_string()
fn fix_title(&self) -> Option<String> {
Some("Remove blank line(s) before function docstring".to_string())
}
}
@@ -86,15 +88,17 @@ pub(crate) struct BlankLineAfterFunction {
num_lines: usize,
}
impl AlwaysFixableViolation for BlankLineAfterFunction {
impl Violation for BlankLineAfterFunction {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes;
#[derive_message_formats]
fn message(&self) -> String {
let BlankLineAfterFunction { num_lines } = self;
format!("No blank lines allowed after function docstring (found {num_lines})")
}
fn fix_title(&self) -> String {
"Remove blank line(s) after function docstring".to_string()
fn fix_title(&self) -> Option<String> {
Some("Remove blank line(s) after function docstring".to_string())
}
}
@@ -115,12 +119,14 @@ pub(crate) fn blank_before_after_function(checker: &Checker, docstring: &Docstri
let mut lines = UniversalNewlineIterator::with_offset(before, function.start()).rev();
let mut blank_lines_before = 0usize;
let mut blank_lines_start = lines.next().map(|l| l.end()).unwrap_or_default();
let mut start_is_line_continuation = false;
for line in lines {
if line.trim().is_empty() {
blank_lines_before += 1;
blank_lines_start = line.start();
} else {
start_is_line_continuation = line.ends_with('\\');
break;
}
}
@@ -132,11 +138,14 @@ pub(crate) fn blank_before_after_function(checker: &Checker, docstring: &Docstri
},
docstring.range(),
);
// Delete the blank line before the docstring.
diagnostic.set_fix(Fix::safe_edit(Edit::deletion(
blank_lines_start,
docstring.line_start(),
)));
// Do not offer fix if a \ would cause it to be a syntax error
if !start_is_line_continuation {
// Delete the blank line before the docstring.
diagnostic.set_fix(Fix::safe_edit(Edit::deletion(
blank_lines_start,
docstring.line_start(),
)));
}
}
}
@@ -156,7 +165,9 @@ pub(crate) fn blank_before_after_function(checker: &Checker, docstring: &Docstri
// Count the number of blank lines after the docstring.
let mut blank_lines_after = 0usize;
let mut lines = UniversalNewlineIterator::with_offset(after, docstring.end()).peekable();
let first_line_end = lines.next().map(|l| l.end()).unwrap_or_default();
let first_line = lines.next();
let first_line_line_continuation = first_line.as_ref().is_some_and(|l| l.ends_with('\\'));
let first_line_end = first_line.map(|l| l.end()).unwrap_or_default();
let mut blank_lines_end = first_line_end;
while let Some(line) = lines.peek() {
@@ -185,11 +196,14 @@ pub(crate) fn blank_before_after_function(checker: &Checker, docstring: &Docstri
},
docstring.range(),
);
// Delete the blank line after the docstring.
diagnostic.set_fix(Fix::safe_edit(Edit::deletion(
first_line_end,
blank_lines_end,
)));
// Do not offer fix if a \ would cause it to be a syntax error
if !first_line_line_continuation {
// Delete the blank line after the docstring.
diagnostic.set_fix(Fix::safe_edit(Edit::deletion(
first_line_end,
blank_lines_end,
)));
}
}
}
}

View File

@@ -82,3 +82,14 @@ D.py:568:5: D201 [*] No blank lines allowed before function docstring (found 1)
568 567 | """Trailing and leading space.
569 568 |
570 569 | More content.
D.py:729:5: D201 No blank lines allowed before function docstring (found 1)
|
727 | def line_continuation_chars():\
728 |
729 | """No fix should be offered for D201/D202 because of the line continuation chars."""\
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ D201
730 |
731 | ...
|
= help: Remove blank line(s) before function docstring

View File

@@ -85,4 +85,15 @@ D.py:568:5: D202 [*] No blank lines allowed after function docstring (found 1)
572 |-
573 572 | pass
574 573 |
575 574 |
575 574 |
D.py:729:5: D202 No blank lines allowed after function docstring (found 1)
|
727 | def line_continuation_chars():\
728 |
729 | """No fix should be offered for D201/D202 because of the line continuation chars."""\
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ D202
730 |
731 | ...
|
= help: Remove blank line(s) after function docstring

View File

@@ -428,3 +428,5 @@ D.py:723:1: D208 [*] Docstring is over-indented
723 |-     Returns:
723 |+ Returns:
724 724 | """
725 725 |
726 726 |

Some files were not shown because too many files have changed in this diff Show More