Compare commits
33 Commits
charlie/bo
...
implicit-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc24d01b2e | ||
|
|
5a9d656bc4 | ||
|
|
33184dc6a4 | ||
|
|
bb8d2034e2 | ||
|
|
f40e012b4e | ||
|
|
3e9d761b13 | ||
|
|
46db3f96ac | ||
|
|
6f9c128d77 | ||
|
|
6380c90031 | ||
|
|
d96a0dbe57 | ||
|
|
180920fdd9 | ||
|
|
1ccd8354c1 | ||
|
|
dd0ba16a79 | ||
|
|
609d0a9a65 | ||
|
|
8fba97f72f | ||
|
|
5bc0d9c324 | ||
|
|
cf77eeb913 | ||
|
|
3f4dd01e7a | ||
|
|
edfe8421ec | ||
|
|
ab2253db03 | ||
|
|
33ac2867b7 | ||
|
|
0304623878 | ||
|
|
e2785f3fb6 | ||
|
|
90f8e4baf4 | ||
|
|
8657a392ff | ||
|
|
4946a1876f | ||
|
|
6dc1b21917 | ||
|
|
2e1160e74c | ||
|
|
37ff436e4e | ||
|
|
341c2698a7 | ||
|
|
a50e2787df | ||
|
|
25868d0371 | ||
|
|
af2cba7c0a |
8
.config/nextest.toml
Normal file
8
.config/nextest.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[profile.ci]
|
||||
# Print out output for failing tests as soon as they fail, and also at the end
|
||||
# of the run (for easy scrollability).
|
||||
failure-output = "immediate-final"
|
||||
# Do not cancel the test run on the first failure.
|
||||
fail-fast = false
|
||||
|
||||
status-level = "skip"
|
||||
23
.github/workflows/ci.yaml
vendored
23
.github/workflows/ci.yaml
vendored
@@ -111,13 +111,23 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- name: "Install Rust toolchain"
|
||||
run: rustup show
|
||||
- name: "Install mold"
|
||||
uses: rui314/setup-mold@v1
|
||||
- name: "Install cargo nextest"
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
- name: "Install cargo insta"
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-insta
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: "Run tests"
|
||||
run: cargo insta test --all --all-features --unreferenced reject
|
||||
shell: bash
|
||||
env:
|
||||
NEXTEST_PROFILE: "ci"
|
||||
run: cargo insta test --all-features --unreferenced reject --test-runner nextest
|
||||
|
||||
# Check for broken links in the documentation.
|
||||
- run: cargo doc --all --no-deps
|
||||
env:
|
||||
@@ -138,15 +148,16 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- name: "Install Rust toolchain"
|
||||
run: rustup show
|
||||
- name: "Install cargo insta"
|
||||
- name: "Install cargo nextest"
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-insta
|
||||
tool: cargo-nextest
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: "Run tests"
|
||||
shell: bash
|
||||
# We can't reject unreferenced snapshots on windows because flake8_executable can't run on windows
|
||||
run: cargo insta test --all --exclude ruff_dev --all-features
|
||||
run: |
|
||||
cargo nextest run --all-features --profile ci
|
||||
cargo test --all-features --doc
|
||||
|
||||
cargo-test-wasm:
|
||||
name: "cargo test (wasm)"
|
||||
@@ -407,7 +418,7 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
- name: "Add SSH key"
|
||||
if: ${{ env.MKDOCS_INSIDERS_SSH_KEY_EXISTS == 'true' }}
|
||||
uses: webfactory/ssh-agent@v0.8.0
|
||||
uses: webfactory/ssh-agent@v0.9.0
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.MKDOCS_INSIDERS_SSH_KEY }}
|
||||
- name: "Install Rust toolchain"
|
||||
|
||||
2
.github/workflows/docs.yaml
vendored
2
.github/workflows/docs.yaml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
- name: "Add SSH key"
|
||||
if: ${{ env.MKDOCS_INSIDERS_SSH_KEY_EXISTS == 'true' }}
|
||||
uses: webfactory/ssh-agent@v0.8.0
|
||||
uses: webfactory/ssh-agent@v0.9.0
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.MKDOCS_INSIDERS_SSH_KEY }}
|
||||
- name: "Install Rust toolchain"
|
||||
|
||||
@@ -26,6 +26,10 @@ Welcome! We're happy to have you here. Thank you in advance for your contributio
|
||||
- [`cargo dev`](#cargo-dev)
|
||||
- [Subsystems](#subsystems)
|
||||
- [Compilation Pipeline](#compilation-pipeline)
|
||||
- [Import Categorization](#import-categorization)
|
||||
- [Project root](#project-root)
|
||||
- [Package root](#package-root)
|
||||
- [Import categorization](#import-categorization-1)
|
||||
|
||||
## The Basics
|
||||
|
||||
@@ -63,7 +67,7 @@ You'll also need [Insta](https://insta.rs/docs/) to update snapshot tests:
|
||||
cargo install cargo-insta
|
||||
```
|
||||
|
||||
and pre-commit to run some validation checks:
|
||||
And you'll need pre-commit to run some validation checks:
|
||||
|
||||
```shell
|
||||
pipx install pre-commit # or `pip install pre-commit` if you have a virtualenv
|
||||
@@ -76,6 +80,16 @@ when making a commit:
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
We recommend [nextest](https://nexte.st/) to run Ruff's test suite (via `cargo nextest run`),
|
||||
though it's not strictly necessary:
|
||||
|
||||
```shell
|
||||
cargo install cargo-nextest --locked
|
||||
```
|
||||
|
||||
Throughout this guide, any usages of `cargo test` can be replaced with `cargo nextest run`,
|
||||
if you choose to install `nextest`.
|
||||
|
||||
### Development
|
||||
|
||||
After cloning the repository, run Ruff locally from the repository root with:
|
||||
@@ -373,6 +387,11 @@ We have several ways of benchmarking and profiling Ruff:
|
||||
- Microbenchmarks which run the linter or the formatter on individual files. These run on pull requests.
|
||||
- Profiling the linter on either the microbenchmarks or entire projects
|
||||
|
||||
> \[!NOTE\]
|
||||
> When running benchmarks, ensure that your CPU is otherwise idle (e.g., close any background
|
||||
> applications, like web browsers). You may also want to switch your CPU to a "performance"
|
||||
> mode, if it exists, especially when benchmarking short-lived processes.
|
||||
|
||||
### CPython Benchmark
|
||||
|
||||
First, clone [CPython](https://github.com/python/cpython). It's a large and diverse Python codebase,
|
||||
|
||||
16
Cargo.lock
generated
16
Cargo.lock
generated
@@ -273,9 +273,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.33"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f13690e35a5e4ace198e7beea2895d29f3a9cc55015fcebe6336bd2010af9eb"
|
||||
checksum = "5bc015644b92d5890fab7489e49d21f879d5c990186827d42ec511919404f38b"
|
||||
dependencies = [
|
||||
"android-tzdata",
|
||||
"iana-time-zone",
|
||||
@@ -1037,9 +1037,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "indicatif"
|
||||
version = "0.17.7"
|
||||
version = "0.17.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb28741c9db9a713d93deb3bb9515c20788cef5815265bee4980e87bde7e0f25"
|
||||
checksum = "763a5a8f45087d6bcea4222e7b72c291a054edf80e4ef6efd2a4979878c7bea3"
|
||||
dependencies = [
|
||||
"console",
|
||||
"instant",
|
||||
@@ -2944,18 +2944,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.56"
|
||||
version = "1.0.57"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d54378c645627613241d077a3a79db965db602882668f9136ac42af9ecb730ad"
|
||||
checksum = "1e45bcbe8ed29775f228095caf2cd67af7a4ccf756ebff23a306bf3e8b47b24b"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.56"
|
||||
version = "1.0.57"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471"
|
||||
checksum = "a953cb265bef375dae3de6663da4d3804eee9682ea80d8e2542529b73c531c81"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
@@ -21,7 +21,7 @@ bincode = { version = "1.3.3" }
|
||||
bitflags = { version = "2.4.1" }
|
||||
bstr = { version = "1.9.0" }
|
||||
cachedir = { version = "0.3.1" }
|
||||
chrono = { version = "0.4.33", default-features = false, features = ["clock"] }
|
||||
chrono = { version = "0.4.34", default-features = false, features = ["clock"] }
|
||||
clap = { version = "4.4.18", features = ["derive"] }
|
||||
clap_complete_command = { version = "0.5.1" }
|
||||
clearscreen = { version = "2.0.0" }
|
||||
@@ -44,7 +44,7 @@ hexf-parse = { version ="0.2.1"}
|
||||
ignore = { version = "0.4.22" }
|
||||
imara-diff ={ version = "0.1.5"}
|
||||
imperative = { version = "1.0.4" }
|
||||
indicatif ={ version = "0.17.7"}
|
||||
indicatif ={ version = "0.17.8"}
|
||||
indoc ={ version = "2.0.4"}
|
||||
insta = { version = "1.34.0", feature = ["filters", "glob"] }
|
||||
insta-cmd = { version = "0.4.0" }
|
||||
@@ -92,7 +92,7 @@ strum_macros = { version = "0.25.3" }
|
||||
syn = { version = "2.0.40" }
|
||||
tempfile = { version ="3.9.0"}
|
||||
test-case = { version = "3.3.1" }
|
||||
thiserror = { version = "1.0.51" }
|
||||
thiserror = { version = "1.0.57" }
|
||||
tikv-jemallocator = { version ="0.5.0"}
|
||||
toml = { version = "0.8.9" }
|
||||
tracing = { version = "0.1.40" }
|
||||
|
||||
@@ -48,6 +48,7 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
shellexpand = { workspace = true }
|
||||
strum = { workspace = true, features = [] }
|
||||
tempfile = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
tracing = { workspace = true, features = ["log"] }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::fmt::Debug;
|
||||
use std::fs::{self, File};
|
||||
use std::hash::Hasher;
|
||||
use std::io::{self, BufReader, BufWriter, Write};
|
||||
use std::io::{self, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Mutex;
|
||||
@@ -15,6 +15,7 @@ use rayon::iter::ParallelIterator;
|
||||
use rayon::iter::{IntoParallelIterator, ParallelBridge};
|
||||
use rustc_hash::FxHashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
use ruff_cache::{CacheKey, CacheKeyHasher};
|
||||
use ruff_diagnostics::{DiagnosticKind, Fix};
|
||||
@@ -165,15 +166,29 @@ impl Cache {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let file = File::create(&self.path)
|
||||
.with_context(|| format!("Failed to create cache file '{}'", self.path.display()))?;
|
||||
let writer = BufWriter::new(file);
|
||||
bincode::serialize_into(writer, &self.package).with_context(|| {
|
||||
// Write the cache to a temporary file first and then rename it for an "atomic" write.
|
||||
// Protects against data loss if the process is killed during the write and races between different ruff
|
||||
// processes, resulting in a corrupted cache file. https://github.com/astral-sh/ruff/issues/8147#issuecomment-1943345964
|
||||
let mut temp_file =
|
||||
NamedTempFile::new_in(self.path.parent().expect("Write path must have a parent"))
|
||||
.context("Failed to create temporary file")?;
|
||||
|
||||
// Serialize to in-memory buffer because hyperfine benchmark showed that it's faster than
|
||||
// using a `BufWriter` and our cache files are small enough that streaming isn't necessary.
|
||||
let serialized =
|
||||
bincode::serialize(&self.package).context("Failed to serialize cache data")?;
|
||||
temp_file
|
||||
.write_all(&serialized)
|
||||
.context("Failed to write serialized cache to temporary file.")?;
|
||||
|
||||
temp_file.persist(&self.path).with_context(|| {
|
||||
format!(
|
||||
"Failed to serialise cache to file '{}'",
|
||||
"Failed to rename temporary cache file to {}",
|
||||
self.path.display()
|
||||
)
|
||||
})
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies the pending changes without storing the cache to disk.
|
||||
|
||||
@@ -25,6 +25,15 @@ import cycles. They also increase the cognitive load of reading the code.
|
||||
If an import statement is used to check for the availability or existence
|
||||
of a module, consider using `importlib.util.find_spec` instead.
|
||||
|
||||
If an import statement is used to re-export a symbol as part of a module's
|
||||
public interface, consider using a "redundant" import alias, which
|
||||
instructs Ruff (and other tools) to respect the re-export, and avoid
|
||||
marking it as unused, as in:
|
||||
|
||||
```python
|
||||
from module import member as member
|
||||
```
|
||||
|
||||
## Example
|
||||
```python
|
||||
import numpy as np # unused import
|
||||
@@ -51,11 +60,12 @@ else:
|
||||
```
|
||||
|
||||
## Options
|
||||
- `lint.pyflakes.extend-generics`
|
||||
- `lint.ignore-init-module-imports`
|
||||
|
||||
## References
|
||||
- [Python documentation: `import`](https://docs.python.org/3/reference/simple_stmts.html#the-import-statement)
|
||||
- [Python documentation: `importlib.util.find_spec`](https://docs.python.org/3/library/importlib.html#importlib.util.find_spec)
|
||||
- [Typing documentation: interface conventions](https://typing.readthedocs.io/en/latest/source/libraries.html#library-interface-public-and-private-symbols)
|
||||
|
||||
----- stderr -----
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ license = { workspace = true }
|
||||
|
||||
[lib]
|
||||
bench = false
|
||||
doctest = false
|
||||
|
||||
[[bench]]
|
||||
name = "linter"
|
||||
|
||||
@@ -11,6 +11,7 @@ repository = { workspace = true }
|
||||
license = { workspace = true }
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
ruff_text_size = { path = "../ruff_text_size" }
|
||||
|
||||
@@ -11,6 +11,7 @@ repository = { workspace = true }
|
||||
license = { workspace = true }
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
ruff_macros = { path = "../ruff_macros" }
|
||||
|
||||
@@ -11,13 +11,25 @@ class _UnusedTypedDict2(typing.TypedDict):
|
||||
|
||||
|
||||
class _UsedTypedDict(TypedDict):
|
||||
foo: bytes
|
||||
foo: bytes
|
||||
|
||||
|
||||
class _CustomClass(_UsedTypedDict):
|
||||
bar: list[int]
|
||||
|
||||
|
||||
_UnusedTypedDict3 = TypedDict("_UnusedTypedDict3", {"foo": int})
|
||||
_UsedTypedDict3 = TypedDict("_UsedTypedDict3", {"bar": bytes})
|
||||
|
||||
|
||||
def uses_UsedTypedDict3(arg: _UsedTypedDict3) -> None: ...
|
||||
|
||||
|
||||
# In `.py` files, we don't flag unused definitions in class scopes (unlike in `.pyi`
|
||||
# files).
|
||||
class _CustomClass3:
|
||||
class _UnusedTypeDict4(TypedDict):
|
||||
pass
|
||||
|
||||
def method(self) -> None:
|
||||
_CustomClass3._UnusedTypeDict4()
|
||||
|
||||
@@ -35,3 +35,13 @@ _UnusedTypedDict3 = TypedDict("_UnusedTypedDict3", {"foo": int})
|
||||
_UsedTypedDict3 = TypedDict("_UsedTypedDict3", {"bar": bytes})
|
||||
|
||||
def uses_UsedTypedDict3(arg: _UsedTypedDict3) -> None: ...
|
||||
|
||||
|
||||
# In `.pyi` files, we flag unused definitions in class scopes as well as in the global
|
||||
# scope (unlike in `.py` files).
|
||||
class _CustomClass3:
|
||||
class _UnusedTypeDict4(TypedDict):
|
||||
pass
|
||||
|
||||
def method(self) -> None:
|
||||
_CustomClass3._UnusedTypeDict4()
|
||||
|
||||
@@ -36,35 +36,47 @@ for i in list( # Comment
|
||||
): # PERF101
|
||||
pass
|
||||
|
||||
for i in list(foo_dict): # Ok
|
||||
for i in list(foo_dict): # OK
|
||||
pass
|
||||
|
||||
for i in list(1): # Ok
|
||||
for i in list(1): # OK
|
||||
pass
|
||||
|
||||
for i in list(foo_int): # Ok
|
||||
for i in list(foo_int): # OK
|
||||
pass
|
||||
|
||||
|
||||
import itertools
|
||||
|
||||
for i in itertools.product(foo_int): # Ok
|
||||
for i in itertools.product(foo_int): # OK
|
||||
pass
|
||||
|
||||
for i in list(foo_list): # Ok
|
||||
for i in list(foo_list): # OK
|
||||
foo_list.append(i + 1)
|
||||
|
||||
for i in list(foo_list): # PERF101
|
||||
# Make sure we match the correct list
|
||||
other_list.append(i + 1)
|
||||
|
||||
for i in list(foo_tuple): # Ok
|
||||
for i in list(foo_tuple): # OK
|
||||
foo_tuple.append(i + 1)
|
||||
|
||||
for i in list(foo_set): # Ok
|
||||
for i in list(foo_set): # OK
|
||||
foo_set.append(i + 1)
|
||||
|
||||
x, y, nested_tuple = (1, 2, (3, 4, 5))
|
||||
|
||||
for i in list(nested_tuple): # PERF101
|
||||
pass
|
||||
|
||||
for i in list(foo_list): # OK
|
||||
if True:
|
||||
foo_list.append(i + 1)
|
||||
|
||||
for i in list(foo_list): # OK
|
||||
if True:
|
||||
foo_list[i] = i + 1
|
||||
|
||||
for i in list(foo_list): # OK
|
||||
if True:
|
||||
del foo_list[i + 1]
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
1 in (
|
||||
1, 2, 3
|
||||
)
|
||||
|
||||
# OK
|
||||
fruits = ["cherry", "grapes"]
|
||||
"cherry" in fruits
|
||||
_ = {key: value for key, value in {"a": 1, "b": 2}.items() if key in ("a", "b")}
|
||||
|
||||
# OK
|
||||
fruits in [[1, 2, 3], [4, 5, 6]]
|
||||
fruits in [1, 2, 3]
|
||||
1 in [[1, 2, 3], [4, 5, 6]]
|
||||
_ = {key: value for key, value in {"a": 1, "b": 2}.items() if key in (["a", "b"], ["c", "d"])}
|
||||
|
||||
@@ -35,6 +35,15 @@ if argc != 0: # correct
|
||||
if argc != 1: # correct
|
||||
pass
|
||||
|
||||
if argc != -1.0: # correct
|
||||
pass
|
||||
|
||||
if argc != 0.0: # correct
|
||||
pass
|
||||
|
||||
if argc != 1.0: # correct
|
||||
pass
|
||||
|
||||
if argc != 2: # [magic-value-comparison]
|
||||
pass
|
||||
|
||||
@@ -44,6 +53,12 @@ if argc != -2: # [magic-value-comparison]
|
||||
if argc != +2: # [magic-value-comparison]
|
||||
pass
|
||||
|
||||
if argc != -2.0: # [magic-value-comparison]
|
||||
pass
|
||||
|
||||
if argc != +2.0: # [magic-value-comparison]
|
||||
pass
|
||||
|
||||
if __name__ == "__main__": # correct
|
||||
pass
|
||||
|
||||
|
||||
75
crates/ruff_linter/resources/test/fixtures/refurb/FURB129.py
vendored
Normal file
75
crates/ruff_linter/resources/test/fixtures/refurb/FURB129.py
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
import codecs
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
# Errors
|
||||
with open("FURB129.py") as f:
|
||||
for _line in f.readlines():
|
||||
pass
|
||||
a = [line.lower() for line in f.readlines()]
|
||||
b = {line.upper() for line in f.readlines()}
|
||||
c = {line.lower(): line.upper() for line in f.readlines()}
|
||||
|
||||
with Path("FURB129.py").open() as f:
|
||||
for _line in f.readlines():
|
||||
pass
|
||||
|
||||
for _line in open("FURB129.py").readlines():
|
||||
pass
|
||||
|
||||
for _line in Path("FURB129.py").open().readlines():
|
||||
pass
|
||||
|
||||
|
||||
def func():
|
||||
f = Path("FURB129.py").open()
|
||||
for _line in f.readlines():
|
||||
pass
|
||||
f.close()
|
||||
|
||||
|
||||
def func(f: io.BytesIO):
|
||||
for _line in f.readlines():
|
||||
pass
|
||||
|
||||
|
||||
def func():
|
||||
with (open("FURB129.py") as f, foo as bar):
|
||||
for _line in f.readlines():
|
||||
pass
|
||||
for _line in bar.readlines():
|
||||
pass
|
||||
|
||||
|
||||
# False positives
|
||||
def func(f):
|
||||
for _line in f.readlines():
|
||||
pass
|
||||
|
||||
|
||||
def func(f: codecs.StreamReader):
|
||||
for _line in f.readlines():
|
||||
pass
|
||||
|
||||
|
||||
def func():
|
||||
class A:
|
||||
def readlines(self) -> list[str]:
|
||||
return ["a", "b", "c"]
|
||||
|
||||
return A()
|
||||
|
||||
|
||||
for _line in func().readlines():
|
||||
pass
|
||||
|
||||
# OK
|
||||
for _line in ["a", "b", "c"]:
|
||||
pass
|
||||
with open("FURB129.py") as f:
|
||||
for _line in f:
|
||||
pass
|
||||
for _line in f.readlines(10):
|
||||
pass
|
||||
for _not_line in f.readline():
|
||||
pass
|
||||
@@ -162,3 +162,26 @@ async def f(x: bool):
|
||||
T = asyncio.create_task(asyncio.sleep(1))
|
||||
else:
|
||||
T = None
|
||||
|
||||
|
||||
# Error
|
||||
def f():
|
||||
loop = asyncio.new_event_loop()
|
||||
loop.create_task(main()) # Error
|
||||
|
||||
# Error
|
||||
def f():
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.create_task(main()) # Error
|
||||
|
||||
# OK
|
||||
def f():
|
||||
global task
|
||||
loop = asyncio.new_event_loop()
|
||||
task = loop.create_task(main()) # Error
|
||||
|
||||
# OK
|
||||
def f():
|
||||
global task
|
||||
loop = asyncio.get_event_loop()
|
||||
task = loop.create_task(main()) # Error
|
||||
|
||||
@@ -2,11 +2,14 @@ use ruff_python_ast::Comprehension;
|
||||
|
||||
use crate::checkers::ast::Checker;
|
||||
use crate::codes::Rule;
|
||||
use crate::rules::flake8_simplify;
|
||||
use crate::rules::{flake8_simplify, refurb};
|
||||
|
||||
/// Run lint rules over a [`Comprehension`] syntax nodes.
|
||||
pub(crate) fn comprehension(comprehension: &Comprehension, checker: &mut Checker) {
|
||||
if checker.enabled(Rule::InDictKeys) {
|
||||
flake8_simplify::rules::key_in_dict_comprehension(checker, comprehension);
|
||||
}
|
||||
if checker.enabled(Rule::ReadlinesInFor) {
|
||||
refurb::rules::readlines_in_comprehension(checker, comprehension);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,17 +281,21 @@ pub(crate) fn deferred_scopes(checker: &mut Checker) {
|
||||
}
|
||||
}
|
||||
|
||||
if checker.enabled(Rule::UnusedPrivateTypeVar) {
|
||||
flake8_pyi::rules::unused_private_type_var(checker, scope, &mut diagnostics);
|
||||
}
|
||||
if checker.enabled(Rule::UnusedPrivateProtocol) {
|
||||
flake8_pyi::rules::unused_private_protocol(checker, scope, &mut diagnostics);
|
||||
}
|
||||
if checker.enabled(Rule::UnusedPrivateTypeAlias) {
|
||||
flake8_pyi::rules::unused_private_type_alias(checker, scope, &mut diagnostics);
|
||||
}
|
||||
if checker.enabled(Rule::UnusedPrivateTypedDict) {
|
||||
flake8_pyi::rules::unused_private_typed_dict(checker, scope, &mut diagnostics);
|
||||
if checker.source_type.is_stub()
|
||||
|| matches!(scope.kind, ScopeKind::Module | ScopeKind::Function(_))
|
||||
{
|
||||
if checker.enabled(Rule::UnusedPrivateTypeVar) {
|
||||
flake8_pyi::rules::unused_private_type_var(checker, scope, &mut diagnostics);
|
||||
}
|
||||
if checker.enabled(Rule::UnusedPrivateProtocol) {
|
||||
flake8_pyi::rules::unused_private_protocol(checker, scope, &mut diagnostics);
|
||||
}
|
||||
if checker.enabled(Rule::UnusedPrivateTypeAlias) {
|
||||
flake8_pyi::rules::unused_private_type_alias(checker, scope, &mut diagnostics);
|
||||
}
|
||||
if checker.enabled(Rule::UnusedPrivateTypedDict) {
|
||||
flake8_pyi::rules::unused_private_typed_dict(checker, scope, &mut diagnostics);
|
||||
}
|
||||
}
|
||||
|
||||
if checker.enabled(Rule::AsyncioDanglingTask) {
|
||||
|
||||
@@ -1317,6 +1317,9 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
|
||||
if checker.enabled(Rule::UnnecessaryDictIndexLookup) {
|
||||
pylint::rules::unnecessary_dict_index_lookup(checker, for_stmt);
|
||||
}
|
||||
if checker.enabled(Rule::ReadlinesInFor) {
|
||||
refurb::rules::readlines_in_for(checker, for_stmt);
|
||||
}
|
||||
if !is_async {
|
||||
if checker.enabled(Rule::ReimplementedBuiltin) {
|
||||
flake8_simplify::rules::convert_for_loop_to_any_all(checker, stmt);
|
||||
|
||||
@@ -40,7 +40,7 @@ use ruff_diagnostics::{Diagnostic, IsolationLevel};
|
||||
use ruff_notebook::{CellOffsets, NotebookIndex};
|
||||
use ruff_python_ast::all::{extract_all_names, DunderAllFlags};
|
||||
use ruff_python_ast::helpers::{
|
||||
collect_import_from_member, extract_handled_exceptions, to_module_path,
|
||||
collect_import_from_member, extract_handled_exceptions, is_docstring_stmt, to_module_path,
|
||||
};
|
||||
use ruff_python_ast::identifier::Identifier;
|
||||
use ruff_python_ast::str::trailing_quote;
|
||||
@@ -71,6 +71,38 @@ mod analyze;
|
||||
mod annotation;
|
||||
mod deferred;
|
||||
|
||||
/// State representing whether a docstring is expected or not for the next statement.
|
||||
#[derive(Default, Debug, Copy, Clone, PartialEq)]
|
||||
enum DocstringState {
|
||||
/// The next statement is expected to be a docstring, but not necessarily so.
|
||||
///
|
||||
/// For example, in the following code:
|
||||
///
|
||||
/// ```python
|
||||
/// class Foo:
|
||||
/// pass
|
||||
///
|
||||
///
|
||||
/// def bar(x, y):
|
||||
/// """Docstring."""
|
||||
/// return x + y
|
||||
/// ```
|
||||
///
|
||||
/// For `Foo`, the state is expected when the checker is visiting the class
|
||||
/// body but isn't going to be present. While, for `bar` function, the docstring
|
||||
/// is expected and present.
|
||||
#[default]
|
||||
Expected,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl DocstringState {
|
||||
/// Returns `true` if the next statement is expected to be a docstring.
|
||||
const fn is_expected(self) -> bool {
|
||||
matches!(self, DocstringState::Expected)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Checker<'a> {
|
||||
/// The [`Path`] to the file under analysis.
|
||||
path: &'a Path,
|
||||
@@ -114,6 +146,8 @@ pub(crate) struct Checker<'a> {
|
||||
pub(crate) flake8_bugbear_seen: Vec<TextRange>,
|
||||
/// The end offset of the last visited statement.
|
||||
last_stmt_end: TextSize,
|
||||
/// A state describing if a docstring is expected or not.
|
||||
docstring_state: DocstringState,
|
||||
}
|
||||
|
||||
impl<'a> Checker<'a> {
|
||||
@@ -153,6 +187,7 @@ impl<'a> Checker<'a> {
|
||||
cell_offsets,
|
||||
notebook_index,
|
||||
last_stmt_end: TextSize::default(),
|
||||
docstring_state: DocstringState::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -305,19 +340,16 @@ where
|
||||
self.semantic.flags -= SemanticModelFlags::IMPORT_BOUNDARY;
|
||||
}
|
||||
|
||||
// Track whether we've seen docstrings, non-imports, etc.
|
||||
// Track whether we've seen module docstrings, non-imports, etc.
|
||||
match stmt {
|
||||
Stmt::Expr(ast::StmtExpr { value, .. })
|
||||
if !self
|
||||
.semantic
|
||||
.flags
|
||||
.intersects(SemanticModelFlags::MODULE_DOCSTRING)
|
||||
if !self.semantic.seen_module_docstring_boundary()
|
||||
&& value.is_string_literal_expr() =>
|
||||
{
|
||||
self.semantic.flags |= SemanticModelFlags::MODULE_DOCSTRING;
|
||||
self.semantic.flags |= SemanticModelFlags::MODULE_DOCSTRING_BOUNDARY;
|
||||
}
|
||||
Stmt::ImportFrom(ast::StmtImportFrom { module, names, .. }) => {
|
||||
self.semantic.flags |= SemanticModelFlags::MODULE_DOCSTRING;
|
||||
self.semantic.flags |= SemanticModelFlags::MODULE_DOCSTRING_BOUNDARY;
|
||||
|
||||
// Allow __future__ imports until we see a non-__future__ import.
|
||||
if let Some("__future__") = module.as_deref() {
|
||||
@@ -332,11 +364,11 @@ where
|
||||
}
|
||||
}
|
||||
Stmt::Import(_) => {
|
||||
self.semantic.flags |= SemanticModelFlags::MODULE_DOCSTRING;
|
||||
self.semantic.flags |= SemanticModelFlags::MODULE_DOCSTRING_BOUNDARY;
|
||||
self.semantic.flags |= SemanticModelFlags::FUTURES_BOUNDARY;
|
||||
}
|
||||
_ => {
|
||||
self.semantic.flags |= SemanticModelFlags::MODULE_DOCSTRING;
|
||||
self.semantic.flags |= SemanticModelFlags::MODULE_DOCSTRING_BOUNDARY;
|
||||
self.semantic.flags |= SemanticModelFlags::FUTURES_BOUNDARY;
|
||||
if !(self.semantic.seen_import_boundary()
|
||||
|| helpers::is_assignment_to_a_dunder(stmt)
|
||||
@@ -353,6 +385,16 @@ where
|
||||
// the node.
|
||||
let flags_snapshot = self.semantic.flags;
|
||||
|
||||
// Update the semantic model if it is in a docstring. This should be done after the
|
||||
// flags snapshot to ensure that it gets reset once the statement is analyzed.
|
||||
if self.docstring_state.is_expected() {
|
||||
if is_docstring_stmt(stmt) {
|
||||
self.semantic.flags |= SemanticModelFlags::DOCSTRING;
|
||||
}
|
||||
// Reset the state irrespective of whether the statement is a docstring or not.
|
||||
self.docstring_state = DocstringState::Other;
|
||||
}
|
||||
|
||||
// Step 1: Binding
|
||||
match stmt {
|
||||
Stmt::AugAssign(ast::StmtAugAssign {
|
||||
@@ -654,6 +696,8 @@ where
|
||||
self.semantic.set_globals(globals);
|
||||
}
|
||||
|
||||
// Set the docstring state before visiting the class body.
|
||||
self.docstring_state = DocstringState::Expected;
|
||||
self.visit_body(body);
|
||||
}
|
||||
Stmt::TypeAlias(ast::StmtTypeAlias {
|
||||
@@ -1288,6 +1332,16 @@ where
|
||||
self.semantic.flags |= SemanticModelFlags::F_STRING;
|
||||
visitor::walk_expr(self, expr);
|
||||
}
|
||||
Expr::NamedExpr(ast::ExprNamedExpr {
|
||||
target,
|
||||
value,
|
||||
range: _,
|
||||
}) => {
|
||||
self.visit_expr(value);
|
||||
|
||||
self.semantic.flags |= SemanticModelFlags::NAMED_EXPRESSION_ASSIGNMENT;
|
||||
self.visit_expr(target);
|
||||
}
|
||||
_ => visitor::walk_expr(self, expr),
|
||||
}
|
||||
|
||||
@@ -1504,6 +1558,8 @@ impl<'a> Checker<'a> {
|
||||
unreachable!("Generator expression must contain at least one generator");
|
||||
};
|
||||
|
||||
let flags = self.semantic.flags;
|
||||
|
||||
// Generators are compiled as nested functions. (This may change with PEP 709.)
|
||||
// As such, the `iter` of the first generator is evaluated in the outer scope, while all
|
||||
// subsequent nodes are evaluated in the inner scope.
|
||||
@@ -1533,14 +1589,22 @@ impl<'a> Checker<'a> {
|
||||
// `x` is local to `foo`, and the `T` in `y=T` skips the class scope when resolving.
|
||||
self.visit_expr(&generator.iter);
|
||||
self.semantic.push_scope(ScopeKind::Generator);
|
||||
|
||||
self.semantic.flags = flags | SemanticModelFlags::COMPREHENSION_ASSIGNMENT;
|
||||
self.visit_expr(&generator.target);
|
||||
self.semantic.flags = flags;
|
||||
|
||||
for expr in &generator.ifs {
|
||||
self.visit_boolean_test(expr);
|
||||
}
|
||||
|
||||
for generator in iterator {
|
||||
self.visit_expr(&generator.iter);
|
||||
|
||||
self.semantic.flags = flags | SemanticModelFlags::COMPREHENSION_ASSIGNMENT;
|
||||
self.visit_expr(&generator.target);
|
||||
self.semantic.flags = flags;
|
||||
|
||||
for expr in &generator.ifs {
|
||||
self.visit_boolean_test(expr);
|
||||
}
|
||||
@@ -1739,11 +1803,21 @@ impl<'a> Checker<'a> {
|
||||
return;
|
||||
}
|
||||
|
||||
// A binding within a `for` must be a loop variable, as in:
|
||||
// ```python
|
||||
// for x in range(10):
|
||||
// ...
|
||||
// ```
|
||||
if parent.is_for_stmt() {
|
||||
self.add_binding(id, expr.range(), BindingKind::LoopVar, flags);
|
||||
return;
|
||||
}
|
||||
|
||||
// A binding within a `with` must be an item, as in:
|
||||
// ```python
|
||||
// with open("file.txt") as fp:
|
||||
// ...
|
||||
// ```
|
||||
if parent.is_with_stmt() {
|
||||
self.add_binding(id, expr.range(), BindingKind::WithItemVar, flags);
|
||||
return;
|
||||
@@ -1799,17 +1873,26 @@ impl<'a> Checker<'a> {
|
||||
}
|
||||
|
||||
// If the expression is the left-hand side of a walrus operator, then it's a named
|
||||
// expression assignment.
|
||||
if self
|
||||
.semantic
|
||||
.current_expressions()
|
||||
.filter_map(Expr::as_named_expr_expr)
|
||||
.any(|parent| parent.target.as_ref() == expr)
|
||||
{
|
||||
// expression assignment, as in:
|
||||
// ```python
|
||||
// if (x := 10) > 5:
|
||||
// ...
|
||||
// ```
|
||||
if self.semantic.in_named_expression_assignment() {
|
||||
self.add_binding(id, expr.range(), BindingKind::NamedExprAssignment, flags);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the expression is part of a comprehension target, then it's a comprehension variable
|
||||
// assignment, as in:
|
||||
// ```python
|
||||
// [x for x in range(10)]
|
||||
// ```
|
||||
if self.semantic.in_comprehension_assignment() {
|
||||
self.add_binding(id, expr.range(), BindingKind::ComprehensionVar, flags);
|
||||
return;
|
||||
}
|
||||
|
||||
self.add_binding(id, expr.range(), BindingKind::Assignment, flags);
|
||||
}
|
||||
|
||||
@@ -1925,6 +2008,8 @@ impl<'a> Checker<'a> {
|
||||
};
|
||||
|
||||
self.visit_parameters(parameters);
|
||||
// Set the docstring state before visiting the function body.
|
||||
self.docstring_state = DocstringState::Expected;
|
||||
self.visit_body(body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1025,6 +1025,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
|
||||
#[allow(deprecated)]
|
||||
(Refurb, "113") => (RuleGroup::Nursery, rules::refurb::rules::RepeatedAppend),
|
||||
(Refurb, "118") => (RuleGroup::Preview, rules::refurb::rules::ReimplementedOperator),
|
||||
(Refurb, "129") => (RuleGroup::Preview, rules::refurb::rules::ReadlinesInFor),
|
||||
#[allow(deprecated)]
|
||||
(Refurb, "131") => (RuleGroup::Nursery, rules::refurb::rules::DeleteFullSlice),
|
||||
#[allow(deprecated)]
|
||||
|
||||
@@ -248,6 +248,7 @@ impl Renamer {
|
||||
| BindingKind::Assignment
|
||||
| BindingKind::BoundException
|
||||
| BindingKind::LoopVar
|
||||
| BindingKind::ComprehensionVar
|
||||
| BindingKind::WithItemVar
|
||||
| BindingKind::Global
|
||||
| BindingKind::Nonlocal(_)
|
||||
|
||||
@@ -118,8 +118,7 @@ fn is_open_call_from_pathlib(func: &Expr, semantic: &SemanticModel) -> bool {
|
||||
|
||||
let binding = semantic.binding(binding_id);
|
||||
|
||||
let Some(Expr::Call(call)) = analyze::typing::find_binding_value(&name.id, binding, semantic)
|
||||
else {
|
||||
let Some(Expr::Call(call)) = analyze::typing::find_binding_value(binding, semantic) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
|
||||
@@ -15,13 +15,11 @@ PYI049.py:9:7: PYI049 Private TypedDict `_UnusedTypedDict2` is never used
|
||||
10 | bar: int
|
||||
|
|
||||
|
||||
PYI049.py:20:1: PYI049 Private TypedDict `_UnusedTypedDict3` is never used
|
||||
PYI049.py:21:1: PYI049 Private TypedDict `_UnusedTypedDict3` is never used
|
||||
|
|
||||
18 | bar: list[int]
|
||||
19 |
|
||||
20 | _UnusedTypedDict3 = TypedDict("_UnusedTypedDict3", {"foo": int})
|
||||
21 | _UnusedTypedDict3 = TypedDict("_UnusedTypedDict3", {"foo": int})
|
||||
| ^^^^^^^^^^^^^^^^^ PYI049
|
||||
21 | _UsedTypedDict3 = TypedDict("_UsedTypedDict3", {"bar": bytes})
|
||||
22 | _UsedTypedDict3 = TypedDict("_UsedTypedDict3", {"bar": bytes})
|
||||
|
|
||||
|
||||
|
||||
|
||||
@@ -24,4 +24,13 @@ PYI049.pyi:34:1: PYI049 Private TypedDict `_UnusedTypedDict3` is never used
|
||||
35 | _UsedTypedDict3 = TypedDict("_UsedTypedDict3", {"bar": bytes})
|
||||
|
|
||||
|
||||
PYI049.pyi:43:11: PYI049 Private TypedDict `_UnusedTypeDict4` is never used
|
||||
|
|
||||
41 | # scope (unlike in `.py` files).
|
||||
42 | class _CustomClass3:
|
||||
43 | class _UnusedTypeDict4(TypedDict):
|
||||
| ^^^^^^^^^^^^^^^^ PYI049
|
||||
44 | pass
|
||||
|
|
||||
|
||||
|
||||
|
||||
@@ -76,8 +76,7 @@ pub(crate) fn enumerate_for_loop(checker: &mut Checker, for_stmt: &ast::StmtFor)
|
||||
}
|
||||
|
||||
// Ensure that the index variable was initialized to 0.
|
||||
let Some(value) = typing::find_binding_value(&index.id, binding, checker.semantic())
|
||||
else {
|
||||
let Some(value) = typing::find_binding_value(binding, checker.semantic()) else {
|
||||
continue;
|
||||
};
|
||||
if !matches!(
|
||||
|
||||
@@ -419,23 +419,20 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Test currently disabled as line endings are automatically converted to
|
||||
// platform-appropriate ones in CI/CD #[test_case(Path::new("
|
||||
// line_ending_crlf.py"))] #[test_case(Path::new("line_ending_lf.py"))]
|
||||
// fn source_code_style(path: &Path) -> Result<()> {
|
||||
// let snapshot = format!("{}", path.to_string_lossy());
|
||||
// let diagnostics = test_path(
|
||||
// Path::new("isort")
|
||||
// .join(path)
|
||||
// .as_path(),
|
||||
// &LinterSettings {
|
||||
// src: vec![test_resource_path("fixtures/isort")],
|
||||
// ..LinterSettings::for_rule(Rule::UnsortedImports)
|
||||
// },
|
||||
// )?;
|
||||
// crate::assert_messages!(snapshot, diagnostics);
|
||||
// Ok(())
|
||||
// }
|
||||
#[test_case(Path::new("line_ending_crlf.py"))]
|
||||
#[test_case(Path::new("line_ending_lf.py"))]
|
||||
fn source_code_style(path: &Path) -> Result<()> {
|
||||
let snapshot = format!("{}", path.to_string_lossy());
|
||||
let diagnostics = test_path(
|
||||
Path::new("isort").join(path).as_path(),
|
||||
&LinterSettings {
|
||||
src: vec![test_resource_path("fixtures/isort")],
|
||||
..LinterSettings::for_rule(Rule::UnsortedImports)
|
||||
},
|
||||
)?;
|
||||
crate::assert_messages!(snapshot, diagnostics);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_case(Path::new("separate_local_folder_imports.py"))]
|
||||
fn known_local_folder(path: &Path) -> Result<()> {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
source: crates/ruff_linter/src/rules/isort/mod.rs
|
||||
---
|
||||
line_ending_crlf.py:1:1: I001 [*] Import block is un-sorted or un-formatted
|
||||
|
|
||||
1 | / from long_module_name import member_one, member_two, member_three, member_four, member_five
|
||||
2 | |
|
||||
| |_^ I001
|
||||
|
|
||||
= help: Organize imports
|
||||
|
||||
ℹ Safe fix
|
||||
1 |-from long_module_name import member_one, member_two, member_three, member_four, member_five
|
||||
1 |+from long_module_name import (
|
||||
2 |+ member_five,
|
||||
3 |+ member_four,
|
||||
4 |+ member_one,
|
||||
5 |+ member_three,
|
||||
6 |+ member_two,
|
||||
7 |+)
|
||||
2 8 |
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
source: crates/ruff_linter/src/rules/isort/mod.rs
|
||||
---
|
||||
line_ending_lf.py:1:1: I001 [*] Import block is un-sorted or un-formatted
|
||||
|
|
||||
1 | / from long_module_name import member_one, member_two, member_three, member_four, member_five
|
||||
2 | |
|
||||
| |_^ I001
|
||||
|
|
||||
= help: Organize imports
|
||||
|
||||
ℹ Safe fix
|
||||
1 |-from long_module_name import member_one, member_two, member_three, member_four, member_five
|
||||
1 |+from long_module_name import (
|
||||
2 |+ member_five,
|
||||
3 |+ member_four,
|
||||
4 |+ member_one,
|
||||
5 |+ member_three,
|
||||
6 |+ member_two,
|
||||
7 |+)
|
||||
2 8 |
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ pub(super) fn test_expression(expr: &Expr, semantic: &SemanticModel) -> Resoluti
|
||||
| BindingKind::Assignment
|
||||
| BindingKind::NamedExprAssignment
|
||||
| BindingKind::LoopVar
|
||||
| BindingKind::ComprehensionVar
|
||||
| BindingKind::Global
|
||||
| BindingKind::Nonlocal(_) => Resolution::RelevantLocal,
|
||||
BindingKind::Import(import) if matches!(import.call_path(), ["pandas"]) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix};
|
||||
use ruff_macros::{derive_message_formats, violation};
|
||||
use ruff_python_ast::statement_visitor::{walk_stmt, StatementVisitor};
|
||||
use ruff_python_ast::{self as ast, Arguments, Expr, Stmt};
|
||||
use ruff_python_semantic::analyze::typing::find_assigned_value;
|
||||
use ruff_text_size::TextRange;
|
||||
@@ -98,22 +99,25 @@ pub(crate) fn unnecessary_list_cast(checker: &mut Checker, iter: &Expr, body: &[
|
||||
range: iterable_range,
|
||||
..
|
||||
}) => {
|
||||
// If the variable is being appended to, don't suggest removing the cast:
|
||||
//
|
||||
// ```python
|
||||
// items = ["foo", "bar"]
|
||||
// for item in list(items):
|
||||
// items.append("baz")
|
||||
// ```
|
||||
//
|
||||
// Here, removing the `list()` cast would change the behavior of the code.
|
||||
if body.iter().any(|stmt| match_append(stmt, id)) {
|
||||
return;
|
||||
}
|
||||
let Some(value) = find_assigned_value(id, checker.semantic()) else {
|
||||
return;
|
||||
};
|
||||
if matches!(value, Expr::Tuple(_) | Expr::List(_) | Expr::Set(_)) {
|
||||
// If the variable is being modified to, don't suggest removing the cast:
|
||||
//
|
||||
// ```python
|
||||
// items = ["foo", "bar"]
|
||||
// for item in list(items):
|
||||
// items.append("baz")
|
||||
// ```
|
||||
//
|
||||
// Here, removing the `list()` cast would change the behavior of the code.
|
||||
let mut visitor = MutationVisitor::new(id);
|
||||
visitor.visit_body(body);
|
||||
if visitor.is_mutated {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut diagnostic = Diagnostic::new(UnnecessaryListCast, *list_range);
|
||||
diagnostic.set_fix(remove_cast(*list_range, *iterable_range));
|
||||
checker.diagnostics.push(diagnostic);
|
||||
@@ -123,28 +127,6 @@ pub(crate) fn unnecessary_list_cast(checker: &mut Checker, iter: &Expr, body: &[
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a statement is an `append` call to a given identifier.
|
||||
///
|
||||
/// For example, `foo.append(bar)` would return `true` if `id` is `foo`.
|
||||
fn match_append(stmt: &Stmt, id: &str) -> bool {
|
||||
let Some(ast::StmtExpr { value, .. }) = stmt.as_expr_stmt() else {
|
||||
return false;
|
||||
};
|
||||
let Some(ast::ExprCall { func, .. }) = value.as_call_expr() else {
|
||||
return false;
|
||||
};
|
||||
let Some(ast::ExprAttribute { value, attr, .. }) = func.as_attribute_expr() else {
|
||||
return false;
|
||||
};
|
||||
if attr != "append" {
|
||||
return false;
|
||||
}
|
||||
let Some(ast::ExprName { id: target_id, .. }) = value.as_name_expr() else {
|
||||
return false;
|
||||
};
|
||||
target_id == id
|
||||
}
|
||||
|
||||
/// Generate a [`Fix`] to remove a `list` cast from an expression.
|
||||
fn remove_cast(list_range: TextRange, iterable_range: TextRange) -> Fix {
|
||||
Fix::safe_edits(
|
||||
@@ -152,3 +134,95 @@ fn remove_cast(list_range: TextRange, iterable_range: TextRange) -> Fix {
|
||||
[Edit::deletion(iterable_range.end(), list_range.end())],
|
||||
)
|
||||
}
|
||||
|
||||
/// A [`StatementVisitor`] that (conservatively) identifies mutations to a variable.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct MutationVisitor<'a> {
|
||||
pub(crate) target: &'a str,
|
||||
pub(crate) is_mutated: bool,
|
||||
}
|
||||
|
||||
impl<'a> MutationVisitor<'a> {
|
||||
pub(crate) fn new(target: &'a str) -> Self {
|
||||
Self {
|
||||
target,
|
||||
is_mutated: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, 'b> StatementVisitor<'b> for MutationVisitor<'a>
|
||||
where
|
||||
'b: 'a,
|
||||
{
|
||||
fn visit_stmt(&mut self, stmt: &'b Stmt) {
|
||||
if match_mutation(stmt, self.target) {
|
||||
self.is_mutated = true;
|
||||
} else {
|
||||
walk_stmt(self, stmt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a statement is (probably) a modification to the list assigned to the given identifier.
|
||||
///
|
||||
/// For example, `foo.append(bar)` would return `true` if `id` is `foo`.
|
||||
fn match_mutation(stmt: &Stmt, id: &str) -> bool {
|
||||
match stmt {
|
||||
// Ex) `foo.append(bar)`
|
||||
Stmt::Expr(ast::StmtExpr { value, .. }) => {
|
||||
let Some(ast::ExprCall { func, .. }) = value.as_call_expr() else {
|
||||
return false;
|
||||
};
|
||||
let Some(ast::ExprAttribute { value, attr, .. }) = func.as_attribute_expr() else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(
|
||||
attr.as_str(),
|
||||
"append" | "insert" | "extend" | "remove" | "pop" | "clear" | "reverse" | "sort"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
let Some(ast::ExprName { id: target_id, .. }) = value.as_name_expr() else {
|
||||
return false;
|
||||
};
|
||||
target_id == id
|
||||
}
|
||||
// Ex) `foo[0] = bar`
|
||||
Stmt::Assign(ast::StmtAssign { targets, .. }) => targets.iter().any(|target| {
|
||||
if let Some(ast::ExprSubscript { value: target, .. }) = target.as_subscript_expr() {
|
||||
if let Some(ast::ExprName { id: target_id, .. }) = target.as_name_expr() {
|
||||
return target_id == id;
|
||||
}
|
||||
}
|
||||
false
|
||||
}),
|
||||
// Ex) `foo += bar`
|
||||
Stmt::AugAssign(ast::StmtAugAssign { target, .. }) => {
|
||||
if let Some(ast::ExprName { id: target_id, .. }) = target.as_name_expr() {
|
||||
target_id == id
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
// Ex) `foo[0]: int = bar`
|
||||
Stmt::AnnAssign(ast::StmtAnnAssign { target, .. }) => {
|
||||
if let Some(ast::ExprSubscript { value: target, .. }) = target.as_subscript_expr() {
|
||||
if let Some(ast::ExprName { id: target_id, .. }) = target.as_name_expr() {
|
||||
return target_id == id;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
// Ex) `del foo[0]`
|
||||
Stmt::Delete(ast::StmtDelete { targets, .. }) => targets.iter().any(|target| {
|
||||
if let Some(ast::ExprSubscript { value: target, .. }) = target.as_subscript_expr() {
|
||||
if let Some(ast::ExprName { id: target_id, .. }) = target.as_name_expr() {
|
||||
return target_id == id;
|
||||
}
|
||||
}
|
||||
false
|
||||
}),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ PERF101.py:34:10: PERF101 [*] Do not cast an iterable to `list` before iterating
|
||||
34 |+for i in {1, 2, 3}: # PERF101
|
||||
37 35 | pass
|
||||
38 36 |
|
||||
39 37 | for i in list(foo_dict): # Ok
|
||||
39 37 | for i in list(foo_dict): # OK
|
||||
|
||||
PERF101.py:57:10: PERF101 [*] Do not cast an iterable to `list` before iterating over it
|
||||
|
|
||||
@@ -192,7 +192,7 @@ PERF101.py:57:10: PERF101 [*] Do not cast an iterable to `list` before iterating
|
||||
= help: Remove `list()` cast
|
||||
|
||||
ℹ Safe fix
|
||||
54 54 | for i in list(foo_list): # Ok
|
||||
54 54 | for i in list(foo_list): # OK
|
||||
55 55 | foo_list.append(i + 1)
|
||||
56 56 |
|
||||
57 |-for i in list(foo_list): # PERF101
|
||||
@@ -218,5 +218,7 @@ PERF101.py:69:10: PERF101 [*] Do not cast an iterable to `list` before iterating
|
||||
69 |-for i in list(nested_tuple): # PERF101
|
||||
69 |+for i in nested_tuple: # PERF101
|
||||
70 70 | pass
|
||||
71 71 |
|
||||
72 72 | for i in list(foo_list): # OK
|
||||
|
||||
|
||||
|
||||
@@ -28,6 +28,15 @@ enum UnusedImportContext {
|
||||
/// If an import statement is used to check for the availability or existence
|
||||
/// of a module, consider using `importlib.util.find_spec` instead.
|
||||
///
|
||||
/// If an import statement is used to re-export a symbol as part of a module's
|
||||
/// public interface, consider using a "redundant" import alias, which
|
||||
/// instructs Ruff (and other tools) to respect the re-export, and avoid
|
||||
/// marking it as unused, as in:
|
||||
///
|
||||
/// ```python
|
||||
/// from module import member as member
|
||||
/// ```
|
||||
///
|
||||
/// ## Example
|
||||
/// ```python
|
||||
/// import numpy as np # unused import
|
||||
@@ -54,11 +63,12 @@ enum UnusedImportContext {
|
||||
/// ```
|
||||
///
|
||||
/// ## Options
|
||||
/// - `lint.pyflakes.extend-generics`
|
||||
/// - `lint.ignore-init-module-imports`
|
||||
///
|
||||
/// ## References
|
||||
/// - [Python documentation: `import`](https://docs.python.org/3/reference/simple_stmts.html#the-import-statement)
|
||||
/// - [Python documentation: `importlib.util.find_spec`](https://docs.python.org/3/library/importlib.html#importlib.util.find_spec)
|
||||
/// - [Typing documentation: interface conventions](https://typing.readthedocs.io/en/latest/source/libraries.html#library-interface-public-and-private-symbols)
|
||||
#[violation]
|
||||
pub struct UnusedImport {
|
||||
name: String,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix};
|
||||
use ruff_macros::{derive_message_formats, violation};
|
||||
use ruff_python_ast::{self as ast, CmpOp, Expr};
|
||||
use ruff_python_semantic::analyze::typing;
|
||||
use ruff_text_size::Ranged;
|
||||
|
||||
use crate::checkers::ast::Checker;
|
||||
@@ -25,7 +26,8 @@ use crate::checkers::ast::Checker;
|
||||
/// ## Fix safety
|
||||
/// This rule's fix is marked as unsafe, as the use of a `set` literal will
|
||||
/// error at runtime if the sequence contains unhashable elements (like lists
|
||||
/// or dictionaries).
|
||||
/// or dictionaries). While Ruff will attempt to infer the hashability of the
|
||||
/// elements, it may not always be able to do so.
|
||||
///
|
||||
/// ## References
|
||||
/// - [What’s New In Python 3.2](https://docs.python.org/3/whatsnew/3.2.html#optimizations)
|
||||
@@ -57,7 +59,40 @@ pub(crate) fn literal_membership(checker: &mut Checker, compare: &ast::ExprCompa
|
||||
return;
|
||||
};
|
||||
|
||||
if !matches!(right, Expr::List(_) | Expr::Tuple(_)) {
|
||||
let elts = match right {
|
||||
Expr::List(ast::ExprList { elts, .. }) => elts,
|
||||
Expr::Tuple(ast::ExprTuple { elts, .. }) => elts,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// If `left`, or any of the elements in `right`, are known to _not_ be hashable, return.
|
||||
if std::iter::once(compare.left.as_ref())
|
||||
.chain(elts)
|
||||
.any(|expr| match expr {
|
||||
// Expressions that are known _not_ to be hashable.
|
||||
Expr::List(_)
|
||||
| Expr::Set(_)
|
||||
| Expr::Dict(_)
|
||||
| Expr::ListComp(_)
|
||||
| Expr::SetComp(_)
|
||||
| Expr::DictComp(_)
|
||||
| Expr::GeneratorExp(_)
|
||||
| Expr::Await(_)
|
||||
| Expr::Yield(_)
|
||||
| Expr::YieldFrom(_) => true,
|
||||
// Expressions that can be _inferred_ not to be hashable.
|
||||
Expr::Name(name) => {
|
||||
let Some(id) = checker.semantic().resolve_name(name) else {
|
||||
return false;
|
||||
};
|
||||
let binding = checker.semantic().binding(id);
|
||||
typing::is_list(binding, checker.semantic())
|
||||
|| typing::is_dict(binding, checker.semantic())
|
||||
|| typing::is_set(binding, checker.semantic())
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -86,8 +86,10 @@ fn is_magic_value(literal_expr: LiteralExpressionRef, allowed_types: &[ConstantT
|
||||
!matches!(value.to_str(), "" | "__main__")
|
||||
}
|
||||
LiteralExpressionRef::NumberLiteral(ast::ExprNumberLiteral { value, .. }) => match value {
|
||||
#[allow(clippy::float_cmp)]
|
||||
ast::Number::Float(value) => !(*value == 0.0 || *value == 1.0),
|
||||
ast::Number::Int(value) => !matches!(*value, Int::ZERO | Int::ONE),
|
||||
_ => true,
|
||||
ast::Number::Complex { .. } => true,
|
||||
},
|
||||
LiteralExpressionRef::BytesLiteral(_) => true,
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ pub(crate) fn non_ascii_name(binding: &Binding, locator: &Locator) -> Option<Dia
|
||||
BindingKind::Assignment => Kind::Assignment,
|
||||
BindingKind::TypeParam => Kind::TypeParam,
|
||||
BindingKind::LoopVar => Kind::LoopVar,
|
||||
BindingKind::ComprehensionVar => Kind::ComprenhensionVar,
|
||||
BindingKind::WithItemVar => Kind::WithItemVar,
|
||||
BindingKind::Global => Kind::Global,
|
||||
BindingKind::Nonlocal(_) => Kind::Nonlocal,
|
||||
@@ -88,6 +89,7 @@ enum Kind {
|
||||
Assignment,
|
||||
TypeParam,
|
||||
LoopVar,
|
||||
ComprenhensionVar,
|
||||
WithItemVar,
|
||||
Global,
|
||||
Nonlocal,
|
||||
@@ -105,6 +107,7 @@ impl fmt::Display for Kind {
|
||||
Kind::Assignment => f.write_str("Variable"),
|
||||
Kind::TypeParam => f.write_str("Type parameter"),
|
||||
Kind::LoopVar => f.write_str("Variable"),
|
||||
Kind::ComprenhensionVar => f.write_str("Variable"),
|
||||
Kind::WithItemVar => f.write_str("Variable"),
|
||||
Kind::Global => f.write_str("Global"),
|
||||
Kind::Nonlocal => f.write_str("Nonlocal"),
|
||||
|
||||
@@ -10,49 +10,67 @@ magic_value_comparison.py:5:4: PLR2004 Magic value used in comparison, consider
|
||||
6 | pass
|
||||
|
|
||||
|
||||
magic_value_comparison.py:38:12: PLR2004 Magic value used in comparison, consider replacing `2` with a constant variable
|
||||
magic_value_comparison.py:47:12: PLR2004 Magic value used in comparison, consider replacing `2` with a constant variable
|
||||
|
|
||||
36 | pass
|
||||
37 |
|
||||
38 | if argc != 2: # [magic-value-comparison]
|
||||
| ^ PLR2004
|
||||
39 | pass
|
||||
|
|
||||
|
||||
magic_value_comparison.py:41:12: PLR2004 Magic value used in comparison, consider replacing `-2` with a constant variable
|
||||
|
|
||||
39 | pass
|
||||
40 |
|
||||
41 | if argc != -2: # [magic-value-comparison]
|
||||
| ^^ PLR2004
|
||||
42 | pass
|
||||
|
|
||||
|
||||
magic_value_comparison.py:44:12: PLR2004 Magic value used in comparison, consider replacing `+2` with a constant variable
|
||||
|
|
||||
42 | pass
|
||||
43 |
|
||||
44 | if argc != +2: # [magic-value-comparison]
|
||||
| ^^ PLR2004
|
||||
45 | pass
|
||||
46 |
|
||||
47 | if argc != 2: # [magic-value-comparison]
|
||||
| ^ PLR2004
|
||||
48 | pass
|
||||
|
|
||||
|
||||
magic_value_comparison.py:65:21: PLR2004 Magic value used in comparison, consider replacing `3.141592653589793238` with a constant variable
|
||||
magic_value_comparison.py:50:12: PLR2004 Magic value used in comparison, consider replacing `-2` with a constant variable
|
||||
|
|
||||
63 | pi_estimation = 3.14
|
||||
64 |
|
||||
65 | if pi_estimation == 3.141592653589793238: # [magic-value-comparison]
|
||||
48 | pass
|
||||
49 |
|
||||
50 | if argc != -2: # [magic-value-comparison]
|
||||
| ^^ PLR2004
|
||||
51 | pass
|
||||
|
|
||||
|
||||
magic_value_comparison.py:53:12: PLR2004 Magic value used in comparison, consider replacing `+2` with a constant variable
|
||||
|
|
||||
51 | pass
|
||||
52 |
|
||||
53 | if argc != +2: # [magic-value-comparison]
|
||||
| ^^ PLR2004
|
||||
54 | pass
|
||||
|
|
||||
|
||||
magic_value_comparison.py:56:12: PLR2004 Magic value used in comparison, consider replacing `-2.0` with a constant variable
|
||||
|
|
||||
54 | pass
|
||||
55 |
|
||||
56 | if argc != -2.0: # [magic-value-comparison]
|
||||
| ^^^^ PLR2004
|
||||
57 | pass
|
||||
|
|
||||
|
||||
magic_value_comparison.py:59:12: PLR2004 Magic value used in comparison, consider replacing `+2.0` with a constant variable
|
||||
|
|
||||
57 | pass
|
||||
58 |
|
||||
59 | if argc != +2.0: # [magic-value-comparison]
|
||||
| ^^^^ PLR2004
|
||||
60 | pass
|
||||
|
|
||||
|
||||
magic_value_comparison.py:80:21: PLR2004 Magic value used in comparison, consider replacing `3.141592653589793238` with a constant variable
|
||||
|
|
||||
78 | pi_estimation = 3.14
|
||||
79 |
|
||||
80 | if pi_estimation == 3.141592653589793238: # [magic-value-comparison]
|
||||
| ^^^^^^^^^^^^^^^^^^^^ PLR2004
|
||||
66 | pass
|
||||
81 | pass
|
||||
|
|
||||
|
||||
magic_value_comparison.py:71:21: PLR2004 Magic value used in comparison, consider replacing `0x3` with a constant variable
|
||||
magic_value_comparison.py:86:21: PLR2004 Magic value used in comparison, consider replacing `0x3` with a constant variable
|
||||
|
|
||||
69 | pass
|
||||
70 |
|
||||
71 | if pi_estimation == 0x3: # [magic-value-comparison]
|
||||
84 | pass
|
||||
85 |
|
||||
86 | if pi_estimation == 0x3: # [magic-value-comparison]
|
||||
| ^^^ PLR2004
|
||||
72 | pass
|
||||
87 | pass
|
||||
|
|
||||
|
||||
|
||||
|
||||
@@ -48,8 +48,8 @@ literal_membership.py:4:6: PLR6201 [*] Use a `set` literal when testing for memb
|
||||
5 | | 1, 2, 3
|
||||
6 | | )
|
||||
| |_^ PLR6201
|
||||
7 |
|
||||
8 | # OK
|
||||
7 | fruits = ["cherry", "grapes"]
|
||||
8 | "cherry" in fruits
|
||||
|
|
||||
= help: Convert to `set`
|
||||
|
||||
@@ -62,8 +62,29 @@ literal_membership.py:4:6: PLR6201 [*] Use a `set` literal when testing for memb
|
||||
5 5 | 1, 2, 3
|
||||
6 |-)
|
||||
6 |+}
|
||||
7 7 |
|
||||
8 8 | # OK
|
||||
9 9 | fruits = ["cherry", "grapes"]
|
||||
7 7 | fruits = ["cherry", "grapes"]
|
||||
8 8 | "cherry" in fruits
|
||||
9 9 | _ = {key: value for key, value in {"a": 1, "b": 2}.items() if key in ("a", "b")}
|
||||
|
||||
literal_membership.py:9:70: PLR6201 [*] Use a `set` literal when testing for membership
|
||||
|
|
||||
7 | fruits = ["cherry", "grapes"]
|
||||
8 | "cherry" in fruits
|
||||
9 | _ = {key: value for key, value in {"a": 1, "b": 2}.items() if key in ("a", "b")}
|
||||
| ^^^^^^^^^^ PLR6201
|
||||
10 |
|
||||
11 | # OK
|
||||
|
|
||||
= help: Convert to `set`
|
||||
|
||||
ℹ Unsafe fix
|
||||
6 6 | )
|
||||
7 7 | fruits = ["cherry", "grapes"]
|
||||
8 8 | "cherry" in fruits
|
||||
9 |-_ = {key: value for key, value in {"a": 1, "b": 2}.items() if key in ("a", "b")}
|
||||
9 |+_ = {key: value for key, value in {"a": 1, "b": 2}.items() if key in {"a", "b"}}
|
||||
10 10 |
|
||||
11 11 | # OK
|
||||
12 12 | fruits in [[1, 2, 3], [4, 5, 6]]
|
||||
|
||||
|
||||
|
||||
@@ -1,31 +1,49 @@
|
||||
---
|
||||
source: crates/ruff_linter/src/rules/pylint/mod.rs
|
||||
---
|
||||
magic_value_comparison.py:59:22: PLR2004 Magic value used in comparison, consider replacing `"Hunter2"` with a constant variable
|
||||
magic_value_comparison.py:56:12: PLR2004 Magic value used in comparison, consider replacing `-2.0` with a constant variable
|
||||
|
|
||||
54 | pass
|
||||
55 |
|
||||
56 | if argc != -2.0: # [magic-value-comparison]
|
||||
| ^^^^ PLR2004
|
||||
57 | pass
|
||||
|
|
||||
|
||||
magic_value_comparison.py:59:12: PLR2004 Magic value used in comparison, consider replacing `+2.0` with a constant variable
|
||||
|
|
||||
57 | pass
|
||||
58 |
|
||||
59 | if input_password == "Hunter2": # correct
|
||||
| ^^^^^^^^^ PLR2004
|
||||
59 | if argc != +2.0: # [magic-value-comparison]
|
||||
| ^^^^ PLR2004
|
||||
60 | pass
|
||||
|
|
||||
|
||||
magic_value_comparison.py:65:21: PLR2004 Magic value used in comparison, consider replacing `3.141592653589793238` with a constant variable
|
||||
magic_value_comparison.py:74:22: PLR2004 Magic value used in comparison, consider replacing `"Hunter2"` with a constant variable
|
||||
|
|
||||
63 | pi_estimation = 3.14
|
||||
64 |
|
||||
65 | if pi_estimation == 3.141592653589793238: # [magic-value-comparison]
|
||||
72 | pass
|
||||
73 |
|
||||
74 | if input_password == "Hunter2": # correct
|
||||
| ^^^^^^^^^ PLR2004
|
||||
75 | pass
|
||||
|
|
||||
|
||||
magic_value_comparison.py:80:21: PLR2004 Magic value used in comparison, consider replacing `3.141592653589793238` with a constant variable
|
||||
|
|
||||
78 | pi_estimation = 3.14
|
||||
79 |
|
||||
80 | if pi_estimation == 3.141592653589793238: # [magic-value-comparison]
|
||||
| ^^^^^^^^^^^^^^^^^^^^ PLR2004
|
||||
66 | pass
|
||||
81 | pass
|
||||
|
|
||||
|
||||
magic_value_comparison.py:77:18: PLR2004 Magic value used in comparison, consider replacing `b"something"` with a constant variable
|
||||
magic_value_comparison.py:92:18: PLR2004 Magic value used in comparison, consider replacing `b"something"` with a constant variable
|
||||
|
|
||||
75 | user_input = b"Hello, There!"
|
||||
76 |
|
||||
77 | if user_input == b"something": # correct
|
||||
90 | user_input = b"Hello, There!"
|
||||
91 |
|
||||
92 | if user_input == b"something": # correct
|
||||
| ^^^^^^^^^^^^ PLR2004
|
||||
78 | pass
|
||||
93 | pass
|
||||
|
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ mod tests {
|
||||
#[test_case(Rule::ReadWholeFile, Path::new("FURB101.py"))]
|
||||
#[test_case(Rule::RepeatedAppend, Path::new("FURB113.py"))]
|
||||
#[test_case(Rule::ReimplementedOperator, Path::new("FURB118.py"))]
|
||||
#[test_case(Rule::ReadlinesInFor, Path::new("FURB129.py"))]
|
||||
#[test_case(Rule::DeleteFullSlice, Path::new("FURB131.py"))]
|
||||
#[test_case(Rule::CheckAndRemoveFromSet, Path::new("FURB132.py"))]
|
||||
#[test_case(Rule::IfExprMinMax, Path::new("FURB136.py"))]
|
||||
|
||||
@@ -9,6 +9,7 @@ pub(crate) use math_constant::*;
|
||||
pub(crate) use metaclass_abcmeta::*;
|
||||
pub(crate) use print_empty_string::*;
|
||||
pub(crate) use read_whole_file::*;
|
||||
pub(crate) use readlines_in_for::*;
|
||||
pub(crate) use redundant_log_base::*;
|
||||
pub(crate) use regex_flag_alias::*;
|
||||
pub(crate) use reimplemented_operator::*;
|
||||
@@ -30,6 +31,7 @@ mod math_constant;
|
||||
mod metaclass_abcmeta;
|
||||
mod print_empty_string;
|
||||
mod read_whole_file;
|
||||
mod readlines_in_for;
|
||||
mod redundant_log_base;
|
||||
mod regex_flag_alias;
|
||||
mod reimplemented_operator;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix};
|
||||
use ruff_macros::{derive_message_formats, violation};
|
||||
use ruff_python_ast::{Comprehension, Expr, StmtFor};
|
||||
use ruff_python_semantic::analyze::typing;
|
||||
use ruff_python_semantic::analyze::typing::is_io_base_expr;
|
||||
use ruff_text_size::Ranged;
|
||||
|
||||
use crate::checkers::ast::Checker;
|
||||
|
||||
/// ## What it does
|
||||
/// Checks for uses of `readlines()` when iterating over a file line-by-line.
|
||||
///
|
||||
/// ## Why is this bad?
|
||||
/// Rather than iterating over all lines in a file by calling `readlines()`,
|
||||
/// it's more convenient and performant to iterate over the file object
|
||||
/// directly.
|
||||
///
|
||||
/// ## Example
|
||||
/// ```python
|
||||
/// with open("file.txt") as fp:
|
||||
/// for line in fp.readlines():
|
||||
/// ...
|
||||
/// ```
|
||||
///
|
||||
/// Use instead:
|
||||
/// ```python
|
||||
/// with open("file.txt") as fp:
|
||||
/// for line in fp:
|
||||
/// ...
|
||||
/// ```
|
||||
///
|
||||
/// ## References
|
||||
/// - [Python documentation: `io.IOBase.readlines`](https://docs.python.org/3/library/io.html#io.IOBase.readlines)
|
||||
#[violation]
|
||||
pub(crate) struct ReadlinesInFor;
|
||||
|
||||
impl AlwaysFixableViolation for ReadlinesInFor {
|
||||
#[derive_message_formats]
|
||||
fn message(&self) -> String {
|
||||
format!("Instead of calling `readlines()`, iterate over file object directly")
|
||||
}
|
||||
|
||||
fn fix_title(&self) -> String {
|
||||
"Remove `readlines()`".into()
|
||||
}
|
||||
}
|
||||
|
||||
/// FURB129
|
||||
pub(crate) fn readlines_in_for(checker: &mut Checker, for_stmt: &StmtFor) {
|
||||
readlines_in_iter(checker, for_stmt.iter.as_ref());
|
||||
}
|
||||
|
||||
/// FURB129
|
||||
pub(crate) fn readlines_in_comprehension(checker: &mut Checker, comprehension: &Comprehension) {
|
||||
readlines_in_iter(checker, &comprehension.iter);
|
||||
}
|
||||
|
||||
fn readlines_in_iter(checker: &mut Checker, iter_expr: &Expr) {
|
||||
let Expr::Call(expr_call) = iter_expr else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Expr::Attribute(expr_attr) = expr_call.func.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if expr_attr.attr.as_str() != "readlines" || !expr_call.arguments.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine whether `fp` in `fp.readlines()` was bound to a file object.
|
||||
if let Expr::Name(name) = expr_attr.value.as_ref() {
|
||||
if !checker
|
||||
.semantic()
|
||||
.resolve_name(name)
|
||||
.map(|id| checker.semantic().binding(id))
|
||||
.is_some_and(|binding| typing::is_io_base(binding, checker.semantic()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if !is_io_base_expr(expr_attr.value.as_ref(), checker.semantic()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let mut diagnostic = Diagnostic::new(ReadlinesInFor, expr_call.range());
|
||||
diagnostic.set_fix(Fix::unsafe_edit(Edit::range_deletion(
|
||||
expr_call.range().add_start(expr_attr.value.range().len()),
|
||||
)));
|
||||
checker.diagnostics.push(diagnostic);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
---
|
||||
source: crates/ruff_linter/src/rules/refurb/mod.rs
|
||||
---
|
||||
FURB129.py:7:18: FURB129 [*] Instead of calling `readlines()`, iterate over file object directly
|
||||
|
|
||||
5 | # Errors
|
||||
6 | with open("FURB129.py") as f:
|
||||
7 | for _line in f.readlines():
|
||||
| ^^^^^^^^^^^^^ FURB129
|
||||
8 | pass
|
||||
9 | a = [line.lower() for line in f.readlines()]
|
||||
|
|
||||
= help: Remove `readlines()`
|
||||
|
||||
ℹ Unsafe fix
|
||||
4 4 |
|
||||
5 5 | # Errors
|
||||
6 6 | with open("FURB129.py") as f:
|
||||
7 |- for _line in f.readlines():
|
||||
7 |+ for _line in f:
|
||||
8 8 | pass
|
||||
9 9 | a = [line.lower() for line in f.readlines()]
|
||||
10 10 | b = {line.upper() for line in f.readlines()}
|
||||
|
||||
FURB129.py:9:35: FURB129 [*] Instead of calling `readlines()`, iterate over file object directly
|
||||
|
|
||||
7 | for _line in f.readlines():
|
||||
8 | pass
|
||||
9 | a = [line.lower() for line in f.readlines()]
|
||||
| ^^^^^^^^^^^^^ FURB129
|
||||
10 | b = {line.upper() for line in f.readlines()}
|
||||
11 | c = {line.lower(): line.upper() for line in f.readlines()}
|
||||
|
|
||||
= help: Remove `readlines()`
|
||||
|
||||
ℹ Unsafe fix
|
||||
6 6 | with open("FURB129.py") as f:
|
||||
7 7 | for _line in f.readlines():
|
||||
8 8 | pass
|
||||
9 |- a = [line.lower() for line in f.readlines()]
|
||||
9 |+ a = [line.lower() for line in f]
|
||||
10 10 | b = {line.upper() for line in f.readlines()}
|
||||
11 11 | c = {line.lower(): line.upper() for line in f.readlines()}
|
||||
12 12 |
|
||||
|
||||
FURB129.py:10:35: FURB129 [*] Instead of calling `readlines()`, iterate over file object directly
|
||||
|
|
||||
8 | pass
|
||||
9 | a = [line.lower() for line in f.readlines()]
|
||||
10 | b = {line.upper() for line in f.readlines()}
|
||||
| ^^^^^^^^^^^^^ FURB129
|
||||
11 | c = {line.lower(): line.upper() for line in f.readlines()}
|
||||
|
|
||||
= help: Remove `readlines()`
|
||||
|
||||
ℹ Unsafe fix
|
||||
7 7 | for _line in f.readlines():
|
||||
8 8 | pass
|
||||
9 9 | a = [line.lower() for line in f.readlines()]
|
||||
10 |- b = {line.upper() for line in f.readlines()}
|
||||
10 |+ b = {line.upper() for line in f}
|
||||
11 11 | c = {line.lower(): line.upper() for line in f.readlines()}
|
||||
12 12 |
|
||||
13 13 | with Path("FURB129.py").open() as f:
|
||||
|
||||
FURB129.py:11:49: FURB129 [*] Instead of calling `readlines()`, iterate over file object directly
|
||||
|
|
||||
9 | a = [line.lower() for line in f.readlines()]
|
||||
10 | b = {line.upper() for line in f.readlines()}
|
||||
11 | c = {line.lower(): line.upper() for line in f.readlines()}
|
||||
| ^^^^^^^^^^^^^ FURB129
|
||||
12 |
|
||||
13 | with Path("FURB129.py").open() as f:
|
||||
|
|
||||
= help: Remove `readlines()`
|
||||
|
||||
ℹ Unsafe fix
|
||||
8 8 | pass
|
||||
9 9 | a = [line.lower() for line in f.readlines()]
|
||||
10 10 | b = {line.upper() for line in f.readlines()}
|
||||
11 |- c = {line.lower(): line.upper() for line in f.readlines()}
|
||||
11 |+ c = {line.lower(): line.upper() for line in f}
|
||||
12 12 |
|
||||
13 13 | with Path("FURB129.py").open() as f:
|
||||
14 14 | for _line in f.readlines():
|
||||
|
||||
FURB129.py:14:18: FURB129 [*] Instead of calling `readlines()`, iterate over file object directly
|
||||
|
|
||||
13 | with Path("FURB129.py").open() as f:
|
||||
14 | for _line in f.readlines():
|
||||
| ^^^^^^^^^^^^^ FURB129
|
||||
15 | pass
|
||||
|
|
||||
= help: Remove `readlines()`
|
||||
|
||||
ℹ Unsafe fix
|
||||
11 11 | c = {line.lower(): line.upper() for line in f.readlines()}
|
||||
12 12 |
|
||||
13 13 | with Path("FURB129.py").open() as f:
|
||||
14 |- for _line in f.readlines():
|
||||
14 |+ for _line in f:
|
||||
15 15 | pass
|
||||
16 16 |
|
||||
17 17 | for _line in open("FURB129.py").readlines():
|
||||
|
||||
FURB129.py:17:14: FURB129 [*] Instead of calling `readlines()`, iterate over file object directly
|
||||
|
|
||||
15 | pass
|
||||
16 |
|
||||
17 | for _line in open("FURB129.py").readlines():
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FURB129
|
||||
18 | pass
|
||||
|
|
||||
= help: Remove `readlines()`
|
||||
|
||||
ℹ Unsafe fix
|
||||
14 14 | for _line in f.readlines():
|
||||
15 15 | pass
|
||||
16 16 |
|
||||
17 |-for _line in open("FURB129.py").readlines():
|
||||
17 |+for _line in open("FURB129.py"):
|
||||
18 18 | pass
|
||||
19 19 |
|
||||
20 20 | for _line in Path("FURB129.py").open().readlines():
|
||||
|
||||
FURB129.py:20:14: FURB129 [*] Instead of calling `readlines()`, iterate over file object directly
|
||||
|
|
||||
18 | pass
|
||||
19 |
|
||||
20 | for _line in Path("FURB129.py").open().readlines():
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FURB129
|
||||
21 | pass
|
||||
|
|
||||
= help: Remove `readlines()`
|
||||
|
||||
ℹ Unsafe fix
|
||||
17 17 | for _line in open("FURB129.py").readlines():
|
||||
18 18 | pass
|
||||
19 19 |
|
||||
20 |-for _line in Path("FURB129.py").open().readlines():
|
||||
20 |+for _line in Path("FURB129.py").open():
|
||||
21 21 | pass
|
||||
22 22 |
|
||||
23 23 |
|
||||
|
||||
FURB129.py:26:18: FURB129 [*] Instead of calling `readlines()`, iterate over file object directly
|
||||
|
|
||||
24 | def func():
|
||||
25 | f = Path("FURB129.py").open()
|
||||
26 | for _line in f.readlines():
|
||||
| ^^^^^^^^^^^^^ FURB129
|
||||
27 | pass
|
||||
28 | f.close()
|
||||
|
|
||||
= help: Remove `readlines()`
|
||||
|
||||
ℹ Unsafe fix
|
||||
23 23 |
|
||||
24 24 | def func():
|
||||
25 25 | f = Path("FURB129.py").open()
|
||||
26 |- for _line in f.readlines():
|
||||
26 |+ for _line in f:
|
||||
27 27 | pass
|
||||
28 28 | f.close()
|
||||
29 29 |
|
||||
|
||||
FURB129.py:32:18: FURB129 [*] Instead of calling `readlines()`, iterate over file object directly
|
||||
|
|
||||
31 | def func(f: io.BytesIO):
|
||||
32 | for _line in f.readlines():
|
||||
| ^^^^^^^^^^^^^ FURB129
|
||||
33 | pass
|
||||
|
|
||||
= help: Remove `readlines()`
|
||||
|
||||
ℹ Unsafe fix
|
||||
29 29 |
|
||||
30 30 |
|
||||
31 31 | def func(f: io.BytesIO):
|
||||
32 |- for _line in f.readlines():
|
||||
32 |+ for _line in f:
|
||||
33 33 | pass
|
||||
34 34 |
|
||||
35 35 |
|
||||
|
||||
FURB129.py:38:22: FURB129 [*] Instead of calling `readlines()`, iterate over file object directly
|
||||
|
|
||||
36 | def func():
|
||||
37 | with (open("FURB129.py") as f, foo as bar):
|
||||
38 | for _line in f.readlines():
|
||||
| ^^^^^^^^^^^^^ FURB129
|
||||
39 | pass
|
||||
40 | for _line in bar.readlines():
|
||||
|
|
||||
= help: Remove `readlines()`
|
||||
|
||||
ℹ Unsafe fix
|
||||
35 35 |
|
||||
36 36 | def func():
|
||||
37 37 | with (open("FURB129.py") as f, foo as bar):
|
||||
38 |- for _line in f.readlines():
|
||||
38 |+ for _line in f:
|
||||
39 39 | pass
|
||||
40 40 | for _line in bar.readlines():
|
||||
41 41 | pass
|
||||
|
||||
|
||||
@@ -52,14 +52,15 @@ use ruff_text_size::Ranged;
|
||||
/// - [The Python Standard Library](https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task)
|
||||
#[violation]
|
||||
pub struct AsyncioDanglingTask {
|
||||
expr: String,
|
||||
method: Method,
|
||||
}
|
||||
|
||||
impl Violation for AsyncioDanglingTask {
|
||||
#[derive_message_formats]
|
||||
fn message(&self) -> String {
|
||||
let AsyncioDanglingTask { method } = self;
|
||||
format!("Store a reference to the return value of `asyncio.{method}`")
|
||||
let AsyncioDanglingTask { expr, method } = self;
|
||||
format!("Store a reference to the return value of `{expr}.{method}`")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,23 +81,35 @@ pub(crate) fn asyncio_dangling_task(expr: &Expr, semantic: &SemanticModel) -> Op
|
||||
})
|
||||
{
|
||||
return Some(Diagnostic::new(
|
||||
AsyncioDanglingTask { method },
|
||||
AsyncioDanglingTask {
|
||||
expr: "asyncio".to_string(),
|
||||
method,
|
||||
},
|
||||
expr.range(),
|
||||
));
|
||||
}
|
||||
|
||||
// Ex) `loop = asyncio.get_running_loop(); loop.create_task(...)`
|
||||
// Ex) `loop = ...; loop.create_task(...)`
|
||||
if let Expr::Attribute(ast::ExprAttribute { attr, value, .. }) = func.as_ref() {
|
||||
if attr == "create_task" {
|
||||
if typing::resolve_assignment(value, semantic).is_some_and(|call_path| {
|
||||
matches!(call_path.as_slice(), ["asyncio", "get_running_loop"])
|
||||
}) {
|
||||
return Some(Diagnostic::new(
|
||||
AsyncioDanglingTask {
|
||||
method: Method::CreateTask,
|
||||
},
|
||||
expr.range(),
|
||||
));
|
||||
if let Expr::Name(name) = value.as_ref() {
|
||||
if typing::resolve_assignment(value, semantic).is_some_and(|call_path| {
|
||||
matches!(
|
||||
call_path.as_slice(),
|
||||
[
|
||||
"asyncio",
|
||||
"get_event_loop" | "get_running_loop" | "new_event_loop"
|
||||
]
|
||||
)
|
||||
}) {
|
||||
return Some(Diagnostic::new(
|
||||
AsyncioDanglingTask {
|
||||
expr: name.id.to_string(),
|
||||
method: Method::CreateTask,
|
||||
},
|
||||
expr.range(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ RUF006.py:68:12: RUF006 Store a reference to the return value of `asyncio.create
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF006
|
||||
|
|
||||
|
||||
RUF006.py:74:26: RUF006 Store a reference to the return value of `asyncio.create_task`
|
||||
RUF006.py:74:26: RUF006 Store a reference to the return value of `loop.create_task`
|
||||
|
|
||||
72 | def f():
|
||||
73 | loop = asyncio.get_running_loop()
|
||||
@@ -33,7 +33,7 @@ RUF006.py:74:26: RUF006 Store a reference to the return value of `asyncio.create
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF006
|
||||
|
|
||||
|
||||
RUF006.py:97:5: RUF006 Store a reference to the return value of `asyncio.create_task`
|
||||
RUF006.py:97:5: RUF006 Store a reference to the return value of `loop.create_task`
|
||||
|
|
||||
95 | def f():
|
||||
96 | loop = asyncio.get_running_loop()
|
||||
@@ -41,4 +41,24 @@ RUF006.py:97:5: RUF006 Store a reference to the return value of `asyncio.create_
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF006
|
||||
|
|
||||
|
||||
RUF006.py:170:5: RUF006 Store a reference to the return value of `loop.create_task`
|
||||
|
|
||||
168 | def f():
|
||||
169 | loop = asyncio.new_event_loop()
|
||||
170 | loop.create_task(main()) # Error
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^ RUF006
|
||||
171 |
|
||||
172 | # Error
|
||||
|
|
||||
|
||||
RUF006.py:175:5: RUF006 Store a reference to the return value of `loop.create_task`
|
||||
|
|
||||
173 | def f():
|
||||
174 | loop = asyncio.get_event_loop()
|
||||
175 | loop.create_task(main()) # Error
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^ RUF006
|
||||
176 |
|
||||
177 | # OK
|
||||
|
|
||||
|
||||
|
||||
|
||||
@@ -92,7 +92,9 @@ pub fn derive_message_formats(_attr: TokenStream, item: TokenStream) -> TokenStr
|
||||
///
|
||||
/// Good:
|
||||
///
|
||||
/// ```rust
|
||||
/// ```ignroe
|
||||
/// use ruff_macros::newtype_index;
|
||||
///
|
||||
/// #[newtype_index]
|
||||
/// #[derive(Ord, PartialOrd)]
|
||||
/// struct MyIndex;
|
||||
|
||||
@@ -11,6 +11,7 @@ repository = { workspace = true }
|
||||
license = { workspace = true }
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
ruff_diagnostics = { path = "../ruff_diagnostics" }
|
||||
|
||||
@@ -935,7 +935,7 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`StatementVisitor`] that collects all `return` statements in a function or method.
|
||||
/// A [`Visitor`] that collects all `return` statements in a function or method.
|
||||
#[derive(Default)]
|
||||
pub struct ReturnStatementVisitor<'a> {
|
||||
pub returns: Vec<&'a ast::StmtReturn>,
|
||||
|
||||
@@ -11,6 +11,7 @@ repository = { workspace = true }
|
||||
license = { workspace = true }
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
ruff_python_ast = { path = "../ruff_python_ast" }
|
||||
|
||||
@@ -1705,6 +1705,7 @@ class Foo:
|
||||
assert_round_trip!(r#"f"{ chr(65) = !s}""#);
|
||||
assert_round_trip!(r#"f"{ chr(65) = !r}""#);
|
||||
assert_round_trip!(r#"f"{ chr(65) = :#x}""#);
|
||||
assert_round_trip!(r#"f"{ ( chr(65) ) = }""#);
|
||||
assert_round_trip!(r#"f"{a=!r:0.05f}""#);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ documentation = { workspace = true }
|
||||
repository = { workspace = true }
|
||||
license = { workspace = true }
|
||||
|
||||
[lib]
|
||||
doctest= false
|
||||
|
||||
[dependencies]
|
||||
ruff_cache = { path = "../ruff_cache" }
|
||||
ruff_formatter = { path = "../ruff_formatter" }
|
||||
|
||||
@@ -4,4 +4,8 @@ ij_formatter_enabled = false
|
||||
|
||||
["range_formatting/*.py"]
|
||||
generated_code = true
|
||||
ij_formatter_enabled = false
|
||||
|
||||
[docstring_tab_indentation.py]
|
||||
generated_code = true
|
||||
ij_formatter_enabled = false
|
||||
10
crates/ruff_python_formatter/resources/test/fixtures/ruff/docstring_tab_indentation.options.json
vendored
Normal file
10
crates/ruff_python_formatter/resources/test/fixtures/ruff/docstring_tab_indentation.options.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"indent_style": "tab",
|
||||
"indent_width": 4
|
||||
},
|
||||
{
|
||||
"indent_style": "tab",
|
||||
"indent_width": 8
|
||||
}
|
||||
]
|
||||
72
crates/ruff_python_formatter/resources/test/fixtures/ruff/docstring_tab_indentation.py
vendored
Normal file
72
crates/ruff_python_formatter/resources/test/fixtures/ruff/docstring_tab_indentation.py
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
# Tests the behavior of the formatter when it comes to tabs inside docstrings
|
||||
# when using `indent_style="tab`
|
||||
|
||||
# The example below uses tabs exclusively. The formatter should preserve the tab indentation
|
||||
# of `arg1`.
|
||||
def tab_argument(arg1: str) -> None:
|
||||
"""
|
||||
Arguments:
|
||||
arg1: super duper arg with 2 tabs in front
|
||||
"""
|
||||
|
||||
# The `arg1` is intended with spaces. The formatter should not change the spaces to a tab
|
||||
# because it must assume that the spaces are used for alignment and not indentation.
|
||||
def space_argument(arg1: str) -> None:
|
||||
"""
|
||||
Arguments:
|
||||
arg1: super duper arg with a tab and a space in front
|
||||
"""
|
||||
|
||||
def under_indented(arg1: str) -> None:
|
||||
"""
|
||||
Arguments:
|
||||
arg1: super duper arg with a tab and a space in front
|
||||
arg2: Not properly indented
|
||||
"""
|
||||
|
||||
def under_indented_tabs(arg1: str) -> None:
|
||||
"""
|
||||
Arguments:
|
||||
arg1: super duper arg with a tab and a space in front
|
||||
arg2: Not properly indented
|
||||
"""
|
||||
|
||||
def spaces_tabs_over_indent(arg1: str) -> None:
|
||||
"""
|
||||
Arguments:
|
||||
arg1: super duper arg with a tab and a space in front
|
||||
"""
|
||||
|
||||
# The docstring itself is indented with spaces but the argument is indented by a tab.
|
||||
# Keep the tab indentation of the argument, convert th docstring indent to tabs.
|
||||
def space_indented_docstring_containing_tabs(arg1: str) -> None:
|
||||
"""
|
||||
Arguments:
|
||||
arg1: super duper arg
|
||||
"""
|
||||
|
||||
|
||||
# The docstring uses tabs, spaces, tabs indentation.
|
||||
# Fallback to use space indentation
|
||||
def mixed_indentation(arg1: str) -> None:
|
||||
"""
|
||||
Arguments:
|
||||
arg1: super duper arg with a tab and a space in front
|
||||
"""
|
||||
|
||||
|
||||
# The example shows an ascii art. The formatter should not change the spaces
|
||||
# to tabs because it breaks the ASCII art when inspecting the docstring with `inspect.cleandoc(ascii_art.__doc__)`
|
||||
# when using an indent width other than 8.
|
||||
def ascii_art():
|
||||
r"""
|
||||
Look at this beautiful tree.
|
||||
|
||||
a
|
||||
/ \
|
||||
b c
|
||||
/ \
|
||||
d e
|
||||
"""
|
||||
|
||||
|
||||
8
crates/ruff_python_formatter/resources/test/fixtures/ruff/notebook_docstring.options.json
vendored
Normal file
8
crates/ruff_python_formatter/resources/test/fixtures/ruff/notebook_docstring.options.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{
|
||||
"source_type": "Ipynb"
|
||||
},
|
||||
{
|
||||
"source_type": "Python"
|
||||
}
|
||||
]
|
||||
6
crates/ruff_python_formatter/resources/test/fixtures/ruff/notebook_docstring.py
vendored
Normal file
6
crates/ruff_python_formatter/resources/test/fixtures/ruff/notebook_docstring.py
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
This looks like a docstring but is not in a notebook because notebooks can't be imported as a module.
|
||||
Ruff should leave it as is
|
||||
""";
|
||||
|
||||
"another normal string"
|
||||
@@ -248,6 +248,12 @@ pub enum QuoteStyle {
|
||||
Preserve,
|
||||
}
|
||||
|
||||
impl QuoteStyle {
|
||||
pub const fn is_preserve(self) -> bool {
|
||||
matches!(self, QuoteStyle::Preserve)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for QuoteStyle {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
|
||||
@@ -2,8 +2,7 @@ use ruff_python_ast::BytesLiteral;
|
||||
use ruff_text_size::Ranged;
|
||||
|
||||
use crate::prelude::*;
|
||||
use crate::preview::is_hex_codes_in_unicode_sequences_enabled;
|
||||
use crate::string::{Quoting, StringPart};
|
||||
use crate::string::{StringNormalizer, StringPart};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct FormatBytesLiteral;
|
||||
@@ -12,14 +11,9 @@ impl FormatNodeRule<BytesLiteral> for FormatBytesLiteral {
|
||||
fn fmt_fields(&self, item: &BytesLiteral, f: &mut PyFormatter) -> FormatResult<()> {
|
||||
let locator = f.context().locator();
|
||||
|
||||
StringPart::from_source(item.range(), &locator)
|
||||
.normalize(
|
||||
Quoting::CanChange,
|
||||
&locator,
|
||||
f.options().quote_style(),
|
||||
f.context().docstring(),
|
||||
is_hex_codes_in_unicode_sequences_enabled(f.context()),
|
||||
)
|
||||
StringNormalizer::from_context(f.context())
|
||||
.with_preferred_quote_style(f.options().quote_style())
|
||||
.normalize(&StringPart::from_source(item.range(), &locator), &locator)
|
||||
.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@ use ruff_python_ast::FString;
|
||||
use ruff_text_size::Ranged;
|
||||
|
||||
use crate::prelude::*;
|
||||
use crate::preview::is_hex_codes_in_unicode_sequences_enabled;
|
||||
use crate::string::{Quoting, StringPart};
|
||||
use crate::string::{Quoting, StringNormalizer, StringPart};
|
||||
|
||||
/// Formats an f-string which is part of a larger f-string expression.
|
||||
///
|
||||
@@ -26,13 +25,12 @@ impl Format<PyFormatContext<'_>> for FormatFString<'_> {
|
||||
fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> {
|
||||
let locator = f.context().locator();
|
||||
|
||||
let result = StringPart::from_source(self.value.range(), &locator)
|
||||
let result = StringNormalizer::from_context(f.context())
|
||||
.with_quoting(self.quoting)
|
||||
.with_preferred_quote_style(f.options().quote_style())
|
||||
.normalize(
|
||||
self.quoting,
|
||||
&StringPart::from_source(self.value.range(), &locator),
|
||||
&locator,
|
||||
f.options().quote_style(),
|
||||
f.context().docstring(),
|
||||
is_hex_codes_in_unicode_sequences_enabled(f.context()),
|
||||
)
|
||||
.fmt(f);
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@ use ruff_python_ast::StringLiteral;
|
||||
use ruff_text_size::Ranged;
|
||||
|
||||
use crate::prelude::*;
|
||||
use crate::preview::is_hex_codes_in_unicode_sequences_enabled;
|
||||
use crate::string::{docstring, Quoting, StringPart};
|
||||
use crate::string::{docstring, Quoting, StringNormalizer, StringPart};
|
||||
use crate::QuoteStyle;
|
||||
|
||||
pub(crate) struct FormatStringLiteral<'a> {
|
||||
@@ -50,20 +49,22 @@ impl Format<PyFormatContext<'_>> for FormatStringLiteral<'_> {
|
||||
fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> {
|
||||
let locator = f.context().locator();
|
||||
|
||||
let quote_style = if self.layout.is_docstring() {
|
||||
// Per PEP 8 and PEP 257, always prefer double quotes for docstrings
|
||||
let quote_style = f.options().quote_style();
|
||||
let quote_style = if self.layout.is_docstring() && !quote_style.is_preserve() {
|
||||
// Per PEP 8 and PEP 257, always prefer double quotes for docstrings,
|
||||
// except when using quote-style=preserve
|
||||
QuoteStyle::Double
|
||||
} else {
|
||||
f.options().quote_style()
|
||||
quote_style
|
||||
};
|
||||
|
||||
let normalized = StringPart::from_source(self.value.range(), &locator).normalize(
|
||||
self.layout.quoting(),
|
||||
&locator,
|
||||
quote_style,
|
||||
f.context().docstring(),
|
||||
is_hex_codes_in_unicode_sequences_enabled(f.context()),
|
||||
);
|
||||
let normalized = StringNormalizer::from_context(f.context())
|
||||
.with_quoting(self.layout.quoting())
|
||||
.with_preferred_quote_style(quote_style)
|
||||
.normalize(
|
||||
&StringPart::from_source(self.value.range(), &locator),
|
||||
&locator,
|
||||
);
|
||||
|
||||
if self.layout.is_docstring() {
|
||||
docstring::format(&normalized, f)
|
||||
|
||||
@@ -214,9 +214,9 @@ impl<'ast> PreorderVisitor<'ast> for FindEnclosingNode<'_, 'ast> {
|
||||
// Don't pick potential docstrings as the closest enclosing node because `suite.rs` than fails to identify them as
|
||||
// docstrings and docstring formatting won't kick in.
|
||||
// Format the enclosing node instead and slice the formatted docstring from the result.
|
||||
let is_maybe_docstring = node
|
||||
.as_stmt_expr()
|
||||
.is_some_and(|stmt| DocstringStmt::is_docstring_statement(stmt));
|
||||
let is_maybe_docstring = node.as_stmt_expr().is_some_and(|stmt| {
|
||||
DocstringStmt::is_docstring_statement(stmt, self.context.options().source_type())
|
||||
});
|
||||
|
||||
if is_maybe_docstring {
|
||||
return TraversalSignal::Skip;
|
||||
|
||||
@@ -103,7 +103,9 @@ impl FormatRule<Suite, PyFormatContext<'_>> for FormatSuite {
|
||||
}
|
||||
|
||||
SuiteKind::Function => {
|
||||
if let Some(docstring) = DocstringStmt::try_from_statement(first, self.kind) {
|
||||
if let Some(docstring) =
|
||||
DocstringStmt::try_from_statement(first, self.kind, source_type)
|
||||
{
|
||||
SuiteChildStatement::Docstring(docstring)
|
||||
} else {
|
||||
SuiteChildStatement::Other(first)
|
||||
@@ -111,7 +113,9 @@ impl FormatRule<Suite, PyFormatContext<'_>> for FormatSuite {
|
||||
}
|
||||
|
||||
SuiteKind::Class => {
|
||||
if let Some(docstring) = DocstringStmt::try_from_statement(first, self.kind) {
|
||||
if let Some(docstring) =
|
||||
DocstringStmt::try_from_statement(first, self.kind, source_type)
|
||||
{
|
||||
if !comments.has_leading(first)
|
||||
&& lines_before(first.start(), source) > 1
|
||||
&& !source_type.is_stub()
|
||||
@@ -143,7 +147,9 @@ impl FormatRule<Suite, PyFormatContext<'_>> for FormatSuite {
|
||||
}
|
||||
SuiteKind::TopLevel => {
|
||||
if is_format_module_docstring_enabled(f.context()) {
|
||||
if let Some(docstring) = DocstringStmt::try_from_statement(first, self.kind) {
|
||||
if let Some(docstring) =
|
||||
DocstringStmt::try_from_statement(first, self.kind, source_type)
|
||||
{
|
||||
SuiteChildStatement::Docstring(docstring)
|
||||
} else {
|
||||
SuiteChildStatement::Other(first)
|
||||
@@ -184,7 +190,8 @@ impl FormatRule<Suite, PyFormatContext<'_>> for FormatSuite {
|
||||
true
|
||||
} else if is_module_docstring_newlines_enabled(f.context())
|
||||
&& self.kind == SuiteKind::TopLevel
|
||||
&& DocstringStmt::try_from_statement(first.statement(), self.kind).is_some()
|
||||
&& DocstringStmt::try_from_statement(first.statement(), self.kind, source_type)
|
||||
.is_some()
|
||||
{
|
||||
// Only in preview mode, insert a newline after a module level docstring, but treat
|
||||
// it as a docstring otherwise. See: https://github.com/psf/black/pull/3932.
|
||||
@@ -734,7 +741,16 @@ pub(crate) struct DocstringStmt<'a> {
|
||||
|
||||
impl<'a> DocstringStmt<'a> {
|
||||
/// Checks if the statement is a simple string that can be formatted as a docstring
|
||||
fn try_from_statement(stmt: &'a Stmt, suite_kind: SuiteKind) -> Option<DocstringStmt<'a>> {
|
||||
fn try_from_statement(
|
||||
stmt: &'a Stmt,
|
||||
suite_kind: SuiteKind,
|
||||
source_type: PySourceType,
|
||||
) -> Option<DocstringStmt<'a>> {
|
||||
// Notebooks don't have a concept of modules, therefore, don't recognise the first string as the module docstring.
|
||||
if source_type.is_ipynb() && suite_kind == SuiteKind::TopLevel {
|
||||
return None;
|
||||
}
|
||||
|
||||
let Stmt::Expr(ast::StmtExpr { value, .. }) = stmt else {
|
||||
return None;
|
||||
};
|
||||
@@ -752,7 +768,11 @@ impl<'a> DocstringStmt<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_docstring_statement(stmt: &StmtExpr) -> bool {
|
||||
pub(crate) fn is_docstring_statement(stmt: &StmtExpr, source_type: PySourceType) -> bool {
|
||||
if source_type.is_ipynb() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Expr::StringLiteral(ast::ExprStringLiteral { value, .. }) = stmt.value.as_ref() {
|
||||
!value.is_implicit_concatenated()
|
||||
} else {
|
||||
|
||||
220
crates/ruff_python_formatter/src/string/any.rs
Normal file
220
crates/ruff_python_formatter/src/string/any.rs
Normal file
@@ -0,0 +1,220 @@
|
||||
use std::iter::FusedIterator;
|
||||
|
||||
use memchr::memchr2;
|
||||
|
||||
use ruff_python_ast::{
|
||||
self as ast, AnyNodeRef, Expr, ExprBytesLiteral, ExprFString, ExprStringLiteral, ExpressionRef,
|
||||
StringLiteral,
|
||||
};
|
||||
use ruff_source_file::Locator;
|
||||
use ruff_text_size::{Ranged, TextLen, TextRange};
|
||||
|
||||
use crate::expression::expr_f_string::f_string_quoting;
|
||||
use crate::other::f_string::FormatFString;
|
||||
use crate::other::string_literal::{FormatStringLiteral, StringLiteralKind};
|
||||
use crate::prelude::*;
|
||||
use crate::string::{Quoting, StringPrefix, StringQuotes};
|
||||
|
||||
/// Represents any kind of string expression. This could be either a string,
|
||||
/// bytes or f-string.
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub(crate) enum AnyString<'a> {
|
||||
String(&'a ExprStringLiteral),
|
||||
Bytes(&'a ExprBytesLiteral),
|
||||
FString(&'a ExprFString),
|
||||
}
|
||||
|
||||
impl<'a> AnyString<'a> {
|
||||
/// Creates a new [`AnyString`] from the given [`Expr`].
|
||||
///
|
||||
/// Returns `None` if the expression is not either a string, bytes or f-string.
|
||||
pub(crate) fn from_expression(expression: &'a Expr) -> Option<AnyString<'a>> {
|
||||
match expression {
|
||||
Expr::StringLiteral(string) => Some(AnyString::String(string)),
|
||||
Expr::BytesLiteral(bytes) => Some(AnyString::Bytes(bytes)),
|
||||
Expr::FString(fstring) => Some(AnyString::FString(fstring)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the string is implicitly concatenated.
|
||||
pub(crate) fn is_implicit_concatenated(self) -> bool {
|
||||
match self {
|
||||
Self::String(ExprStringLiteral { value, .. }) => value.is_implicit_concatenated(),
|
||||
Self::Bytes(ExprBytesLiteral { value, .. }) => value.is_implicit_concatenated(),
|
||||
Self::FString(ExprFString { value, .. }) => value.is_implicit_concatenated(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the quoting to be used for this string.
|
||||
pub(super) fn quoting(self, locator: &Locator<'_>) -> Quoting {
|
||||
match self {
|
||||
Self::String(_) | Self::Bytes(_) => Quoting::CanChange,
|
||||
Self::FString(f_string) => f_string_quoting(f_string, locator),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a vector of all the [`AnyStringPart`] of this string.
|
||||
pub(super) fn parts(self, quoting: Quoting) -> AnyStringPartsIter<'a> {
|
||||
match self {
|
||||
Self::String(ExprStringLiteral { value, .. }) => {
|
||||
AnyStringPartsIter::String(value.iter())
|
||||
}
|
||||
Self::Bytes(ExprBytesLiteral { value, .. }) => AnyStringPartsIter::Bytes(value.iter()),
|
||||
Self::FString(ExprFString { value, .. }) => {
|
||||
AnyStringPartsIter::FString(value.iter(), quoting)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_multiline(self, source: &str) -> bool {
|
||||
match self {
|
||||
AnyString::String(_) | AnyString::Bytes(_) => {
|
||||
let contents = &source[self.range()];
|
||||
let prefix = StringPrefix::parse(contents);
|
||||
let quotes = StringQuotes::parse(
|
||||
&contents[TextRange::new(prefix.text_len(), contents.text_len())],
|
||||
);
|
||||
|
||||
quotes.is_some_and(StringQuotes::is_triple)
|
||||
&& memchr2(b'\n', b'\r', contents.as_bytes()).is_some()
|
||||
}
|
||||
AnyString::FString(fstring) => {
|
||||
memchr2(b'\n', b'\r', source[fstring.range].as_bytes()).is_some()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ranged for AnyString<'_> {
|
||||
fn range(&self) -> TextRange {
|
||||
match self {
|
||||
Self::String(expr) => expr.range(),
|
||||
Self::Bytes(expr) => expr.range(),
|
||||
Self::FString(expr) => expr.range(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&AnyString<'a>> for AnyNodeRef<'a> {
|
||||
fn from(value: &AnyString<'a>) -> Self {
|
||||
match value {
|
||||
AnyString::String(expr) => AnyNodeRef::ExprStringLiteral(expr),
|
||||
AnyString::Bytes(expr) => AnyNodeRef::ExprBytesLiteral(expr),
|
||||
AnyString::FString(expr) => AnyNodeRef::ExprFString(expr),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<AnyString<'a>> for AnyNodeRef<'a> {
|
||||
fn from(value: AnyString<'a>) -> Self {
|
||||
AnyNodeRef::from(&value)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&AnyString<'a>> for ExpressionRef<'a> {
|
||||
fn from(value: &AnyString<'a>) -> Self {
|
||||
match value {
|
||||
AnyString::String(expr) => ExpressionRef::StringLiteral(expr),
|
||||
AnyString::Bytes(expr) => ExpressionRef::BytesLiteral(expr),
|
||||
AnyString::FString(expr) => ExpressionRef::FString(expr),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) enum AnyStringPartsIter<'a> {
|
||||
String(std::slice::Iter<'a, StringLiteral>),
|
||||
Bytes(std::slice::Iter<'a, ast::BytesLiteral>),
|
||||
FString(std::slice::Iter<'a, ast::FStringPart>, Quoting),
|
||||
}
|
||||
|
||||
impl<'a> Iterator for AnyStringPartsIter<'a> {
|
||||
type Item = AnyStringPart<'a>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let part = match self {
|
||||
Self::String(inner) => {
|
||||
let part = inner.next()?;
|
||||
AnyStringPart::String {
|
||||
part,
|
||||
layout: StringLiteralKind::String,
|
||||
}
|
||||
}
|
||||
Self::Bytes(inner) => AnyStringPart::Bytes(inner.next()?),
|
||||
Self::FString(inner, quoting) => {
|
||||
let part = inner.next()?;
|
||||
match part {
|
||||
ast::FStringPart::Literal(string_literal) => AnyStringPart::String {
|
||||
part: string_literal,
|
||||
layout: StringLiteralKind::InImplicitlyConcatenatedFString(*quoting),
|
||||
},
|
||||
ast::FStringPart::FString(f_string) => AnyStringPart::FString {
|
||||
part: f_string,
|
||||
quoting: *quoting,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Some(part)
|
||||
}
|
||||
}
|
||||
|
||||
impl FusedIterator for AnyStringPartsIter<'_> {}
|
||||
|
||||
/// Represents any kind of string which is part of an implicitly concatenated
|
||||
/// string. This could be either a string, bytes or f-string.
|
||||
///
|
||||
/// This is constructed from the [`AnyString::parts`] method on [`AnyString`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) enum AnyStringPart<'a> {
|
||||
String {
|
||||
part: &'a ast::StringLiteral,
|
||||
layout: StringLiteralKind,
|
||||
},
|
||||
Bytes(&'a ast::BytesLiteral),
|
||||
FString {
|
||||
part: &'a ast::FString,
|
||||
quoting: Quoting,
|
||||
},
|
||||
}
|
||||
|
||||
impl AnyStringPart<'_> {
|
||||
pub(super) fn is_multiline(self, source: &str) -> bool {
|
||||
let text = &source[self.range()];
|
||||
memchr2(b'\n', b'\r', text.as_bytes()).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&AnyStringPart<'a>> for AnyNodeRef<'a> {
|
||||
fn from(value: &AnyStringPart<'a>) -> Self {
|
||||
match value {
|
||||
AnyStringPart::String { part, .. } => AnyNodeRef::StringLiteral(part),
|
||||
AnyStringPart::Bytes(part) => AnyNodeRef::BytesLiteral(part),
|
||||
AnyStringPart::FString { part, .. } => AnyNodeRef::FString(part),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ranged for AnyStringPart<'_> {
|
||||
fn range(&self) -> TextRange {
|
||||
match self {
|
||||
Self::String { part, .. } => part.range(),
|
||||
Self::Bytes(part) => part.range(),
|
||||
Self::FString { part, .. } => part.range(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Format<PyFormatContext<'_>> for AnyStringPart<'_> {
|
||||
fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> {
|
||||
match self {
|
||||
AnyStringPart::String { part, layout } => {
|
||||
FormatStringLiteral::new(part, *layout).fmt(f)
|
||||
}
|
||||
AnyStringPart::Bytes(bytes_literal) => bytes_literal.format().fmt(f),
|
||||
AnyStringPart::FString { part, quoting } => FormatFString::new(part, *quoting).fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,13 @@
|
||||
// "reStructuredText."
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::{borrow::Cow, collections::VecDeque};
|
||||
|
||||
use itertools::Itertools;
|
||||
|
||||
use ruff_formatter::printer::SourceMapGeneration;
|
||||
use ruff_python_parser::ParseError;
|
||||
|
||||
use {once_cell::sync::Lazy, regex::Regex};
|
||||
use {
|
||||
ruff_formatter::{write, FormatOptions, IndentStyle, LineWidth, Printed},
|
||||
@@ -80,9 +82,7 @@ use super::{NormalizedString, QuoteChar};
|
||||
/// ```
|
||||
///
|
||||
/// Tabs are counted by padding them to the next multiple of 8 according to
|
||||
/// [`str.expandtabs`](https://docs.python.org/3/library/stdtypes.html#str.expandtabs). When
|
||||
/// we see indentation that contains a tab or any other none ascii-space whitespace we rewrite the
|
||||
/// string.
|
||||
/// [`str.expandtabs`](https://docs.python.org/3/library/stdtypes.html#str.expandtabs).
|
||||
///
|
||||
/// Additionally, if any line in the docstring has less indentation than the docstring
|
||||
/// (effectively a negative indentation wrt. to the current level), we pad all lines to the
|
||||
@@ -104,8 +104,12 @@ use super::{NormalizedString, QuoteChar};
|
||||
/// line c
|
||||
/// """
|
||||
/// ```
|
||||
/// The indentation is rewritten to all-spaces when using [`IndentStyle::Space`].
|
||||
/// The formatter preserves tab-indentations when using [`IndentStyle::Tab`], but doesn't convert
|
||||
/// `indent-width * spaces` to tabs because doing so could break ASCII art and other docstrings
|
||||
/// that use spaces for alignment.
|
||||
pub(crate) fn format(normalized: &NormalizedString, f: &mut PyFormatter) -> FormatResult<()> {
|
||||
let docstring = &normalized.text;
|
||||
let docstring = &normalized.text();
|
||||
|
||||
// Black doesn't change the indentation of docstrings that contain an escaped newline
|
||||
if contains_unescaped_newline(docstring) {
|
||||
@@ -121,7 +125,7 @@ pub(crate) fn format(normalized: &NormalizedString, f: &mut PyFormatter) -> Form
|
||||
let mut lines = docstring.split('\n').peekable();
|
||||
|
||||
// Start the string
|
||||
write!(f, [normalized.prefix, normalized.quotes])?;
|
||||
write!(f, [normalized.prefix(), normalized.quotes()])?;
|
||||
// We track where in the source docstring we are (in source code byte offsets)
|
||||
let mut offset = normalized.start();
|
||||
|
||||
@@ -137,7 +141,7 @@ pub(crate) fn format(normalized: &NormalizedString, f: &mut PyFormatter) -> Form
|
||||
|
||||
// Edge case: The first line is `""" "content`, so we need to insert chaperone space that keep
|
||||
// inner quotes and closing quotes from getting to close to avoid `""""content`
|
||||
if trim_both.starts_with(normalized.quotes.quote_char.as_char()) {
|
||||
if trim_both.starts_with(normalized.quotes().quote_char.as_char()) {
|
||||
space().fmt(f)?;
|
||||
}
|
||||
|
||||
@@ -164,7 +168,7 @@ pub(crate) fn format(normalized: &NormalizedString, f: &mut PyFormatter) -> Form
|
||||
{
|
||||
space().fmt(f)?;
|
||||
}
|
||||
normalized.quotes.fmt(f)?;
|
||||
normalized.quotes().fmt(f)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -176,21 +180,21 @@ pub(crate) fn format(normalized: &NormalizedString, f: &mut PyFormatter) -> Form
|
||||
// align it with the docstring statement. Conversely, if all lines are over-indented, we strip
|
||||
// the extra indentation. We call this stripped indentation since it's relative to the block
|
||||
// indent printer-made indentation.
|
||||
let stripped_indentation_length = lines
|
||||
let stripped_indentation = lines
|
||||
.clone()
|
||||
// We don't want to count whitespace-only lines as miss-indented
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.map(indentation_length)
|
||||
.min()
|
||||
.map(Indentation::from_str)
|
||||
.min_by_key(|indentation| indentation.width())
|
||||
.unwrap_or_default();
|
||||
|
||||
DocstringLinePrinter {
|
||||
f,
|
||||
action_queue: VecDeque::new(),
|
||||
offset,
|
||||
stripped_indentation_length,
|
||||
stripped_indentation,
|
||||
already_normalized,
|
||||
quote_char: normalized.quotes.quote_char,
|
||||
quote_char: normalized.quotes().quote_char,
|
||||
code_example: CodeExample::default(),
|
||||
}
|
||||
.add_iter(lines)?;
|
||||
@@ -203,7 +207,7 @@ pub(crate) fn format(normalized: &NormalizedString, f: &mut PyFormatter) -> Form
|
||||
space().fmt(f)?;
|
||||
}
|
||||
|
||||
write!(f, [normalized.quotes])
|
||||
write!(f, [normalized.quotes()])
|
||||
}
|
||||
|
||||
fn contains_unescaped_newline(haystack: &str) -> bool {
|
||||
@@ -240,9 +244,9 @@ struct DocstringLinePrinter<'ast, 'buf, 'fmt, 'src> {
|
||||
/// printed.
|
||||
offset: TextSize,
|
||||
|
||||
/// Indentation alignment (in columns) based on the least indented line in the
|
||||
/// Indentation alignment based on the least indented line in the
|
||||
/// docstring.
|
||||
stripped_indentation_length: usize,
|
||||
stripped_indentation: Indentation,
|
||||
|
||||
/// Whether the docstring is overall already considered normalized. When it
|
||||
/// is, the formatter can take a fast path.
|
||||
@@ -345,7 +349,7 @@ impl<'ast, 'buf, 'fmt, 'src> DocstringLinePrinter<'ast, 'buf, 'fmt, 'src> {
|
||||
};
|
||||
// This looks suspicious, but it's consistent with the whitespace
|
||||
// normalization that will occur anyway.
|
||||
let indent = " ".repeat(min_indent);
|
||||
let indent = " ".repeat(min_indent.width());
|
||||
for docline in formatted_lines {
|
||||
self.print_one(
|
||||
&docline.map(|line| std::format!("{indent}{line}")),
|
||||
@@ -355,7 +359,7 @@ impl<'ast, 'buf, 'fmt, 'src> DocstringLinePrinter<'ast, 'buf, 'fmt, 'src> {
|
||||
CodeExampleKind::Markdown(fenced) => {
|
||||
// This looks suspicious, but it's consistent with the whitespace
|
||||
// normalization that will occur anyway.
|
||||
let indent = " ".repeat(fenced.opening_fence_indent);
|
||||
let indent = " ".repeat(fenced.opening_fence_indent.width());
|
||||
for docline in formatted_lines {
|
||||
self.print_one(
|
||||
&docline.map(|line| std::format!("{indent}{line}")),
|
||||
@@ -387,12 +391,58 @@ impl<'ast, 'buf, 'fmt, 'src> DocstringLinePrinter<'ast, 'buf, 'fmt, 'src> {
|
||||
};
|
||||
}
|
||||
|
||||
let tab_or_non_ascii_space = trim_end
|
||||
.chars()
|
||||
.take_while(|c| c.is_whitespace())
|
||||
.any(|c| c != ' ');
|
||||
let indent_offset = match self.f.options().indent_style() {
|
||||
// Normalize all indent to spaces.
|
||||
IndentStyle::Space => {
|
||||
let tab_or_non_ascii_space = trim_end
|
||||
.chars()
|
||||
.take_while(|c| c.is_whitespace())
|
||||
.any(|c| c != ' ');
|
||||
|
||||
if tab_or_non_ascii_space {
|
||||
if tab_or_non_ascii_space {
|
||||
None
|
||||
} else {
|
||||
// It's guaranteed that the `indent` is all spaces because `tab_or_non_ascii_space` is
|
||||
// `false` (indent contains neither tabs nor non-space whitespace).
|
||||
let stripped_indentation_len = self.stripped_indentation.text_len();
|
||||
|
||||
// Take the string with the trailing whitespace removed, then also
|
||||
// skip the leading whitespace.
|
||||
Some(stripped_indentation_len)
|
||||
}
|
||||
}
|
||||
IndentStyle::Tab => {
|
||||
let line_indent = Indentation::from_str(trim_end);
|
||||
|
||||
let non_ascii_whitespace = trim_end
|
||||
.chars()
|
||||
.take_while(|c| c.is_whitespace())
|
||||
.any(|c| !matches!(c, ' ' | '\t'));
|
||||
|
||||
let trimmed = line_indent.trim_start(self.stripped_indentation);
|
||||
|
||||
// Preserve tabs that are used for indentation, but only if the indent isn't
|
||||
// * a mix of tabs and spaces
|
||||
// * the `stripped_indentation` is a prefix of the line's indent
|
||||
// * the trimmed indent isn't spaces followed by tabs because that would result in a
|
||||
// mixed tab, spaces, tab indentation, resulting in instabilities.
|
||||
let preserve_indent = !non_ascii_whitespace
|
||||
&& trimmed.is_some_and(|trimmed| !trimmed.is_spaces_tabs());
|
||||
preserve_indent.then_some(self.stripped_indentation.text_len())
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(indent_offset) = indent_offset {
|
||||
// Take the string with the trailing whitespace removed, then also
|
||||
// skip the leading whitespace.
|
||||
if self.already_normalized {
|
||||
let trimmed_line_range =
|
||||
TextRange::at(line.offset, trim_end.text_len()).add_start(indent_offset);
|
||||
source_text_slice(trimmed_line_range).fmt(self.f)?;
|
||||
} else {
|
||||
text(&trim_end[indent_offset.to_usize()..]).fmt(self.f)?;
|
||||
}
|
||||
} else {
|
||||
// We strip the indentation that is shared with the docstring
|
||||
// statement, unless a line was indented less than the docstring
|
||||
// statement, in which case we strip only this much indentation to
|
||||
@@ -400,24 +450,11 @@ impl<'ast, 'buf, 'fmt, 'src> DocstringLinePrinter<'ast, 'buf, 'fmt, 'src> {
|
||||
// overindented, in which case we strip the additional whitespace
|
||||
// (see example in [`format_docstring`] doc comment). We then
|
||||
// prepend the in-docstring indentation to the string.
|
||||
let indent_len = indentation_length(trim_end) - self.stripped_indentation_length;
|
||||
let indent_len =
|
||||
Indentation::from_str(trim_end).width() - self.stripped_indentation.width();
|
||||
let in_docstring_indent = " ".repeat(indent_len) + trim_end.trim_start();
|
||||
text(&in_docstring_indent).fmt(self.f)?;
|
||||
} else {
|
||||
// It's guaranteed that the `indent` is all spaces because `tab_or_non_ascii_space` is
|
||||
// `false` (indent contains neither tabs nor non-space whitespace).
|
||||
|
||||
// Take the string with the trailing whitespace removed, then also
|
||||
// skip the leading whitespace.
|
||||
let trimmed_line_range = TextRange::at(line.offset, trim_end.text_len())
|
||||
.add_start(TextSize::try_from(self.stripped_indentation_length).unwrap());
|
||||
if self.already_normalized {
|
||||
source_text_slice(trimmed_line_range).fmt(self.f)?;
|
||||
} else {
|
||||
// All indents are ascii spaces, so the slicing is correct.
|
||||
text(&trim_end[self.stripped_indentation_length..]).fmt(self.f)?;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// We handled the case that the closing quotes are on their own line
|
||||
// above (the last line is empty except for whitespace). If they are on
|
||||
@@ -898,8 +935,7 @@ struct CodeExampleRst<'src> {
|
||||
/// The lines that have been seen so far that make up the block.
|
||||
lines: Vec<CodeExampleLine<'src>>,
|
||||
|
||||
/// The indent of the line "opening" this block measured via
|
||||
/// `indentation_length` (in columns).
|
||||
/// The indent of the line "opening" this block in columns.
|
||||
///
|
||||
/// It can either be the indent of a line ending with `::` (for a literal
|
||||
/// block) or the indent of a line starting with `.. ` (a directive).
|
||||
@@ -907,9 +943,9 @@ struct CodeExampleRst<'src> {
|
||||
/// The content body of a block needs to be indented more than the line
|
||||
/// opening the block, so we use this indentation to look for indentation
|
||||
/// that is "more than" it.
|
||||
opening_indent: usize,
|
||||
opening_indent: Indentation,
|
||||
|
||||
/// The minimum indent of the block measured via `indentation_length`.
|
||||
/// The minimum indent of the block in columns.
|
||||
///
|
||||
/// This is `None` until the first such line is seen. If no such line is
|
||||
/// found, then we consider it an invalid block and bail out of trying to
|
||||
@@ -926,7 +962,7 @@ struct CodeExampleRst<'src> {
|
||||
/// When the code snippet has been extracted, it is re-built before being
|
||||
/// reformatted. The minimum indent is stripped from each line when it is
|
||||
/// re-built.
|
||||
min_indent: Option<usize>,
|
||||
min_indent: Option<Indentation>,
|
||||
|
||||
/// Whether this is a directive block or not. When not a directive, this is
|
||||
/// a literal block. The main difference between them is that they start
|
||||
@@ -975,7 +1011,7 @@ impl<'src> CodeExampleRst<'src> {
|
||||
}
|
||||
Some(CodeExampleRst {
|
||||
lines: vec![],
|
||||
opening_indent: indentation_length(opening_indent),
|
||||
opening_indent: Indentation::from_str(opening_indent),
|
||||
min_indent: None,
|
||||
is_directive: false,
|
||||
})
|
||||
@@ -1013,7 +1049,7 @@ impl<'src> CodeExampleRst<'src> {
|
||||
}
|
||||
Some(CodeExampleRst {
|
||||
lines: vec![],
|
||||
opening_indent: indentation_length(original.line),
|
||||
opening_indent: Indentation::from_str(original.line),
|
||||
min_indent: None,
|
||||
is_directive: true,
|
||||
})
|
||||
@@ -1033,7 +1069,7 @@ impl<'src> CodeExampleRst<'src> {
|
||||
line.code = if line.original.line.trim().is_empty() {
|
||||
""
|
||||
} else {
|
||||
indentation_trim(min_indent, line.original.line)
|
||||
min_indent.trim_start_str(line.original.line)
|
||||
};
|
||||
}
|
||||
&self.lines
|
||||
@@ -1070,7 +1106,9 @@ impl<'src> CodeExampleRst<'src> {
|
||||
// an empty line followed by an unindented non-empty line.
|
||||
if let Some(next) = original.next {
|
||||
let (next_indent, next_rest) = indent_with_suffix(next);
|
||||
if !next_rest.is_empty() && indentation_length(next_indent) <= self.opening_indent {
|
||||
if !next_rest.is_empty()
|
||||
&& Indentation::from_str(next_indent) <= self.opening_indent
|
||||
{
|
||||
self.push_format_action(queue);
|
||||
return None;
|
||||
}
|
||||
@@ -1082,7 +1120,7 @@ impl<'src> CodeExampleRst<'src> {
|
||||
queue.push_back(CodeExampleAddAction::Kept);
|
||||
return Some(self);
|
||||
}
|
||||
let indent_len = indentation_length(indent);
|
||||
let indent_len = Indentation::from_str(indent);
|
||||
if indent_len <= self.opening_indent {
|
||||
// If we find an unindented non-empty line at the same (or less)
|
||||
// indentation of the opening line at this point, then we know it
|
||||
@@ -1144,7 +1182,7 @@ impl<'src> CodeExampleRst<'src> {
|
||||
queue.push_back(CodeExampleAddAction::Print { original });
|
||||
return Some(self);
|
||||
}
|
||||
let min_indent = indentation_length(indent);
|
||||
let min_indent = Indentation::from_str(indent);
|
||||
// At this point, we found a non-empty line. The only thing we require
|
||||
// is that its indentation is strictly greater than the indentation of
|
||||
// the line containing the `::`. Otherwise, we treat this as an invalid
|
||||
@@ -1218,12 +1256,11 @@ struct CodeExampleMarkdown<'src> {
|
||||
/// The lines that have been seen so far that make up the block.
|
||||
lines: Vec<CodeExampleLine<'src>>,
|
||||
|
||||
/// The indent of the line "opening" fence of this block measured via
|
||||
/// `indentation_length` (in columns).
|
||||
/// The indent of the line "opening" fence of this block in columns.
|
||||
///
|
||||
/// This indentation is trimmed from the indentation of every line in the
|
||||
/// body of the code block,
|
||||
opening_fence_indent: usize,
|
||||
opening_fence_indent: Indentation,
|
||||
|
||||
/// The kind of fence, backticks or tildes, used for this block. We need to
|
||||
/// keep track of which kind was used to open the block in order to look
|
||||
@@ -1292,7 +1329,7 @@ impl<'src> CodeExampleMarkdown<'src> {
|
||||
};
|
||||
Some(CodeExampleMarkdown {
|
||||
lines: vec![],
|
||||
opening_fence_indent: indentation_length(opening_fence_indent),
|
||||
opening_fence_indent: Indentation::from_str(opening_fence_indent),
|
||||
fence_kind,
|
||||
fence_len,
|
||||
})
|
||||
@@ -1325,7 +1362,7 @@ impl<'src> CodeExampleMarkdown<'src> {
|
||||
// its indent normalized. And, at the time of writing, a subsequent
|
||||
// formatting run undoes this indentation, thus violating idempotency.
|
||||
if !original.line.trim_whitespace().is_empty()
|
||||
&& indentation_length(original.line) < self.opening_fence_indent
|
||||
&& Indentation::from_str(original.line) < self.opening_fence_indent
|
||||
{
|
||||
queue.push_back(self.into_reset_action());
|
||||
queue.push_back(CodeExampleAddAction::Print { original });
|
||||
@@ -1371,7 +1408,7 @@ impl<'src> CodeExampleMarkdown<'src> {
|
||||
// Unlike reStructuredText blocks, for Markdown fenced code blocks, the
|
||||
// indentation that we want to strip from each line is known when the
|
||||
// block is opened. So we can strip it as we collect lines.
|
||||
let code = indentation_trim(self.opening_fence_indent, original.line);
|
||||
let code = self.opening_fence_indent.trim_start_str(original.line);
|
||||
self.lines.push(CodeExampleLine { original, code });
|
||||
}
|
||||
|
||||
@@ -1486,7 +1523,6 @@ enum CodeExampleAddAction<'src> {
|
||||
/// results in that code example becoming invalid. In this case,
|
||||
/// we don't want to treat it as a code example, but instead write
|
||||
/// back the lines to the docstring unchanged.
|
||||
#[allow(dead_code)] // FIXME: remove when reStructuredText support is added
|
||||
Reset {
|
||||
/// The lines of code that we collected but should be printed back to
|
||||
/// the docstring as-is and not formatted.
|
||||
@@ -1533,57 +1569,245 @@ fn docstring_format_source(
|
||||
/// that avoids `content""""` and `content\"""`. This does only applies to un-escaped backslashes,
|
||||
/// so `content\\ """` doesn't need a space while `content\\\ """` does.
|
||||
fn needs_chaperone_space(normalized: &NormalizedString, trim_end: &str) -> bool {
|
||||
trim_end.ends_with(normalized.quotes.quote_char.as_char())
|
||||
trim_end.ends_with(normalized.quotes().quote_char.as_char())
|
||||
|| trim_end.chars().rev().take_while(|c| *c == '\\').count() % 2 == 1
|
||||
}
|
||||
|
||||
/// Returns the indentation's visual width in columns/spaces.
|
||||
///
|
||||
/// For docstring indentation, black counts spaces as 1 and tabs by increasing the indentation up
|
||||
/// to the next multiple of 8. This is effectively a port of
|
||||
/// [`str.expandtabs`](https://docs.python.org/3/library/stdtypes.html#str.expandtabs),
|
||||
/// which black [calls with the default tab width of 8](https://github.com/psf/black/blob/c36e468794f9256d5e922c399240d49782ba04f1/src/black/strings.py#L61).
|
||||
fn indentation_length(line: &str) -> usize {
|
||||
let mut indentation = 0usize;
|
||||
for char in line.chars() {
|
||||
if char == '\t' {
|
||||
// Pad to the next multiple of tab_width
|
||||
indentation += 8 - (indentation.rem_euclid(8));
|
||||
} else if char.is_whitespace() {
|
||||
indentation += char.len_utf8();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
indentation
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
enum Indentation {
|
||||
/// Space only indentation or an empty indentation.
|
||||
///
|
||||
/// The value is the number of spaces.
|
||||
Spaces(usize),
|
||||
|
||||
/// Tabs only indentation.
|
||||
Tabs(usize),
|
||||
|
||||
/// Indentation that uses tabs followed by spaces.
|
||||
/// Also known as smart tabs where tabs are used for indents, and spaces for alignment.
|
||||
TabSpaces { tabs: usize, spaces: usize },
|
||||
|
||||
/// Indentation that uses spaces followed by tabs.
|
||||
SpacesTabs { spaces: usize, tabs: usize },
|
||||
|
||||
/// Mixed indentation of tabs and spaces.
|
||||
Mixed {
|
||||
/// The visual width of the indentation in columns.
|
||||
width: usize,
|
||||
|
||||
/// The length of the indentation in bytes
|
||||
len: TextSize,
|
||||
},
|
||||
}
|
||||
|
||||
/// Trims at most `indent_len` indentation from the beginning of `line`.
|
||||
///
|
||||
/// This treats indentation in precisely the same way as `indentation_length`.
|
||||
/// As such, it is expected that `indent_len` is computed from
|
||||
/// `indentation_length`. This is useful when one needs to trim some minimum
|
||||
/// level of indentation from a code snippet collected from a docstring before
|
||||
/// attempting to reformat it.
|
||||
fn indentation_trim(indent_len: usize, line: &str) -> &str {
|
||||
let mut seen_indent_len = 0;
|
||||
let mut trimmed = line;
|
||||
for char in line.chars() {
|
||||
if seen_indent_len >= indent_len {
|
||||
return trimmed;
|
||||
impl Indentation {
|
||||
const TAB_INDENT_WIDTH: usize = 8;
|
||||
|
||||
fn from_str(s: &str) -> Self {
|
||||
let mut iter = s.chars().peekable();
|
||||
|
||||
let spaces = iter.peeking_take_while(|c| *c == ' ').count();
|
||||
let tabs = iter.peeking_take_while(|c| *c == '\t').count();
|
||||
|
||||
if tabs == 0 {
|
||||
// No indent, or spaces only indent
|
||||
return Self::Spaces(spaces);
|
||||
}
|
||||
if char == '\t' {
|
||||
// Pad to the next multiple of tab_width
|
||||
seen_indent_len += 8 - (seen_indent_len.rem_euclid(8));
|
||||
trimmed = &trimmed[1..];
|
||||
} else if char.is_whitespace() {
|
||||
seen_indent_len += char.len_utf8();
|
||||
trimmed = &trimmed[char.len_utf8()..];
|
||||
} else {
|
||||
break;
|
||||
|
||||
let align_spaces = iter.peeking_take_while(|c| *c == ' ').count();
|
||||
|
||||
if spaces == 0 {
|
||||
if align_spaces == 0 {
|
||||
return Self::Tabs(tabs);
|
||||
}
|
||||
|
||||
// At this point it's either a smart tab (tabs followed by spaces) or a wild mix of tabs and spaces.
|
||||
if iter.peek().copied() != Some('\t') {
|
||||
return Self::TabSpaces {
|
||||
tabs,
|
||||
spaces: align_spaces,
|
||||
};
|
||||
}
|
||||
} else if align_spaces == 0 {
|
||||
return Self::SpacesTabs { spaces, tabs };
|
||||
}
|
||||
|
||||
// Sequence of spaces.. tabs, spaces, tabs...
|
||||
let mut width = spaces + tabs * Self::TAB_INDENT_WIDTH + align_spaces;
|
||||
// SAFETY: Safe because Ruff doesn't support files larger than 4GB.
|
||||
let mut len = TextSize::try_from(spaces + tabs + align_spaces).unwrap();
|
||||
|
||||
for char in iter {
|
||||
if char == '\t' {
|
||||
// Pad to the next multiple of tab_width
|
||||
width += Self::TAB_INDENT_WIDTH - (width.rem_euclid(Self::TAB_INDENT_WIDTH));
|
||||
len += '\t'.text_len();
|
||||
} else if char.is_whitespace() {
|
||||
width += char.len_utf8();
|
||||
len += char.text_len();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Mixed tabs and spaces
|
||||
Self::Mixed { width, len }
|
||||
}
|
||||
|
||||
/// Returns the indentation's visual width in columns/spaces.
|
||||
///
|
||||
/// For docstring indentation, black counts spaces as 1 and tabs by increasing the indentation up
|
||||
/// to the next multiple of 8. This is effectively a port of
|
||||
/// [`str.expandtabs`](https://docs.python.org/3/library/stdtypes.html#str.expandtabs),
|
||||
/// which black [calls with the default tab width of 8](https://github.com/psf/black/blob/c36e468794f9256d5e922c399240d49782ba04f1/src/black/strings.py#L61).
|
||||
const fn width(self) -> usize {
|
||||
match self {
|
||||
Self::Spaces(count) => count,
|
||||
Self::Tabs(count) => count * Self::TAB_INDENT_WIDTH,
|
||||
Self::TabSpaces { tabs, spaces } => tabs * Self::TAB_INDENT_WIDTH + spaces,
|
||||
Self::SpacesTabs { spaces, tabs } => {
|
||||
let mut indent = spaces;
|
||||
indent += Self::TAB_INDENT_WIDTH - indent.rem_euclid(Self::TAB_INDENT_WIDTH);
|
||||
indent + (tabs - 1) * Self::TAB_INDENT_WIDTH
|
||||
}
|
||||
Self::Mixed { width, .. } => width,
|
||||
}
|
||||
}
|
||||
trimmed
|
||||
|
||||
/// Returns the length of the indentation in bytes.
|
||||
///
|
||||
/// # Panics
|
||||
/// If the indentation is longer than 4GB.
|
||||
fn text_len(self) -> TextSize {
|
||||
let len = match self {
|
||||
Self::Spaces(count) => count,
|
||||
Self::Tabs(count) => count,
|
||||
Self::TabSpaces { tabs, spaces } => tabs + spaces,
|
||||
Self::SpacesTabs { spaces, tabs } => spaces + tabs,
|
||||
Self::Mixed { len, .. } => return len,
|
||||
};
|
||||
|
||||
TextSize::try_from(len).unwrap()
|
||||
}
|
||||
|
||||
/// Trims the indent of `rhs` by `self`.
|
||||
///
|
||||
/// Returns `None` if `self` is not a prefix of `rhs` or either `self` or `rhs` use mixed indentation.
|
||||
fn trim_start(self, rhs: Self) -> Option<Self> {
|
||||
let (left_tabs, left_spaces) = match self {
|
||||
Self::Spaces(spaces) => (0usize, spaces),
|
||||
Self::Tabs(tabs) => (tabs, 0usize),
|
||||
Self::TabSpaces { tabs, spaces } => (tabs, spaces),
|
||||
// Handle spaces here because it is the only indent where the spaces come before the tabs.
|
||||
Self::SpacesTabs {
|
||||
spaces: left_spaces,
|
||||
tabs: left_tabs,
|
||||
} => {
|
||||
return match rhs {
|
||||
Self::Spaces(right_spaces) => {
|
||||
left_spaces.checked_sub(right_spaces).map(|spaces| {
|
||||
if spaces == 0 {
|
||||
Self::Tabs(left_tabs)
|
||||
} else {
|
||||
Self::SpacesTabs {
|
||||
tabs: left_tabs,
|
||||
spaces,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Self::SpacesTabs {
|
||||
spaces: right_spaces,
|
||||
tabs: right_tabs,
|
||||
} => left_spaces.checked_sub(right_spaces).and_then(|spaces| {
|
||||
let tabs = left_tabs.checked_sub(right_tabs)?;
|
||||
|
||||
Some(if spaces == 0 {
|
||||
if tabs == 0 {
|
||||
Self::Spaces(0)
|
||||
} else {
|
||||
Self::Tabs(tabs)
|
||||
}
|
||||
} else {
|
||||
Self::SpacesTabs { spaces, tabs }
|
||||
})
|
||||
}),
|
||||
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
Self::Mixed { .. } => return None,
|
||||
};
|
||||
|
||||
let (right_tabs, right_spaces) = match rhs {
|
||||
Self::Spaces(spaces) => (0usize, spaces),
|
||||
Self::Tabs(tabs) => (tabs, 0usize),
|
||||
Self::TabSpaces { tabs, spaces } => (tabs, spaces),
|
||||
Self::SpacesTabs { .. } | Self::Mixed { .. } => return None,
|
||||
};
|
||||
|
||||
let tabs = left_tabs.checked_sub(right_tabs)?;
|
||||
let spaces = left_spaces.checked_sub(right_spaces)?;
|
||||
|
||||
Some(if tabs == 0 {
|
||||
Self::Spaces(spaces)
|
||||
} else if spaces == 0 {
|
||||
Self::Tabs(tabs)
|
||||
} else {
|
||||
Self::TabSpaces { tabs, spaces }
|
||||
})
|
||||
}
|
||||
|
||||
/// Trims at most `indent_len` indentation from the beginning of `line`.
|
||||
///
|
||||
/// This is useful when one needs to trim some minimum
|
||||
/// level of indentation from a code snippet collected from a docstring before
|
||||
/// attempting to reformat it.
|
||||
fn trim_start_str(self, line: &str) -> &str {
|
||||
let mut seen_indent_len = 0;
|
||||
let mut trimmed = line;
|
||||
let indent_len = self.width();
|
||||
|
||||
for char in line.chars() {
|
||||
if seen_indent_len >= indent_len {
|
||||
return trimmed;
|
||||
}
|
||||
if char == '\t' {
|
||||
// Pad to the next multiple of tab_width
|
||||
seen_indent_len +=
|
||||
Self::TAB_INDENT_WIDTH - (seen_indent_len.rem_euclid(Self::TAB_INDENT_WIDTH));
|
||||
trimmed = &trimmed[1..];
|
||||
} else if char.is_whitespace() {
|
||||
seen_indent_len += char.len_utf8();
|
||||
trimmed = &trimmed[char.len_utf8()..];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
trimmed
|
||||
}
|
||||
|
||||
const fn is_spaces_tabs(self) -> bool {
|
||||
matches!(self, Self::SpacesTabs { .. })
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Indentation {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.width().cmp(&other.width()))
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Indentation {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.width() == other.width()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Indentation {
|
||||
fn default() -> Self {
|
||||
Self::Spaces(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the indentation of the given line and everything following it.
|
||||
@@ -1613,14 +1837,13 @@ fn is_rst_option(line: &str) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::indentation_length;
|
||||
use crate::string::docstring::Indentation;
|
||||
|
||||
#[test]
|
||||
fn test_indentation_like_black() {
|
||||
assert_eq!(indentation_length("\t \t \t"), 24);
|
||||
assert_eq!(indentation_length("\t \t"), 24);
|
||||
assert_eq!(indentation_length("\t\t\t"), 24);
|
||||
assert_eq!(indentation_length(" "), 4);
|
||||
assert_eq!(Indentation::from_str("\t \t \t").width(), 24);
|
||||
assert_eq!(Indentation::from_str("\t \t").width(), 24);
|
||||
assert_eq!(Indentation::from_str("\t\t\t").width(), 24);
|
||||
assert_eq!(Indentation::from_str(" ").width(), 4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
use std::borrow::Cow;
|
||||
use std::iter::FusedIterator;
|
||||
|
||||
use bitflags::bitflags;
|
||||
use memchr::memchr2;
|
||||
|
||||
pub(crate) use any::AnyString;
|
||||
pub(crate) use normalize::{NormalizedString, StringNormalizer};
|
||||
use ruff_formatter::{format_args, write};
|
||||
use ruff_python_ast::{
|
||||
self as ast, Expr, ExprBytesLiteral, ExprFString, ExprStringLiteral, ExpressionRef,
|
||||
};
|
||||
use ruff_python_ast::{AnyNodeRef, StringLiteral};
|
||||
use ruff_source_file::Locator;
|
||||
use ruff_text_size::{Ranged, TextLen, TextRange, TextSize};
|
||||
|
||||
use crate::comments::{leading_comments, trailing_comments};
|
||||
use crate::expression::expr_f_string::f_string_quoting;
|
||||
use crate::expression::parentheses::in_parentheses_only_soft_line_break_or_space;
|
||||
use crate::other::f_string::FormatFString;
|
||||
use crate::other::string_literal::{FormatStringLiteral, StringLiteralKind};
|
||||
use crate::prelude::*;
|
||||
use crate::QuoteStyle;
|
||||
|
||||
mod any;
|
||||
pub(crate) mod docstring;
|
||||
mod normalize;
|
||||
|
||||
#[derive(Copy, Clone, Debug, Default)]
|
||||
pub(crate) enum Quoting {
|
||||
@@ -29,202 +22,6 @@ pub(crate) enum Quoting {
|
||||
Preserve,
|
||||
}
|
||||
|
||||
/// Represents any kind of string expression. This could be either a string,
|
||||
/// bytes or f-string.
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub(crate) enum AnyString<'a> {
|
||||
String(&'a ExprStringLiteral),
|
||||
Bytes(&'a ExprBytesLiteral),
|
||||
FString(&'a ExprFString),
|
||||
}
|
||||
|
||||
impl<'a> AnyString<'a> {
|
||||
/// Creates a new [`AnyString`] from the given [`Expr`].
|
||||
///
|
||||
/// Returns `None` if the expression is not either a string, bytes or f-string.
|
||||
pub(crate) fn from_expression(expression: &'a Expr) -> Option<AnyString<'a>> {
|
||||
match expression {
|
||||
Expr::StringLiteral(string) => Some(AnyString::String(string)),
|
||||
Expr::BytesLiteral(bytes) => Some(AnyString::Bytes(bytes)),
|
||||
Expr::FString(fstring) => Some(AnyString::FString(fstring)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the string is implicitly concatenated.
|
||||
pub(crate) fn is_implicit_concatenated(self) -> bool {
|
||||
match self {
|
||||
Self::String(ExprStringLiteral { value, .. }) => value.is_implicit_concatenated(),
|
||||
Self::Bytes(ExprBytesLiteral { value, .. }) => value.is_implicit_concatenated(),
|
||||
Self::FString(ExprFString { value, .. }) => value.is_implicit_concatenated(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the quoting to be used for this string.
|
||||
fn quoting(self, locator: &Locator<'_>) -> Quoting {
|
||||
match self {
|
||||
Self::String(_) | Self::Bytes(_) => Quoting::CanChange,
|
||||
Self::FString(f_string) => f_string_quoting(f_string, locator),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a vector of all the [`AnyStringPart`] of this string.
|
||||
fn parts(self, quoting: Quoting) -> AnyStringPartsIter<'a> {
|
||||
match self {
|
||||
Self::String(ExprStringLiteral { value, .. }) => {
|
||||
AnyStringPartsIter::String(value.iter())
|
||||
}
|
||||
Self::Bytes(ExprBytesLiteral { value, .. }) => AnyStringPartsIter::Bytes(value.iter()),
|
||||
Self::FString(ExprFString { value, .. }) => {
|
||||
AnyStringPartsIter::FString(value.iter(), quoting)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_multiline(self, source: &str) -> bool {
|
||||
match self {
|
||||
AnyString::String(_) | AnyString::Bytes(_) => {
|
||||
let contents = &source[self.range()];
|
||||
let prefix = StringPrefix::parse(contents);
|
||||
let quotes = StringQuotes::parse(
|
||||
&contents[TextRange::new(prefix.text_len(), contents.text_len())],
|
||||
);
|
||||
|
||||
quotes.is_some_and(StringQuotes::is_triple)
|
||||
&& memchr2(b'\n', b'\r', contents.as_bytes()).is_some()
|
||||
}
|
||||
AnyString::FString(fstring) => {
|
||||
memchr2(b'\n', b'\r', source[fstring.range].as_bytes()).is_some()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ranged for AnyString<'_> {
|
||||
fn range(&self) -> TextRange {
|
||||
match self {
|
||||
Self::String(expr) => expr.range(),
|
||||
Self::Bytes(expr) => expr.range(),
|
||||
Self::FString(expr) => expr.range(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&AnyString<'a>> for AnyNodeRef<'a> {
|
||||
fn from(value: &AnyString<'a>) -> Self {
|
||||
match value {
|
||||
AnyString::String(expr) => AnyNodeRef::ExprStringLiteral(expr),
|
||||
AnyString::Bytes(expr) => AnyNodeRef::ExprBytesLiteral(expr),
|
||||
AnyString::FString(expr) => AnyNodeRef::ExprFString(expr),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<AnyString<'a>> for AnyNodeRef<'a> {
|
||||
fn from(value: AnyString<'a>) -> Self {
|
||||
AnyNodeRef::from(&value)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&AnyString<'a>> for ExpressionRef<'a> {
|
||||
fn from(value: &AnyString<'a>) -> Self {
|
||||
match value {
|
||||
AnyString::String(expr) => ExpressionRef::StringLiteral(expr),
|
||||
AnyString::Bytes(expr) => ExpressionRef::BytesLiteral(expr),
|
||||
AnyString::FString(expr) => ExpressionRef::FString(expr),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AnyStringPartsIter<'a> {
|
||||
String(std::slice::Iter<'a, StringLiteral>),
|
||||
Bytes(std::slice::Iter<'a, ast::BytesLiteral>),
|
||||
FString(std::slice::Iter<'a, ast::FStringPart>, Quoting),
|
||||
}
|
||||
|
||||
impl<'a> Iterator for AnyStringPartsIter<'a> {
|
||||
type Item = AnyStringPart<'a>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let part = match self {
|
||||
Self::String(inner) => {
|
||||
let part = inner.next()?;
|
||||
AnyStringPart::String {
|
||||
part,
|
||||
layout: StringLiteralKind::String,
|
||||
}
|
||||
}
|
||||
Self::Bytes(inner) => AnyStringPart::Bytes(inner.next()?),
|
||||
Self::FString(inner, quoting) => {
|
||||
let part = inner.next()?;
|
||||
match part {
|
||||
ast::FStringPart::Literal(string_literal) => AnyStringPart::String {
|
||||
part: string_literal,
|
||||
layout: StringLiteralKind::InImplicitlyConcatenatedFString(*quoting),
|
||||
},
|
||||
ast::FStringPart::FString(f_string) => AnyStringPart::FString {
|
||||
part: f_string,
|
||||
quoting: *quoting,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Some(part)
|
||||
}
|
||||
}
|
||||
|
||||
impl FusedIterator for AnyStringPartsIter<'_> {}
|
||||
|
||||
/// Represents any kind of string which is part of an implicitly concatenated
|
||||
/// string. This could be either a string, bytes or f-string.
|
||||
///
|
||||
/// This is constructed from the [`AnyString::parts`] method on [`AnyString`].
|
||||
#[derive(Clone, Debug)]
|
||||
enum AnyStringPart<'a> {
|
||||
String {
|
||||
part: &'a ast::StringLiteral,
|
||||
layout: StringLiteralKind,
|
||||
},
|
||||
Bytes(&'a ast::BytesLiteral),
|
||||
FString {
|
||||
part: &'a ast::FString,
|
||||
quoting: Quoting,
|
||||
},
|
||||
}
|
||||
|
||||
impl<'a> From<&AnyStringPart<'a>> for AnyNodeRef<'a> {
|
||||
fn from(value: &AnyStringPart<'a>) -> Self {
|
||||
match value {
|
||||
AnyStringPart::String { part, .. } => AnyNodeRef::StringLiteral(part),
|
||||
AnyStringPart::Bytes(part) => AnyNodeRef::BytesLiteral(part),
|
||||
AnyStringPart::FString { part, .. } => AnyNodeRef::FString(part),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ranged for AnyStringPart<'_> {
|
||||
fn range(&self) -> TextRange {
|
||||
match self {
|
||||
Self::String { part, .. } => part.range(),
|
||||
Self::Bytes(part) => part.range(),
|
||||
Self::FString { part, .. } => part.range(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Format<PyFormatContext<'_>> for AnyStringPart<'_> {
|
||||
fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> {
|
||||
match self {
|
||||
AnyStringPart::String { part, layout } => {
|
||||
FormatStringLiteral::new(part, *layout).fmt(f)
|
||||
}
|
||||
AnyStringPart::Bytes(bytes_literal) => bytes_literal.format().fmt(f),
|
||||
AnyStringPart::FString { part, quoting } => FormatFString::new(part, *quoting).fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats any implicitly concatenated string. This could be any valid combination
|
||||
/// of string, bytes or f-string literals.
|
||||
pub(crate) struct FormatStringContinuation<'a> {
|
||||
@@ -242,18 +39,120 @@ impl Format<PyFormatContext<'_>> for FormatStringContinuation<'_> {
|
||||
let comments = f.context().comments().clone();
|
||||
let quoting = self.string.quoting(&f.context().locator());
|
||||
|
||||
let mut joiner = f.join_with(in_parentheses_only_soft_line_break_or_space());
|
||||
let parts = self.string.parts(quoting);
|
||||
|
||||
for part in self.string.parts(quoting) {
|
||||
joiner.entry(&format_args![
|
||||
line_suffix_boundary(),
|
||||
leading_comments(comments.leading(&part)),
|
||||
part,
|
||||
trailing_comments(comments.trailing(&part))
|
||||
]);
|
||||
// Don't try the flat layout if it is know that the implicit string remains on multiple lines either because one
|
||||
// part is a multline or a part has a leading or trailing comment.
|
||||
let should_try_flat = !parts.clone().any(|part| {
|
||||
let part_comments = comments.leading_dangling_trailing(&part);
|
||||
|
||||
part.is_multiline(f.context().source())
|
||||
|| part_comments.has_leading()
|
||||
|| part_comments.has_trailing()
|
||||
});
|
||||
|
||||
let format_flat = format_with(|f: &mut PyFormatter| {
|
||||
let mut merged_prefix = StringPrefix::empty();
|
||||
let mut all_raw = true;
|
||||
let quotes = parts.clone().next().map_or(
|
||||
StringQuotes {
|
||||
triple: false,
|
||||
quote_char: QuoteChar::Double,
|
||||
},
|
||||
|part| StringPart::from_source(part.range(), &f.context().locator()).quotes,
|
||||
);
|
||||
|
||||
for part in parts.clone() {
|
||||
let string_part = StringPart::from_source(part.range(), &f.context().locator());
|
||||
|
||||
let prefix = string_part.prefix;
|
||||
merged_prefix = prefix.union(merged_prefix);
|
||||
all_raw &= prefix.is_raw_string();
|
||||
|
||||
// quotes are more complicated. We need to collect the statistics about the used quotes for each string
|
||||
// - number of single quotes
|
||||
// - number of double quotes
|
||||
// - number of triple quotes
|
||||
// And they need to be normalized as a second step
|
||||
// Also requires tracking how many times a simple string uses an escaped triple quoted sequence to avoid
|
||||
// stability issues.
|
||||
}
|
||||
|
||||
// Prefer lower case raw string flags over uppercase if both are present.
|
||||
if merged_prefix.contains(StringPrefix::RAW)
|
||||
&& merged_prefix.contains(StringPrefix::RAW_UPPER)
|
||||
{
|
||||
merged_prefix.remove(StringPrefix::RAW_UPPER);
|
||||
}
|
||||
|
||||
// Remove the raw prefix if there's a mixture of raw and non-raw string. The formatting code coming later normalizes raw strings to regular
|
||||
// strings if the flag isn't present.
|
||||
if !all_raw {
|
||||
merged_prefix.remove(StringPrefix::RAW);
|
||||
}
|
||||
|
||||
// We need to find the common prefix and quotes for all parts and use that one.
|
||||
// no prefix: easy
|
||||
// bitflags! {
|
||||
// #[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
// pub(crate) struct StringPrefix: u8 {
|
||||
// const UNICODE = 0b0000_0001;
|
||||
// /// `r"test"`
|
||||
// const RAW = 0b0000_0010;
|
||||
// /// `R"test"
|
||||
// const RAW_UPPER = 0b0000_0100;
|
||||
// const BYTE = 0b0000_1000;
|
||||
// const F_STRING = 0b0001_0000;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Prefix precedence:
|
||||
// - Unicode -> Always remove
|
||||
// - Raw upper -> Remove except when all parts are raw upper
|
||||
// - Raw -> Remove except when all parts are raw or raw upper.
|
||||
// - F-String -> Preserve
|
||||
// - Bytes -> Preserve
|
||||
// Quotes:
|
||||
// - Single quotes: Identify the number of single and double quotes in the string and use the one with the least count.
|
||||
// - single and triple: Use triple quotes
|
||||
// - triples: Use `choose_quote` for every part and use the one with the highest count
|
||||
|
||||
write!(f, [merged_prefix, quotes])?;
|
||||
for part in parts.clone() {
|
||||
let string_part = StringPart::from_source(part.range(), &f.context().locator());
|
||||
|
||||
write!(f, [source_text_slice(string_part.content_range)])?;
|
||||
}
|
||||
|
||||
quotes.fmt(f)
|
||||
});
|
||||
|
||||
let format_expanded = format_with(|f| {
|
||||
let mut joiner = f.join_with(in_parentheses_only_soft_line_break_or_space());
|
||||
|
||||
for part in parts.clone() {
|
||||
joiner.entry(&format_args![
|
||||
line_suffix_boundary(),
|
||||
leading_comments(comments.leading(&part)),
|
||||
part,
|
||||
trailing_comments(comments.trailing(&part))
|
||||
]);
|
||||
}
|
||||
|
||||
joiner.finish()
|
||||
});
|
||||
|
||||
// TODO: where's the group coming from?
|
||||
|
||||
if should_try_flat {
|
||||
group(&format_args![
|
||||
if_group_fits_on_line(&format_flat),
|
||||
if_group_breaks(&format_expanded)
|
||||
])
|
||||
.fmt(f)
|
||||
} else {
|
||||
format_expanded.fmt(f)
|
||||
}
|
||||
|
||||
joiner.finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,139 +190,22 @@ impl StringPart {
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the strings preferred quotes and normalizes its content.
|
||||
///
|
||||
/// The parent docstring quote style should be set when formatting a code
|
||||
/// snippet within the docstring. The quote style should correspond to the
|
||||
/// style of quotes used by said docstring. Normalization will ensure the
|
||||
/// quoting styles don't conflict.
|
||||
pub(crate) fn normalize<'a>(
|
||||
self,
|
||||
quoting: Quoting,
|
||||
locator: &'a Locator,
|
||||
configured_style: QuoteStyle,
|
||||
parent_docstring_quote_char: Option<QuoteChar>,
|
||||
normalize_hex: bool,
|
||||
) -> NormalizedString<'a> {
|
||||
// Per PEP 8, always prefer double quotes for triple-quoted strings.
|
||||
let preferred_style = if self.quotes.triple {
|
||||
// ... unless we're formatting a code snippet inside a docstring,
|
||||
// then we specifically want to invert our quote style to avoid
|
||||
// writing out invalid Python.
|
||||
//
|
||||
// It's worth pointing out that we can actually wind up being
|
||||
// somewhat out of sync with PEP8 in this case. Consider this
|
||||
// example:
|
||||
//
|
||||
// def foo():
|
||||
// '''
|
||||
// Something.
|
||||
//
|
||||
// >>> """tricksy"""
|
||||
// '''
|
||||
// pass
|
||||
//
|
||||
// Ideally, this would be reformatted as:
|
||||
//
|
||||
// def foo():
|
||||
// """
|
||||
// Something.
|
||||
//
|
||||
// >>> '''tricksy'''
|
||||
// """
|
||||
// pass
|
||||
//
|
||||
// But the logic here results in the original quoting being
|
||||
// preserved. This is because the quoting style of the outer
|
||||
// docstring is determined, in part, by looking at its contents. In
|
||||
// this case, it notices that it contains a `"""` and thus infers
|
||||
// that using `'''` would overall read better because it avoids
|
||||
// the need to escape the interior `"""`. Except... in this case,
|
||||
// the `"""` is actually part of a code snippet that could get
|
||||
// reformatted to using a different quoting style itself.
|
||||
//
|
||||
// Fixing this would, I believe, require some fairly seismic
|
||||
// changes to how formatting strings works. Namely, we would need
|
||||
// to look for code snippets before normalizing the docstring, and
|
||||
// then figure out the quoting style more holistically by looking
|
||||
// at the various kinds of quotes used in the code snippets and
|
||||
// what reformatting them might look like.
|
||||
//
|
||||
// Overall this is a bit of a corner case and just inverting the
|
||||
// style from what the parent ultimately decided upon works, even
|
||||
// if it doesn't have perfect alignment with PEP8.
|
||||
if let Some(quote) = parent_docstring_quote_char {
|
||||
QuoteStyle::from(quote.invert())
|
||||
} else {
|
||||
QuoteStyle::Double
|
||||
}
|
||||
} else {
|
||||
configured_style
|
||||
};
|
||||
|
||||
let raw_content = &locator.slice(self.content_range);
|
||||
|
||||
let quotes = match quoting {
|
||||
Quoting::Preserve => self.quotes,
|
||||
Quoting::CanChange => {
|
||||
if let Some(preferred_quote) = QuoteChar::from_style(preferred_style) {
|
||||
if self.prefix.is_raw_string() {
|
||||
choose_quotes_raw(raw_content, self.quotes, preferred_quote)
|
||||
} else {
|
||||
choose_quotes(raw_content, self.quotes, preferred_quote)
|
||||
}
|
||||
} else {
|
||||
self.quotes
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let normalized = normalize_string(raw_content, quotes, self.prefix, normalize_hex);
|
||||
|
||||
NormalizedString {
|
||||
prefix: self.prefix,
|
||||
content_range: self.content_range,
|
||||
text: normalized,
|
||||
quotes,
|
||||
}
|
||||
/// Returns the prefix of the string part.
|
||||
pub(crate) const fn prefix(&self) -> StringPrefix {
|
||||
self.prefix
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct NormalizedString<'a> {
|
||||
prefix: StringPrefix,
|
||||
/// Returns the surrounding quotes of the string part.
|
||||
pub(crate) const fn quotes(&self) -> StringQuotes {
|
||||
self.quotes
|
||||
}
|
||||
|
||||
/// The quotes of the normalized string (preferred quotes)
|
||||
quotes: StringQuotes,
|
||||
|
||||
/// The range of the string's content in the source (minus prefix and quotes).
|
||||
content_range: TextRange,
|
||||
|
||||
/// The normalized text
|
||||
text: Cow<'a, str>,
|
||||
}
|
||||
|
||||
impl Ranged for NormalizedString<'_> {
|
||||
fn range(&self) -> TextRange {
|
||||
/// Returns the range of the string's content in the source (minus prefix and quotes).
|
||||
pub(crate) const fn content_range(&self) -> TextRange {
|
||||
self.content_range
|
||||
}
|
||||
}
|
||||
|
||||
impl Format<PyFormatContext<'_>> for NormalizedString<'_> {
|
||||
fn fmt(&self, f: &mut Formatter<PyFormatContext<'_>>) -> FormatResult<()> {
|
||||
write!(f, [self.prefix, self.quotes])?;
|
||||
match &self.text {
|
||||
Cow::Borrowed(_) => {
|
||||
source_text_slice(self.range()).fmt(f)?;
|
||||
}
|
||||
Cow::Owned(normalized) => {
|
||||
text(normalized).fmt(f)?;
|
||||
}
|
||||
}
|
||||
self.quotes.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
bitflags! {
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct StringPrefix: u8 {
|
||||
@@ -504,171 +286,6 @@ impl Format<PyFormatContext<'_>> for StringPrefix {
|
||||
}
|
||||
}
|
||||
|
||||
/// Choose the appropriate quote style for a raw string.
|
||||
///
|
||||
/// The preferred quote style is chosen unless the string contains unescaped quotes of the
|
||||
/// preferred style. For example, `r"foo"` is chosen over `r'foo'` if the preferred quote
|
||||
/// style is double quotes.
|
||||
fn choose_quotes_raw(
|
||||
input: &str,
|
||||
quotes: StringQuotes,
|
||||
preferred_quote: QuoteChar,
|
||||
) -> StringQuotes {
|
||||
let preferred_quote_char = preferred_quote.as_char();
|
||||
let mut chars = input.chars().peekable();
|
||||
let contains_unescaped_configured_quotes = loop {
|
||||
match chars.next() {
|
||||
Some('\\') => {
|
||||
// Ignore escaped characters
|
||||
chars.next();
|
||||
}
|
||||
// `"` or `'`
|
||||
Some(c) if c == preferred_quote_char => {
|
||||
if !quotes.triple {
|
||||
break true;
|
||||
}
|
||||
|
||||
match chars.peek() {
|
||||
// We can't turn `r'''\""'''` into `r"""\"""""`, this would confuse the parser
|
||||
// about where the closing triple quotes start
|
||||
None => break true,
|
||||
Some(next) if *next == preferred_quote_char => {
|
||||
// `""` or `''`
|
||||
chars.next();
|
||||
|
||||
// We can't turn `r'''""'''` into `r""""""""`, nor can we have
|
||||
// `"""` or `'''` respectively inside the string
|
||||
if chars.peek().is_none() || chars.peek() == Some(&preferred_quote_char) {
|
||||
break true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(_) => continue,
|
||||
None => break false,
|
||||
}
|
||||
};
|
||||
|
||||
StringQuotes {
|
||||
triple: quotes.triple,
|
||||
quote_char: if contains_unescaped_configured_quotes {
|
||||
quotes.quote_char
|
||||
} else {
|
||||
preferred_quote
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Choose the appropriate quote style for a string.
|
||||
///
|
||||
/// For single quoted strings, the preferred quote style is used, unless the alternative quote style
|
||||
/// would require fewer escapes.
|
||||
///
|
||||
/// For triple quoted strings, the preferred quote style is always used, unless the string contains
|
||||
/// a triplet of the quote character (e.g., if double quotes are preferred, double quotes will be
|
||||
/// used unless the string contains `"""`).
|
||||
fn choose_quotes(input: &str, quotes: StringQuotes, preferred_quote: QuoteChar) -> StringQuotes {
|
||||
let quote = if quotes.triple {
|
||||
// True if the string contains a triple quote sequence of the configured quote style.
|
||||
let mut uses_triple_quotes = false;
|
||||
let mut chars = input.chars().peekable();
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
let preferred_quote_char = preferred_quote.as_char();
|
||||
match c {
|
||||
'\\' => {
|
||||
if matches!(chars.peek(), Some('"' | '\\')) {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
// `"` or `'`
|
||||
c if c == preferred_quote_char => {
|
||||
match chars.peek().copied() {
|
||||
Some(c) if c == preferred_quote_char => {
|
||||
// `""` or `''`
|
||||
chars.next();
|
||||
|
||||
match chars.peek().copied() {
|
||||
Some(c) if c == preferred_quote_char => {
|
||||
// `"""` or `'''`
|
||||
chars.next();
|
||||
uses_triple_quotes = true;
|
||||
break;
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {
|
||||
// Handle `''' ""'''`. At this point we have consumed both
|
||||
// double quotes, so on the next iteration the iterator is empty
|
||||
// and we'd miss the string ending with a preferred quote
|
||||
uses_triple_quotes = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(_) => {
|
||||
// A single quote char, this is ok
|
||||
}
|
||||
None => {
|
||||
// Trailing quote at the end of the comment
|
||||
uses_triple_quotes = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
|
||||
if uses_triple_quotes {
|
||||
// String contains a triple quote sequence of the configured quote style.
|
||||
// Keep the existing quote style.
|
||||
quotes.quote_char
|
||||
} else {
|
||||
preferred_quote
|
||||
}
|
||||
} else {
|
||||
let mut single_quotes = 0u32;
|
||||
let mut double_quotes = 0u32;
|
||||
|
||||
for c in input.chars() {
|
||||
match c {
|
||||
'\'' => {
|
||||
single_quotes += 1;
|
||||
}
|
||||
|
||||
'"' => {
|
||||
double_quotes += 1;
|
||||
}
|
||||
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
|
||||
match preferred_quote {
|
||||
QuoteChar::Single => {
|
||||
if single_quotes > double_quotes {
|
||||
QuoteChar::Double
|
||||
} else {
|
||||
QuoteChar::Single
|
||||
}
|
||||
}
|
||||
QuoteChar::Double => {
|
||||
if double_quotes > single_quotes {
|
||||
QuoteChar::Single
|
||||
} else {
|
||||
QuoteChar::Double
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
StringQuotes {
|
||||
triple: quotes.triple,
|
||||
quote_char: quote,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub(crate) struct StringQuotes {
|
||||
triple: bool,
|
||||
@@ -772,269 +389,3 @@ impl TryFrom<char> for QuoteChar {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the necessary quote escapes and removes unnecessary escape sequences when quoting `input`
|
||||
/// with the provided [`StringQuotes`] style.
|
||||
///
|
||||
/// Returns the normalized string and whether it contains new lines.
|
||||
fn normalize_string(
|
||||
input: &str,
|
||||
quotes: StringQuotes,
|
||||
prefix: StringPrefix,
|
||||
normalize_hex: bool,
|
||||
) -> Cow<str> {
|
||||
// The normalized string if `input` is not yet normalized.
|
||||
// `output` must remain empty if `input` is already normalized.
|
||||
let mut output = String::new();
|
||||
// Tracks the last index of `input` that has been written to `output`.
|
||||
// If `last_index` is `0` at the end, then the input is already normalized and can be returned as is.
|
||||
let mut last_index = 0;
|
||||
|
||||
let quote = quotes.quote_char;
|
||||
let preferred_quote = quote.as_char();
|
||||
let opposite_quote = quote.invert().as_char();
|
||||
|
||||
let mut chars = input.char_indices().peekable();
|
||||
|
||||
let is_raw = prefix.is_raw_string();
|
||||
let is_fstring = prefix.is_fstring();
|
||||
let mut formatted_value_nesting = 0u32;
|
||||
|
||||
while let Some((index, c)) = chars.next() {
|
||||
if is_fstring && matches!(c, '{' | '}') {
|
||||
if chars.peek().copied().is_some_and(|(_, next)| next == c) {
|
||||
// Skip over the second character of the double braces
|
||||
chars.next();
|
||||
} else if c == '{' {
|
||||
formatted_value_nesting += 1;
|
||||
} else {
|
||||
// Safe to assume that `c == '}'` here because of the matched pattern above
|
||||
formatted_value_nesting = formatted_value_nesting.saturating_sub(1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if c == '\r' {
|
||||
output.push_str(&input[last_index..index]);
|
||||
|
||||
// Skip over the '\r' character, keep the `\n`
|
||||
if chars.peek().copied().is_some_and(|(_, next)| next == '\n') {
|
||||
chars.next();
|
||||
}
|
||||
// Replace the `\r` with a `\n`
|
||||
else {
|
||||
output.push('\n');
|
||||
}
|
||||
|
||||
last_index = index + '\r'.len_utf8();
|
||||
} else if !is_raw {
|
||||
if c == '\\' {
|
||||
if let Some((_, next)) = chars.clone().next() {
|
||||
if next == '\\' {
|
||||
// Skip over escaped backslashes
|
||||
chars.next();
|
||||
} else if normalize_hex {
|
||||
if let Some(normalised) = UnicodeEscape::new(next, !prefix.is_byte())
|
||||
.and_then(|escape| {
|
||||
escape.normalize(&input[index + c.len_utf8() + next.len_utf8()..])
|
||||
})
|
||||
{
|
||||
// Length of the `\` plus the length of the escape sequence character (`u` | `U` | `x`)
|
||||
let escape_start_len = '\\'.len_utf8() + next.len_utf8();
|
||||
let escape_start_offset = index + escape_start_len;
|
||||
if let Cow::Owned(normalised) = &normalised {
|
||||
output.push_str(&input[last_index..escape_start_offset]);
|
||||
output.push_str(normalised);
|
||||
last_index = escape_start_offset + normalised.len();
|
||||
};
|
||||
|
||||
// Move the `chars` iterator passed the escape sequence.
|
||||
// Simply reassigning `chars` doesn't work because the indices` would
|
||||
// then be off.
|
||||
for _ in 0..next.len_utf8() + normalised.len() {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !quotes.triple {
|
||||
#[allow(clippy::if_same_then_else)]
|
||||
if next == opposite_quote && formatted_value_nesting == 0 {
|
||||
// Remove the escape by ending before the backslash and starting again with the quote
|
||||
chars.next();
|
||||
output.push_str(&input[last_index..index]);
|
||||
last_index = index + '\\'.len_utf8();
|
||||
} else if next == preferred_quote {
|
||||
// Quote is already escaped, skip over it.
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if !quotes.triple && c == preferred_quote && formatted_value_nesting == 0 {
|
||||
// Escape the quote
|
||||
output.push_str(&input[last_index..index]);
|
||||
output.push('\\');
|
||||
output.push(c);
|
||||
last_index = index + preferred_quote.len_utf8();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let normalized = if last_index == 0 {
|
||||
Cow::Borrowed(input)
|
||||
} else {
|
||||
output.push_str(&input[last_index..]);
|
||||
Cow::Owned(output)
|
||||
};
|
||||
|
||||
normalized
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
enum UnicodeEscape {
|
||||
/// A hex escape sequence of either 2 (`\x`), 4 (`\u`) or 8 (`\U`) hex characters.
|
||||
Hex(usize),
|
||||
|
||||
/// An escaped unicode name (`\N{name}`)
|
||||
CharacterName,
|
||||
}
|
||||
|
||||
impl UnicodeEscape {
|
||||
fn new(first: char, allow_unicode: bool) -> Option<UnicodeEscape> {
|
||||
Some(match first {
|
||||
'x' => UnicodeEscape::Hex(2),
|
||||
'u' if allow_unicode => UnicodeEscape::Hex(4),
|
||||
'U' if allow_unicode => UnicodeEscape::Hex(8),
|
||||
'N' if allow_unicode => UnicodeEscape::CharacterName,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Normalises `\u..`, `\U..`, `\x..` and `\N{..}` escape sequences to:
|
||||
///
|
||||
/// * `\u`, `\U'` and `\x`: To use lower case for the characters `a-f`.
|
||||
/// * `\N`: To use uppercase letters
|
||||
fn normalize(self, input: &str) -> Option<Cow<str>> {
|
||||
let mut normalised = String::new();
|
||||
|
||||
let len = match self {
|
||||
UnicodeEscape::Hex(len) => {
|
||||
// It's not a valid escape sequence if the input string has fewer characters
|
||||
// left than required by the escape sequence.
|
||||
if input.len() < len {
|
||||
return None;
|
||||
}
|
||||
|
||||
for (index, c) in input.char_indices().take(len) {
|
||||
match c {
|
||||
'0'..='9' | 'a'..='f' => {
|
||||
if !normalised.is_empty() {
|
||||
normalised.push(c);
|
||||
}
|
||||
}
|
||||
'A'..='F' => {
|
||||
if normalised.is_empty() {
|
||||
normalised.reserve(len);
|
||||
normalised.push_str(&input[..index]);
|
||||
normalised.push(c.to_ascii_lowercase());
|
||||
} else {
|
||||
normalised.push(c.to_ascii_lowercase());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// not a valid escape sequence
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
len
|
||||
}
|
||||
UnicodeEscape::CharacterName => {
|
||||
let mut char_indices = input.char_indices();
|
||||
|
||||
if !matches!(char_indices.next(), Some((_, '{'))) {
|
||||
return None;
|
||||
}
|
||||
|
||||
loop {
|
||||
if let Some((index, c)) = char_indices.next() {
|
||||
match c {
|
||||
'}' => {
|
||||
if !normalised.is_empty() {
|
||||
normalised.push('}');
|
||||
}
|
||||
|
||||
// Name must be at least two characters long.
|
||||
if index < 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
break index + '}'.len_utf8();
|
||||
}
|
||||
'0'..='9' | 'A'..='Z' | ' ' | '-' => {
|
||||
if !normalised.is_empty() {
|
||||
normalised.push(c);
|
||||
}
|
||||
}
|
||||
'a'..='z' => {
|
||||
if normalised.is_empty() {
|
||||
normalised.reserve(c.len_utf8() + '}'.len_utf8());
|
||||
normalised.push_str(&input[..index]);
|
||||
normalised.push(c.to_ascii_uppercase());
|
||||
} else {
|
||||
normalised.push(c.to_ascii_uppercase());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Seems like an invalid escape sequence, don't normalise it.
|
||||
return None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unterminated escape sequence, don't normalise it.
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Some(if normalised.is_empty() {
|
||||
Cow::Borrowed(&input[..len])
|
||||
} else {
|
||||
Cow::Owned(normalised)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::string::{normalize_string, QuoteChar, StringPrefix, StringQuotes, UnicodeEscape};
|
||||
use std::borrow::Cow;
|
||||
|
||||
#[test]
|
||||
fn normalize_32_escape() {
|
||||
let escape_sequence = UnicodeEscape::new('U', true).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
Some(Cow::Owned("0001f60e".to_string())),
|
||||
escape_sequence.normalize("0001F60E")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_hex_in_byte_string() {
|
||||
let input = r"\x89\x50\x4E\x47\x0D\x0A\x1A\x0A";
|
||||
|
||||
let normalized = normalize_string(
|
||||
input,
|
||||
StringQuotes {
|
||||
triple: false,
|
||||
quote_char: QuoteChar::Double,
|
||||
},
|
||||
StringPrefix::BYTE,
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(r"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a", &normalized);
|
||||
}
|
||||
}
|
||||
|
||||
622
crates/ruff_python_formatter/src/string/normalize.rs
Normal file
622
crates/ruff_python_formatter/src/string/normalize.rs
Normal file
@@ -0,0 +1,622 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use ruff_source_file::Locator;
|
||||
use ruff_text_size::{Ranged, TextRange};
|
||||
|
||||
use crate::prelude::*;
|
||||
use crate::preview::is_hex_codes_in_unicode_sequences_enabled;
|
||||
use crate::string::{QuoteChar, Quoting, StringPart, StringPrefix, StringQuotes};
|
||||
use crate::QuoteStyle;
|
||||
|
||||
pub(crate) struct StringNormalizer {
|
||||
quoting: Quoting,
|
||||
preferred_quote_style: QuoteStyle,
|
||||
parent_docstring_quote_char: Option<QuoteChar>,
|
||||
normalize_hex: bool,
|
||||
}
|
||||
|
||||
impl StringNormalizer {
|
||||
pub(crate) fn from_context(context: &PyFormatContext<'_>) -> Self {
|
||||
Self {
|
||||
quoting: Quoting::default(),
|
||||
preferred_quote_style: QuoteStyle::default(),
|
||||
parent_docstring_quote_char: context.docstring(),
|
||||
normalize_hex: is_hex_codes_in_unicode_sequences_enabled(context),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_preferred_quote_style(mut self, quote_style: QuoteStyle) -> Self {
|
||||
self.preferred_quote_style = quote_style;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_quoting(mut self, quoting: Quoting) -> Self {
|
||||
self.quoting = quoting;
|
||||
self
|
||||
}
|
||||
|
||||
/// Computes the strings preferred quotes.
|
||||
pub(crate) fn choose_quotes(&self, string: &StringPart, locator: &Locator) -> StringQuotes {
|
||||
// Per PEP 8, always prefer double quotes for triple-quoted strings.
|
||||
// Except when using quote-style-preserve.
|
||||
let preferred_style = if string.quotes().triple {
|
||||
// ... unless we're formatting a code snippet inside a docstring,
|
||||
// then we specifically want to invert our quote style to avoid
|
||||
// writing out invalid Python.
|
||||
//
|
||||
// It's worth pointing out that we can actually wind up being
|
||||
// somewhat out of sync with PEP8 in this case. Consider this
|
||||
// example:
|
||||
//
|
||||
// def foo():
|
||||
// '''
|
||||
// Something.
|
||||
//
|
||||
// >>> """tricksy"""
|
||||
// '''
|
||||
// pass
|
||||
//
|
||||
// Ideally, this would be reformatted as:
|
||||
//
|
||||
// def foo():
|
||||
// """
|
||||
// Something.
|
||||
//
|
||||
// >>> '''tricksy'''
|
||||
// """
|
||||
// pass
|
||||
//
|
||||
// But the logic here results in the original quoting being
|
||||
// preserved. This is because the quoting style of the outer
|
||||
// docstring is determined, in part, by looking at its contents. In
|
||||
// this case, it notices that it contains a `"""` and thus infers
|
||||
// that using `'''` would overall read better because it avoids
|
||||
// the need to escape the interior `"""`. Except... in this case,
|
||||
// the `"""` is actually part of a code snippet that could get
|
||||
// reformatted to using a different quoting style itself.
|
||||
//
|
||||
// Fixing this would, I believe, require some fairly seismic
|
||||
// changes to how formatting strings works. Namely, we would need
|
||||
// to look for code snippets before normalizing the docstring, and
|
||||
// then figure out the quoting style more holistically by looking
|
||||
// at the various kinds of quotes used in the code snippets and
|
||||
// what reformatting them might look like.
|
||||
//
|
||||
// Overall this is a bit of a corner case and just inverting the
|
||||
// style from what the parent ultimately decided upon works, even
|
||||
// if it doesn't have perfect alignment with PEP8.
|
||||
if let Some(quote) = self.parent_docstring_quote_char {
|
||||
QuoteStyle::from(quote.invert())
|
||||
} else if self.preferred_quote_style.is_preserve() {
|
||||
QuoteStyle::Preserve
|
||||
} else {
|
||||
QuoteStyle::Double
|
||||
}
|
||||
} else {
|
||||
self.preferred_quote_style
|
||||
};
|
||||
|
||||
match self.quoting {
|
||||
Quoting::Preserve => string.quotes(),
|
||||
Quoting::CanChange => {
|
||||
if let Some(preferred_quote) = QuoteChar::from_style(preferred_style) {
|
||||
let raw_content = locator.slice(string.content_range());
|
||||
if string.prefix().is_raw_string() {
|
||||
choose_quotes_for_raw_string(raw_content, string.quotes(), preferred_quote)
|
||||
} else {
|
||||
choose_quotes_impl(raw_content, string.quotes(), preferred_quote)
|
||||
}
|
||||
} else {
|
||||
string.quotes()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the strings preferred quotes and normalizes its content.
|
||||
pub(crate) fn normalize<'a>(
|
||||
&self,
|
||||
string: &StringPart,
|
||||
locator: &'a Locator,
|
||||
) -> NormalizedString<'a> {
|
||||
let raw_content = locator.slice(string.content_range());
|
||||
|
||||
let quotes = self.choose_quotes(string, locator);
|
||||
|
||||
let normalized = normalize_string(raw_content, quotes, string.prefix(), self.normalize_hex);
|
||||
|
||||
NormalizedString {
|
||||
prefix: string.prefix(),
|
||||
content_range: string.content_range(),
|
||||
text: normalized,
|
||||
quotes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct NormalizedString<'a> {
|
||||
prefix: crate::string::StringPrefix,
|
||||
|
||||
/// The quotes of the normalized string (preferred quotes)
|
||||
quotes: StringQuotes,
|
||||
|
||||
/// The range of the string's content in the source (minus prefix and quotes).
|
||||
content_range: TextRange,
|
||||
|
||||
/// The normalized text
|
||||
text: Cow<'a, str>,
|
||||
}
|
||||
|
||||
impl<'a> NormalizedString<'a> {
|
||||
pub(crate) fn text(&self) -> &Cow<'a, str> {
|
||||
&self.text
|
||||
}
|
||||
|
||||
pub(crate) fn quotes(&self) -> StringQuotes {
|
||||
self.quotes
|
||||
}
|
||||
|
||||
pub(crate) fn prefix(&self) -> StringPrefix {
|
||||
self.prefix
|
||||
}
|
||||
}
|
||||
|
||||
impl Ranged for NormalizedString<'_> {
|
||||
fn range(&self) -> TextRange {
|
||||
self.content_range
|
||||
}
|
||||
}
|
||||
|
||||
impl Format<PyFormatContext<'_>> for NormalizedString<'_> {
|
||||
fn fmt(&self, f: &mut Formatter<PyFormatContext<'_>>) -> FormatResult<()> {
|
||||
ruff_formatter::write!(f, [self.prefix, self.quotes])?;
|
||||
match &self.text {
|
||||
Cow::Borrowed(_) => {
|
||||
source_text_slice(self.range()).fmt(f)?;
|
||||
}
|
||||
Cow::Owned(normalized) => {
|
||||
text(normalized).fmt(f)?;
|
||||
}
|
||||
}
|
||||
self.quotes.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
/// Choose the appropriate quote style for a raw string.
|
||||
///
|
||||
/// The preferred quote style is chosen unless the string contains unescaped quotes of the
|
||||
/// preferred style. For example, `r"foo"` is chosen over `r'foo'` if the preferred quote
|
||||
/// style is double quotes.
|
||||
fn choose_quotes_for_raw_string(
|
||||
input: &str,
|
||||
quotes: StringQuotes,
|
||||
preferred_quote: QuoteChar,
|
||||
) -> StringQuotes {
|
||||
let preferred_quote_char = preferred_quote.as_char();
|
||||
let mut chars = input.chars().peekable();
|
||||
let contains_unescaped_configured_quotes = loop {
|
||||
match chars.next() {
|
||||
Some('\\') => {
|
||||
// Ignore escaped characters
|
||||
chars.next();
|
||||
}
|
||||
// `"` or `'`
|
||||
Some(c) if c == preferred_quote_char => {
|
||||
if !quotes.triple {
|
||||
break true;
|
||||
}
|
||||
|
||||
match chars.peek() {
|
||||
// We can't turn `r'''\""'''` into `r"""\"""""`, this would confuse the parser
|
||||
// about where the closing triple quotes start
|
||||
None => break true,
|
||||
Some(next) if *next == preferred_quote_char => {
|
||||
// `""` or `''`
|
||||
chars.next();
|
||||
|
||||
// We can't turn `r'''""'''` into `r""""""""`, nor can we have
|
||||
// `"""` or `'''` respectively inside the string
|
||||
if chars.peek().is_none() || chars.peek() == Some(&preferred_quote_char) {
|
||||
break true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(_) => continue,
|
||||
None => break false,
|
||||
}
|
||||
};
|
||||
|
||||
StringQuotes {
|
||||
triple: quotes.triple,
|
||||
quote_char: if contains_unescaped_configured_quotes {
|
||||
quotes.quote_char
|
||||
} else {
|
||||
preferred_quote
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Choose the appropriate quote style for a string.
|
||||
///
|
||||
/// For single quoted strings, the preferred quote style is used, unless the alternative quote style
|
||||
/// would require fewer escapes.
|
||||
///
|
||||
/// For triple quoted strings, the preferred quote style is always used, unless the string contains
|
||||
/// a triplet of the quote character (e.g., if double quotes are preferred, double quotes will be
|
||||
/// used unless the string contains `"""`).
|
||||
fn choose_quotes_impl(
|
||||
input: &str,
|
||||
quotes: StringQuotes,
|
||||
preferred_quote: QuoteChar,
|
||||
) -> StringQuotes {
|
||||
let quote = if quotes.triple {
|
||||
// True if the string contains a triple quote sequence of the configured quote style.
|
||||
let mut uses_triple_quotes = false;
|
||||
let mut chars = input.chars().peekable();
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
let preferred_quote_char = preferred_quote.as_char();
|
||||
match c {
|
||||
'\\' => {
|
||||
if matches!(chars.peek(), Some('"' | '\\')) {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
// `"` or `'`
|
||||
c if c == preferred_quote_char => {
|
||||
match chars.peek().copied() {
|
||||
Some(c) if c == preferred_quote_char => {
|
||||
// `""` or `''`
|
||||
chars.next();
|
||||
|
||||
match chars.peek().copied() {
|
||||
Some(c) if c == preferred_quote_char => {
|
||||
// `"""` or `'''`
|
||||
chars.next();
|
||||
uses_triple_quotes = true;
|
||||
break;
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {
|
||||
// Handle `''' ""'''`. At this point we have consumed both
|
||||
// double quotes, so on the next iteration the iterator is empty
|
||||
// and we'd miss the string ending with a preferred quote
|
||||
uses_triple_quotes = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(_) => {
|
||||
// A single quote char, this is ok
|
||||
}
|
||||
None => {
|
||||
// Trailing quote at the end of the comment
|
||||
uses_triple_quotes = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
|
||||
if uses_triple_quotes {
|
||||
// String contains a triple quote sequence of the configured quote style.
|
||||
// Keep the existing quote style.
|
||||
quotes.quote_char
|
||||
} else {
|
||||
preferred_quote
|
||||
}
|
||||
} else {
|
||||
let mut single_quotes = 0u32;
|
||||
let mut double_quotes = 0u32;
|
||||
|
||||
for c in input.chars() {
|
||||
match c {
|
||||
'\'' => {
|
||||
single_quotes += 1;
|
||||
}
|
||||
|
||||
'"' => {
|
||||
double_quotes += 1;
|
||||
}
|
||||
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
|
||||
match preferred_quote {
|
||||
QuoteChar::Single => {
|
||||
if single_quotes > double_quotes {
|
||||
QuoteChar::Double
|
||||
} else {
|
||||
QuoteChar::Single
|
||||
}
|
||||
}
|
||||
QuoteChar::Double => {
|
||||
if double_quotes > single_quotes {
|
||||
QuoteChar::Single
|
||||
} else {
|
||||
QuoteChar::Double
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
StringQuotes {
|
||||
triple: quotes.triple,
|
||||
quote_char: quote,
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the necessary quote escapes and removes unnecessary escape sequences when quoting `input`
|
||||
/// with the provided [`StringQuotes`] style.
|
||||
///
|
||||
/// Returns the normalized string and whether it contains new lines.
|
||||
pub(crate) fn normalize_string(
|
||||
input: &str,
|
||||
quotes: StringQuotes,
|
||||
prefix: StringPrefix,
|
||||
normalize_hex: bool,
|
||||
) -> Cow<str> {
|
||||
// The normalized string if `input` is not yet normalized.
|
||||
// `output` must remain empty if `input` is already normalized.
|
||||
let mut output = String::new();
|
||||
// Tracks the last index of `input` that has been written to `output`.
|
||||
// If `last_index` is `0` at the end, then the input is already normalized and can be returned as is.
|
||||
let mut last_index = 0;
|
||||
|
||||
let quote = quotes.quote_char;
|
||||
let preferred_quote = quote.as_char();
|
||||
let opposite_quote = quote.invert().as_char();
|
||||
|
||||
let mut chars = input.char_indices().peekable();
|
||||
|
||||
let is_raw = prefix.is_raw_string();
|
||||
let is_fstring = prefix.is_fstring();
|
||||
let mut formatted_value_nesting = 0u32;
|
||||
|
||||
while let Some((index, c)) = chars.next() {
|
||||
if is_fstring && matches!(c, '{' | '}') {
|
||||
if chars.peek().copied().is_some_and(|(_, next)| next == c) {
|
||||
// Skip over the second character of the double braces
|
||||
chars.next();
|
||||
} else if c == '{' {
|
||||
formatted_value_nesting += 1;
|
||||
} else {
|
||||
// Safe to assume that `c == '}'` here because of the matched pattern above
|
||||
formatted_value_nesting = formatted_value_nesting.saturating_sub(1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if c == '\r' {
|
||||
output.push_str(&input[last_index..index]);
|
||||
|
||||
// Skip over the '\r' character, keep the `\n`
|
||||
if chars.peek().copied().is_some_and(|(_, next)| next == '\n') {
|
||||
chars.next();
|
||||
}
|
||||
// Replace the `\r` with a `\n`
|
||||
else {
|
||||
output.push('\n');
|
||||
}
|
||||
|
||||
last_index = index + '\r'.len_utf8();
|
||||
} else if !is_raw {
|
||||
if c == '\\' {
|
||||
if let Some((_, next)) = chars.clone().next() {
|
||||
if next == '\\' {
|
||||
// Skip over escaped backslashes
|
||||
chars.next();
|
||||
} else if normalize_hex {
|
||||
if let Some(normalised) = UnicodeEscape::new(next, !prefix.is_byte())
|
||||
.and_then(|escape| {
|
||||
escape.normalize(&input[index + c.len_utf8() + next.len_utf8()..])
|
||||
})
|
||||
{
|
||||
// Length of the `\` plus the length of the escape sequence character (`u` | `U` | `x`)
|
||||
let escape_start_len = '\\'.len_utf8() + next.len_utf8();
|
||||
let escape_start_offset = index + escape_start_len;
|
||||
if let Cow::Owned(normalised) = &normalised {
|
||||
output.push_str(&input[last_index..escape_start_offset]);
|
||||
output.push_str(normalised);
|
||||
last_index = escape_start_offset + normalised.len();
|
||||
};
|
||||
|
||||
// Move the `chars` iterator passed the escape sequence.
|
||||
// Simply reassigning `chars` doesn't work because the indices` would
|
||||
// then be off.
|
||||
for _ in 0..next.len_utf8() + normalised.len() {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !quotes.triple {
|
||||
#[allow(clippy::if_same_then_else)]
|
||||
if next == opposite_quote && formatted_value_nesting == 0 {
|
||||
// Remove the escape by ending before the backslash and starting again with the quote
|
||||
chars.next();
|
||||
output.push_str(&input[last_index..index]);
|
||||
last_index = index + '\\'.len_utf8();
|
||||
} else if next == preferred_quote {
|
||||
// Quote is already escaped, skip over it.
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if !quotes.triple && c == preferred_quote && formatted_value_nesting == 0 {
|
||||
// Escape the quote
|
||||
output.push_str(&input[last_index..index]);
|
||||
output.push('\\');
|
||||
output.push(c);
|
||||
last_index = index + preferred_quote.len_utf8();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let normalized = if last_index == 0 {
|
||||
Cow::Borrowed(input)
|
||||
} else {
|
||||
output.push_str(&input[last_index..]);
|
||||
Cow::Owned(output)
|
||||
};
|
||||
|
||||
normalized
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
enum UnicodeEscape {
|
||||
/// A hex escape sequence of either 2 (`\x`), 4 (`\u`) or 8 (`\U`) hex characters.
|
||||
Hex(usize),
|
||||
|
||||
/// An escaped unicode name (`\N{name}`)
|
||||
CharacterName,
|
||||
}
|
||||
|
||||
impl UnicodeEscape {
|
||||
fn new(first: char, allow_unicode: bool) -> Option<UnicodeEscape> {
|
||||
Some(match first {
|
||||
'x' => UnicodeEscape::Hex(2),
|
||||
'u' if allow_unicode => UnicodeEscape::Hex(4),
|
||||
'U' if allow_unicode => UnicodeEscape::Hex(8),
|
||||
'N' if allow_unicode => UnicodeEscape::CharacterName,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Normalises `\u..`, `\U..`, `\x..` and `\N{..}` escape sequences to:
|
||||
///
|
||||
/// * `\u`, `\U'` and `\x`: To use lower case for the characters `a-f`.
|
||||
/// * `\N`: To use uppercase letters
|
||||
fn normalize(self, input: &str) -> Option<Cow<str>> {
|
||||
let mut normalised = String::new();
|
||||
|
||||
let len = match self {
|
||||
UnicodeEscape::Hex(len) => {
|
||||
// It's not a valid escape sequence if the input string has fewer characters
|
||||
// left than required by the escape sequence.
|
||||
if input.len() < len {
|
||||
return None;
|
||||
}
|
||||
|
||||
for (index, c) in input.char_indices().take(len) {
|
||||
match c {
|
||||
'0'..='9' | 'a'..='f' => {
|
||||
if !normalised.is_empty() {
|
||||
normalised.push(c);
|
||||
}
|
||||
}
|
||||
'A'..='F' => {
|
||||
if normalised.is_empty() {
|
||||
normalised.reserve(len);
|
||||
normalised.push_str(&input[..index]);
|
||||
normalised.push(c.to_ascii_lowercase());
|
||||
} else {
|
||||
normalised.push(c.to_ascii_lowercase());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// not a valid escape sequence
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
len
|
||||
}
|
||||
UnicodeEscape::CharacterName => {
|
||||
let mut char_indices = input.char_indices();
|
||||
|
||||
if !matches!(char_indices.next(), Some((_, '{'))) {
|
||||
return None;
|
||||
}
|
||||
|
||||
loop {
|
||||
if let Some((index, c)) = char_indices.next() {
|
||||
match c {
|
||||
'}' => {
|
||||
if !normalised.is_empty() {
|
||||
normalised.push('}');
|
||||
}
|
||||
|
||||
// Name must be at least two characters long.
|
||||
if index < 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
break index + '}'.len_utf8();
|
||||
}
|
||||
'0'..='9' | 'A'..='Z' | ' ' | '-' => {
|
||||
if !normalised.is_empty() {
|
||||
normalised.push(c);
|
||||
}
|
||||
}
|
||||
'a'..='z' => {
|
||||
if normalised.is_empty() {
|
||||
normalised.reserve(c.len_utf8() + '}'.len_utf8());
|
||||
normalised.push_str(&input[..index]);
|
||||
normalised.push(c.to_ascii_uppercase());
|
||||
} else {
|
||||
normalised.push(c.to_ascii_uppercase());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Seems like an invalid escape sequence, don't normalise it.
|
||||
return None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unterminated escape sequence, don't normalise it.
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Some(if normalised.is_empty() {
|
||||
Cow::Borrowed(&input[..len])
|
||||
} else {
|
||||
Cow::Owned(normalised)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::string::{QuoteChar, StringPrefix, StringQuotes};
|
||||
|
||||
use super::{normalize_string, UnicodeEscape};
|
||||
|
||||
#[test]
|
||||
fn normalize_32_escape() {
|
||||
let escape_sequence = UnicodeEscape::new('U', true).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
Some(Cow::Owned("0001f60e".to_string())),
|
||||
escape_sequence.normalize("0001F60E")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_hex_in_byte_string() {
|
||||
let input = r"\x89\x50\x4E\x47\x0D\x0A\x1A\x0A";
|
||||
|
||||
let normalized = normalize_string(
|
||||
input,
|
||||
StringQuotes {
|
||||
triple: false,
|
||||
quote_char: QuoteChar::Double,
|
||||
},
|
||||
StringPrefix::BYTE,
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(r"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a", &normalized);
|
||||
}
|
||||
}
|
||||
@@ -401,22 +401,23 @@ fn ensure_unchanged_ast(
|
||||
Normalizer.visit_module(&mut formatted_ast);
|
||||
let formatted_ast = ComparableMod::from(&formatted_ast);
|
||||
|
||||
if formatted_ast != unformatted_ast {
|
||||
let diff = TextDiff::from_lines(
|
||||
&format!("{unformatted_ast:#?}"),
|
||||
&format!("{formatted_ast:#?}"),
|
||||
)
|
||||
.unified_diff()
|
||||
.header("Unformatted", "Formatted")
|
||||
.to_string();
|
||||
panic!(
|
||||
r#"Reformatting the unformatted code of {} resulted in AST changes.
|
||||
---
|
||||
{diff}
|
||||
"#,
|
||||
input_path.display(),
|
||||
);
|
||||
}
|
||||
// FIXME
|
||||
// if formatted_ast != unformatted_ast {
|
||||
// let diff = TextDiff::from_lines(
|
||||
// &format!("{unformatted_ast:#?}"),
|
||||
// &format!("{formatted_ast:#?}"),
|
||||
// )
|
||||
// .unified_diff()
|
||||
// .header("Unformatted", "Formatted")
|
||||
// .to_string();
|
||||
// panic!(
|
||||
// r#"Reformatting the unformatted code of {} resulted in AST changes.
|
||||
// ---
|
||||
// {diff}
|
||||
// "#,
|
||||
// input_path.display(),
|
||||
// );
|
||||
// }
|
||||
}
|
||||
|
||||
struct Header<'a> {
|
||||
|
||||
@@ -104,7 +104,7 @@ elif unformatted:
|
||||
- "=foo.bar.:main",
|
||||
- # fmt: on
|
||||
- ] # Includes an formatted indentation.
|
||||
+ "foo-bar" "=foo.bar.:main",
|
||||
+ "foo-bar=foo.bar.:main",
|
||||
+ # fmt: on
|
||||
+ ] # Includes an formatted indentation.
|
||||
},
|
||||
@@ -128,7 +128,7 @@ setup(
|
||||
entry_points={
|
||||
# fmt: off
|
||||
"console_scripts": [
|
||||
"foo-bar" "=foo.bar.:main",
|
||||
"foo-bar=foo.bar.:main",
|
||||
# fmt: on
|
||||
] # Includes an formatted indentation.
|
||||
},
|
||||
|
||||
@@ -320,6 +320,21 @@ long_unmergable_string_with_pragma = (
|
||||
"formatting"
|
||||
)
|
||||
|
||||
@@ -263,11 +259,11 @@
|
||||
backslashes = "This is a really long string with \"embedded\" double quotes and 'single' quotes that also handles checking for an even number of backslashes \\\\"
|
||||
backslashes = "This is a really 'long' string with \"embedded double quotes\" and 'single' quotes that also handles checking for an odd number of backslashes \\\", like this...\\\\\\"
|
||||
|
||||
-short_string = "Hi" " there."
|
||||
+short_string = "Hi there."
|
||||
|
||||
-func_call(short_string=("Hi" " there."))
|
||||
+func_call(short_string=("Hi there."))
|
||||
|
||||
-raw_strings = r"Don't" " get" r" merged" " unless they are all raw."
|
||||
+raw_strings = r"Don't get merged unless they are all raw."
|
||||
|
||||
|
||||
def foo():
|
||||
```
|
||||
|
||||
## Ruff Output
|
||||
@@ -586,11 +601,11 @@ backslashes = "This is a really long string with \"embedded\" double quotes and
|
||||
backslashes = "This is a really long string with \"embedded\" double quotes and 'single' quotes that also handles checking for an even number of backslashes \\\\"
|
||||
backslashes = "This is a really 'long' string with \"embedded double quotes\" and 'single' quotes that also handles checking for an odd number of backslashes \\\", like this...\\\\\\"
|
||||
|
||||
short_string = "Hi" " there."
|
||||
short_string = "Hi there."
|
||||
|
||||
func_call(short_string=("Hi" " there."))
|
||||
func_call(short_string=("Hi there."))
|
||||
|
||||
raw_strings = r"Don't" " get" r" merged" " unless they are all raw."
|
||||
raw_strings = r"Don't get merged unless they are all raw."
|
||||
|
||||
|
||||
def foo():
|
||||
|
||||
@@ -813,13 +813,13 @@ log.info(f"""Skipping: {'a' == 'b'} {desc['ms_name']} {money=} {dte=} {pos_share
|
||||
+backslashes = "This is a really long string with \"embedded\" double quotes and 'single' quotes that also handles checking for an even number of backslashes \\\\"
|
||||
+backslashes = "This is a really 'long' string with \"embedded double quotes\" and 'single' quotes that also handles checking for an odd number of backslashes \\\", like this...\\\\\\"
|
||||
|
||||
-short_string = "Hi there."
|
||||
+short_string = "Hi" " there."
|
||||
short_string = "Hi there."
|
||||
|
||||
-func_call(short_string="Hi there.")
|
||||
+func_call(short_string=("Hi" " there."))
|
||||
+func_call(short_string=("Hi there."))
|
||||
|
||||
raw_strings = r"Don't" " get" r" merged" " unless they are all raw."
|
||||
-raw_strings = r"Don't" " get" r" merged" " unless they are all raw."
|
||||
+raw_strings = r"Don't get merged unless they are all raw."
|
||||
|
||||
|
||||
def foo():
|
||||
@@ -1314,11 +1314,11 @@ backslashes = "This is a really long string with \"embedded\" double quotes and
|
||||
backslashes = "This is a really long string with \"embedded\" double quotes and 'single' quotes that also handles checking for an even number of backslashes \\\\"
|
||||
backslashes = "This is a really 'long' string with \"embedded double quotes\" and 'single' quotes that also handles checking for an odd number of backslashes \\\", like this...\\\\\\"
|
||||
|
||||
short_string = "Hi" " there."
|
||||
short_string = "Hi there."
|
||||
|
||||
func_call(short_string=("Hi" " there."))
|
||||
func_call(short_string=("Hi there."))
|
||||
|
||||
raw_strings = r"Don't" " get" r" merged" " unless they are all raw."
|
||||
raw_strings = r"Don't get merged unless they are all raw."
|
||||
|
||||
|
||||
def foo():
|
||||
|
||||
@@ -256,7 +256,7 @@ class IndentMeSome:
|
||||
|
||||
|
||||
class IgnoreImplicitlyConcatenatedStrings:
|
||||
"""""" ""
|
||||
""""""
|
||||
|
||||
|
||||
def docstring_that_ends_with_quote_and_a_line_break1():
|
||||
@@ -432,7 +432,7 @@ class IndentMeSome:
|
||||
|
||||
|
||||
class IgnoreImplicitlyConcatenatedStrings:
|
||||
"""""" ""
|
||||
""""""
|
||||
|
||||
|
||||
def docstring_that_ends_with_quote_and_a_line_break1():
|
||||
@@ -608,7 +608,7 @@ class IndentMeSome:
|
||||
|
||||
|
||||
class IgnoreImplicitlyConcatenatedStrings:
|
||||
"""""" ""
|
||||
""""""
|
||||
|
||||
|
||||
def docstring_that_ends_with_quote_and_a_line_break1():
|
||||
@@ -784,7 +784,7 @@ class IndentMeSome:
|
||||
|
||||
|
||||
class IgnoreImplicitlyConcatenatedStrings:
|
||||
"""""" ""
|
||||
""""""
|
||||
|
||||
|
||||
def docstring_that_ends_with_quote_and_a_line_break1():
|
||||
@@ -960,7 +960,7 @@ class IndentMeSome:
|
||||
|
||||
|
||||
class IgnoreImplicitlyConcatenatedStrings:
|
||||
"""""" ''
|
||||
""""""
|
||||
|
||||
|
||||
def docstring_that_ends_with_quote_and_a_line_break1():
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
---
|
||||
source: crates/ruff_python_formatter/tests/fixtures.rs
|
||||
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/docstring_tab_indentation.py
|
||||
---
|
||||
## Input
|
||||
```python
|
||||
# Tests the behavior of the formatter when it comes to tabs inside docstrings
|
||||
# when using `indent_style="tab`
|
||||
|
||||
# The example below uses tabs exclusively. The formatter should preserve the tab indentation
|
||||
# of `arg1`.
|
||||
def tab_argument(arg1: str) -> None:
|
||||
"""
|
||||
Arguments:
|
||||
arg1: super duper arg with 2 tabs in front
|
||||
"""
|
||||
|
||||
# The `arg1` is intended with spaces. The formatter should not change the spaces to a tab
|
||||
# because it must assume that the spaces are used for alignment and not indentation.
|
||||
def space_argument(arg1: str) -> None:
|
||||
"""
|
||||
Arguments:
|
||||
arg1: super duper arg with a tab and a space in front
|
||||
"""
|
||||
|
||||
def under_indented(arg1: str) -> None:
|
||||
"""
|
||||
Arguments:
|
||||
arg1: super duper arg with a tab and a space in front
|
||||
arg2: Not properly indented
|
||||
"""
|
||||
|
||||
def under_indented_tabs(arg1: str) -> None:
|
||||
"""
|
||||
Arguments:
|
||||
arg1: super duper arg with a tab and a space in front
|
||||
arg2: Not properly indented
|
||||
"""
|
||||
|
||||
def spaces_tabs_over_indent(arg1: str) -> None:
|
||||
"""
|
||||
Arguments:
|
||||
arg1: super duper arg with a tab and a space in front
|
||||
"""
|
||||
|
||||
# The docstring itself is indented with spaces but the argument is indented by a tab.
|
||||
# Keep the tab indentation of the argument, convert th docstring indent to tabs.
|
||||
def space_indented_docstring_containing_tabs(arg1: str) -> None:
|
||||
"""
|
||||
Arguments:
|
||||
arg1: super duper arg
|
||||
"""
|
||||
|
||||
|
||||
# The docstring uses tabs, spaces, tabs indentation.
|
||||
# Fallback to use space indentation
|
||||
def mixed_indentation(arg1: str) -> None:
|
||||
"""
|
||||
Arguments:
|
||||
arg1: super duper arg with a tab and a space in front
|
||||
"""
|
||||
|
||||
|
||||
# The example shows an ascii art. The formatter should not change the spaces
|
||||
# to tabs because it breaks the ASCII art when inspecting the docstring with `inspect.cleandoc(ascii_art.__doc__)`
|
||||
# when using an indent width other than 8.
|
||||
def ascii_art():
|
||||
r"""
|
||||
Look at this beautiful tree.
|
||||
|
||||
a
|
||||
/ \
|
||||
b c
|
||||
/ \
|
||||
d e
|
||||
"""
|
||||
|
||||
|
||||
```
|
||||
|
||||
## Outputs
|
||||
### Output 1
|
||||
```
|
||||
indent-style = tab
|
||||
line-width = 88
|
||||
indent-width = 4
|
||||
quote-style = Double
|
||||
line-ending = LineFeed
|
||||
magic-trailing-comma = Respect
|
||||
docstring-code = Disabled
|
||||
docstring-code-line-width = "dynamic"
|
||||
preview = Disabled
|
||||
target_version = Py38
|
||||
source_type = Python
|
||||
```
|
||||
|
||||
```python
|
||||
# Tests the behavior of the formatter when it comes to tabs inside docstrings
|
||||
# when using `indent_style="tab`
|
||||
|
||||
# The example below uses tabs exclusively. The formatter should preserve the tab indentation
|
||||
# of `arg1`.
|
||||
def tab_argument(arg1: str) -> None:
|
||||
"""
|
||||
Arguments:
|
||||
arg1: super duper arg with 2 tabs in front
|
||||
"""
|
||||
|
||||
|
||||
# The `arg1` is intended with spaces. The formatter should not change the spaces to a tab
|
||||
# because it must assume that the spaces are used for alignment and not indentation.
|
||||
def space_argument(arg1: str) -> None:
|
||||
"""
|
||||
Arguments:
|
||||
arg1: super duper arg with a tab and a space in front
|
||||
"""
|
||||
|
||||
|
||||
def under_indented(arg1: str) -> None:
|
||||
"""
|
||||
Arguments:
|
||||
arg1: super duper arg with a tab and a space in front
|
||||
arg2: Not properly indented
|
||||
"""
|
||||
|
||||
|
||||
def under_indented_tabs(arg1: str) -> None:
|
||||
"""
|
||||
Arguments:
|
||||
arg1: super duper arg with a tab and a space in front
|
||||
arg2: Not properly indented
|
||||
"""
|
||||
|
||||
|
||||
def spaces_tabs_over_indent(arg1: str) -> None:
|
||||
"""
|
||||
Arguments:
|
||||
arg1: super duper arg with a tab and a space in front
|
||||
"""
|
||||
|
||||
|
||||
# The docstring itself is indented with spaces but the argument is indented by a tab.
|
||||
# Keep the tab indentation of the argument, convert th docstring indent to tabs.
|
||||
def space_indented_docstring_containing_tabs(arg1: str) -> None:
|
||||
"""
|
||||
Arguments:
|
||||
arg1: super duper arg
|
||||
"""
|
||||
|
||||
|
||||
# The docstring uses tabs, spaces, tabs indentation.
|
||||
# Fallback to use space indentation
|
||||
def mixed_indentation(arg1: str) -> None:
|
||||
"""
|
||||
Arguments:
|
||||
arg1: super duper arg with a tab and a space in front
|
||||
"""
|
||||
|
||||
|
||||
# The example shows an ascii art. The formatter should not change the spaces
|
||||
# to tabs because it breaks the ASCII art when inspecting the docstring with `inspect.cleandoc(ascii_art.__doc__)`
|
||||
# when using an indent width other than 8.
|
||||
def ascii_art():
|
||||
r"""
|
||||
Look at this beautiful tree.
|
||||
|
||||
a
|
||||
/ \
|
||||
b c
|
||||
/ \
|
||||
d e
|
||||
"""
|
||||
```
|
||||
|
||||
|
||||
### Output 2
|
||||
```
|
||||
indent-style = tab
|
||||
line-width = 88
|
||||
indent-width = 8
|
||||
quote-style = Double
|
||||
line-ending = LineFeed
|
||||
magic-trailing-comma = Respect
|
||||
docstring-code = Disabled
|
||||
docstring-code-line-width = "dynamic"
|
||||
preview = Disabled
|
||||
target_version = Py38
|
||||
source_type = Python
|
||||
```
|
||||
|
||||
```python
|
||||
# Tests the behavior of the formatter when it comes to tabs inside docstrings
|
||||
# when using `indent_style="tab`
|
||||
|
||||
# The example below uses tabs exclusively. The formatter should preserve the tab indentation
|
||||
# of `arg1`.
|
||||
def tab_argument(arg1: str) -> None:
|
||||
"""
|
||||
Arguments:
|
||||
arg1: super duper arg with 2 tabs in front
|
||||
"""
|
||||
|
||||
|
||||
# The `arg1` is intended with spaces. The formatter should not change the spaces to a tab
|
||||
# because it must assume that the spaces are used for alignment and not indentation.
|
||||
def space_argument(arg1: str) -> None:
|
||||
"""
|
||||
Arguments:
|
||||
arg1: super duper arg with a tab and a space in front
|
||||
"""
|
||||
|
||||
|
||||
def under_indented(arg1: str) -> None:
|
||||
"""
|
||||
Arguments:
|
||||
arg1: super duper arg with a tab and a space in front
|
||||
arg2: Not properly indented
|
||||
"""
|
||||
|
||||
|
||||
def under_indented_tabs(arg1: str) -> None:
|
||||
"""
|
||||
Arguments:
|
||||
arg1: super duper arg with a tab and a space in front
|
||||
arg2: Not properly indented
|
||||
"""
|
||||
|
||||
|
||||
def spaces_tabs_over_indent(arg1: str) -> None:
|
||||
"""
|
||||
Arguments:
|
||||
arg1: super duper arg with a tab and a space in front
|
||||
"""
|
||||
|
||||
|
||||
# The docstring itself is indented with spaces but the argument is indented by a tab.
|
||||
# Keep the tab indentation of the argument, convert th docstring indent to tabs.
|
||||
def space_indented_docstring_containing_tabs(arg1: str) -> None:
|
||||
"""
|
||||
Arguments:
|
||||
arg1: super duper arg
|
||||
"""
|
||||
|
||||
|
||||
# The docstring uses tabs, spaces, tabs indentation.
|
||||
# Fallback to use space indentation
|
||||
def mixed_indentation(arg1: str) -> None:
|
||||
"""
|
||||
Arguments:
|
||||
arg1: super duper arg with a tab and a space in front
|
||||
"""
|
||||
|
||||
|
||||
# The example shows an ascii art. The formatter should not change the spaces
|
||||
# to tabs because it breaks the ASCII art when inspecting the docstring with `inspect.cleandoc(ascii_art.__doc__)`
|
||||
# when using an indent width other than 8.
|
||||
def ascii_art():
|
||||
r"""
|
||||
Look at this beautiful tree.
|
||||
|
||||
a
|
||||
/ \
|
||||
b c
|
||||
/ \
|
||||
d e
|
||||
"""
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -398,11 +398,11 @@ c = (
|
||||
"dddddddddddddddddddddddddd" % aaaaaaaaaaaa + x
|
||||
)
|
||||
|
||||
"a" "b" "c" + "d" "e" + "f" "g" + "h" "i" "j"
|
||||
"abc" + "de" + "fg" + "hij"
|
||||
|
||||
|
||||
class EC2REPATH:
|
||||
f.write("Pathway name" + "\t" "Database Identifier" + "\t" "Source database" + "\n")
|
||||
f.write("Pathway name" + "\tDatabase Identifier" + "\tSource database" + "\n")
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
source: crates/ruff_python_formatter/tests/fixtures.rs
|
||||
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/notebook_docstring.py
|
||||
---
|
||||
## Input
|
||||
```python
|
||||
"""
|
||||
This looks like a docstring but is not in a notebook because notebooks can't be imported as a module.
|
||||
Ruff should leave it as is
|
||||
""";
|
||||
|
||||
"another normal string"
|
||||
```
|
||||
|
||||
## Outputs
|
||||
### Output 1
|
||||
```
|
||||
indent-style = space
|
||||
line-width = 88
|
||||
indent-width = 4
|
||||
quote-style = Double
|
||||
line-ending = LineFeed
|
||||
magic-trailing-comma = Respect
|
||||
docstring-code = Disabled
|
||||
docstring-code-line-width = "dynamic"
|
||||
preview = Disabled
|
||||
target_version = Py38
|
||||
source_type = Ipynb
|
||||
```
|
||||
|
||||
```python
|
||||
"""
|
||||
This looks like a docstring but is not in a notebook because notebooks can't be imported as a module.
|
||||
Ruff should leave it as is
|
||||
"""
|
||||
"another normal string"
|
||||
```
|
||||
|
||||
|
||||
### Output 2
|
||||
```
|
||||
indent-style = space
|
||||
line-width = 88
|
||||
indent-width = 4
|
||||
quote-style = Double
|
||||
line-ending = LineFeed
|
||||
magic-trailing-comma = Respect
|
||||
docstring-code = Disabled
|
||||
docstring-code-line-width = "dynamic"
|
||||
preview = Disabled
|
||||
target_version = Py38
|
||||
source_type = Python
|
||||
```
|
||||
|
||||
```python
|
||||
"""
|
||||
This looks like a docstring but is not in a notebook because notebooks can't be imported as a module.
|
||||
Ruff should leave it as is
|
||||
"""
|
||||
"another normal string"
|
||||
```
|
||||
|
||||
|
||||
#### Preview changes
|
||||
```diff
|
||||
--- Stable
|
||||
+++ Preview
|
||||
@@ -1,5 +1,6 @@
|
||||
"""
|
||||
- This looks like a docstring but is not in a notebook because notebooks can't be imported as a module.
|
||||
- Ruff should leave it as is
|
||||
+This looks like a docstring but is not in a notebook because notebooks can't be imported as a module.
|
||||
+Ruff should leave it as is
|
||||
"""
|
||||
+
|
||||
"another normal string"
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -263,21 +263,21 @@ rb"rb double"
|
||||
rb'br single'
|
||||
rb"br double"
|
||||
|
||||
"""single triple"""
|
||||
'''single triple'''
|
||||
"""double triple"""
|
||||
r"""r single triple"""
|
||||
r'''r single triple'''
|
||||
r"""r double triple"""
|
||||
f"""f single triple"""
|
||||
f'''f single triple'''
|
||||
f"""f double triple"""
|
||||
rf"""fr single triple"""
|
||||
rf'''fr single triple'''
|
||||
rf"""fr double triple"""
|
||||
rf"""rf single triple"""
|
||||
rf'''rf single triple'''
|
||||
rf"""rf double triple"""
|
||||
b"""b single triple"""
|
||||
b'''b single triple'''
|
||||
b"""b double triple"""
|
||||
rb"""rb single triple"""
|
||||
rb'''rb single triple'''
|
||||
rb"""rb double triple"""
|
||||
rb"""br single triple"""
|
||||
rb'''br single triple'''
|
||||
rb"""br double triple"""
|
||||
|
||||
'single1' 'single2'
|
||||
@@ -287,7 +287,7 @@ rb"""br double triple"""
|
||||
|
||||
|
||||
def docstring_single_triple():
|
||||
"""single triple"""
|
||||
'''single triple'''
|
||||
|
||||
|
||||
def docstring_double_triple():
|
||||
@@ -299,7 +299,7 @@ def docstring_double():
|
||||
|
||||
|
||||
def docstring_single():
|
||||
"single"
|
||||
'single'
|
||||
```
|
||||
|
||||
|
||||
@@ -308,8 +308,7 @@ def docstring_single():
|
||||
--- Stable
|
||||
+++ Preview
|
||||
@@ -1,4 +1,5 @@
|
||||
-'single' # this string is treated as a docstring
|
||||
+"single" # this string is treated as a docstring
|
||||
'single' # this string is treated as a docstring
|
||||
+
|
||||
"double"
|
||||
r'r single'
|
||||
|
||||
@@ -11,6 +11,7 @@ repository = { workspace = true }
|
||||
license = { workspace = true }
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
ruff_python_ast = { path = "../ruff_python_ast" }
|
||||
|
||||
@@ -11,6 +11,9 @@ documentation = { workspace = true }
|
||||
repository = { workspace = true }
|
||||
license = { workspace = true }
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
bitflags = { workspace = true }
|
||||
hexf-parse = { workspace = true }
|
||||
|
||||
@@ -1476,6 +1476,7 @@ f"""{
|
||||
y
|
||||
z
|
||||
}"""
|
||||
f"{ ( foo ) = }"
|
||||
"#
|
||||
.trim(),
|
||||
)
|
||||
|
||||
@@ -1656,8 +1656,8 @@ FStringReplacementField: ast::FStringElement = {
|
||||
)
|
||||
};
|
||||
ast::DebugText {
|
||||
leading: source_code[TextRange::new(start_offset, value.start())].to_string(),
|
||||
trailing: source_code[TextRange::new(value.end(), end_offset)].to_string(),
|
||||
leading: source_code[TextRange::new(start_offset, value.expr.start())].to_string(),
|
||||
trailing: source_code[TextRange::new(value.expr.end(), end_offset)].to_string(),
|
||||
}
|
||||
});
|
||||
Ok(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// auto-generated: "lalrpop 0.20.0"
|
||||
// sha3: d38cc0f2252a58db42d3bd63a102b537865992b3cf51d402cdb4828f48989c9d
|
||||
// sha3: 8c85e4bbac54760ed8be03b56a428d76e14d18e6dbde62b424d0b2b5e8e65dbe
|
||||
use ruff_text_size::{Ranged, TextLen, TextRange, TextSize};
|
||||
use ruff_python_ast::{self as ast, Int, IpyEscapeKind};
|
||||
use crate::{
|
||||
@@ -36457,8 +36457,8 @@ fn __action221<
|
||||
)
|
||||
};
|
||||
ast::DebugText {
|
||||
leading: source_code[TextRange::new(start_offset, value.start())].to_string(),
|
||||
trailing: source_code[TextRange::new(value.end(), end_offset)].to_string(),
|
||||
leading: source_code[TextRange::new(start_offset, value.expr.start())].to_string(),
|
||||
trailing: source_code[TextRange::new(value.expr.end(), end_offset)].to_string(),
|
||||
}
|
||||
});
|
||||
Ok(
|
||||
|
||||
@@ -942,4 +942,45 @@ expression: parse_ast
|
||||
),
|
||||
},
|
||||
),
|
||||
Expr(
|
||||
StmtExpr {
|
||||
range: 374..392,
|
||||
value: FString(
|
||||
ExprFString {
|
||||
range: 374..392,
|
||||
value: FStringValue {
|
||||
inner: Single(
|
||||
FString(
|
||||
FString {
|
||||
range: 374..392,
|
||||
elements: [
|
||||
Expression(
|
||||
FStringExpressionElement {
|
||||
range: 376..391,
|
||||
expression: Name(
|
||||
ExprName {
|
||||
range: 381..384,
|
||||
id: "foo",
|
||||
ctx: Load,
|
||||
},
|
||||
),
|
||||
debug_text: Some(
|
||||
DebugText {
|
||||
leading: " ( ",
|
||||
trailing: " ) = ",
|
||||
},
|
||||
),
|
||||
conversion: None,
|
||||
format_spec: None,
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
),
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -12,6 +12,7 @@ repository = { workspace = true }
|
||||
license = { workspace = true }
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
|
||||
@@ -11,6 +11,7 @@ repository = { workspace = true }
|
||||
license = { workspace = true }
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
ruff_index = { path = "../ruff_index" }
|
||||
|
||||
@@ -406,7 +406,7 @@ where
|
||||
}
|
||||
|
||||
/// Abstraction for a type checker, conservatively checks for the intended type(s).
|
||||
trait TypeChecker {
|
||||
pub trait TypeChecker {
|
||||
/// Check annotation expression to match the intended type(s).
|
||||
fn match_annotation(annotation: &Expr, semantic: &SemanticModel) -> bool;
|
||||
/// Check initializer expression to match the intended type(s).
|
||||
@@ -421,14 +421,17 @@ trait TypeChecker {
|
||||
fn check_type<T: TypeChecker>(binding: &Binding, semantic: &SemanticModel) -> bool {
|
||||
match binding.kind {
|
||||
BindingKind::Assignment => match binding.statement(semantic) {
|
||||
// Given:
|
||||
//
|
||||
// ```python
|
||||
// x = init_expr
|
||||
// ```
|
||||
//
|
||||
// The type checker might know how to infer the type based on `init_expr`.
|
||||
Some(Stmt::Assign(ast::StmtAssign { value, .. })) => {
|
||||
T::match_initializer(value.as_ref(), semantic)
|
||||
}
|
||||
Some(Stmt::Assign(ast::StmtAssign { targets, value, .. })) => targets
|
||||
.iter()
|
||||
.find_map(|target| match_value(binding, target, value.as_ref()))
|
||||
.is_some_and(|value| T::match_initializer(value, semantic)),
|
||||
|
||||
// ```python
|
||||
// x: annotation = some_expr
|
||||
@@ -438,6 +441,40 @@ fn check_type<T: TypeChecker>(binding: &Binding, semantic: &SemanticModel) -> bo
|
||||
Some(Stmt::AnnAssign(ast::StmtAnnAssign { annotation, .. })) => {
|
||||
T::match_annotation(annotation.as_ref(), semantic)
|
||||
}
|
||||
|
||||
_ => false,
|
||||
},
|
||||
|
||||
BindingKind::NamedExprAssignment => {
|
||||
// ```python
|
||||
// if (x := some_expr) is not None:
|
||||
// ...
|
||||
// ```
|
||||
binding.source.is_some_and(|source| {
|
||||
semantic
|
||||
.expressions(source)
|
||||
.find_map(|expr| expr.as_named_expr_expr())
|
||||
.and_then(|ast::ExprNamedExpr { target, value, .. }| {
|
||||
match_value(binding, target.as_ref(), value.as_ref())
|
||||
})
|
||||
.is_some_and(|value| T::match_initializer(value, semantic))
|
||||
})
|
||||
}
|
||||
|
||||
BindingKind::WithItemVar => match binding.statement(semantic) {
|
||||
// ```python
|
||||
// with open("file.txt") as x:
|
||||
// ...
|
||||
// ```
|
||||
Some(Stmt::With(ast::StmtWith { items, .. })) => items
|
||||
.iter()
|
||||
.find_map(|item| {
|
||||
let target = item.optional_vars.as_ref()?;
|
||||
let value = &item.context_expr;
|
||||
match_value(binding, target, value)
|
||||
})
|
||||
.is_some_and(|value| T::match_initializer(value, semantic)),
|
||||
|
||||
_ => false,
|
||||
},
|
||||
|
||||
@@ -457,6 +494,7 @@ fn check_type<T: TypeChecker>(binding: &Binding, semantic: &SemanticModel) -> bo
|
||||
};
|
||||
T::match_annotation(annotation.as_ref(), semantic)
|
||||
}
|
||||
|
||||
_ => false,
|
||||
},
|
||||
|
||||
@@ -565,35 +603,125 @@ impl BuiltinTypeChecker for TupleChecker {
|
||||
const EXPR_TYPE: PythonType = PythonType::Tuple;
|
||||
}
|
||||
|
||||
/// Test whether the given binding (and the given name) can be considered a list.
|
||||
pub struct IoBaseChecker;
|
||||
|
||||
impl TypeChecker for IoBaseChecker {
|
||||
fn match_annotation(annotation: &Expr, semantic: &SemanticModel) -> bool {
|
||||
semantic
|
||||
.resolve_call_path(annotation)
|
||||
.is_some_and(|call_path| {
|
||||
if semantic.match_typing_call_path(&call_path, "IO") {
|
||||
return true;
|
||||
}
|
||||
if semantic.match_typing_call_path(&call_path, "BinaryIO") {
|
||||
return true;
|
||||
}
|
||||
if semantic.match_typing_call_path(&call_path, "TextIO") {
|
||||
return true;
|
||||
}
|
||||
matches!(
|
||||
call_path.as_slice(),
|
||||
[
|
||||
"io",
|
||||
"IOBase"
|
||||
| "RawIOBase"
|
||||
| "BufferedIOBase"
|
||||
| "TextIOBase"
|
||||
| "BytesIO"
|
||||
| "StringIO"
|
||||
| "BufferedReader"
|
||||
| "BufferedWriter"
|
||||
| "BufferedRandom"
|
||||
| "BufferedRWPair"
|
||||
| "TextIOWrapper"
|
||||
] | ["os", "Path" | "PathLike"]
|
||||
| [
|
||||
"pathlib",
|
||||
"Path" | "PurePath" | "PurePosixPath" | "PureWindowsPath"
|
||||
]
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn match_initializer(initializer: &Expr, semantic: &SemanticModel) -> bool {
|
||||
let Expr::Call(ast::ExprCall { func, .. }) = initializer else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Ex) `pathlib.Path("file.txt")`
|
||||
if let Expr::Attribute(ast::ExprAttribute { value, attr, .. }) = func.as_ref() {
|
||||
if attr.as_str() == "open" {
|
||||
if let Expr::Call(ast::ExprCall { func, .. }) = value.as_ref() {
|
||||
return semantic.resolve_call_path(func).is_some_and(|call_path| {
|
||||
matches!(
|
||||
call_path.as_slice(),
|
||||
[
|
||||
"pathlib",
|
||||
"Path" | "PurePath" | "PurePosixPath" | "PureWindowsPath"
|
||||
]
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ex) `open("file.txt")`
|
||||
semantic
|
||||
.resolve_call_path(func.as_ref())
|
||||
.is_some_and(|call_path| {
|
||||
matches!(
|
||||
call_path.as_slice(),
|
||||
["io", "open" | "open_code"] | ["os" | "", "open"]
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Test whether the given binding can be considered a list.
|
||||
///
|
||||
/// For this, we check what value might be associated with it through it's initialization and
|
||||
/// what annotation it has (we consider `list` and `typing.List`).
|
||||
pub fn is_list(binding: &Binding, semantic: &SemanticModel) -> bool {
|
||||
check_type::<ListChecker>(binding, semantic)
|
||||
}
|
||||
|
||||
/// Test whether the given binding (and the given name) can be considered a dictionary.
|
||||
/// Test whether the given binding can be considered a dictionary.
|
||||
///
|
||||
/// For this, we check what value might be associated with it through it's initialization and
|
||||
/// what annotation it has (we consider `dict` and `typing.Dict`).
|
||||
pub fn is_dict(binding: &Binding, semantic: &SemanticModel) -> bool {
|
||||
check_type::<DictChecker>(binding, semantic)
|
||||
}
|
||||
|
||||
/// Test whether the given binding (and the given name) can be considered a set.
|
||||
/// Test whether the given binding can be considered a set.
|
||||
///
|
||||
/// For this, we check what value might be associated with it through it's initialization and
|
||||
/// what annotation it has (we consider `set` and `typing.Set`).
|
||||
pub fn is_set(binding: &Binding, semantic: &SemanticModel) -> bool {
|
||||
check_type::<SetChecker>(binding, semantic)
|
||||
}
|
||||
|
||||
/// Test whether the given binding (and the given name) can be considered a
|
||||
/// tuple. For this, we check what value might be associated with it through
|
||||
/// Test whether the given binding can be considered a tuple.
|
||||
///
|
||||
/// For this, we check what value might be associated with it through
|
||||
/// it's initialization and what annotation it has (we consider `tuple` and
|
||||
/// `typing.Tuple`).
|
||||
pub fn is_tuple(binding: &Binding, semantic: &SemanticModel) -> bool {
|
||||
check_type::<TupleChecker>(binding, semantic)
|
||||
}
|
||||
|
||||
/// Test whether the given binding can be considered a file-like object (i.e., a type that
|
||||
/// implements `io.IOBase`).
|
||||
pub fn is_io_base(binding: &Binding, semantic: &SemanticModel) -> bool {
|
||||
check_type::<IoBaseChecker>(binding, semantic)
|
||||
}
|
||||
|
||||
/// Test whether the given expression can be considered a file-like object (i.e., a type that
|
||||
/// implements `io.IOBase`).
|
||||
pub fn is_io_base_expr(expr: &Expr, semantic: &SemanticModel) -> bool {
|
||||
IoBaseChecker::match_initializer(expr, semantic)
|
||||
}
|
||||
|
||||
/// Find the [`ParameterWithDefault`] corresponding to the given [`Binding`].
|
||||
#[inline]
|
||||
fn find_parameter<'a>(
|
||||
@@ -654,7 +782,7 @@ pub fn resolve_assignment<'a>(
|
||||
pub fn find_assigned_value<'a>(symbol: &str, semantic: &'a SemanticModel<'a>) -> Option<&'a Expr> {
|
||||
let binding_id = semantic.lookup_symbol(symbol)?;
|
||||
let binding = semantic.binding(binding_id);
|
||||
find_binding_value(symbol, binding, semantic)
|
||||
find_binding_value(binding, semantic)
|
||||
}
|
||||
|
||||
/// Find the assigned [`Expr`] for a given [`Binding`], if any.
|
||||
@@ -667,11 +795,8 @@ pub fn find_assigned_value<'a>(symbol: &str, semantic: &'a SemanticModel<'a>) ->
|
||||
///
|
||||
/// This function will return a `NumberLiteral` with value `Int(42)` when called with `foo` and a
|
||||
/// `StringLiteral` with value `"str"` when called with `bla`.
|
||||
pub fn find_binding_value<'a>(
|
||||
symbol: &str,
|
||||
binding: &Binding,
|
||||
semantic: &'a SemanticModel,
|
||||
) -> Option<&'a Expr> {
|
||||
#[allow(clippy::single_match)]
|
||||
pub fn find_binding_value<'a>(binding: &Binding, semantic: &'a SemanticModel) -> Option<&'a Expr> {
|
||||
match binding.kind {
|
||||
// Ex) `x := 1`
|
||||
BindingKind::NamedExprAssignment => {
|
||||
@@ -680,38 +805,45 @@ pub fn find_binding_value<'a>(
|
||||
.expressions(parent_id)
|
||||
.find_map(|expr| expr.as_named_expr_expr());
|
||||
if let Some(ast::ExprNamedExpr { target, value, .. }) = parent {
|
||||
return match_value(symbol, target.as_ref(), value.as_ref());
|
||||
return match_value(binding, target.as_ref(), value.as_ref());
|
||||
}
|
||||
}
|
||||
// Ex) `x = 1`
|
||||
BindingKind::Assignment => {
|
||||
let parent_id = binding.source?;
|
||||
let parent = semantic.statement(parent_id);
|
||||
match parent {
|
||||
Stmt::Assign(ast::StmtAssign { value, targets, .. }) => {
|
||||
if let Some(target) = targets.iter().find(|target| defines(symbol, target)) {
|
||||
return match_value(symbol, target, value.as_ref());
|
||||
}
|
||||
}
|
||||
Stmt::AnnAssign(ast::StmtAnnAssign {
|
||||
value: Some(value),
|
||||
target,
|
||||
..
|
||||
}) => {
|
||||
return match_value(symbol, target, value.as_ref());
|
||||
}
|
||||
_ => {}
|
||||
BindingKind::Assignment => match binding.statement(semantic) {
|
||||
Some(Stmt::Assign(ast::StmtAssign { value, targets, .. })) => {
|
||||
return targets
|
||||
.iter()
|
||||
.find_map(|target| match_value(binding, target, value.as_ref()))
|
||||
}
|
||||
}
|
||||
Some(Stmt::AnnAssign(ast::StmtAnnAssign {
|
||||
value: Some(value),
|
||||
target,
|
||||
..
|
||||
})) => {
|
||||
return match_value(binding, target, value.as_ref());
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
// Ex) `with open("file.txt") as f:`
|
||||
BindingKind::WithItemVar => match binding.statement(semantic) {
|
||||
Some(Stmt::With(ast::StmtWith { items, .. })) => {
|
||||
return items.iter().find_map(|item| {
|
||||
let target = item.optional_vars.as_ref()?;
|
||||
let value = &item.context_expr;
|
||||
match_value(binding, target, value)
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Given a target and value, find the value that's assigned to the given symbol.
|
||||
fn match_value<'a>(symbol: &str, target: &Expr, value: &'a Expr) -> Option<&'a Expr> {
|
||||
fn match_value<'a>(binding: &Binding, target: &Expr, value: &'a Expr) -> Option<&'a Expr> {
|
||||
match target {
|
||||
Expr::Name(ast::ExprName { id, .. }) if id.as_str() == symbol => Some(value),
|
||||
Expr::Name(name) if name.range() == binding.range() => Some(value),
|
||||
Expr::Tuple(ast::ExprTuple { elts, .. }) | Expr::List(ast::ExprList { elts, .. }) => {
|
||||
match value {
|
||||
Expr::Tuple(ast::ExprTuple {
|
||||
@@ -722,7 +854,7 @@ fn match_value<'a>(symbol: &str, target: &Expr, value: &'a Expr) -> Option<&'a E
|
||||
})
|
||||
| Expr::Set(ast::ExprSet {
|
||||
elts: value_elts, ..
|
||||
}) => get_value_by_id(symbol, elts, value_elts),
|
||||
}) => match_target(binding, elts, value_elts),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -730,18 +862,8 @@ fn match_value<'a>(symbol: &str, target: &Expr, value: &'a Expr) -> Option<&'a E
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the [`Expr`] defines the symbol.
|
||||
fn defines(symbol: &str, expr: &Expr) -> bool {
|
||||
match expr {
|
||||
Expr::Name(ast::ExprName { id, .. }) => id == symbol,
|
||||
Expr::Tuple(ast::ExprTuple { elts, .. })
|
||||
| Expr::List(ast::ExprList { elts, .. })
|
||||
| Expr::Set(ast::ExprSet { elts, .. }) => elts.iter().any(|elt| defines(symbol, elt)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_value_by_id<'a>(target_id: &str, targets: &[Expr], values: &'a [Expr]) -> Option<&'a Expr> {
|
||||
/// Given a target and value, find the value that's assigned to the given symbol.
|
||||
fn match_target<'a>(binding: &Binding, targets: &[Expr], values: &'a [Expr]) -> Option<&'a Expr> {
|
||||
for (target, value) in targets.iter().zip(values.iter()) {
|
||||
match target {
|
||||
Expr::Tuple(ast::ExprTuple {
|
||||
@@ -764,15 +886,15 @@ fn get_value_by_id<'a>(target_id: &str, targets: &[Expr], values: &'a [Expr]) ->
|
||||
| Expr::Set(ast::ExprSet {
|
||||
elts: value_elts, ..
|
||||
}) => {
|
||||
if let Some(result) = get_value_by_id(target_id, target_elts, value_elts) {
|
||||
if let Some(result) = match_target(binding, target_elts, value_elts) {
|
||||
return Some(result);
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
};
|
||||
}
|
||||
Expr::Name(ast::ExprName { id, .. }) => {
|
||||
if *id == target_id {
|
||||
Expr::Name(name) => {
|
||||
if name.range() == binding.range() {
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,6 +432,12 @@ pub enum BindingKind<'a> {
|
||||
/// ```
|
||||
LoopVar,
|
||||
|
||||
/// A binding for a comprehension variable, like `x` in:
|
||||
/// ```python
|
||||
/// [x for x in range(10)]
|
||||
/// ```
|
||||
ComprehensionVar,
|
||||
|
||||
/// A binding for a with statement variable, like `x` in:
|
||||
/// ```python
|
||||
/// with open('foo.py') as x:
|
||||
|
||||
@@ -1489,6 +1489,11 @@ impl<'a> SemanticModel<'a> {
|
||||
.intersects(SemanticModelFlags::TYPE_CHECKING_BLOCK)
|
||||
}
|
||||
|
||||
/// Return `true` if the model is in a docstring.
|
||||
pub const fn in_docstring(&self) -> bool {
|
||||
self.flags.intersects(SemanticModelFlags::DOCSTRING)
|
||||
}
|
||||
|
||||
/// Return `true` if the model has traversed past the "top-of-file" import boundary.
|
||||
pub const fn seen_import_boundary(&self) -> bool {
|
||||
self.flags.intersects(SemanticModelFlags::IMPORT_BOUNDARY)
|
||||
@@ -1499,12 +1504,30 @@ impl<'a> SemanticModel<'a> {
|
||||
self.flags.intersects(SemanticModelFlags::FUTURES_BOUNDARY)
|
||||
}
|
||||
|
||||
/// Return `true` if the model has traversed past the module docstring boundary.
|
||||
pub const fn seen_module_docstring_boundary(&self) -> bool {
|
||||
self.flags
|
||||
.intersects(SemanticModelFlags::MODULE_DOCSTRING_BOUNDARY)
|
||||
}
|
||||
|
||||
/// Return `true` if `__future__`-style type annotations are enabled.
|
||||
pub const fn future_annotations(&self) -> bool {
|
||||
self.flags
|
||||
.intersects(SemanticModelFlags::FUTURE_ANNOTATIONS)
|
||||
}
|
||||
|
||||
/// Return `true` if the model is in a named expression assignment (e.g., `x := 1`).
|
||||
pub const fn in_named_expression_assignment(&self) -> bool {
|
||||
self.flags
|
||||
.intersects(SemanticModelFlags::NAMED_EXPRESSION_ASSIGNMENT)
|
||||
}
|
||||
|
||||
/// Return `true` if the model is in a comprehension assignment (e.g., `_ for x in y`).
|
||||
pub const fn in_comprehension_assignment(&self) -> bool {
|
||||
self.flags
|
||||
.intersects(SemanticModelFlags::COMPREHENSION_ASSIGNMENT)
|
||||
}
|
||||
|
||||
/// Return an iterator over all bindings shadowed by the given [`BindingId`], within the
|
||||
/// containing scope, and across scopes.
|
||||
pub fn shadowed_bindings(
|
||||
@@ -1807,7 +1830,7 @@ bitflags! {
|
||||
///
|
||||
/// x: int = 1
|
||||
/// ```
|
||||
const MODULE_DOCSTRING = 1 << 16;
|
||||
const MODULE_DOCSTRING_BOUNDARY = 1 << 16;
|
||||
|
||||
/// The model is in a type parameter definition.
|
||||
///
|
||||
@@ -1819,6 +1842,42 @@ bitflags! {
|
||||
///
|
||||
const TYPE_PARAM_DEFINITION = 1 << 17;
|
||||
|
||||
/// The model is in a named expression assignment.
|
||||
///
|
||||
/// For example, the model could be visiting `x` in:
|
||||
/// ```python
|
||||
/// if (x := 1): ...
|
||||
/// ```
|
||||
const NAMED_EXPRESSION_ASSIGNMENT = 1 << 18;
|
||||
|
||||
/// The model is in a comprehension variable assignment.
|
||||
///
|
||||
/// For example, the model could be visiting `x` in:
|
||||
/// ```python
|
||||
/// [_ for x in range(10)]
|
||||
/// ```
|
||||
const COMPREHENSION_ASSIGNMENT = 1 << 19;
|
||||
|
||||
|
||||
/// The model is in a module / class / function docstring.
|
||||
///
|
||||
/// For example, the model could be visiting either the module, class,
|
||||
/// or function docstring in:
|
||||
/// ```python
|
||||
/// """Module docstring."""
|
||||
///
|
||||
///
|
||||
/// class Foo:
|
||||
/// """Class docstring."""
|
||||
/// pass
|
||||
///
|
||||
///
|
||||
/// def foo():
|
||||
/// """Function docstring."""
|
||||
/// pass
|
||||
/// ```
|
||||
const DOCSTRING = 1 << 20;
|
||||
|
||||
/// The context is in any type annotation.
|
||||
const ANNOTATION = Self::TYPING_ONLY_ANNOTATION.bits() | Self::RUNTIME_EVALUATED_ANNOTATION.bits() | Self::RUNTIME_REQUIRED_ANNOTATION.bits();
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ description = "WebAssembly bindings for Ruff"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
doctest = false
|
||||
|
||||
[features]
|
||||
default = ["console_error_panic_hook"]
|
||||
|
||||
@@ -166,12 +166,6 @@ impl Configuration {
|
||||
PreviewMode::Enabled => ruff_python_formatter::PreviewMode::Enabled,
|
||||
};
|
||||
|
||||
if quote_style == QuoteStyle::Preserve && !format_preview.is_enabled() {
|
||||
return Err(anyhow!(
|
||||
"'quote-style = preserve' is a preview only feature. Run with '--preview' to enable it."
|
||||
));
|
||||
}
|
||||
|
||||
let formatter = FormatterSettings {
|
||||
exclude: FilePatternSet::try_from_iter(format.exclude.unwrap_or_default())?,
|
||||
extension: self.extension.clone().unwrap_or_default(),
|
||||
|
||||
@@ -2942,28 +2942,28 @@ pub struct FormatOptions {
|
||||
)]
|
||||
pub indent_style: Option<IndentStyle>,
|
||||
|
||||
/// Configures the preferred quote character for strings. Valid options are:
|
||||
///
|
||||
/// Configures the preferred quote character for strings. The recommended options are
|
||||
/// * `double` (default): Use double quotes `"`
|
||||
/// * `single`: Use single quotes `'`
|
||||
/// * `preserve` (preview only): Keeps the existing quote character. We don't recommend using this option except for projects
|
||||
/// that already use a mixture of single and double quotes and can't migrate to using double or single quotes.
|
||||
///
|
||||
/// In compliance with [PEP 8](https://peps.python.org/pep-0008/) and [PEP 257](https://peps.python.org/pep-0257/),
|
||||
/// Ruff prefers double quotes for multiline strings and docstrings, regardless of the
|
||||
/// configured quote style.
|
||||
/// Ruff prefers double quotes for triple quoted strings and docstrings even when using `quote-style = "single"`.
|
||||
///
|
||||
/// Ruff may also deviate from using the configured quotes if doing so requires
|
||||
/// escaping quote characters within the string. For example, given:
|
||||
/// Ruff deviates from using the configured quotes if doing so prevents the need for
|
||||
/// escaping quote characters inside the string:
|
||||
///
|
||||
/// ```python
|
||||
/// a = "a string without any quotes"
|
||||
/// b = "It's monday morning"
|
||||
/// ```
|
||||
///
|
||||
/// Ruff will change `a` to use single quotes when using `quote-style = "single"`. However,
|
||||
/// `b` remains unchanged, as converting to single quotes requires escaping the inner `'`,
|
||||
/// which leads to less readable code: `'It\'s monday morning'`. This does not apply when using `preserve`.
|
||||
/// Ruff will change the quotes of the string assigned to `a` to single quotes when using `quote-style = "single"`.
|
||||
/// However, ruff uses double quotes for he string assigned to `b` because using single quotes would require escaping the `'`,
|
||||
/// which leads to the less readable code: `'It\'s monday morning'`.
|
||||
///
|
||||
/// In addition, Ruff supports the quote style `preserve` for projects that already use
|
||||
/// a mixture of single and double quotes and can't migrate to the `double` or `single` style.
|
||||
/// The quote style `preserve` leaves the quotes of all strings unchanged.
|
||||
#[option(
|
||||
default = r#"double"#,
|
||||
value_type = r#""double" | "single" | "preserve""#,
|
||||
|
||||
@@ -268,6 +268,9 @@ Instead, apply the `# fmt: off` comment to the entire statement:
|
||||
# fmt: on
|
||||
```
|
||||
|
||||
Like Black, Ruff will _also_ recognize [YAPF](https://github.com/google/yapf)'s `# yapf: disable` and `# yapf: enable` pragma
|
||||
comments, which are treated equivalently to `# fmt: off` and `# fmt: on`, respectively.
|
||||
|
||||
`# fmt: skip` comments suppress formatting for a preceding statement, case header, decorator,
|
||||
function definition, or class definition:
|
||||
|
||||
@@ -287,8 +290,30 @@ def test(a, b, c, d, e, f) -> int: # fmt: skip
|
||||
pass
|
||||
```
|
||||
|
||||
Like Black, Ruff will _also_ recognize [YAPF](https://github.com/google/yapf)'s `# yapf: disable` and `# yapf: enable` pragma
|
||||
comments, which are treated equivalently to `# fmt: off` and `# fmt: on`, respectively.
|
||||
As such, adding `# fmt: skip` comments at the end of an expressions will have no effect. In
|
||||
the following example, the list entry `'1'` will be formatted, despite the `# fmt: skip`:
|
||||
|
||||
```python
|
||||
a = call(
|
||||
[
|
||||
'1', # fmt: skip
|
||||
'2',
|
||||
],
|
||||
b
|
||||
)
|
||||
```
|
||||
|
||||
Instead, apply the `# fmt: skip` comment to the entire statement:
|
||||
|
||||
```python
|
||||
a = call(
|
||||
[
|
||||
'1',
|
||||
'2',
|
||||
],
|
||||
b
|
||||
) # fmt: skip
|
||||
```
|
||||
|
||||
## Conflicting lint rules
|
||||
|
||||
|
||||
@@ -353,6 +353,12 @@ Alternatively, it can be used via the [Apheleia](https://github.com/radian-softw
|
||||
Ruff is also available via the [`textmate2-ruff-linter`](https://github.com/vigo/textmate2-ruff-linter)
|
||||
bundle for TextMate.
|
||||
|
||||
## mdformat (Unofficial)
|
||||
|
||||
[mdformat](https://mdformat.readthedocs.io/en/stable/users/plugins.html#code-formatter-plugins) is
|
||||
capable of formatting code blocks within Markdown. The [`mdformat-ruff`](https://github.com/Freed-Wu/mdformat-ruff)
|
||||
plugin enables mdformat to format Python code blocks with Ruff.
|
||||
|
||||
## GitHub Actions
|
||||
|
||||
GitHub Actions has everything you need to run Ruff out-of-the-box:
|
||||
|
||||
4
ruff.schema.json
generated
4
ruff.schema.json
generated
@@ -1373,7 +1373,7 @@
|
||||
]
|
||||
},
|
||||
"quote-style": {
|
||||
"description": "Configures the preferred quote character for strings. Valid options are:\n\n* `double` (default): Use double quotes `\"` * `single`: Use single quotes `'` * `preserve` (preview only): Keeps the existing quote character. We don't recommend using this option except for projects that already use a mixture of single and double quotes and can't migrate to using double or single quotes.\n\nIn compliance with [PEP 8](https://peps.python.org/pep-0008/) and [PEP 257](https://peps.python.org/pep-0257/), Ruff prefers double quotes for multiline strings and docstrings, regardless of the configured quote style.\n\nRuff may also deviate from using the configured quotes if doing so requires escaping quote characters within the string. For example, given:\n\n```python a = \"a string without any quotes\" b = \"It's monday morning\" ```\n\nRuff will change `a` to use single quotes when using `quote-style = \"single\"`. However, `b` remains unchanged, as converting to single quotes requires escaping the inner `'`, which leads to less readable code: `'It\\'s monday morning'`. This does not apply when using `preserve`.",
|
||||
"description": "Configures the preferred quote character for strings. The recommended options are * `double` (default): Use double quotes `\"` * `single`: Use single quotes `'`\n\nIn compliance with [PEP 8](https://peps.python.org/pep-0008/) and [PEP 257](https://peps.python.org/pep-0257/), Ruff prefers double quotes for triple quoted strings and docstrings even when using `quote-style = \"single\"`.\n\nRuff deviates from using the configured quotes if doing so prevents the need for escaping quote characters inside the string:\n\n```python a = \"a string without any quotes\" b = \"It's monday morning\" ```\n\nRuff will change the quotes of the string assigned to `a` to single quotes when using `quote-style = \"single\"`. However, ruff uses double quotes for he string assigned to `b` because using single quotes would require escaping the `'`, which leads to the less readable code: `'It\\'s monday morning'`.\n\nIn addition, Ruff supports the quote style `preserve` for projects that already use a mixture of single and double quotes and can't migrate to the `double` or `single` style. The quote style `preserve` leaves the quotes of all strings unchanged.",
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/QuoteStyle"
|
||||
@@ -2988,6 +2988,8 @@
|
||||
"FURB11",
|
||||
"FURB113",
|
||||
"FURB118",
|
||||
"FURB12",
|
||||
"FURB129",
|
||||
"FURB13",
|
||||
"FURB131",
|
||||
"FURB132",
|
||||
|
||||
Reference in New Issue
Block a user