Compare commits

..

3 Commits

Author SHA1 Message Date
Brent Westbrook
cabdd969ec convert to serializable diagnostics 2025-07-21 16:09:52 -04:00
Brent Westbrook
2e5c8b9799 Revert "custom serializers"
This reverts commit e1219bc27c.
2025-07-21 16:09:43 -04:00
Brent Westbrook
e1219bc27c custom serializers 2025-07-21 16:09:27 -04:00
189 changed files with 2267 additions and 10307 deletions

View File

@@ -143,12 +143,12 @@ jobs:
env:
MERGE_BASE: ${{ steps.merge_base.outputs.sha }}
run: |
# NOTE: Do not exclude all Markdown files here, but rather use
# specific exclude patterns like 'docs/**'), because tests for
# 'ty' are written in Markdown.
if git diff --quiet "${MERGE_BASE}...HEAD" -- \
if git diff --quiet "${MERGE_BASE}...HEAD" -- ':**' \
':!**/*.md' \
':crates/ty_python_semantic/resources/mdtest/**/*.md' \
':!docs/**' \
':!assets/**' \
':.github/workflows/ci.yaml' \
; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else

View File

@@ -64,7 +64,7 @@ jobs:
cd ..
uv tool install "git+https://github.com/astral-sh/ecosystem-analyzer@27dd66d9e397d986ef9c631119ee09556eab8af9"
uv tool install "git+https://github.com/astral-sh/ecosystem-analyzer@f0eec0e549684d8e1d7b8bc3e351202124b63bda"
ecosystem-analyzer \
--repository ruff \

View File

@@ -49,7 +49,7 @@ jobs:
cd ..
uv tool install "git+https://github.com/astral-sh/ecosystem-analyzer@27dd66d9e397d986ef9c631119ee09556eab8af9"
uv tool install "git+https://github.com/astral-sh/ecosystem-analyzer@f0eec0e549684d8e1d7b8bc3e351202124b63bda"
ecosystem-analyzer \
--verbose \

View File

@@ -1,23 +1,5 @@
# Changelog
## 0.12.5
### Preview features
- \[`flake8-use-pathlib`\] Add autofix for `PTH101`, `PTH104`, `PTH105`, `PTH121` ([#19404](https://github.com/astral-sh/ruff/pull/19404))
- \[`ruff`\] Support byte strings (`RUF055`) ([#18926](https://github.com/astral-sh/ruff/pull/18926))
### Bug fixes
- Fix `unreachable` panic in parser ([#19183](https://github.com/astral-sh/ruff/pull/19183))
- \[`flake8-pyi`\] Skip fix if all `Union` members are `None` (`PYI016`) ([#19416](https://github.com/astral-sh/ruff/pull/19416))
- \[`perflint`\] Parenthesize generator expressions (`PERF401`) ([#19325](https://github.com/astral-sh/ruff/pull/19325))
- \[`pylint`\] Handle empty comments after line continuation (`PLR2044`) ([#19405](https://github.com/astral-sh/ruff/pull/19405))
### Rule changes
- \[`pep8-naming`\] Fix `N802` false positives for `CGIHTTPRequestHandler` and `SimpleHTTPRequestHandler` ([#19432](https://github.com/astral-sh/ruff/pull/19432))
## 0.12.4
### Preview features

57
Cargo.lock generated
View File

@@ -261,18 +261,6 @@ version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
[[package]]
name = "bitvec"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
dependencies = [
"funty",
"radium",
"tap",
"wyz",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -1133,12 +1121,6 @@ dependencies = [
"libc",
]
[[package]]
name = "funty"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -2566,12 +2548,6 @@ version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5"
[[package]]
name = "radium"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]]
name = "rand"
version = "0.8.5"
@@ -2734,7 +2710,7 @@ dependencies = [
[[package]]
name = "ruff"
version = "0.12.5"
version = "0.12.4"
dependencies = [
"anyhow",
"argfile",
@@ -2851,6 +2827,7 @@ dependencies = [
"anstyle",
"arc-swap",
"camino",
"countme",
"dashmap",
"dunce",
"etcetera",
@@ -2985,7 +2962,7 @@ dependencies = [
[[package]]
name = "ruff_linter"
version = "0.12.5"
version = "0.12.4"
dependencies = [
"aho-corasick",
"anyhow",
@@ -3317,7 +3294,7 @@ dependencies = [
[[package]]
name = "ruff_wasm"
version = "0.12.5"
version = "0.12.4"
dependencies = [
"console_error_panic_hook",
"console_log",
@@ -3791,12 +3768,6 @@ dependencies = [
"syn",
]
[[package]]
name = "tap"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tempfile"
version = "3.20.0"
@@ -4211,7 +4182,6 @@ version = "0.0.0"
dependencies = [
"bitflags 2.9.1",
"insta",
"itertools 0.14.0",
"regex",
"ruff_db",
"ruff_python_ast",
@@ -4220,10 +4190,11 @@ dependencies = [
"ruff_source_file",
"ruff_text_size",
"rustc-hash",
"salsa",
"smallvec",
"tracing",
"ty_project",
"ty_python_semantic",
"ty_vendored",
]
[[package]]
@@ -4257,6 +4228,7 @@ dependencies = [
"thiserror 2.0.12",
"toml 0.9.2",
"tracing",
"ty_ide",
"ty_python_semantic",
"ty_vendored",
]
@@ -4267,7 +4239,6 @@ version = "0.0.0"
dependencies = [
"anyhow",
"bitflags 2.9.1",
"bitvec",
"camino",
"colored 3.0.0",
"compact_str",
@@ -4304,7 +4275,6 @@ dependencies = [
"strum_macros",
"tempfile",
"test-case",
"thin-vec",
"thiserror 2.0.12",
"tracing",
"ty_python_semantic",
@@ -4320,13 +4290,10 @@ dependencies = [
"anyhow",
"bitflags 2.9.1",
"crossbeam",
"dunce",
"insta",
"jod-thread",
"libc",
"lsp-server",
"lsp-types",
"regex",
"ruff_db",
"ruff_notebook",
"ruff_python_ast",
@@ -4337,7 +4304,6 @@ dependencies = [
"serde",
"serde_json",
"shellexpand",
"tempfile",
"thiserror 2.0.12",
"tracing",
"tracing-subscriber",
@@ -5126,15 +5092,6 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
[[package]]
name = "wyz"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
dependencies = [
"tap",
]
[[package]]
name = "yansi"
version = "1.0.1"

View File

@@ -57,9 +57,6 @@ assert_fs = { version = "1.1.0" }
argfile = { version = "0.2.0" }
bincode = { version = "2.0.0" }
bitflags = { version = "2.5.0" }
bitvec = { version = "1.0.1", default-features = false, features = [
"alloc",
] }
bstr = { version = "1.9.1" }
cachedir = { version = "0.3.1" }
camino = { version = "1.1.7" }
@@ -166,7 +163,6 @@ strum_macros = { version = "0.27.0" }
syn = { version = "2.0.55" }
tempfile = { version = "3.9.0" }
test-case = { version = "3.3.1" }
thin-vec = { version = "0.2.14" }
thiserror = { version = "2.0.0" }
tikv-jemallocator = { version = "0.6.0" }
toml = { version = "0.9.0" }

View File

@@ -148,8 +148,8 @@ curl -LsSf https://astral.sh/ruff/install.sh | sh
powershell -c "irm https://astral.sh/ruff/install.ps1 | iex"
# For a specific version.
curl -LsSf https://astral.sh/ruff/0.12.5/install.sh | sh
powershell -c "irm https://astral.sh/ruff/0.12.5/install.ps1 | iex"
curl -LsSf https://astral.sh/ruff/0.12.4/install.sh | sh
powershell -c "irm https://astral.sh/ruff/0.12.4/install.ps1 | iex"
```
You can also install Ruff via [Homebrew](https://formulae.brew.sh/formula/ruff), [Conda](https://anaconda.org/conda-forge/ruff),
@@ -182,7 +182,7 @@ Ruff can also be used as a [pre-commit](https://pre-commit.com/) hook via [`ruff
```yaml
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.12.5
rev: v0.12.4
hooks:
# Run the linter.
- id: ruff-check

View File

@@ -1,6 +1,6 @@
[package]
name = "ruff"
version = "0.12.5"
version = "0.12.4"
publish = true
authors = { workspace = true }
edition = { workspace = true }

View File

@@ -18,14 +18,12 @@ use rustc_hash::FxHashMap;
use tempfile::NamedTempFile;
use ruff_cache::{CacheKey, CacheKeyHasher};
use ruff_db::diagnostic::Diagnostic;
use ruff_db::diagnostic::{Diagnostic, SerializableDiagnostics};
use ruff_diagnostics::Fix;
use ruff_linter::message::create_lint_diagnostic;
use ruff_linter::package::PackageRoot;
use ruff_linter::{VERSION, warn_user};
use ruff_macros::CacheKey;
use ruff_notebook::NotebookIndex;
use ruff_source_file::SourceFileBuilder;
use ruff_text_size::{TextRange, TextSize};
use ruff_workspace::Settings;
use ruff_workspace::resolver::Resolver;
@@ -345,22 +343,7 @@ impl FileCache {
let diagnostics = if lint.messages.is_empty() {
Vec::new()
} else {
let file = SourceFileBuilder::new(path.to_string_lossy(), &*lint.source).finish();
lint.messages
.iter()
.map(|msg| {
create_lint_diagnostic(
&msg.body,
msg.suggestion.as_ref(),
msg.range,
msg.fix.clone(),
msg.parent,
file.clone(),
msg.noqa_offset,
msg.rule,
)
})
.collect()
lint.messages.to_diagnostics()
};
let notebook_indexes = if let Some(notebook_index) = lint.notebook_index.as_ref() {
FxHashMap::from_iter([(path.to_string_lossy().to_string(), notebook_index.clone())])
@@ -415,7 +398,8 @@ pub(crate) struct LintCacheData {
/// Imports made.
// pub(super) imports: ImportMap,
/// Diagnostic messages.
pub(super) messages: Vec<CacheMessage>,
#[bincode(with_serde)]
pub(super) messages: SerializableDiagnostics,
/// Source code of the file.
///
/// # Notes
@@ -438,30 +422,7 @@ impl LintCacheData {
String::new() // No messages, no need to keep the source!
};
let messages = diagnostics
.iter()
// Parse the kebab-case rule name into a `Rule`. This will fail for syntax errors, so
// this also serves to filter them out, but we shouldn't be caching files with syntax
// errors anyway.
.filter_map(|msg| Some((msg.name().parse().ok()?, msg)))
.map(|(rule, msg)| {
// Make sure that all message use the same source file.
assert_eq!(
msg.expect_ruff_source_file(),
diagnostics.first().unwrap().expect_ruff_source_file(),
"message uses a different source file"
);
CacheMessage {
rule,
body: msg.body().to_string(),
suggestion: msg.first_help_text().map(ToString::to_string),
range: msg.expect_range(),
parent: msg.parent(),
fix: msg.fix().cloned(),
noqa_offset: msg.noqa_offset(),
}
})
.collect();
let messages = SerializableDiagnostics::new(diagnostics);
Self {
messages,

View File

@@ -264,7 +264,6 @@ impl Printer {
.with_show_fix_diff(self.flags.intersects(Flags::SHOW_FIX_DIFF))
.with_show_source(self.format == OutputFormat::Full)
.with_unsafe_fixes(self.unsafe_fixes)
.with_preview(preview)
.emit(writer, &diagnostics.inner, &context)?;
if self.flags.intersects(Flags::SHOW_FIX_SUMMARY) {

View File

@@ -25,6 +25,7 @@ ty_static = { workspace = true }
anstyle = { workspace = true }
arc-swap = { workspace = true }
camino = { workspace = true }
countme = { workspace = true }
dashmap = { workspace = true }
dunce = { workspace = true }
filetime = { workspace = true }
@@ -58,11 +59,6 @@ tempfile = { workspace = true }
cache = ["ruff_cache"]
junit = ["dep:quick-junit"]
os = ["ignore", "dep:etcetera"]
serde = [
"camino/serde1",
"dep:serde",
"dep:serde_json",
"ruff_diagnostics/serde",
]
serde = ["camino/serde1", "dep:serde", "dep:serde_json", "ruff_diagnostics/serde"]
# Exposes testing utilities.
testing = ["tracing-subscriber"]

View File

@@ -1,6 +1,6 @@
use std::{fmt::Formatter, path::Path, sync::Arc};
use ruff_diagnostics::{Applicability, Fix};
use ruff_diagnostics::Fix;
use ruff_source_file::{LineColumn, SourceCode, SourceFile};
use ruff_annotate_snippets::Level as AnnotateLevel;
@@ -9,6 +9,9 @@ use ruff_text_size::{Ranged, TextRange, TextSize};
pub use self::render::{DisplayDiagnostic, DisplayDiagnostics, FileResolver, Input};
use crate::{Db, files::File};
#[cfg(feature = "serde")]
pub use serde_diagnostics::SerializableDiagnostics;
mod render;
mod stylesheet;
@@ -122,14 +125,7 @@ impl Diagnostic {
/// directly. If callers want or need to avoid cloning the diagnostic
/// message, then they can also pass a `DiagnosticMessage` directly.
pub fn info<'a>(&mut self, message: impl IntoDiagnosticMessage + 'a) {
self.sub(SubDiagnostic::new(SubDiagnosticSeverity::Info, message));
}
/// Adds a "help" sub-diagnostic with the given message.
///
/// See the closely related [`Diagnostic::info`] method for more details.
pub fn help<'a>(&mut self, message: impl IntoDiagnosticMessage + 'a) {
self.sub(SubDiagnostic::new(SubDiagnosticSeverity::Help, message));
self.sub(SubDiagnostic::new(Severity::Info, message));
}
/// Adds a "sub" diagnostic to this diagnostic.
@@ -384,15 +380,9 @@ impl Diagnostic {
self.primary_message()
}
/// Returns the message of the first sub-diagnostic with a `Help` severity.
///
/// Note that this is used as the fix title/suggestion for some of Ruff's output formats, but in
/// general this is not the guaranteed meaning of such a message.
pub fn first_help_text(&self) -> Option<&str> {
self.sub_diagnostics()
.iter()
.find(|sub| matches!(sub.inner.severity, SubDiagnosticSeverity::Help))
.map(|sub| sub.inner.message.as_str())
/// Returns the fix suggestion for the violation.
pub fn suggestion(&self) -> Option<&str> {
self.primary_annotation()?.get_message()
}
/// Returns the URL for the rule documentation, if it exists.
@@ -578,10 +568,7 @@ impl SubDiagnostic {
/// Callers can pass anything that implements `std::fmt::Display`
/// directly. If callers want or need to avoid cloning the diagnostic
/// message, then they can also pass a `DiagnosticMessage` directly.
pub fn new<'a>(
severity: SubDiagnosticSeverity,
message: impl IntoDiagnosticMessage + 'a,
) -> SubDiagnostic {
pub fn new<'a>(severity: Severity, message: impl IntoDiagnosticMessage + 'a) -> SubDiagnostic {
let inner = Box::new(SubDiagnosticInner {
severity,
message: message.into_diagnostic_message(),
@@ -659,7 +646,7 @@ impl SubDiagnostic {
#[derive(Debug, Clone, Eq, PartialEq, get_size2::GetSize)]
struct SubDiagnosticInner {
severity: SubDiagnosticSeverity,
severity: Severity,
message: DiagnosticMessage,
annotations: Vec<Annotation>,
}
@@ -806,6 +793,7 @@ impl Annotation {
/// These tags are used to provide additional information about the annotation.
/// and are passed through to the language server protocol.
#[derive(Debug, Clone, Eq, PartialEq, get_size2::GetSize)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum DiagnosticTag {
/// Unused or unnecessary code. Used for unused parameters, unreachable code, etc.
Unnecessary,
@@ -820,6 +808,7 @@ pub enum DiagnosticTag {
///
/// Rules use kebab case, e.g. `no-foo`.
#[derive(Debug, Copy, Clone, PartialOrd, Ord, PartialEq, Eq, Hash, get_size2::GetSize)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct LintName(&'static str);
impl LintName {
@@ -860,6 +849,7 @@ impl PartialEq<&str> for LintName {
/// Uniquely identifies the kind of a diagnostic.
#[derive(Debug, Copy, Clone, Eq, PartialEq, PartialOrd, Ord, Hash, get_size2::GetSize)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum DiagnosticId {
Panic,
@@ -1157,6 +1147,7 @@ impl From<crate::files::FileRange> for Span {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, get_size2::GetSize)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Severity {
Info,
Warning,
@@ -1186,32 +1177,6 @@ impl Severity {
}
}
/// Like [`Severity`] but exclusively for sub-diagnostics.
///
/// This type only exists to add an additional `Help` severity that isn't present in `Severity` or
/// used for main diagnostics. If we want to add `Severity::Help` in the future, this type could be
/// deleted and the two combined again.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, get_size2::GetSize)]
pub enum SubDiagnosticSeverity {
Help,
Info,
Warning,
Error,
Fatal,
}
impl SubDiagnosticSeverity {
fn to_annotate(self) -> AnnotateLevel {
match self {
SubDiagnosticSeverity::Help => AnnotateLevel::Help,
SubDiagnosticSeverity::Info => AnnotateLevel::Info,
SubDiagnosticSeverity::Warning => AnnotateLevel::Warning,
SubDiagnosticSeverity::Error => AnnotateLevel::Error,
SubDiagnosticSeverity::Fatal => AnnotateLevel::Error,
}
}
}
/// Configuration for rendering diagnostics.
#[derive(Clone, Debug)]
pub struct DisplayDiagnosticConfig {
@@ -1238,15 +1203,6 @@ pub struct DisplayDiagnosticConfig {
reason = "This is currently only used for JSON but will be needed soon for other formats"
)]
preview: bool,
/// Whether to hide the real `Severity` of diagnostics.
///
/// This is intended for temporary use by Ruff, which only has a single `error` severity at the
/// moment. We should be able to remove this option when Ruff gets more severities.
hide_severity: bool,
/// Whether to show the availability of a fix in a diagnostic.
show_fix_status: bool,
/// The lowest applicability that should be shown when reporting diagnostics.
fix_applicability: Applicability,
}
impl DisplayDiagnosticConfig {
@@ -1275,35 +1231,6 @@ impl DisplayDiagnosticConfig {
..self
}
}
/// Whether to hide a diagnostic's severity or not.
pub fn hide_severity(self, yes: bool) -> DisplayDiagnosticConfig {
DisplayDiagnosticConfig {
hide_severity: yes,
..self
}
}
/// Whether to show a fix's availability or not.
pub fn show_fix_status(self, yes: bool) -> DisplayDiagnosticConfig {
DisplayDiagnosticConfig {
show_fix_status: yes,
..self
}
}
/// Set the lowest fix applicability that should be shown.
///
/// In other words, an applicability of `Safe` (the default) would suppress showing fixes or fix
/// availability for unsafe or display-only fixes.
///
/// Note that this option is currently ignored when `hide_severity` is false.
pub fn fix_applicability(self, applicability: Applicability) -> DisplayDiagnosticConfig {
DisplayDiagnosticConfig {
fix_applicability: applicability,
..self
}
}
}
impl Default for DisplayDiagnosticConfig {
@@ -1313,9 +1240,6 @@ impl Default for DisplayDiagnosticConfig {
color: false,
context: 2,
preview: false,
hide_severity: false,
show_fix_status: false,
fix_applicability: Applicability::Safe,
}
}
}
@@ -1427,6 +1351,7 @@ impl std::fmt::Display for ConciseMessage<'_> {
/// a blanket trait implementation for `IntoDiagnosticMessage` for
/// anything that implements `std::fmt::Display`.
#[derive(Clone, Debug, Eq, PartialEq, get_size2::GetSize)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DiagnosticMessage(Box<str>);
impl DiagnosticMessage {
@@ -1490,7 +1415,11 @@ impl<T: std::fmt::Display> IntoDiagnosticMessage for T {
///
/// For Ruff rules this means the noqa code.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Default, Hash, get_size2::GetSize)]
#[cfg_attr(feature = "serde", derive(serde::Serialize), serde(transparent))]
#[cfg_attr(
feature = "serde",
derive(serde::Serialize, serde::Deserialize),
serde(transparent)
)]
pub struct SecondaryCode(String);
impl SecondaryCode {
@@ -1535,3 +1464,205 @@ impl From<&SecondaryCode> for SecondaryCode {
value.clone()
}
}
#[cfg(feature = "serde")]
mod serde_diagnostics {
use std::sync::Arc;
use ruff_diagnostics::Fix;
use ruff_source_file::{SourceFile, SourceFileBuilder};
use ruff_text_size::{TextRange, TextSize};
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use super::{
Annotation, Diagnostic, DiagnosticId, DiagnosticInner, DiagnosticMessage, DiagnosticTag,
LintName, SecondaryCode, Severity, Span, SubDiagnostic, SubDiagnosticInner, UnifiedFile,
};
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub struct SerializableDiagnostics {
source_files: FxHashMap<String, String>,
diagnostics: Vec<SerializableDiagnostic>,
}
impl SerializableDiagnostics {
pub fn new(diagnostics: &[Diagnostic]) -> Self {
let mut source_files = FxHashMap::default();
let mut serializable_diagnostics = Vec::with_capacity(diagnostics.len());
for diagnostic in diagnostics {
let mut subs = Vec::with_capacity(diagnostic.inner.subs.len());
for sub in &diagnostic.inner.subs {
subs.push(SerializableSubDiagnostic {
severity: sub.inner.severity,
message: sub.inner.message.clone(),
annotations: serializable_annotations(
&mut source_files,
&sub.inner.annotations,
),
});
}
serializable_diagnostics.push(SerializableDiagnostic {
id: diagnostic.inner.id,
severity: diagnostic.inner.severity,
message: diagnostic.inner.message.clone(),
annotations: serializable_annotations(
&mut source_files,
&diagnostic.inner.annotations,
),
subs,
fix: diagnostic.inner.fix.clone(),
parent: diagnostic.inner.parent,
noqa_offset: diagnostic.inner.noqa_offset,
secondary_code: diagnostic.inner.secondary_code.clone(),
});
}
Self {
source_files,
diagnostics: serializable_diagnostics,
}
}
pub fn is_empty(&self) -> bool {
self.diagnostics.is_empty()
}
pub fn to_diagnostics(&self) -> Vec<Diagnostic> {
let source_files: FxHashMap<&str, SourceFile> = self
.source_files
.iter()
.map(|(name, contents)| {
(
name.as_str(),
SourceFileBuilder::new(name.clone(), contents.clone()).finish(),
)
})
.collect();
self.diagnostics
.iter()
.map(|diag| Diagnostic {
inner: Arc::new(DiagnosticInner {
id: diag.id,
severity: diag.severity,
message: diag.message.clone(),
annotations: annotations(&source_files, &diag.annotations),
subs: subdiagnostics(&source_files, &diag.subs),
fix: diag.fix.clone(),
parent: diag.parent,
noqa_offset: diag.noqa_offset,
secondary_code: diag.secondary_code.clone(),
}),
})
.collect()
}
}
fn serializable_annotations(
source_files: &mut FxHashMap<String, String>,
annotations: &[Annotation],
) -> Vec<SerializableAnnotation> {
let mut serializable_annotations = Vec::with_capacity(annotations.len());
for annotation in annotations {
let file = annotation.span.expect_ruff_file();
source_files.insert(file.name().to_string(), file.source_text().to_string());
serializable_annotations.push(SerializableAnnotation {
span: SerializableSpan {
name: file.name().to_string(),
range: annotation.span.range,
},
message: annotation.message.clone(),
is_primary: annotation.is_primary,
tags: annotation.tags.clone(),
});
}
serializable_annotations
}
fn annotations(
source_files: &FxHashMap<&str, SourceFile>,
serializable_annotations: &[SerializableAnnotation],
) -> Vec<Annotation> {
serializable_annotations
.iter()
.map(|ann| {
let span = Span {
file: UnifiedFile::Ruff(
source_files
.get(ann.span.name.as_str())
.expect("Expected source file in cache")
.clone(),
),
range: ann.span.range,
};
Annotation {
span,
message: ann.message.clone(),
is_primary: ann.is_primary,
tags: ann.tags.clone(),
}
})
.collect()
}
fn subdiagnostics(
source_files: &FxHashMap<&str, SourceFile>,
serializable_annotations: &[SerializableSubDiagnostic],
) -> Vec<SubDiagnostic> {
serializable_annotations
.iter()
.map(|sub| SubDiagnostic {
inner: Box::new(SubDiagnosticInner {
severity: sub.severity,
message: sub.message.clone(),
annotations: annotations(source_files, &sub.annotations),
}),
})
.collect()
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct SerializableDiagnostic {
id: DiagnosticId,
severity: Severity,
message: DiagnosticMessage,
annotations: Vec<SerializableAnnotation>,
subs: Vec<SerializableSubDiagnostic>,
fix: Option<Fix>,
parent: Option<TextSize>,
noqa_offset: Option<TextSize>,
secondary_code: Option<SecondaryCode>,
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct SerializableAnnotation {
span: SerializableSpan,
message: Option<DiagnosticMessage>,
is_primary: bool,
tags: Vec<DiagnosticTag>,
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct SerializableSubDiagnostic {
severity: Severity,
message: DiagnosticMessage,
annotations: Vec<SerializableAnnotation>,
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct SerializableSpan {
name: String,
range: Option<TextRange>,
}
impl<'de> Deserialize<'de> for LintName {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?.into_boxed_str();
Ok(LintName::of(Box::leak(s)))
}
}
}

View File

@@ -9,7 +9,7 @@ use ruff_notebook::{Notebook, NotebookIndex};
use ruff_source_file::{LineIndex, OneIndexed, SourceCode};
use ruff_text_size::{TextRange, TextSize};
use crate::diagnostic::stylesheet::DiagnosticStylesheet;
use crate::diagnostic::stylesheet::{DiagnosticStylesheet, fmt_styled};
use crate::{
Db,
files::File,
@@ -18,17 +18,14 @@ use crate::{
};
use super::{
Annotation, Diagnostic, DiagnosticFormat, DiagnosticSource, DisplayDiagnosticConfig,
Annotation, Diagnostic, DiagnosticFormat, DiagnosticSource, DisplayDiagnosticConfig, Severity,
SubDiagnostic, UnifiedFile,
};
use azure::AzureRenderer;
use concise::ConciseRenderer;
use pylint::PylintRenderer;
mod azure;
mod concise;
mod full;
#[cfg(feature = "serde")]
mod json;
#[cfg(feature = "serde")]
@@ -107,7 +104,48 @@ impl std::fmt::Display for DisplayDiagnostics<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self.config.format {
DiagnosticFormat::Concise => {
ConciseRenderer::new(self.resolver, self.config).render(f, self.diagnostics)?;
let stylesheet = if self.config.color {
DiagnosticStylesheet::styled()
} else {
DiagnosticStylesheet::plain()
};
for diag in self.diagnostics {
let (severity, severity_style) = match diag.severity() {
Severity::Info => ("info", stylesheet.info),
Severity::Warning => ("warning", stylesheet.warning),
Severity::Error => ("error", stylesheet.error),
Severity::Fatal => ("fatal", stylesheet.error),
};
write!(
f,
"{severity}[{id}]",
severity = fmt_styled(severity, severity_style),
id = fmt_styled(diag.id(), stylesheet.emphasis)
)?;
if let Some(span) = diag.primary_span() {
write!(
f,
" {path}",
path = fmt_styled(span.file().path(self.resolver), stylesheet.emphasis)
)?;
if let Some(range) = span.range() {
let diagnostic_source = span.file().diagnostic_source(self.resolver);
let start = diagnostic_source
.as_source_code()
.line_column(range.start());
write!(
f,
":{line}:{col}",
line = fmt_styled(start.line, stylesheet.emphasis),
col = fmt_styled(start.column, stylesheet.emphasis),
)?;
}
write!(f, ":")?;
}
writeln!(f, " {message}", message = diag.concise_message())?;
}
}
DiagnosticFormat::Full => {
let stylesheet = if self.config.color {
@@ -218,7 +256,7 @@ impl<'a> Resolved<'a> {
/// both.)
#[derive(Debug)]
struct ResolvedDiagnostic<'a> {
level: AnnotateLevel,
severity: Severity,
id: Option<String>,
message: String,
annotations: Vec<ResolvedAnnotation<'a>>,
@@ -243,7 +281,7 @@ impl<'a> ResolvedDiagnostic<'a> {
let id = Some(diag.inner.id.to_string());
let message = diag.inner.message.as_str().to_string();
ResolvedDiagnostic {
level: diag.inner.severity.to_annotate(),
severity: diag.inner.severity,
id,
message,
annotations,
@@ -266,7 +304,7 @@ impl<'a> ResolvedDiagnostic<'a> {
})
.collect();
ResolvedDiagnostic {
level: diag.inner.severity.to_annotate(),
severity: diag.inner.severity,
id: None,
message: diag.inner.message.as_str().to_string(),
annotations,
@@ -333,7 +371,7 @@ impl<'a> ResolvedDiagnostic<'a> {
snippets_by_input
.sort_by(|snips1, snips2| snips1.has_primary.cmp(&snips2.has_primary).reverse());
RenderableDiagnostic {
level: self.level,
severity: self.severity,
id: self.id.as_deref(),
message: &self.message,
snippets_by_input,
@@ -421,7 +459,7 @@ struct Renderable<'r> {
#[derive(Debug)]
struct RenderableDiagnostic<'r> {
/// The severity of the diagnostic.
level: AnnotateLevel,
severity: Severity,
/// The ID of the diagnostic. The ID can usually be used on the CLI or in a
/// config file to change the severity of a lint.
///
@@ -440,6 +478,7 @@ struct RenderableDiagnostic<'r> {
impl RenderableDiagnostic<'_> {
/// Convert this to an "annotate" snippet.
fn to_annotate(&self) -> AnnotateMessage<'_> {
let level = self.severity.to_annotate();
let snippets = self.snippets_by_input.iter().flat_map(|snippets| {
let path = snippets.path;
snippets
@@ -447,7 +486,7 @@ impl RenderableDiagnostic<'_> {
.iter()
.map(|snippet| snippet.to_annotate(path))
});
let mut message = self.level.title(self.message);
let mut message = level.title(self.message);
if let Some(id) = self.id {
message = message.id(id);
}
@@ -823,12 +862,9 @@ fn relativize_path<'p>(cwd: &SystemPath, path: &'p str) -> &'p str {
#[cfg(test)]
mod tests {
use ruff_diagnostics::{Applicability, Edit, Fix};
use ruff_diagnostics::{Edit, Fix};
use crate::diagnostic::{
Annotation, DiagnosticId, IntoDiagnosticMessage, SecondaryCode, Severity, Span,
SubDiagnosticSeverity,
};
use crate::diagnostic::{Annotation, DiagnosticId, SecondaryCode, Severity, Span};
use crate::files::system_path_to_file;
use crate::system::{DbWithWritableSystem, SystemPath};
use crate::tests::TestDb;
@@ -1512,7 +1548,7 @@ watermelon
let mut diag = env.err().primary("animals", "3", "3", "").build();
diag.sub(
env.sub_builder(SubDiagnosticSeverity::Info, "this is a helpful note")
env.sub_builder(Severity::Info, "this is a helpful note")
.build(),
);
insta::assert_snapshot!(
@@ -1541,15 +1577,15 @@ watermelon
let mut diag = env.err().primary("animals", "3", "3", "").build();
diag.sub(
env.sub_builder(SubDiagnosticSeverity::Info, "this is a helpful note")
env.sub_builder(Severity::Info, "this is a helpful note")
.build(),
);
diag.sub(
env.sub_builder(SubDiagnosticSeverity::Info, "another helpful note")
env.sub_builder(Severity::Info, "another helpful note")
.build(),
);
diag.sub(
env.sub_builder(SubDiagnosticSeverity::Info, "and another helpful note")
env.sub_builder(Severity::Info, "and another helpful note")
.build(),
);
insta::assert_snapshot!(
@@ -2271,27 +2307,6 @@ watermelon
self.config = config;
}
/// Hide diagnostic severity when rendering.
pub(super) fn hide_severity(&mut self, yes: bool) {
let mut config = std::mem::take(&mut self.config);
config = config.hide_severity(yes);
self.config = config;
}
/// Show fix availability when rendering.
pub(super) fn show_fix_status(&mut self, yes: bool) {
let mut config = std::mem::take(&mut self.config);
config = config.show_fix_status(yes);
self.config = config;
}
/// The lowest fix applicability to show when rendering.
pub(super) fn fix_applicability(&mut self, applicability: Applicability) {
let mut config = std::mem::take(&mut self.config);
config = config.fix_applicability(applicability);
self.config = config;
}
/// Add a file with the given path and contents to this environment.
pub(super) fn add(&mut self, path: &str, contents: &str) {
let path = SystemPath::new(path);
@@ -2355,7 +2370,7 @@ watermelon
/// sub-diagnostic with "error" severity and canned values for
/// its identifier and message.
fn sub_warn(&mut self) -> SubDiagnosticBuilder<'_> {
self.sub_builder(SubDiagnosticSeverity::Warning, "sub-diagnostic message")
self.sub_builder(Severity::Warning, "sub-diagnostic message")
}
/// Returns a builder for tersely constructing diagnostics.
@@ -2376,11 +2391,7 @@ watermelon
}
/// Returns a builder for tersely constructing sub-diagnostics.
fn sub_builder(
&mut self,
severity: SubDiagnosticSeverity,
message: &str,
) -> SubDiagnosticBuilder<'_> {
fn sub_builder(&mut self, severity: Severity, message: &str) -> SubDiagnosticBuilder<'_> {
let subdiag = SubDiagnostic::new(severity, message);
SubDiagnosticBuilder { env: self, subdiag }
}
@@ -2483,12 +2494,6 @@ watermelon
self.diag.set_noqa_offset(noqa_offset);
self
}
/// Adds a "help" sub-diagnostic with the given message.
fn help(mut self, message: impl IntoDiagnosticMessage) -> DiagnosticBuilder<'e> {
self.diag.help(message);
self
}
}
/// A helper builder for tersely populating a `SubDiagnostic`.
@@ -2595,8 +2600,7 @@ def fibonacci(n):
let diagnostics = vec![
env.builder("unused-import", Severity::Error, "`os` imported but unused")
.primary("fib.py", "1:7", "1:9", "")
.help("Remove unused import: `os`")
.primary("fib.py", "1:7", "1:9", "Remove unused import: `os`")
.secondary_code("F401")
.fix(Fix::unsafe_edit(Edit::range_deletion(TextRange::new(
TextSize::from(0),
@@ -2609,8 +2613,12 @@ def fibonacci(n):
Severity::Error,
"Local variable `x` is assigned to but never used",
)
.primary("fib.py", "6:4", "6:5", "")
.help("Remove assignment to unused variable `x`")
.primary(
"fib.py",
"6:4",
"6:5",
"Remove assignment to unused variable `x`",
)
.secondary_code("F841")
.fix(Fix::unsafe_edit(Edit::deletion(
TextSize::from(94),
@@ -2657,25 +2665,6 @@ if call(foo
}
/// Create Ruff-style diagnostics for testing the various output formats for a notebook.
///
/// The concatenated cells look like this:
///
/// ```python
/// # cell 1
/// import os
/// # cell 2
/// import math
///
/// print('hello world')
/// # cell 3
/// def foo():
/// print()
/// x = 1
/// ```
///
/// The first diagnostic is on the unused `os` import with location cell 1, row 2, column 8
/// (`cell 1:2:8`). The second diagnostic is the unused `math` import at `cell 2:2:8`, and the
/// third diagnostic is an unfixable unused variable at `cell 3:4:5`.
#[allow(
dead_code,
reason = "This is currently only used for JSON but will be needed soon for other formats"
@@ -2731,8 +2720,7 @@ if call(foo
let diagnostics = vec![
env.builder("unused-import", Severity::Error, "`os` imported but unused")
.primary("notebook.ipynb", "2:7", "2:9", "")
.help("Remove unused import: `os`")
.primary("notebook.ipynb", "2:7", "2:9", "Remove unused import: `os`")
.secondary_code("F401")
.fix(Fix::safe_edit(Edit::range_deletion(TextRange::new(
TextSize::from(9),
@@ -2745,8 +2733,12 @@ if call(foo
Severity::Error,
"`math` imported but unused",
)
.primary("notebook.ipynb", "4:7", "4:11", "")
.help("Remove unused import: `math`")
.primary(
"notebook.ipynb",
"4:7",
"4:11",
"Remove unused import: `math`",
)
.secondary_code("F401")
.fix(Fix::safe_edit(Edit::range_deletion(TextRange::new(
TextSize::from(28),
@@ -2759,8 +2751,12 @@ if call(foo
Severity::Error,
"Local variable `x` is assigned to but never used",
)
.primary("notebook.ipynb", "10:4", "10:5", "")
.help("Remove assignment to unused variable `x`")
.primary(
"notebook.ipynb",
"10:4",
"10:5",
"Remove assignment to unused variable `x`",
)
.secondary_code("F841")
.fix(Fix::unsafe_edit(Edit::range_deletion(TextRange::new(
TextSize::from(94),

View File

@@ -1,195 +0,0 @@
use crate::diagnostic::{
Diagnostic, DisplayDiagnosticConfig, Severity,
stylesheet::{DiagnosticStylesheet, fmt_styled},
};
use super::FileResolver;
pub(super) struct ConciseRenderer<'a> {
resolver: &'a dyn FileResolver,
config: &'a DisplayDiagnosticConfig,
}
impl<'a> ConciseRenderer<'a> {
pub(super) fn new(resolver: &'a dyn FileResolver, config: &'a DisplayDiagnosticConfig) -> Self {
Self { resolver, config }
}
pub(super) fn render(
&self,
f: &mut std::fmt::Formatter,
diagnostics: &[Diagnostic],
) -> std::fmt::Result {
let stylesheet = if self.config.color {
DiagnosticStylesheet::styled()
} else {
DiagnosticStylesheet::plain()
};
let sep = fmt_styled(":", stylesheet.separator);
for diag in diagnostics {
if let Some(span) = diag.primary_span() {
write!(
f,
"{path}",
path = fmt_styled(
span.file().relative_path(self.resolver).to_string_lossy(),
stylesheet.emphasis
)
)?;
if let Some(range) = span.range() {
let diagnostic_source = span.file().diagnostic_source(self.resolver);
let start = diagnostic_source
.as_source_code()
.line_column(range.start());
if let Some(notebook_index) = self.resolver.notebook_index(span.file()) {
write!(
f,
"{sep}cell {cell}{sep}{line}{sep}{col}",
cell = notebook_index.cell(start.line).unwrap_or_default(),
line = notebook_index.cell_row(start.line).unwrap_or_default(),
col = start.column,
)?;
} else {
write!(
f,
"{sep}{line}{sep}{col}",
line = start.line,
col = start.column,
)?;
}
}
write!(f, "{sep} ")?;
}
if self.config.hide_severity {
if let Some(code) = diag.secondary_code() {
write!(
f,
"{code} ",
code = fmt_styled(code, stylesheet.secondary_code)
)?;
}
if self.config.show_fix_status {
if let Some(fix) = diag.fix() {
// Do not display an indicator for inapplicable fixes
if fix.applies(self.config.fix_applicability) {
write!(f, "[{fix}] ", fix = fmt_styled("*", stylesheet.separator))?;
}
}
}
} else {
let (severity, severity_style) = match diag.severity() {
Severity::Info => ("info", stylesheet.info),
Severity::Warning => ("warning", stylesheet.warning),
Severity::Error => ("error", stylesheet.error),
Severity::Fatal => ("fatal", stylesheet.error),
};
write!(
f,
"{severity}[{id}] ",
severity = fmt_styled(severity, severity_style),
id = fmt_styled(diag.id(), stylesheet.emphasis)
)?;
}
writeln!(f, "{message}", message = diag.concise_message())?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use ruff_diagnostics::Applicability;
use crate::diagnostic::{
DiagnosticFormat,
render::tests::{
TestEnvironment, create_diagnostics, create_notebook_diagnostics,
create_syntax_error_diagnostics,
},
};
#[test]
fn output() {
let (env, diagnostics) = create_diagnostics(DiagnosticFormat::Concise);
insta::assert_snapshot!(env.render_diagnostics(&diagnostics), @r"
fib.py:1:8: error[unused-import] `os` imported but unused
fib.py:6:5: error[unused-variable] Local variable `x` is assigned to but never used
undef.py:1:4: error[undefined-name] Undefined name `a`
");
}
#[test]
fn show_fixes() {
let (mut env, diagnostics) = create_diagnostics(DiagnosticFormat::Concise);
env.hide_severity(true);
env.show_fix_status(true);
env.fix_applicability(Applicability::DisplayOnly);
insta::assert_snapshot!(env.render_diagnostics(&diagnostics), @r"
fib.py:1:8: F401 [*] `os` imported but unused
fib.py:6:5: F841 [*] Local variable `x` is assigned to but never used
undef.py:1:4: F821 Undefined name `a`
");
}
#[test]
fn show_fixes_preview() {
let (mut env, diagnostics) = create_diagnostics(DiagnosticFormat::Concise);
env.hide_severity(true);
env.show_fix_status(true);
env.fix_applicability(Applicability::DisplayOnly);
env.preview(true);
insta::assert_snapshot!(env.render_diagnostics(&diagnostics), @r"
fib.py:1:8: F401 [*] `os` imported but unused
fib.py:6:5: F841 [*] Local variable `x` is assigned to but never used
undef.py:1:4: F821 Undefined name `a`
");
}
#[test]
fn show_fixes_syntax_errors() {
let (mut env, diagnostics) = create_syntax_error_diagnostics(DiagnosticFormat::Concise);
env.hide_severity(true);
env.show_fix_status(true);
env.fix_applicability(Applicability::DisplayOnly);
insta::assert_snapshot!(env.render_diagnostics(&diagnostics), @r"
syntax_errors.py:1:15: SyntaxError: Expected one or more symbol names after import
syntax_errors.py:3:12: SyntaxError: Expected ')', found newline
");
}
#[test]
fn syntax_errors() {
let (env, diagnostics) = create_syntax_error_diagnostics(DiagnosticFormat::Concise);
insta::assert_snapshot!(env.render_diagnostics(&diagnostics), @r"
syntax_errors.py:1:15: error[invalid-syntax] SyntaxError: Expected one or more symbol names after import
syntax_errors.py:3:12: error[invalid-syntax] SyntaxError: Expected ')', found newline
");
}
#[test]
fn notebook_output() {
let (env, diagnostics) = create_notebook_diagnostics(DiagnosticFormat::Concise);
insta::assert_snapshot!(env.render_diagnostics(&diagnostics), @r"
notebook.ipynb:cell 1:2:8: error[unused-import] `os` imported but unused
notebook.ipynb:cell 2:2:8: error[unused-import] `math` imported but unused
notebook.ipynb:cell 3:4:5: error[unused-variable] Local variable `x` is assigned to but never used
");
}
#[test]
fn missing_file() {
let mut env = TestEnvironment::new();
env.format(DiagnosticFormat::Concise);
let diag = env.err().build();
insta::assert_snapshot!(
env.render(&diag),
@"error[test-diagnostic] main diagnostic message",
);
}
}

View File

@@ -1,66 +0,0 @@
#[cfg(test)]
mod tests {
use crate::diagnostic::{
DiagnosticFormat,
render::tests::{create_diagnostics, create_syntax_error_diagnostics},
};
#[test]
fn output() {
let (env, diagnostics) = create_diagnostics(DiagnosticFormat::Full);
insta::assert_snapshot!(env.render_diagnostics(&diagnostics), @r#"
error[unused-import]: `os` imported but unused
--> fib.py:1:8
|
1 | import os
| ^^
|
help: Remove unused import: `os`
error[unused-variable]: Local variable `x` is assigned to but never used
--> fib.py:6:5
|
4 | def fibonacci(n):
5 | """Compute the nth number in the Fibonacci sequence."""
6 | x = 1
| ^
7 | if n == 0:
8 | return 0
|
help: Remove assignment to unused variable `x`
error[undefined-name]: Undefined name `a`
--> undef.py:1:4
|
1 | if a == 1: pass
| ^
|
"#);
}
#[test]
fn syntax_errors() {
let (env, diagnostics) = create_syntax_error_diagnostics(DiagnosticFormat::Full);
insta::assert_snapshot!(env.render_diagnostics(&diagnostics), @r"
error[invalid-syntax]: SyntaxError: Expected one or more symbol names after import
--> syntax_errors.py:1:15
|
1 | from os import
| ^
2 |
3 | if call(foo
|
error[invalid-syntax]: SyntaxError: Expected ')', found newline
--> syntax_errors.py:3:12
|
1 | from os import
2 |
3 | if call(foo
| ^
4 | def bar():
5 | pass
|
");
}
}

View File

@@ -87,7 +87,7 @@ pub(super) fn diagnostic_to_json<'a>(
let fix = diagnostic.fix().map(|fix| JsonFix {
applicability: fix.applicability(),
message: diagnostic.first_help_text(),
message: diagnostic.suggestion(),
edits: ExpandedEdits {
edits: fix.edits(),
notebook_index,

View File

@@ -41,8 +41,6 @@ pub struct DiagnosticStylesheet {
pub(crate) line_no: Style,
pub(crate) emphasis: Style,
pub(crate) none: Style,
pub(crate) separator: Style,
pub(crate) secondary_code: Style,
}
impl Default for DiagnosticStylesheet {
@@ -64,8 +62,6 @@ impl DiagnosticStylesheet {
line_no: bright_blue.effects(Effects::BOLD),
emphasis: Style::new().effects(Effects::BOLD),
none: Style::new(),
separator: AnsiColor::Cyan.on_default(),
secondary_code: AnsiColor::Red.on_default().effects(Effects::BOLD),
}
}
@@ -79,8 +75,6 @@ impl DiagnosticStylesheet {
line_no: Style::new(),
emphasis: Style::new(),
none: Style::new(),
separator: Style::new(),
secondary_code: Style::new(),
}
}
}

View File

@@ -1,6 +1,7 @@
use std::fmt;
use std::sync::Arc;
use countme::Count;
use dashmap::mapref::entry::Entry;
pub use file_root::{FileRoot, FileRootKind};
pub use path::FilePath;
@@ -311,6 +312,11 @@ pub struct File {
/// the file has been deleted is to change the status to `Deleted`.
#[default]
status: FileStatus,
/// Counter that counts the number of created file instances and active file instances.
/// Only enabled in debug builds.
#[default]
count: Count<File>,
}
// The Salsa heap is tracked separately.

View File

@@ -1,6 +1,8 @@
use std::ops::Deref;
use std::sync::Arc;
use countme::Count;
use ruff_notebook::Notebook;
use ruff_python_ast::PySourceType;
use ruff_source_file::LineIndex;
@@ -36,7 +38,11 @@ pub fn source_text(db: &dyn Db, file: File) -> SourceText {
};
SourceText {
inner: Arc::new(SourceTextInner { kind, read_error }),
inner: Arc::new(SourceTextInner {
kind,
read_error,
count: Count::new(),
}),
}
}
@@ -119,6 +125,8 @@ impl std::fmt::Debug for SourceText {
#[derive(Eq, PartialEq, get_size2::GetSize)]
struct SourceTextInner {
#[get_size(ignore)]
count: Count<SourceText>,
kind: SourceTextKind,
read_error: Option<SourceTextError>,
}

View File

@@ -20,7 +20,7 @@ impl<'a> Resolver<'a> {
match import {
CollectedImport::Import(import) => {
let module = resolve_module(self.db, &import)?;
Some(module.file(self.db)?.path(self.db))
Some(module.file()?.path(self.db))
}
CollectedImport::ImportFrom(import) => {
// Attempt to resolve the member (e.g., given `from foo import bar`, look for `foo.bar`).
@@ -32,7 +32,7 @@ impl<'a> Resolver<'a> {
resolve_module(self.db, &parent?)
})?;
Some(module.file(self.db)?.path(self.db))
Some(module.file()?.path(self.db))
}
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "ruff_linter"
version = "0.12.5"
version = "0.12.4"
publish = false
authors = { workspace = true }
edition = { workspace = true }

View File

@@ -142,7 +142,3 @@ field47: typing.Optional[int] | typing.Optional[dict]
# avoid reporting twice
field48: typing.Union[typing.Optional[typing.Union[complex, complex]], complex]
field49: typing.Optional[complex | complex] | complex
# Regression test for https://github.com/astral-sh/ruff/issues/19403
# Should throw duplicate union member but not fix
isinstance(None, typing.Union[None, None])

View File

@@ -47,19 +47,3 @@ def _():
from builtin import open
with open(p) as _: ... # No error
file = "file_1.py"
rename(file, "file_2.py")
rename(
# commment 1
file, # comment 2
"file_2.py"
,
# comment 3
)
rename(file, "file_2.py", src_dir_fd=None, dst_dir_fd=None)
rename(file, "file_2.py", src_dir_fd=1)

View File

@@ -84,25 +84,3 @@ class MyRequestHandler(BaseHTTPRequestHandler):
def dont_GET(self):
pass
from http.server import CGIHTTPRequestHandler
class MyCGIRequestHandler(CGIHTTPRequestHandler):
def do_OPTIONS(self):
pass
def dont_OPTIONS(self):
pass
from http.server import SimpleHTTPRequestHandler
class MySimpleRequestHandler(SimpleHTTPRequestHandler):
def do_OPTIONS(self):
pass
def dont_OPTIONS(self):
pass

View File

@@ -278,15 +278,3 @@ def f():
for i in src:
if lambda: 0:
dst.append(i)
def f():
i = "xyz"
result = []
for i in range(3):
result.append(x for x in [i])
def f():
i = "xyz"
result = []
for i in range(3):
result.append((x for x in [i]))

View File

@@ -1,5 +0,0 @@
#
x = 0 \
#
+1
print(x)

View File

@@ -1039,10 +1039,14 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) {
flake8_simplify::rules::zip_dict_keys_and_values(checker, call);
}
if checker.any_rule_enabled(&[
Rule::OsChmod,
Rule::OsMkdir,
Rule::OsMakedirs,
Rule::OsRename,
Rule::OsReplace,
Rule::OsStat,
Rule::OsPathJoin,
Rule::OsPathSamefile,
Rule::OsPathSplitext,
Rule::BuiltinOpen,
Rule::PyPath,
@@ -1108,18 +1112,6 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) {
if checker.is_rule_enabled(Rule::OsGetcwd) {
flake8_use_pathlib::rules::os_getcwd(checker, call, segments);
}
if checker.is_rule_enabled(Rule::OsChmod) {
flake8_use_pathlib::rules::os_chmod(checker, call, segments);
}
if checker.is_rule_enabled(Rule::OsRename) {
flake8_use_pathlib::rules::os_rename(checker, call, segments);
}
if checker.is_rule_enabled(Rule::OsReplace) {
flake8_use_pathlib::rules::os_replace(checker, call, segments);
}
if checker.is_rule_enabled(Rule::OsPathSamefile) {
flake8_use_pathlib::rules::os_path_samefile(checker, call, segments);
}
if checker.is_rule_enabled(Rule::PathConstructorCurrentDirectory) {
flake8_use_pathlib::rules::path_constructor_current_directory(
checker, call, segments,

View File

@@ -58,7 +58,7 @@ pub(crate) fn check_tokens(
}
if context.is_rule_enabled(Rule::EmptyComment) {
pylint::rules::empty_comments(context, comment_ranges, locator, indexer);
pylint::rules::empty_comments(context, comment_ranges, locator);
}
if context.is_rule_enabled(Rule::AmbiguousUnicodeCharacterComment) {

View File

@@ -920,11 +920,11 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
// flake8-use-pathlib
(Flake8UsePathlib, "100") => (RuleGroup::Stable, rules::flake8_use_pathlib::rules::OsPathAbspath),
(Flake8UsePathlib, "101") => (RuleGroup::Stable, rules::flake8_use_pathlib::rules::OsChmod),
(Flake8UsePathlib, "101") => (RuleGroup::Stable, rules::flake8_use_pathlib::violations::OsChmod),
(Flake8UsePathlib, "102") => (RuleGroup::Stable, rules::flake8_use_pathlib::violations::OsMkdir),
(Flake8UsePathlib, "103") => (RuleGroup::Stable, rules::flake8_use_pathlib::violations::OsMakedirs),
(Flake8UsePathlib, "104") => (RuleGroup::Stable, rules::flake8_use_pathlib::rules::OsRename),
(Flake8UsePathlib, "105") => (RuleGroup::Stable, rules::flake8_use_pathlib::rules::OsReplace),
(Flake8UsePathlib, "104") => (RuleGroup::Stable, rules::flake8_use_pathlib::violations::OsRename),
(Flake8UsePathlib, "105") => (RuleGroup::Stable, rules::flake8_use_pathlib::violations::OsReplace),
(Flake8UsePathlib, "106") => (RuleGroup::Stable, rules::flake8_use_pathlib::rules::OsRmdir),
(Flake8UsePathlib, "107") => (RuleGroup::Stable, rules::flake8_use_pathlib::rules::OsRemove),
(Flake8UsePathlib, "108") => (RuleGroup::Stable, rules::flake8_use_pathlib::rules::OsUnlink),
@@ -940,7 +940,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
(Flake8UsePathlib, "118") => (RuleGroup::Stable, rules::flake8_use_pathlib::violations::OsPathJoin),
(Flake8UsePathlib, "119") => (RuleGroup::Stable, rules::flake8_use_pathlib::rules::OsPathBasename),
(Flake8UsePathlib, "120") => (RuleGroup::Stable, rules::flake8_use_pathlib::rules::OsPathDirname),
(Flake8UsePathlib, "121") => (RuleGroup::Stable, rules::flake8_use_pathlib::rules::OsPathSamefile),
(Flake8UsePathlib, "121") => (RuleGroup::Stable, rules::flake8_use_pathlib::violations::OsPathSamefile),
(Flake8UsePathlib, "122") => (RuleGroup::Stable, rules::flake8_use_pathlib::violations::OsPathSplitext),
(Flake8UsePathlib, "123") => (RuleGroup::Stable, rules::flake8_use_pathlib::violations::BuiltinOpen),
(Flake8UsePathlib, "124") => (RuleGroup::Stable, rules::flake8_use_pathlib::violations::PyPath),

View File

@@ -75,12 +75,11 @@ where
);
let span = Span::from(file).with_range(range);
let annotation = Annotation::primary(span);
diagnostic.annotate(annotation);
let mut annotation = Annotation::primary(span);
if let Some(suggestion) = suggestion {
diagnostic.help(suggestion);
annotation = annotation.message(suggestion);
}
diagnostic.annotate(annotation);
if let Some(fix) = fix {
diagnostic.set_fix(fix);

View File

@@ -6,12 +6,13 @@ use bitflags::bitflags;
use colored::Colorize;
use ruff_annotate_snippets::{Level, Renderer, Snippet};
use ruff_db::diagnostic::{Diagnostic, DiagnosticFormat, DisplayDiagnosticConfig, SecondaryCode};
use ruff_db::diagnostic::{Diagnostic, SecondaryCode};
use ruff_notebook::NotebookIndex;
use ruff_source_file::OneIndexed;
use ruff_source_file::{LineColumn, OneIndexed};
use ruff_text_size::{TextLen, TextRange, TextSize};
use crate::Locator;
use crate::fs::relativize_path;
use crate::line_width::{IndentWidth, LineWidthBuilder};
use crate::message::diff::Diff;
use crate::message::{Emitter, EmitterContext};
@@ -20,6 +21,8 @@ use crate::settings::types::UnsafeFixes;
bitflags! {
#[derive(Default)]
struct EmitterFlags: u8 {
/// Whether to show the fix status of a diagnostic.
const SHOW_FIX_STATUS = 1 << 0;
/// Whether to show the diff of a fix, for diagnostics that have a fix.
const SHOW_FIX_DIFF = 1 << 1;
/// Whether to show the source code of a diagnostic.
@@ -27,27 +30,17 @@ bitflags! {
}
}
#[derive(Default)]
pub struct TextEmitter {
flags: EmitterFlags,
config: DisplayDiagnosticConfig,
}
impl Default for TextEmitter {
fn default() -> Self {
Self {
flags: EmitterFlags::default(),
config: DisplayDiagnosticConfig::default()
.format(DiagnosticFormat::Concise)
.hide_severity(true)
.color(!cfg!(test) && colored::control::SHOULD_COLORIZE.should_colorize()),
}
}
unsafe_fixes: UnsafeFixes,
}
impl TextEmitter {
#[must_use]
pub fn with_show_fix_status(mut self, show_fix_status: bool) -> Self {
self.config = self.config.show_fix_status(show_fix_status);
self.flags
.set(EmitterFlags::SHOW_FIX_STATUS, show_fix_status);
self
}
@@ -65,15 +58,7 @@ impl TextEmitter {
#[must_use]
pub fn with_unsafe_fixes(mut self, unsafe_fixes: UnsafeFixes) -> Self {
self.config = self
.config
.fix_applicability(unsafe_fixes.required_applicability());
self
}
#[must_use]
pub fn with_preview(mut self, preview: bool) -> Self {
self.config = self.config.preview(preview);
self.unsafe_fixes = unsafe_fixes;
self
}
}
@@ -86,10 +71,51 @@ impl Emitter for TextEmitter {
context: &EmitterContext,
) -> anyhow::Result<()> {
for message in diagnostics {
write!(writer, "{}", message.display(context, &self.config))?;
let filename = message.expect_ruff_filename();
write!(
writer,
"{path}{sep}",
path = relativize_path(&filename).bold(),
sep = ":".cyan(),
)?;
let start_location = message.expect_ruff_start_location();
let notebook_index = context.notebook_index(&filename);
// Check if we're working on a jupyter notebook and translate positions with cell accordingly
let diagnostic_location = if let Some(notebook_index) = notebook_index {
write!(
writer,
"cell {cell}{sep}",
cell = notebook_index
.cell(start_location.line)
.unwrap_or(OneIndexed::MIN),
sep = ":".cyan(),
)?;
LineColumn {
line: notebook_index
.cell_row(start_location.line)
.unwrap_or(OneIndexed::MIN),
column: start_location.column,
}
} else {
start_location
};
writeln!(
writer,
"{row}{sep}{col}{sep} {code_and_body}",
row = diagnostic_location.line,
col = diagnostic_location.column,
sep = ":".cyan(),
code_and_body = RuleCodeAndBody {
message,
show_fix_status: self.flags.intersects(EmitterFlags::SHOW_FIX_STATUS),
unsafe_fixes: self.unsafe_fixes,
}
)?;
if self.flags.intersects(EmitterFlags::SHOW_SOURCE) {
// The `0..0` range is used to highlight file-level diagnostics.
if message.expect_range() != TextRange::default() {
@@ -160,7 +186,7 @@ pub(super) struct MessageCodeFrame<'a> {
impl Display for MessageCodeFrame<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let suggestion = self.message.first_help_text();
let suggestion = self.message.suggestion();
let footers = if let Some(suggestion) = suggestion {
vec![Level::Help.title(suggestion)]
} else {

View File

@@ -134,26 +134,6 @@ pub(crate) const fn is_fix_os_path_dirname_enabled(settings: &LinterSettings) ->
settings.preview.is_enabled()
}
// https://github.com/astral-sh/ruff/pull/19404
pub(crate) const fn is_fix_os_chmod_enabled(settings: &LinterSettings) -> bool {
settings.preview.is_enabled()
}
// https://github.com/astral-sh/ruff/pull/19404
pub(crate) const fn is_fix_os_rename_enabled(settings: &LinterSettings) -> bool {
settings.preview.is_enabled()
}
// https://github.com/astral-sh/ruff/pull/19404
pub(crate) const fn is_fix_os_replace_enabled(settings: &LinterSettings) -> bool {
settings.preview.is_enabled()
}
// https://github.com/astral-sh/ruff/pull/19404
pub(crate) const fn is_fix_os_path_samefile_enabled(settings: &LinterSettings) -> bool {
settings.preview.is_enabled()
}
// https://github.com/astral-sh/ruff/pull/19245
pub(crate) const fn is_fix_os_getcwd_enabled(settings: &LinterSettings) -> bool {
settings.preview.is_enabled()

View File

@@ -64,7 +64,6 @@ pub(crate) fn duplicate_union_member<'a>(checker: &Checker, expr: &'a Expr) {
let mut diagnostics = Vec::new();
let mut union_type = UnionKind::TypingUnion;
let mut optional_present = false;
// Adds a member to `literal_exprs` if it is a `Literal` annotation
let mut check_for_duplicate_members = |expr: &'a Expr, parent: &'a Expr| {
if matches!(parent, Expr::BinOp(_)) {
@@ -75,7 +74,6 @@ pub(crate) fn duplicate_union_member<'a>(checker: &Checker, expr: &'a Expr) {
&& is_optional_type(checker, expr)
{
// If the union member is an `Optional`, add a virtual `None` literal.
optional_present = true;
&VIRTUAL_NONE_LITERAL
} else {
expr
@@ -89,7 +87,7 @@ pub(crate) fn duplicate_union_member<'a>(checker: &Checker, expr: &'a Expr) {
DuplicateUnionMember {
duplicate_name: checker.generator().expr(virtual_expr),
},
// Use the real expression's range for diagnostics.
// Use the real expression's range for diagnostics,
expr.range(),
));
}
@@ -106,13 +104,6 @@ pub(crate) fn duplicate_union_member<'a>(checker: &Checker, expr: &'a Expr) {
return;
}
// Do not reduce `Union[None, ... None]` to avoid introducing a `TypeError` unintentionally
// e.g. `isinstance(None, Union[None, None])`, if reduced to `isinstance(None, None)`, causes
// `TypeError: isinstance() arg 2 must be a type, a tuple of types, or a union` to throw.
if unique_nodes.iter().all(|expr| expr.is_none_literal_expr()) && !optional_present {
return;
}
// Mark [`Fix`] as unsafe when comments are in range.
let applicability = if checker.comment_ranges().intersects(expr.range()) {
Applicability::Unsafe

View File

@@ -974,8 +974,6 @@ PYI016.py:143:61: PYI016 [*] Duplicate union member `complex`
143 |-field48: typing.Union[typing.Optional[typing.Union[complex, complex]], complex]
143 |+field48: typing.Union[typing.Optional[complex], complex]
144 144 | field49: typing.Optional[complex | complex] | complex
145 145 |
146 146 | # Regression test for https://github.com/astral-sh/ruff/issues/19403
PYI016.py:144:36: PYI016 [*] Duplicate union member `complex`
|
@@ -983,8 +981,6 @@ PYI016.py:144:36: PYI016 [*] Duplicate union member `complex`
143 | field48: typing.Union[typing.Optional[typing.Union[complex, complex]], complex]
144 | field49: typing.Optional[complex | complex] | complex
| ^^^^^^^ PYI016
145 |
146 | # Regression test for https://github.com/astral-sh/ruff/issues/19403
|
= help: Remove duplicate union member `complex`
@@ -994,15 +990,3 @@ PYI016.py:144:36: PYI016 [*] Duplicate union member `complex`
143 143 | field48: typing.Union[typing.Optional[typing.Union[complex, complex]], complex]
144 |-field49: typing.Optional[complex | complex] | complex
144 |+field49: typing.Optional[complex] | complex
145 145 |
146 146 | # Regression test for https://github.com/astral-sh/ruff/issues/19403
147 147 | # Should throw duplicate union member but not fix
PYI016.py:148:37: PYI016 Duplicate union member `None`
|
146 | # Regression test for https://github.com/astral-sh/ruff/issues/19403
147 | # Should throw duplicate union member but not fix
148 | isinstance(None, typing.Union[None, None])
| ^^^^ PYI016
|
= help: Remove duplicate union member `None`

View File

@@ -1162,8 +1162,6 @@ PYI016.py:143:61: PYI016 [*] Duplicate union member `complex`
143 |-field48: typing.Union[typing.Optional[typing.Union[complex, complex]], complex]
143 |+field48: typing.Union[None, complex]
144 144 | field49: typing.Optional[complex | complex] | complex
145 145 |
146 146 | # Regression test for https://github.com/astral-sh/ruff/issues/19403
PYI016.py:143:72: PYI016 [*] Duplicate union member `complex`
|
@@ -1181,8 +1179,6 @@ PYI016.py:143:72: PYI016 [*] Duplicate union member `complex`
143 |-field48: typing.Union[typing.Optional[typing.Union[complex, complex]], complex]
143 |+field48: typing.Union[None, complex]
144 144 | field49: typing.Optional[complex | complex] | complex
145 145 |
146 146 | # Regression test for https://github.com/astral-sh/ruff/issues/19403
PYI016.py:144:36: PYI016 [*] Duplicate union member `complex`
|
@@ -1190,8 +1186,6 @@ PYI016.py:144:36: PYI016 [*] Duplicate union member `complex`
143 | field48: typing.Union[typing.Optional[typing.Union[complex, complex]], complex]
144 | field49: typing.Optional[complex | complex] | complex
| ^^^^^^^ PYI016
145 |
146 | # Regression test for https://github.com/astral-sh/ruff/issues/19403
|
= help: Remove duplicate union member `complex`
@@ -1201,9 +1195,6 @@ PYI016.py:144:36: PYI016 [*] Duplicate union member `complex`
143 143 | field48: typing.Union[typing.Optional[typing.Union[complex, complex]], complex]
144 |-field49: typing.Optional[complex | complex] | complex
144 |+field49: None | complex
145 145 |
146 146 | # Regression test for https://github.com/astral-sh/ruff/issues/19403
147 147 | # Should throw duplicate union member but not fix
PYI016.py:144:47: PYI016 [*] Duplicate union member `complex`
|
@@ -1211,8 +1202,6 @@ PYI016.py:144:47: PYI016 [*] Duplicate union member `complex`
143 | field48: typing.Union[typing.Optional[typing.Union[complex, complex]], complex]
144 | field49: typing.Optional[complex | complex] | complex
| ^^^^^^^ PYI016
145 |
146 | # Regression test for https://github.com/astral-sh/ruff/issues/19403
|
= help: Remove duplicate union member `complex`
@@ -1222,15 +1211,3 @@ PYI016.py:144:47: PYI016 [*] Duplicate union member `complex`
143 143 | field48: typing.Union[typing.Optional[typing.Union[complex, complex]], complex]
144 |-field49: typing.Optional[complex | complex] | complex
144 |+field49: None | complex
145 145 |
146 146 | # Regression test for https://github.com/astral-sh/ruff/issues/19403
147 147 | # Should throw duplicate union member but not fix
PYI016.py:148:37: PYI016 Duplicate union member `None`
|
146 | # Regression test for https://github.com/astral-sh/ruff/issues/19403
147 | # Should throw duplicate union member but not fix
148 | isinstance(None, typing.Union[None, None])
| ^^^^ PYI016
|
= help: Remove duplicate union member `None`

View File

@@ -1,8 +1,8 @@
use crate::checkers::ast::Checker;
use crate::importer::ImportRequest;
use crate::{Applicability, Edit, Fix, Violation};
use ruff_python_ast::{self as ast, Expr, ExprCall};
use ruff_python_semantic::{SemanticModel, analyze::typing};
use ruff_python_ast::{self as ast};
use ruff_python_ast::{Expr, ExprCall};
use ruff_text_size::Ranged;
pub(crate) fn is_keyword_only_argument_non_default(arguments: &ast::Arguments, name: &str) -> bool {
@@ -72,85 +72,3 @@ pub(crate) fn check_os_pathlib_single_arg_calls(
});
}
}
pub(crate) fn get_name_expr(expr: &Expr) -> Option<&ast::ExprName> {
match expr {
Expr::Name(name) => Some(name),
Expr::Call(ExprCall { func, .. }) => get_name_expr(func),
_ => None,
}
}
/// Returns `true` if the given expression looks like a file descriptor, i.e., if it is an integer.
pub(crate) fn is_file_descriptor(expr: &Expr, semantic: &SemanticModel) -> bool {
if matches!(
expr,
Expr::NumberLiteral(ast::ExprNumberLiteral {
value: ast::Number::Int(_),
..
})
) {
return true;
}
let Some(name) = get_name_expr(expr) else {
return false;
};
let Some(binding) = semantic.only_binding(name).map(|id| semantic.binding(id)) else {
return false;
};
typing::is_int(binding, semantic)
}
pub(crate) fn check_os_pathlib_two_arg_calls(
checker: &Checker,
call: &ExprCall,
attr: &str,
path_arg: &str,
second_arg: &str,
fix_enabled: bool,
violation: impl Violation,
) {
let range = call.range();
let mut diagnostic = checker.report_diagnostic(violation, call.func.range());
let (Some(path_expr), Some(second_expr)) = (
call.arguments.find_argument_value(path_arg, 0),
call.arguments.find_argument_value(second_arg, 1),
) else {
return;
};
let path_code = checker.locator().slice(path_expr.range());
let second_code = checker.locator().slice(second_expr.range());
if fix_enabled {
diagnostic.try_set_fix(|| {
let (import_edit, binding) = checker.importer().get_or_import_symbol(
&ImportRequest::import("pathlib", "Path"),
call.start(),
checker.semantic(),
)?;
let replacement = if is_pathlib_path_call(checker, path_expr) {
format!("{path_code}.{attr}({second_code})")
} else {
format!("{binding}({path_code}).{attr}({second_code})")
};
let applicability = if checker.comment_ranges().intersects(range) {
Applicability::Unsafe
} else {
Applicability::Safe
};
Ok(Fix::applicable_edits(
Edit::range_replacement(replacement, range),
[import_edit],
applicability,
))
});
}
}

View File

@@ -1,6 +1,5 @@
pub(crate) use glob_rule::*;
pub(crate) use invalid_pathlib_with_suffix::*;
pub(crate) use os_chmod::*;
pub(crate) use os_getcwd::*;
pub(crate) use os_path_abspath::*;
pub(crate) use os_path_basename::*;
@@ -15,11 +14,8 @@ pub(crate) use os_path_isabs::*;
pub(crate) use os_path_isdir::*;
pub(crate) use os_path_isfile::*;
pub(crate) use os_path_islink::*;
pub(crate) use os_path_samefile::*;
pub(crate) use os_readlink::*;
pub(crate) use os_remove::*;
pub(crate) use os_rename::*;
pub(crate) use os_replace::*;
pub(crate) use os_rmdir::*;
pub(crate) use os_sep_split::*;
pub(crate) use os_unlink::*;
@@ -28,7 +24,6 @@ pub(crate) use replaceable_by_pathlib::*;
mod glob_rule;
mod invalid_pathlib_with_suffix;
mod os_chmod;
mod os_getcwd;
mod os_path_abspath;
mod os_path_basename;
@@ -43,11 +38,8 @@ mod os_path_isabs;
mod os_path_isdir;
mod os_path_isfile;
mod os_path_islink;
mod os_path_samefile;
mod os_readlink;
mod os_remove;
mod os_rename;
mod os_replace;
mod os_rmdir;
mod os_sep_split;
mod os_unlink;

View File

@@ -1,94 +0,0 @@
use crate::checkers::ast::Checker;
use crate::preview::is_fix_os_chmod_enabled;
use crate::rules::flake8_use_pathlib::helpers::{
check_os_pathlib_two_arg_calls, is_file_descriptor, is_keyword_only_argument_non_default,
};
use crate::{FixAvailability, Violation};
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::ExprCall;
/// ## What it does
/// Checks for uses of `os.chmod`.
///
/// ## Why is this bad?
/// `pathlib` offers a high-level API for path manipulation, as compared to
/// the lower-level API offered by `os`. When possible, using `Path` object
/// methods such as `Path.chmod()` can improve readability over the `os`
/// module's counterparts (e.g., `os.chmod()`).
///
/// ## Examples
/// ```python
/// import os
///
/// os.chmod("file.py", 0o444)
/// ```
///
/// Use instead:
/// ```python
/// from pathlib import Path
///
/// Path("file.py").chmod(0o444)
/// ```
///
/// ## Known issues
/// While using `pathlib` can improve the readability and type safety of your code,
/// it can be less performant than the lower-level alternatives that work directly with strings,
/// especially on older versions of Python.
///
/// ## Fix Safety
/// This rule's fix is marked as unsafe if the replacement would remove comments attached to the original expression.
///
/// ## References
/// - [Python documentation: `Path.chmod`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.chmod)
/// - [Python documentation: `os.chmod`](https://docs.python.org/3/library/os.html#os.chmod)
/// - [PEP 428 The pathlib module object-oriented filesystem paths](https://peps.python.org/pep-0428/)
/// - [Correspondence between `os` and `pathlib`](https://docs.python.org/3/library/pathlib.html#correspondence-to-tools-in-the-os-module)
/// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/)
/// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/)
#[derive(ViolationMetadata)]
pub(crate) struct OsChmod;
impl Violation for OsChmod {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes;
#[derive_message_formats]
fn message(&self) -> String {
"`os.chmod()` should be replaced by `Path.chmod()`".to_string()
}
fn fix_title(&self) -> Option<String> {
Some("Replace with `Path(...).chmod(...)`".to_string())
}
}
/// PTH101
pub(crate) fn os_chmod(checker: &Checker, call: &ExprCall, segments: &[&str]) {
if segments != ["os", "chmod"] {
return;
}
// `dir_fd` is not supported by pathlib, so check if it's set to non-default values.
// Signature as of Python 3.13 (https://docs.python.org/3/library/os.html#os.chmod)
// ```text
// 0 1 2 3
// os.chmod(path, mode, *, dir_fd=None, follow_symlinks=True)
// ```
if call
.arguments
.find_argument_value("path", 0)
.is_some_and(|expr| is_file_descriptor(expr, checker.semantic()))
|| is_keyword_only_argument_non_default(&call.arguments, "dir_fd")
{
return;
}
check_os_pathlib_two_arg_calls(
checker,
call,
"chmod",
"path",
"mode",
is_fix_os_chmod_enabled(checker.settings()),
OsChmod,
);
}

View File

@@ -1,77 +0,0 @@
use crate::checkers::ast::Checker;
use crate::preview::is_fix_os_path_samefile_enabled;
use crate::rules::flake8_use_pathlib::helpers::check_os_pathlib_two_arg_calls;
use crate::{FixAvailability, Violation};
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::ExprCall;
/// ## What it does
/// Checks for uses of `os.path.samefile`.
///
/// ## Why is this bad?
/// `pathlib` offers a high-level API for path manipulation, as compared to
/// the lower-level API offered by `os.path`. When possible, using `Path` object
/// methods such as `Path.samefile()` can improve readability over the `os.path`
/// module's counterparts (e.g., `os.path.samefile()`).
///
/// ## Examples
/// ```python
/// import os
///
/// os.path.samefile("f1.py", "f2.py")
/// ```
///
/// Use instead:
/// ```python
/// from pathlib import Path
///
/// Path("f1.py").samefile("f2.py")
/// ```
///
/// ## Known issues
/// While using `pathlib` can improve the readability and type safety of your code,
/// it can be less performant than the lower-level alternatives that work directly with strings,
/// especially on older versions of Python.
///
/// ## Fix Safety
/// This rule's fix is marked as unsafe if the replacement would remove comments attached to the original expression.
///
/// ## References
/// - [Python documentation: `Path.samefile`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.samefile)
/// - [Python documentation: `os.path.samefile`](https://docs.python.org/3/library/os.path.html#os.path.samefile)
/// - [PEP 428 The pathlib module object-oriented filesystem paths](https://peps.python.org/pep-0428/)
/// - [Correspondence between `os` and `pathlib`](https://docs.python.org/3/library/pathlib.html#correspondence-to-tools-in-the-os-module)
/// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/)
/// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/)
#[derive(ViolationMetadata)]
pub(crate) struct OsPathSamefile;
impl Violation for OsPathSamefile {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes;
#[derive_message_formats]
fn message(&self) -> String {
"`os.path.samefile()` should be replaced by `Path.samefile()`".to_string()
}
fn fix_title(&self) -> Option<String> {
Some("Replace with `Path(...).samefile()`".to_string())
}
}
/// PTH121
pub(crate) fn os_path_samefile(checker: &Checker, call: &ExprCall, segments: &[&str]) {
if segments != ["os", "path", "samefile"] {
return;
}
check_os_pathlib_two_arg_calls(
checker,
call,
"samefile",
"f1",
"f2",
is_fix_os_path_samefile_enabled(checker.settings()),
OsPathSamefile,
);
}

View File

@@ -1,91 +0,0 @@
use crate::checkers::ast::Checker;
use crate::preview::is_fix_os_rename_enabled;
use crate::rules::flake8_use_pathlib::helpers::{
check_os_pathlib_two_arg_calls, is_keyword_only_argument_non_default,
};
use crate::{FixAvailability, Violation};
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::ExprCall;
/// ## What it does
/// Checks for uses of `os.rename`.
///
/// ## Why is this bad?
/// `pathlib` offers a high-level API for path manipulation, as compared to
/// the lower-level API offered by `os`. When possible, using `Path` object
/// methods such as `Path.rename()` can improve readability over the `os`
/// module's counterparts (e.g., `os.rename()`).
///
/// ## Examples
/// ```python
/// import os
///
/// os.rename("old.py", "new.py")
/// ```
///
/// Use instead:
/// ```python
/// from pathlib import Path
///
/// Path("old.py").rename("new.py")
/// ```
///
/// ## Known issues
/// While using `pathlib` can improve the readability and type safety of your code,
/// it can be less performant than the lower-level alternatives that work directly with strings,
/// especially on older versions of Python.
///
/// ## Fix Safety
/// This rule's fix is marked as unsafe if the replacement would remove comments attached to the original expression.
///
/// ## References
/// - [Python documentation: `Path.rename`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.rename)
/// - [Python documentation: `os.rename`](https://docs.python.org/3/library/os.html#os.rename)
/// - [PEP 428 The pathlib module object-oriented filesystem paths](https://peps.python.org/pep-0428/)
/// - [Correspondence between `os` and `pathlib`](https://docs.python.org/3/library/pathlib.html#correspondence-to-tools-in-the-os-module)
/// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/)
/// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/)
#[derive(ViolationMetadata)]
pub(crate) struct OsRename;
impl Violation for OsRename {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes;
#[derive_message_formats]
fn message(&self) -> String {
"`os.rename()` should be replaced by `Path.rename()`".to_string()
}
fn fix_title(&self) -> Option<String> {
Some("Replace with `Path(...).rename(...)`".to_string())
}
}
/// PTH104
pub(crate) fn os_rename(checker: &Checker, call: &ExprCall, segments: &[&str]) {
if segments != ["os", "rename"] {
return;
}
// `src_dir_fd` and `dst_dir_fd` are not supported by pathlib, so check if they are
// set to non-default values.
// Signature as of Python 3.13 (https://docs.python.org/3/library/os.html#os.rename)
// ```text
// 0 1 2 3
// os.rename(src, dst, *, src_dir_fd=None, dst_dir_fd=None)
// ```
if is_keyword_only_argument_non_default(&call.arguments, "src_dir_fd")
|| is_keyword_only_argument_non_default(&call.arguments, "dst_dir_fd")
{
return;
}
check_os_pathlib_two_arg_calls(
checker,
call,
"rename",
"src",
"dst",
is_fix_os_rename_enabled(checker.settings()),
OsRename,
);
}

View File

@@ -1,94 +0,0 @@
use crate::checkers::ast::Checker;
use crate::preview::is_fix_os_replace_enabled;
use crate::rules::flake8_use_pathlib::helpers::{
check_os_pathlib_two_arg_calls, is_keyword_only_argument_non_default,
};
use crate::{FixAvailability, Violation};
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::ExprCall;
/// ## What it does
/// Checks for uses of `os.replace`.
///
/// ## Why is this bad?
/// `pathlib` offers a high-level API for path manipulation, as compared to
/// the lower-level API offered by `os`. When possible, using `Path` object
/// methods such as `Path.replace()` can improve readability over the `os`
/// module's counterparts (e.g., `os.replace()`).
///
/// Note that `os` functions may be preferable if performance is a concern,
/// e.g., in hot loops.
///
/// ## Examples
/// ```python
/// import os
///
/// os.replace("old.py", "new.py")
/// ```
///
/// Use instead:
/// ```python
/// from pathlib import Path
///
/// Path("old.py").replace("new.py")
/// ```
///
/// ## Known issues
/// While using `pathlib` can improve the readability and type safety of your code,
/// it can be less performant than the lower-level alternatives that work directly with strings,
/// especially on older versions of Python.
///
/// ## Fix Safety
/// This rule's fix is marked as unsafe if the replacement would remove comments attached to the original expression.
///
/// ## References
/// - [Python documentation: `Path.replace`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.replace)
/// - [Python documentation: `os.replace`](https://docs.python.org/3/library/os.html#os.replace)
/// - [PEP 428 The pathlib module object-oriented filesystem paths](https://peps.python.org/pep-0428/)
/// - [Correspondence between `os` and `pathlib`](https://docs.python.org/3/library/pathlib.html#correspondence-to-tools-in-the-os-module)
/// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/)
/// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/)
#[derive(ViolationMetadata)]
pub(crate) struct OsReplace;
impl Violation for OsReplace {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes;
#[derive_message_formats]
fn message(&self) -> String {
"`os.replace()` should be replaced by `Path.replace()`".to_string()
}
fn fix_title(&self) -> Option<String> {
Some("Replace with `Path(...).replace(...)`".to_string())
}
}
/// PTH105
pub(crate) fn os_replace(checker: &Checker, call: &ExprCall, segments: &[&str]) {
if segments != ["os", "replace"] {
return;
}
// `src_dir_fd` and `dst_dir_fd` are not supported by pathlib, so check if they are
// set to non-default values.
// Signature as of Python 3.13 (https://docs.python.org/3/library/os.html#os.replace)
// ```text
// 0 1 2 3
// os.replace(src, dst, *, src_dir_fd=None, dst_dir_fd=None)
// ```
if is_keyword_only_argument_non_default(&call.arguments, "src_dir_fd")
|| is_keyword_only_argument_non_default(&call.arguments, "dst_dir_fd")
{
return;
}
check_os_pathlib_two_arg_calls(
checker,
call,
"replace",
"src",
"dst",
is_fix_os_replace_enabled(checker.settings()),
OsReplace,
);
}

View File

@@ -1,16 +1,14 @@
use ruff_python_ast::{self as ast, Expr, ExprBooleanLiteral, ExprCall};
use ruff_python_semantic::SemanticModel;
use ruff_python_semantic::analyze::typing;
use ruff_text_size::Ranged;
use crate::checkers::ast::Checker;
use crate::rules::flake8_use_pathlib::helpers::{
is_file_descriptor, is_keyword_only_argument_non_default,
};
use crate::rules::flake8_use_pathlib::{
rules::Glob,
violations::{
BuiltinOpen, Joiner, OsListdir, OsMakedirs, OsMkdir, OsPathJoin, OsPathSplitext, OsStat,
OsSymlink, PyPath,
},
use crate::rules::flake8_use_pathlib::helpers::is_keyword_only_argument_non_default;
use crate::rules::flake8_use_pathlib::rules::Glob;
use crate::rules::flake8_use_pathlib::violations::{
BuiltinOpen, Joiner, OsChmod, OsListdir, OsMakedirs, OsMkdir, OsPathJoin, OsPathSamefile,
OsPathSplitext, OsRename, OsReplace, OsStat, OsSymlink, PyPath,
};
pub(crate) fn replaceable_by_pathlib(checker: &Checker, call: &ExprCall) {
@@ -20,6 +18,24 @@ pub(crate) fn replaceable_by_pathlib(checker: &Checker, call: &ExprCall) {
let range = call.func.range();
match qualified_name.segments() {
// PTH101
["os", "chmod"] => {
// `dir_fd` is not supported by pathlib, so check if it's set to non-default values.
// Signature as of Python 3.13 (https://docs.python.org/3/library/os.html#os.chmod)
// ```text
// 0 1 2 3
// os.chmod(path, mode, *, dir_fd=None, follow_symlinks=True)
// ```
if call
.arguments
.find_argument_value("path", 0)
.is_some_and(|expr| is_file_descriptor(expr, checker.semantic()))
|| is_keyword_only_argument_non_default(&call.arguments, "dir_fd")
{
return;
}
checker.report_diagnostic_if_enabled(OsChmod, range)
}
// PTH102
["os", "makedirs"] => checker.report_diagnostic_if_enabled(OsMakedirs, range),
// PTH103
@@ -35,6 +51,38 @@ pub(crate) fn replaceable_by_pathlib(checker: &Checker, call: &ExprCall) {
}
checker.report_diagnostic_if_enabled(OsMkdir, range)
}
// PTH104
["os", "rename"] => {
// `src_dir_fd` and `dst_dir_fd` are not supported by pathlib, so check if they are
// set to non-default values.
// Signature as of Python 3.13 (https://docs.python.org/3/library/os.html#os.rename)
// ```text
// 0 1 2 3
// os.rename(src, dst, *, src_dir_fd=None, dst_dir_fd=None)
// ```
if is_keyword_only_argument_non_default(&call.arguments, "src_dir_fd")
|| is_keyword_only_argument_non_default(&call.arguments, "dst_dir_fd")
{
return;
}
checker.report_diagnostic_if_enabled(OsRename, range)
}
// PTH105
["os", "replace"] => {
// `src_dir_fd` and `dst_dir_fd` are not supported by pathlib, so check if they are
// set to non-default values.
// Signature as of Python 3.13 (https://docs.python.org/3/library/os.html#os.replace)
// ```text
// 0 1 2 3
// os.replace(src, dst, *, src_dir_fd=None, dst_dir_fd=None)
// ```
if is_keyword_only_argument_non_default(&call.arguments, "src_dir_fd")
|| is_keyword_only_argument_non_default(&call.arguments, "dst_dir_fd")
{
return;
}
checker.report_diagnostic_if_enabled(OsReplace, range)
}
// PTH116
["os", "stat"] => {
// `dir_fd` is not supported by pathlib, so check if it's set to non-default values.
@@ -76,6 +124,8 @@ pub(crate) fn replaceable_by_pathlib(checker: &Checker, call: &ExprCall) {
},
range,
),
// PTH121
["os", "path", "samefile"] => checker.report_diagnostic_if_enabled(OsPathSamefile, range),
// PTH122
["os", "path", "splitext"] => checker.report_diagnostic_if_enabled(OsPathSplitext, range),
// PTH211
@@ -184,6 +234,37 @@ pub(crate) fn replaceable_by_pathlib(checker: &Checker, call: &ExprCall) {
};
}
/// Returns `true` if the given expression looks like a file descriptor, i.e., if it is an integer.
fn is_file_descriptor(expr: &Expr, semantic: &SemanticModel) -> bool {
if matches!(
expr,
Expr::NumberLiteral(ast::ExprNumberLiteral {
value: ast::Number::Int(_),
..
})
) {
return true;
}
let Some(name) = get_name_expr(expr) else {
return false;
};
let Some(binding) = semantic.only_binding(name).map(|id| semantic.binding(id)) else {
return false;
};
typing::is_int(binding, semantic)
}
fn get_name_expr(expr: &Expr) -> Option<&ast::ExprName> {
match expr {
Expr::Name(name) => Some(name),
Expr::Call(ExprCall { func, .. }) => get_name_expr(func),
_ => None,
}
}
/// Returns `true` if argument `name` is set to a non-default `None` value.
fn is_argument_non_default(arguments: &ast::Arguments, name: &str, position: usize) -> bool {
arguments

View File

@@ -20,7 +20,6 @@ full_name.py:8:6: PTH101 `os.chmod()` should be replaced by `Path.chmod()`
9 | aaa = os.mkdir(p)
10 | os.makedirs(p)
|
= help: Replace with `Path(...).chmod(...)`
full_name.py:9:7: PTH102 `os.mkdir()` should be replaced by `Path.mkdir()`
|
@@ -51,7 +50,6 @@ full_name.py:11:1: PTH104 `os.rename()` should be replaced by `Path.rename()`
12 | os.replace(p)
13 | os.rmdir(p)
|
= help: Replace with `Path(...).rename(...)`
full_name.py:12:1: PTH105 `os.replace()` should be replaced by `Path.replace()`
|
@@ -62,7 +60,6 @@ full_name.py:12:1: PTH105 `os.replace()` should be replaced by `Path.replace()`
13 | os.rmdir(p)
14 | os.remove(p)
|
= help: Replace with `Path(...).replace(...)`
full_name.py:13:1: PTH106 `os.rmdir()` should be replaced by `Path.rmdir()`
|
@@ -256,7 +253,6 @@ full_name.py:30:1: PTH121 `os.path.samefile()` should be replaced by `Path.samef
31 | os.path.splitext(p)
32 | with open(p) as fp:
|
= help: Replace with `Path(...).samefile()`
full_name.py:31:1: PTH122 `os.path.splitext()` should be replaced by `Path.suffix`, `Path.stem`, and `Path.parent`
|

View File

@@ -20,7 +20,6 @@ import_as.py:8:6: PTH101 `os.chmod()` should be replaced by `Path.chmod()`
9 | aaa = foo.mkdir(p)
10 | foo.makedirs(p)
|
= help: Replace with `Path(...).chmod(...)`
import_as.py:9:7: PTH102 `os.mkdir()` should be replaced by `Path.mkdir()`
|
@@ -51,7 +50,6 @@ import_as.py:11:1: PTH104 `os.rename()` should be replaced by `Path.rename()`
12 | foo.replace(p)
13 | foo.rmdir(p)
|
= help: Replace with `Path(...).rename(...)`
import_as.py:12:1: PTH105 `os.replace()` should be replaced by `Path.replace()`
|
@@ -62,7 +60,6 @@ import_as.py:12:1: PTH105 `os.replace()` should be replaced by `Path.replace()`
13 | foo.rmdir(p)
14 | foo.remove(p)
|
= help: Replace with `Path(...).replace(...)`
import_as.py:13:1: PTH106 `os.rmdir()` should be replaced by `Path.rmdir()`
|
@@ -255,7 +252,6 @@ import_as.py:30:1: PTH121 `os.path.samefile()` should be replaced by `Path.samef
| ^^^^^^^^^^^^^^ PTH121
31 | foo_p.splitext(p)
|
= help: Replace with `Path(...).samefile()`
import_as.py:31:1: PTH122 `os.path.splitext()` should be replaced by `Path.suffix`, `Path.stem`, and `Path.parent`
|

View File

@@ -20,7 +20,6 @@ import_from.py:10:6: PTH101 `os.chmod()` should be replaced by `Path.chmod()`
11 | aaa = mkdir(p)
12 | makedirs(p)
|
= help: Replace with `Path(...).chmod(...)`
import_from.py:11:7: PTH102 `os.mkdir()` should be replaced by `Path.mkdir()`
|
@@ -51,7 +50,6 @@ import_from.py:13:1: PTH104 `os.rename()` should be replaced by `Path.rename()`
14 | replace(p)
15 | rmdir(p)
|
= help: Replace with `Path(...).rename(...)`
import_from.py:14:1: PTH105 `os.replace()` should be replaced by `Path.replace()`
|
@@ -62,7 +60,6 @@ import_from.py:14:1: PTH105 `os.replace()` should be replaced by `Path.replace()
15 | rmdir(p)
16 | remove(p)
|
= help: Replace with `Path(...).replace(...)`
import_from.py:15:1: PTH106 `os.rmdir()` should be replaced by `Path.rmdir()`
|
@@ -256,7 +253,6 @@ import_from.py:32:1: PTH121 `os.path.samefile()` should be replaced by `Path.sam
33 | splitext(p)
34 | with open(p) as fp:
|
= help: Replace with `Path(...).samefile()`
import_from.py:33:1: PTH122 `os.path.splitext()` should be replaced by `Path.suffix`, `Path.stem`, and `Path.parent`
|
@@ -293,36 +289,3 @@ import_from.py:43:10: PTH123 `open()` should be replaced by `Path.open()`
43 | with open(p) as _: ... # Error
| ^^^^ PTH123
|
import_from.py:53:1: PTH104 `os.rename()` should be replaced by `Path.rename()`
|
51 | file = "file_1.py"
52 |
53 | rename(file, "file_2.py")
| ^^^^^^ PTH104
54 |
55 | rename(
|
= help: Replace with `Path(...).rename(...)`
import_from.py:55:1: PTH104 `os.rename()` should be replaced by `Path.rename()`
|
53 | rename(file, "file_2.py")
54 |
55 | rename(
| ^^^^^^ PTH104
56 | # commment 1
57 | file, # comment 2
|
= help: Replace with `Path(...).rename(...)`
import_from.py:63:1: PTH104 `os.rename()` should be replaced by `Path.rename()`
|
61 | )
62 |
63 | rename(file, "file_2.py", src_dir_fd=None, dst_dir_fd=None)
| ^^^^^^ PTH104
64 |
65 | rename(file, "file_2.py", src_dir_fd=1)
|
= help: Replace with `Path(...).rename(...)`

View File

@@ -20,7 +20,6 @@ import_from_as.py:15:6: PTH101 `os.chmod()` should be replaced by `Path.chmod()`
16 | aaa = xmkdir(p)
17 | xmakedirs(p)
|
= help: Replace with `Path(...).chmod(...)`
import_from_as.py:16:7: PTH102 `os.mkdir()` should be replaced by `Path.mkdir()`
|
@@ -51,7 +50,6 @@ import_from_as.py:18:1: PTH104 `os.rename()` should be replaced by `Path.rename(
19 | xreplace(p)
20 | xrmdir(p)
|
= help: Replace with `Path(...).rename(...)`
import_from_as.py:19:1: PTH105 `os.replace()` should be replaced by `Path.replace()`
|
@@ -62,7 +60,6 @@ import_from_as.py:19:1: PTH105 `os.replace()` should be replaced by `Path.replac
20 | xrmdir(p)
21 | xremove(p)
|
= help: Replace with `Path(...).replace(...)`
import_from_as.py:20:1: PTH106 `os.rmdir()` should be replaced by `Path.rmdir()`
|
@@ -255,7 +252,6 @@ import_from_as.py:37:1: PTH121 `os.path.samefile()` should be replaced by `Path.
| ^^^^^^^^^ PTH121
38 | xsplitext(p)
|
= help: Replace with `Path(...).samefile()`
import_from_as.py:38:1: PTH122 `os.path.splitext()` should be replaced by `Path.suffix`, `Path.stem`, and `Path.parent`
|

View File

@@ -34,7 +34,6 @@ full_name.py:8:6: PTH101 `os.chmod()` should be replaced by `Path.chmod()`
9 | aaa = os.mkdir(p)
10 | os.makedirs(p)
|
= help: Replace with `Path(...).chmod(...)`
full_name.py:9:7: PTH102 `os.mkdir()` should be replaced by `Path.mkdir()`
|
@@ -65,7 +64,6 @@ full_name.py:11:1: PTH104 `os.rename()` should be replaced by `Path.rename()`
12 | os.replace(p)
13 | os.rmdir(p)
|
= help: Replace with `Path(...).rename(...)`
full_name.py:12:1: PTH105 `os.replace()` should be replaced by `Path.replace()`
|
@@ -76,7 +74,6 @@ full_name.py:12:1: PTH105 `os.replace()` should be replaced by `Path.replace()`
13 | os.rmdir(p)
14 | os.remove(p)
|
= help: Replace with `Path(...).replace(...)`
full_name.py:13:1: PTH106 [*] `os.rmdir()` should be replaced by `Path.rmdir()`
|
@@ -474,7 +471,6 @@ full_name.py:30:1: PTH121 `os.path.samefile()` should be replaced by `Path.samef
31 | os.path.splitext(p)
32 | with open(p) as fp:
|
= help: Replace with `Path(...).samefile()`
full_name.py:31:1: PTH122 `os.path.splitext()` should be replaced by `Path.suffix`, `Path.stem`, and `Path.parent`
|

View File

@@ -34,7 +34,6 @@ import_as.py:8:6: PTH101 `os.chmod()` should be replaced by `Path.chmod()`
9 | aaa = foo.mkdir(p)
10 | foo.makedirs(p)
|
= help: Replace with `Path(...).chmod(...)`
import_as.py:9:7: PTH102 `os.mkdir()` should be replaced by `Path.mkdir()`
|
@@ -65,7 +64,6 @@ import_as.py:11:1: PTH104 `os.rename()` should be replaced by `Path.rename()`
12 | foo.replace(p)
13 | foo.rmdir(p)
|
= help: Replace with `Path(...).rename(...)`
import_as.py:12:1: PTH105 `os.replace()` should be replaced by `Path.replace()`
|
@@ -76,7 +74,6 @@ import_as.py:12:1: PTH105 `os.replace()` should be replaced by `Path.replace()`
13 | foo.rmdir(p)
14 | foo.remove(p)
|
= help: Replace with `Path(...).replace(...)`
import_as.py:13:1: PTH106 [*] `os.rmdir()` should be replaced by `Path.rmdir()`
|
@@ -472,7 +469,6 @@ import_as.py:30:1: PTH121 `os.path.samefile()` should be replaced by `Path.samef
| ^^^^^^^^^^^^^^ PTH121
31 | foo_p.splitext(p)
|
= help: Replace with `Path(...).samefile()`
import_as.py:31:1: PTH122 `os.path.splitext()` should be replaced by `Path.suffix`, `Path.stem`, and `Path.parent`
|

View File

@@ -35,7 +35,6 @@ import_from.py:10:6: PTH101 `os.chmod()` should be replaced by `Path.chmod()`
11 | aaa = mkdir(p)
12 | makedirs(p)
|
= help: Replace with `Path(...).chmod(...)`
import_from.py:11:7: PTH102 `os.mkdir()` should be replaced by `Path.mkdir()`
|
@@ -66,7 +65,6 @@ import_from.py:13:1: PTH104 `os.rename()` should be replaced by `Path.rename()`
14 | replace(p)
15 | rmdir(p)
|
= help: Replace with `Path(...).rename(...)`
import_from.py:14:1: PTH105 `os.replace()` should be replaced by `Path.replace()`
|
@@ -77,7 +75,6 @@ import_from.py:14:1: PTH105 `os.replace()` should be replaced by `Path.replace()
15 | rmdir(p)
16 | remove(p)
|
= help: Replace with `Path(...).replace(...)`
import_from.py:15:1: PTH106 [*] `os.rmdir()` should be replaced by `Path.rmdir()`
|
@@ -487,7 +484,6 @@ import_from.py:32:1: PTH121 `os.path.samefile()` should be replaced by `Path.sam
33 | splitext(p)
34 | with open(p) as fp:
|
= help: Replace with `Path(...).samefile()`
import_from.py:33:1: PTH122 `os.path.splitext()` should be replaced by `Path.suffix`, `Path.stem`, and `Path.parent`
|
@@ -524,95 +520,3 @@ import_from.py:43:10: PTH123 `open()` should be replaced by `Path.open()`
43 | with open(p) as _: ... # Error
| ^^^^ PTH123
|
import_from.py:53:1: PTH104 [*] `os.rename()` should be replaced by `Path.rename()`
|
51 | file = "file_1.py"
52 |
53 | rename(file, "file_2.py")
| ^^^^^^ PTH104
54 |
55 | rename(
|
= help: Replace with `Path(...).rename(...)`
Safe fix
2 2 | from os import remove, unlink, getcwd, readlink, stat
3 3 | from os.path import abspath, exists, expanduser, isdir, isfile, islink
4 4 | from os.path import isabs, join, basename, dirname, samefile, splitext
5 |+import pathlib
5 6 |
6 7 | p = "/foo"
7 8 | q = "bar"
--------------------------------------------------------------------------------
50 51 |
51 52 | file = "file_1.py"
52 53 |
53 |-rename(file, "file_2.py")
54 |+pathlib.Path(file).rename("file_2.py")
54 55 |
55 56 | rename(
56 57 | # commment 1
import_from.py:55:1: PTH104 [*] `os.rename()` should be replaced by `Path.rename()`
|
53 | rename(file, "file_2.py")
54 |
55 | rename(
| ^^^^^^ PTH104
56 | # commment 1
57 | file, # comment 2
|
= help: Replace with `Path(...).rename(...)`
Unsafe fix
2 2 | from os import remove, unlink, getcwd, readlink, stat
3 3 | from os.path import abspath, exists, expanduser, isdir, isfile, islink
4 4 | from os.path import isabs, join, basename, dirname, samefile, splitext
5 |+import pathlib
5 6 |
6 7 | p = "/foo"
7 8 | q = "bar"
--------------------------------------------------------------------------------
52 53 |
53 54 | rename(file, "file_2.py")
54 55 |
55 |-rename(
56 |- # commment 1
57 |- file, # comment 2
58 |- "file_2.py"
59 |- ,
60 |- # comment 3
61 |-)
56 |+pathlib.Path(file).rename("file_2.py")
62 57 |
63 58 | rename(file, "file_2.py", src_dir_fd=None, dst_dir_fd=None)
64 59 |
import_from.py:63:1: PTH104 [*] `os.rename()` should be replaced by `Path.rename()`
|
61 | )
62 |
63 | rename(file, "file_2.py", src_dir_fd=None, dst_dir_fd=None)
| ^^^^^^ PTH104
64 |
65 | rename(file, "file_2.py", src_dir_fd=1)
|
= help: Replace with `Path(...).rename(...)`
Safe fix
2 2 | from os import remove, unlink, getcwd, readlink, stat
3 3 | from os.path import abspath, exists, expanduser, isdir, isfile, islink
4 4 | from os.path import isabs, join, basename, dirname, samefile, splitext
5 |+import pathlib
5 6 |
6 7 | p = "/foo"
7 8 | q = "bar"
--------------------------------------------------------------------------------
60 61 | # comment 3
61 62 | )
62 63 |
63 |-rename(file, "file_2.py", src_dir_fd=None, dst_dir_fd=None)
64 |+pathlib.Path(file).rename("file_2.py")
64 65 |
65 66 | rename(file, "file_2.py", src_dir_fd=1)

View File

@@ -35,7 +35,6 @@ import_from_as.py:15:6: PTH101 `os.chmod()` should be replaced by `Path.chmod()`
16 | aaa = xmkdir(p)
17 | xmakedirs(p)
|
= help: Replace with `Path(...).chmod(...)`
import_from_as.py:16:7: PTH102 `os.mkdir()` should be replaced by `Path.mkdir()`
|
@@ -66,7 +65,6 @@ import_from_as.py:18:1: PTH104 `os.rename()` should be replaced by `Path.rename(
19 | xreplace(p)
20 | xrmdir(p)
|
= help: Replace with `Path(...).rename(...)`
import_from_as.py:19:1: PTH105 `os.replace()` should be replaced by `Path.replace()`
|
@@ -77,7 +75,6 @@ import_from_as.py:19:1: PTH105 `os.replace()` should be replaced by `Path.replac
20 | xrmdir(p)
21 | xremove(p)
|
= help: Replace with `Path(...).replace(...)`
import_from_as.py:20:1: PTH106 [*] `os.rmdir()` should be replaced by `Path.rmdir()`
|
@@ -485,7 +482,6 @@ import_from_as.py:37:1: PTH121 `os.path.samefile()` should be replaced by `Path.
| ^^^^^^^^^ PTH121
38 | xsplitext(p)
|
= help: Replace with `Path(...).samefile()`
import_from_as.py:38:1: PTH122 `os.path.splitext()` should be replaced by `Path.suffix`, `Path.stem`, and `Path.parent`
|

View File

@@ -2,6 +2,51 @@ use ruff_macros::{ViolationMetadata, derive_message_formats};
use crate::Violation;
/// ## What it does
/// Checks for uses of `os.chmod`.
///
/// ## Why is this bad?
/// `pathlib` offers a high-level API for path manipulation, as compared to
/// the lower-level API offered by `os`. When possible, using `Path` object
/// methods such as `Path.chmod()` can improve readability over the `os`
/// module's counterparts (e.g., `os.chmod()`).
///
/// ## Examples
/// ```python
/// import os
///
/// os.chmod("file.py", 0o444)
/// ```
///
/// Use instead:
/// ```python
/// from pathlib import Path
///
/// Path("file.py").chmod(0o444)
/// ```
///
/// ## Known issues
/// While using `pathlib` can improve the readability and type safety of your code,
/// it can be less performant than the lower-level alternatives that work directly with strings,
/// especially on older versions of Python.
///
/// ## References
/// - [Python documentation: `Path.chmod`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.chmod)
/// - [Python documentation: `os.chmod`](https://docs.python.org/3/library/os.html#os.chmod)
/// - [PEP 428 The pathlib module object-oriented filesystem paths](https://peps.python.org/pep-0428/)
/// - [Correspondence between `os` and `pathlib`](https://docs.python.org/3/library/pathlib.html#correspondence-to-tools-in-the-os-module)
/// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/)
/// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/)
#[derive(ViolationMetadata)]
pub(crate) struct OsChmod;
impl Violation for OsChmod {
#[derive_message_formats]
fn message(&self) -> String {
"`os.chmod()` should be replaced by `Path.chmod()`".to_string()
}
}
/// ## What it does
/// Checks for uses of `os.makedirs`.
///
@@ -92,6 +137,99 @@ impl Violation for OsMkdir {
}
}
/// ## What it does
/// Checks for uses of `os.rename`.
///
/// ## Why is this bad?
/// `pathlib` offers a high-level API for path manipulation, as compared to
/// the lower-level API offered by `os`. When possible, using `Path` object
/// methods such as `Path.rename()` can improve readability over the `os`
/// module's counterparts (e.g., `os.rename()`).
///
/// ## Examples
/// ```python
/// import os
///
/// os.rename("old.py", "new.py")
/// ```
///
/// Use instead:
/// ```python
/// from pathlib import Path
///
/// Path("old.py").rename("new.py")
/// ```
///
/// ## Known issues
/// While using `pathlib` can improve the readability and type safety of your code,
/// it can be less performant than the lower-level alternatives that work directly with strings,
/// especially on older versions of Python.
///
/// ## References
/// - [Python documentation: `Path.rename`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.rename)
/// - [Python documentation: `os.rename`](https://docs.python.org/3/library/os.html#os.rename)
/// - [PEP 428 The pathlib module object-oriented filesystem paths](https://peps.python.org/pep-0428/)
/// - [Correspondence between `os` and `pathlib`](https://docs.python.org/3/library/pathlib.html#correspondence-to-tools-in-the-os-module)
/// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/)
/// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/)
#[derive(ViolationMetadata)]
pub(crate) struct OsRename;
impl Violation for OsRename {
#[derive_message_formats]
fn message(&self) -> String {
"`os.rename()` should be replaced by `Path.rename()`".to_string()
}
}
/// ## What it does
/// Checks for uses of `os.replace`.
///
/// ## Why is this bad?
/// `pathlib` offers a high-level API for path manipulation, as compared to
/// the lower-level API offered by `os`. When possible, using `Path` object
/// methods such as `Path.replace()` can improve readability over the `os`
/// module's counterparts (e.g., `os.replace()`).
///
/// Note that `os` functions may be preferable if performance is a concern,
/// e.g., in hot loops.
///
/// ## Examples
/// ```python
/// import os
///
/// os.replace("old.py", "new.py")
/// ```
///
/// Use instead:
/// ```python
/// from pathlib import Path
///
/// Path("old.py").replace("new.py")
/// ```
///
/// ## Known issues
/// While using `pathlib` can improve the readability and type safety of your code,
/// it can be less performant than the lower-level alternatives that work directly with strings,
/// especially on older versions of Python.
///
/// ## References
/// - [Python documentation: `Path.replace`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.replace)
/// - [Python documentation: `os.replace`](https://docs.python.org/3/library/os.html#os.replace)
/// - [PEP 428 The pathlib module object-oriented filesystem paths](https://peps.python.org/pep-0428/)
/// - [Correspondence between `os` and `pathlib`](https://docs.python.org/3/library/pathlib.html#correspondence-to-tools-in-the-os-module)
/// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/)
/// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/)
#[derive(ViolationMetadata)]
pub(crate) struct OsReplace;
impl Violation for OsReplace {
#[derive_message_formats]
fn message(&self) -> String {
"`os.replace()` should be replaced by `Path.replace()`".to_string()
}
}
/// ## What it does
/// Checks for uses of `os.stat`.
///
@@ -209,6 +347,51 @@ pub(crate) enum Joiner {
Joinpath,
}
/// ## What it does
/// Checks for uses of `os.path.samefile`.
///
/// ## Why is this bad?
/// `pathlib` offers a high-level API for path manipulation, as compared to
/// the lower-level API offered by `os.path`. When possible, using `Path` object
/// methods such as `Path.samefile()` can improve readability over the `os.path`
/// module's counterparts (e.g., `os.path.samefile()`).
///
/// ## Examples
/// ```python
/// import os
///
/// os.path.samefile("f1.py", "f2.py")
/// ```
///
/// Use instead:
/// ```python
/// from pathlib import Path
///
/// Path("f1.py").samefile("f2.py")
/// ```
///
/// ## Known issues
/// While using `pathlib` can improve the readability and type safety of your code,
/// it can be less performant than the lower-level alternatives that work directly with strings,
/// especially on older versions of Python.
///
/// ## References
/// - [Python documentation: `Path.samefile`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.samefile)
/// - [Python documentation: `os.path.samefile`](https://docs.python.org/3/library/os.path.html#os.path.samefile)
/// - [PEP 428 The pathlib module object-oriented filesystem paths](https://peps.python.org/pep-0428/)
/// - [Correspondence between `os` and `pathlib`](https://docs.python.org/3/library/pathlib.html#correspondence-to-tools-in-the-os-module)
/// - [Why you should be using pathlib](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/)
/// - [No really, pathlib is great](https://treyhunner.com/2019/01/no-really-pathlib-is-great/)
#[derive(ViolationMetadata)]
pub(crate) struct OsPathSamefile;
impl Violation for OsPathSamefile {
#[derive_message_formats]
fn message(&self) -> String {
"`os.path.samefile()` should be replaced by `Path.samefile()`".to_string()
}
}
/// ## What it does
/// Checks for uses of `os.path.splitext`.
///

View File

@@ -100,7 +100,7 @@ pub(crate) fn invalid_function_name(
return;
}
// Ignore the do_* methods of the http.server.BaseHTTPRequestHandler class and its subclasses
// Ignore the do_* methods of the http.server.BaseHTTPRequestHandler class
if name.starts_with("do_")
&& parent_class.is_some_and(|class| {
any_base_class(class, semantic, &mut |superclass| {
@@ -108,13 +108,7 @@ pub(crate) fn invalid_function_name(
qualified.is_some_and(|name| {
matches!(
name.segments(),
[
"http",
"server",
"BaseHTTPRequestHandler"
| "CGIHTTPRequestHandler"
| "SimpleHTTPRequestHandler"
]
["http", "server", "BaseHTTPRequestHandler"]
)
})
})

View File

@@ -55,21 +55,3 @@ N802.py:84:9: N802 Function name `dont_GET` should be lowercase
| ^^^^^^^^ N802
85 | pass
|
N802.py:95:9: N802 Function name `dont_OPTIONS` should be lowercase
|
93 | pass
94 |
95 | def dont_OPTIONS(self):
| ^^^^^^^^^^^^ N802
96 | pass
|
N802.py:106:9: N802 Function name `dont_OPTIONS` should be lowercase
|
104 | pass
105 |
106 | def dont_OPTIONS(self):
| ^^^^^^^^^^^^ N802
107 | pass
|

View File

@@ -406,14 +406,7 @@ fn convert_to_list_extend(
};
let target_str = locator.slice(for_stmt.target.range());
let elt_str = locator.slice(to_append);
let generator_str = if to_append
.as_generator_expr()
.is_some_and(|generator| !generator.parenthesized)
{
format!("({elt_str}) {for_type} {target_str} in {for_iter_str}{if_str}")
} else {
format!("{elt_str} {for_type} {target_str} in {for_iter_str}{if_str}")
};
let generator_str = format!("{elt_str} {for_type} {target_str} in {for_iter_str}{if_str}");
let variable_name = locator.slice(binding);
let for_loop_inline_comments = comment_strings_in_range(

View File

@@ -241,27 +241,5 @@ PERF401.py:280:13: PERF401 Use `list.extend` to create a transformed list
279 | if lambda: 0:
280 | dst.append(i)
| ^^^^^^^^^^^^^ PERF401
281 |
282 | def f():
|
= help: Replace for loop with list.extend
PERF401.py:286:9: PERF401 Use a list comprehension to create a transformed list
|
284 | result = []
285 | for i in range(3):
286 | result.append(x for x in [i])
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PERF401
287 |
288 | def f():
|
= help: Replace for loop with list comprehension
PERF401.py:292:9: PERF401 Use a list comprehension to create a transformed list
|
290 | result = []
291 | for i in range(3):
292 | result.append((x for x in [i]))
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PERF401
|
= help: Replace for loop with list comprehension

View File

@@ -566,8 +566,6 @@ PERF401.py:280:13: PERF401 [*] Use `list.extend` to create a transformed list
279 | if lambda: 0:
280 | dst.append(i)
| ^^^^^^^^^^^^^ PERF401
281 |
282 | def f():
|
= help: Replace for loop with list.extend
@@ -579,47 +577,3 @@ PERF401.py:280:13: PERF401 [*] Use `list.extend` to create a transformed list
279 |- if lambda: 0:
280 |- dst.append(i)
278 |+ dst.extend(i for i in src if (lambda: 0))
281 279 |
282 280 | def f():
283 281 | i = "xyz"
PERF401.py:286:9: PERF401 [*] Use a list comprehension to create a transformed list
|
284 | result = []
285 | for i in range(3):
286 | result.append(x for x in [i])
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PERF401
287 |
288 | def f():
|
= help: Replace for loop with list comprehension
Unsafe fix
281 281 |
282 282 | def f():
283 283 | i = "xyz"
284 |- result = []
285 |- for i in range(3):
286 |- result.append(x for x in [i])
284 |+ result = [(x for x in [i]) for i in range(3)]
287 285 |
288 286 | def f():
289 287 | i = "xyz"
PERF401.py:292:9: PERF401 [*] Use a list comprehension to create a transformed list
|
290 | result = []
291 | for i in range(3):
292 | result.append((x for x in [i]))
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PERF401
|
= help: Replace for loop with list comprehension
Unsafe fix
287 287 |
288 288 | def f():
289 289 | i = "xyz"
290 |- result = []
291 |- for i in range(3):
292 |- result.append((x for x in [i]))
290 |+ result = [(x for x in [i]) for i in range(3)]

View File

@@ -48,7 +48,6 @@ mod tests {
#[test_case(Rule::ComparisonWithItself, Path::new("comparison_with_itself.py"))]
#[test_case(Rule::EqWithoutHash, Path::new("eq_without_hash.py"))]
#[test_case(Rule::EmptyComment, Path::new("empty_comment.py"))]
#[test_case(Rule::EmptyComment, Path::new("empty_comment_line_continuation.py"))]
#[test_case(Rule::ManualFromImport, Path::new("import_aliasing.py"))]
#[test_case(Rule::IfStmtMinMax, Path::new("if_stmt_min_max.py"))]
#[test_case(Rule::SingleStringSlots, Path::new("single_string_slots.py"))]

View File

@@ -1,5 +1,4 @@
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_index::Indexer;
use ruff_python_trivia::{CommentRanges, is_python_whitespace};
use ruff_source_file::LineRanges;
use ruff_text_size::{TextRange, TextSize};
@@ -50,7 +49,6 @@ pub(crate) fn empty_comments(
context: &LintContext,
comment_ranges: &CommentRanges,
locator: &Locator,
indexer: &Indexer,
) {
let block_comments = comment_ranges.block_comments(locator.contents());
@@ -61,12 +59,12 @@ pub(crate) fn empty_comments(
}
// If the line contains an empty comment, add a diagnostic.
empty_comment(context, range, locator, indexer);
empty_comment(context, range, locator);
}
}
/// Return a [`Diagnostic`] if the comment at the given [`TextRange`] is empty.
fn empty_comment(context: &LintContext, range: TextRange, locator: &Locator, indexer: &Indexer) {
fn empty_comment(context: &LintContext, range: TextRange, locator: &Locator) {
// Check: is the comment empty?
if !locator
.slice(range)
@@ -97,20 +95,12 @@ fn empty_comment(context: &LintContext, range: TextRange, locator: &Locator, ind
}
});
// If there is no character preceding the comment, this comment must be on its own physical line.
// If there is a line preceding the empty comment's line, check if it ends in a line continuation character. (`\`)
let is_on_same_logical_line = indexer
.preceded_by_continuations(first_hash_col, locator.contents())
.is_some();
if let Some(mut diagnostic) = context
.report_diagnostic_if_enabled(EmptyComment, TextRange::new(first_hash_col, line.end()))
{
diagnostic.set_fix(Fix::safe_edit(
if let Some(deletion_start_col) = deletion_start_col {
Edit::deletion(line.start() + deletion_start_col, line.end())
} else if is_on_same_logical_line {
Edit::deletion(first_hash_col, line.end())
} else {
Edit::range_deletion(locator.full_line_range(first_hash_col))
},

View File

@@ -1,36 +0,0 @@
---
source: crates/ruff_linter/src/rules/pylint/mod.rs
---
empty_comment_line_continuation.py:1:1: PLR2044 [*] Line with empty comment
|
1 | #
| ^ PLR2044
2 | x = 0 \
3 | #
|
= help: Delete the empty comment
Safe fix
1 |-#
2 1 | x = 0 \
3 2 | #
4 3 | +1
empty_comment_line_continuation.py:3:1: PLR2044 [*] Line with empty comment
|
1 | #
2 | x = 0 \
3 | #
| ^ PLR2044
4 | +1
5 | print(x)
|
= help: Delete the empty comment
Safe fix
1 1 | #
2 2 | x = 0 \
3 |-#
3 |+
4 4 | +1
5 5 | print(x)

View File

@@ -272,7 +272,7 @@ Either ensure you always emit a fix or change `Violation::FIX_AVAILABILITY` to e
}
assert!(
!(fixable && diagnostic.first_help_text().is_none()),
!(fixable && diagnostic.suggestion().is_none()),
"Diagnostic emitted by {rule:?} is fixable but \
`Violation::fix_title` returns `None`"
);

View File

@@ -235,7 +235,12 @@ impl TraversalSignal {
}
pub fn walk_annotation<'a, V: SourceOrderVisitor<'a> + ?Sized>(visitor: &mut V, expr: &'a Expr) {
visitor.visit_expr(expr);
let node = AnyNodeRef::from(expr);
if visitor.enter_node(node).is_traverse() {
visitor.visit_expr(expr);
}
visitor.leave_node(node);
}
pub fn walk_decorator<'a, V>(visitor: &mut V, decorator: &'a Decorator)

View File

@@ -163,7 +163,7 @@ fn stem(path: &str) -> &str {
}
/// Infer the [`Visibility`] of a module from its path.
pub(crate) fn module_visibility(module: Module) -> Visibility {
pub(crate) fn module_visibility(module: &Module) -> Visibility {
match &module.source {
ModuleSource::Path(path) => {
if path.iter().any(|m| is_private_module(m)) {

View File

@@ -223,7 +223,7 @@ impl<'a> Definitions<'a> {
// visibility.
let visibility = {
match &definition {
Definition::Module(module) => module_visibility(*module),
Definition::Module(module) => module_visibility(module),
Definition::Member(member) => match member.kind {
MemberKind::Class(class) => {
let parent = &definitions[member.parent];

View File

@@ -238,7 +238,7 @@ fn to_lsp_diagnostic(
let name = diagnostic.name();
let body = diagnostic.body().to_string();
let fix = diagnostic.fix();
let suggestion = diagnostic.first_help_text();
let suggestion = diagnostic.suggestion();
let code = diagnostic.secondary_code();
let fix = fix.and_then(|fix| fix.applies(Applicability::Unsafe).then_some(fix));

View File

@@ -1,6 +1,6 @@
[package]
name = "ruff_wasm"
version = "0.12.5"
version = "0.12.4"
publish = false
authors = { workspace = true }
edition = { workspace = true }

View File

@@ -234,7 +234,7 @@ impl Workspace {
start_location: source_code.line_column(msg.expect_range().start()).into(),
end_location: source_code.line_column(msg.expect_range().end()).into(),
fix: msg.fix().map(|fix| ExpandedFix {
message: msg.first_help_text().map(ToString::to_string),
message: msg.suggestion().map(ToString::to_string),
edits: fix
.edits()
.iter()

View File

@@ -16,7 +16,7 @@ Checks for byte-strings in type annotation positions.
**Why is this bad?**
Static analysis tools like ty can't analyze type annotations that use byte-string notation.
Static analysis tools like ty can't analyse type annotations that use byte-string notation.
**Examples**
@@ -257,7 +257,7 @@ Checks for f-strings in type annotation positions.
**Why is this bad?**
Static analysis tools like ty can't analyze type annotations that use f-string notation.
Static analysis tools like ty can't analyse type annotations that use f-string notation.
**Examples**
@@ -286,7 +286,7 @@ Checks for implicit concatenated strings in type annotation positions.
**Why is this bad?**
Static analysis tools like ty can't analyze type annotations that use implicit concatenated strings.
Static analysis tools like ty can't analyse type annotations that use implicit concatenated strings.
**Examples**
@@ -1276,7 +1276,7 @@ Checks for raw-strings in type annotation positions.
**Why is this bad?**
Static analysis tools like ty can't analyze type annotations that use raw-string notation.
Static analysis tools like ty can't analyse type annotations that use raw-string notation.
**Examples**

View File

@@ -676,7 +676,7 @@ fn invalid_include_pattern_concise_output() -> anyhow::Result<()> {
----- stderr -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
ty failed
Cause: ty.toml:4:5: error[invalid-glob] Invalid include pattern: Too many stars at position 5
Cause: error[invalid-glob] ty.toml:4:5: Invalid include pattern: Too many stars at position 5
");
Ok(())

View File

@@ -592,8 +592,8 @@ fn concise_diagnostics() -> anyhow::Result<()> {
success: false
exit_code: 1
----- stdout -----
test.py:2:7: warning[unresolved-reference] Name `x` used when not defined
test.py:3:7: error[non-subscriptable] Cannot subscript object of type `Literal[4]` with no `__getitem__` method
warning[unresolved-reference] test.py:2:7: Name `x` used when not defined
error[non-subscriptable] test.py:3:7: Cannot subscript object of type `Literal[4]` with no `__getitem__` method
Found 2 diagnostics
----- stderr -----
@@ -627,7 +627,7 @@ fn concise_revealed_type() -> anyhow::Result<()> {
success: true
exit_code: 0
----- stdout -----
test.py:5:13: info[revealed-type] Revealed type: `Literal["hello"]`
info[revealed-type] test.py:5:13: Revealed type: `Literal["hello"]`
Found 1 diagnostic
----- stderr -----

View File

@@ -230,21 +230,6 @@ impl TestCase {
fn system_file(&self, path: impl AsRef<SystemPath>) -> Result<File, FileError> {
system_path_to_file(self.db(), path.as_ref())
}
fn module<'c>(&'c self, name: &str) -> Module<'c> {
resolve_module(self.db(), &ModuleName::new(name).unwrap()).expect("module to be present")
}
fn sorted_submodule_names(&self, parent_module_name: &str) -> Vec<String> {
let mut names = self
.module(parent_module_name)
.all_submodules(self.db())
.iter()
.map(|name| name.as_str().to_string())
.collect::<Vec<String>>();
names.sort();
names
}
}
trait MatchEvent {
@@ -1413,7 +1398,7 @@ mod unix {
let baz = resolve_module(case.db(), &ModuleName::new_static("bar.baz").unwrap())
.expect("Expected bar.baz to exist in site-packages.");
let baz_project = case.project_path("bar/baz.py");
let baz_file = baz.file(case.db()).unwrap();
let baz_file = baz.file().unwrap();
assert_eq!(source_text(case.db(), baz_file).as_str(), "def baz(): ...");
assert_eq!(
@@ -1488,7 +1473,7 @@ mod unix {
let baz = resolve_module(case.db(), &ModuleName::new_static("bar.baz").unwrap())
.expect("Expected bar.baz to exist in site-packages.");
let baz_file = baz.file(case.db()).unwrap();
let baz_file = baz.file().unwrap();
let bar_baz = case.project_path("bar/baz.py");
let patched_bar_baz = case.project_path("patched/bar/baz.py");
@@ -1609,10 +1594,7 @@ mod unix {
"def baz(): ..."
);
assert_eq!(
baz.file(case.db())
.unwrap()
.path(case.db())
.as_system_path(),
baz.file().unwrap().path(case.db()).as_system_path(),
Some(&*baz_original)
);
@@ -1909,9 +1891,19 @@ fn rename_files_casing_only() -> anyhow::Result<()> {
#[test]
fn submodule_cache_invalidation_created() -> anyhow::Result<()> {
let mut case = setup([("lib.py", ""), ("bar/__init__.py", ""), ("bar/foo.py", "")])?;
let module = resolve_module(case.db(), &ModuleName::new("bar").unwrap()).expect("`bar` module");
let get_submodules = |db: &dyn Db, module: &Module| {
let mut names = module
.all_submodules(db)
.iter()
.map(|name| name.as_str().to_string())
.collect::<Vec<String>>();
names.sort();
names.join("\n")
};
insta::assert_snapshot!(
case.sorted_submodule_names("bar").join("\n"),
get_submodules(case.db(), &module),
@"foo",
);
@@ -1920,7 +1912,7 @@ fn submodule_cache_invalidation_created() -> anyhow::Result<()> {
case.apply_changes(changes, None);
insta::assert_snapshot!(
case.sorted_submodule_names("bar").join("\n"),
get_submodules(case.db(), &module),
@r"
foo
wazoo
@@ -1940,9 +1932,19 @@ fn submodule_cache_invalidation_deleted() -> anyhow::Result<()> {
("bar/foo.py", ""),
("bar/wazoo.py", ""),
])?;
let module = resolve_module(case.db(), &ModuleName::new("bar").unwrap()).expect("`bar` module");
let get_submodules = |db: &dyn Db, module: &Module| {
let mut names = module
.all_submodules(db)
.iter()
.map(|name| name.as_str().to_string())
.collect::<Vec<String>>();
names.sort();
names.join("\n")
};
insta::assert_snapshot!(
case.sorted_submodule_names("bar").join("\n"),
get_submodules(case.db(), &module),
@r"
foo
wazoo
@@ -1954,7 +1956,7 @@ fn submodule_cache_invalidation_deleted() -> anyhow::Result<()> {
case.apply_changes(changes, None);
insta::assert_snapshot!(
case.sorted_submodule_names("bar").join("\n"),
get_submodules(case.db(), &module),
@"foo",
);
@@ -1966,9 +1968,19 @@ fn submodule_cache_invalidation_deleted() -> anyhow::Result<()> {
#[test]
fn submodule_cache_invalidation_created_then_deleted() -> anyhow::Result<()> {
let mut case = setup([("lib.py", ""), ("bar/__init__.py", ""), ("bar/foo.py", "")])?;
let module = resolve_module(case.db(), &ModuleName::new("bar").unwrap()).expect("`bar` module");
let get_submodules = |db: &dyn Db, module: &Module| {
let mut names = module
.all_submodules(db)
.iter()
.map(|name| name.as_str().to_string())
.collect::<Vec<String>>();
names.sort();
names.join("\n")
};
insta::assert_snapshot!(
case.sorted_submodule_names("bar").join("\n"),
get_submodules(case.db(), &module),
@"foo",
);
@@ -1981,7 +1993,7 @@ fn submodule_cache_invalidation_created_then_deleted() -> anyhow::Result<()> {
case.apply_changes(changes, None);
insta::assert_snapshot!(
case.sorted_submodule_names("bar").join("\n"),
get_submodules(case.db(), &module),
@"foo",
);
@@ -1994,9 +2006,19 @@ fn submodule_cache_invalidation_created_then_deleted() -> anyhow::Result<()> {
#[test]
fn submodule_cache_invalidation_after_pyproject_created() -> anyhow::Result<()> {
let mut case = setup([("lib.py", ""), ("bar/__init__.py", ""), ("bar/foo.py", "")])?;
let module = resolve_module(case.db(), &ModuleName::new("bar").unwrap()).expect("`bar` module");
let get_submodules = |db: &dyn Db, module: &Module| {
let mut names = module
.all_submodules(db)
.iter()
.map(|name| name.as_str().to_string())
.collect::<Vec<String>>();
names.sort();
names.join("\n")
};
insta::assert_snapshot!(
case.sorted_submodule_names("bar").join("\n"),
get_submodules(case.db(), &module),
@"foo",
);
@@ -2007,7 +2029,7 @@ fn submodule_cache_invalidation_after_pyproject_created() -> anyhow::Result<()>
case.apply_changes(changes, None);
insta::assert_snapshot!(
case.sorted_submodule_names("bar").join("\n"),
get_submodules(case.db(), &module),
@r"
foo
wazoo

View File

@@ -19,15 +19,15 @@ ruff_python_trivia = { workspace = true }
ruff_source_file = { workspace = true }
ruff_text_size = { workspace = true }
ty_python_semantic = { workspace = true }
ty_project = { workspace = true, features = ["testing"] }
itertools = { workspace = true }
regex = { workspace = true }
rustc-hash = { workspace = true }
salsa = { workspace = true }
smallvec = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
ty_vendored = { workspace = true }
insta = { workspace = true, features = ["filters"] }

117
crates/ty_ide/src/db.rs Normal file
View File

@@ -0,0 +1,117 @@
use ty_python_semantic::Db as SemanticDb;
#[salsa::db]
pub trait Db: SemanticDb {}
#[cfg(test)]
pub(crate) mod tests {
use std::sync::{Arc, Mutex};
use super::Db;
use ruff_db::Db as SourceDb;
use ruff_db::files::{File, Files};
use ruff_db::system::{DbWithTestSystem, System, TestSystem};
use ruff_db::vendored::VendoredFileSystem;
use ty_python_semantic::lint::{LintRegistry, RuleSelection};
use ty_python_semantic::{Db as SemanticDb, Program, default_lint_registry};
type Events = Arc<Mutex<Vec<salsa::Event>>>;
#[salsa::db]
#[derive(Clone)]
pub(crate) struct TestDb {
storage: salsa::Storage<Self>,
files: Files,
system: TestSystem,
vendored: VendoredFileSystem,
events: Events,
rule_selection: Arc<RuleSelection>,
}
#[expect(dead_code)]
impl TestDb {
pub(crate) fn new() -> Self {
let events = Events::default();
Self {
storage: salsa::Storage::new(Some(Box::new({
let events = events.clone();
move |event| {
tracing::trace!("event: {event:?}");
let mut events = events.lock().unwrap();
events.push(event);
}
}))),
system: TestSystem::default(),
vendored: ty_vendored::file_system().clone(),
events,
files: Files::default(),
rule_selection: Arc::new(RuleSelection::from_registry(default_lint_registry())),
}
}
/// Takes the salsa events.
pub(crate) fn take_salsa_events(&mut self) -> Vec<salsa::Event> {
let mut events = self.events.lock().unwrap();
std::mem::take(&mut *events)
}
/// Clears the salsa events.
///
/// ## Panics
/// If there are any pending salsa snapshots.
pub(crate) fn clear_salsa_events(&mut self) {
self.take_salsa_events();
}
}
impl DbWithTestSystem for TestDb {
fn test_system(&self) -> &TestSystem {
&self.system
}
fn test_system_mut(&mut self) -> &mut TestSystem {
&mut self.system
}
}
#[salsa::db]
impl SourceDb for TestDb {
fn vendored(&self) -> &VendoredFileSystem {
&self.vendored
}
fn system(&self) -> &dyn System {
&self.system
}
fn files(&self) -> &Files {
&self.files
}
fn python_version(&self) -> ruff_python_ast::PythonVersion {
Program::get(self).python_version(self)
}
}
#[salsa::db]
impl SemanticDb for TestDb {
fn should_check_file(&self, file: File) -> bool {
!file.path(self).is_vendored_path()
}
fn rule_selection(&self, _file: File) -> &RuleSelection {
&self.rule_selection
}
fn lint_registry(&self) -> &LintRegistry {
default_lint_registry()
}
}
#[salsa::db]
impl Db for TestDb {}
#[salsa::db]
impl salsa::Database for TestDb {}
}

View File

@@ -52,7 +52,9 @@ pub(crate) fn covering_node(root: AnyNodeRef, range: TextRange) -> CoveringNode
if visitor.ancestors.is_empty() {
visitor.ancestors.push(root);
}
CoveringNode::from_ancestors(visitor.ancestors)
CoveringNode {
nodes: visitor.ancestors,
}
}
/// The node with a minimal range that fully contains the search range.
@@ -65,12 +67,6 @@ pub(crate) struct CoveringNode<'a> {
}
impl<'a> CoveringNode<'a> {
/// Creates a new `CoveringNode` from a list of ancestor nodes.
/// The ancestors should be ordered from root to the covering node.
pub(crate) fn from_ancestors(ancestors: Vec<AnyNodeRef<'a>>) -> Self {
Self { nodes: ancestors }
}
/// Returns the covering node found.
pub(crate) fn node(&self) -> AnyNodeRef<'a> {
*self

View File

@@ -2,8 +2,6 @@ pub use crate::goto_declaration::goto_declaration;
pub use crate::goto_definition::goto_definition;
pub use crate::goto_type_definition::goto_type_definition;
use std::borrow::Cow;
use crate::find_node::covering_node;
use crate::stub_mapping::StubMapper;
use ruff_db::parsed::ParsedModuleRef;
@@ -272,270 +270,10 @@ impl GotoTarget<'_> {
definitions_to_navigation_targets(db, stub_mapper, definitions)
}
// For exception variables, they are their own definitions (like parameters)
GotoTarget::ExceptVariable(except_handler) => {
if let Some(name) = &except_handler.name {
let range = name.range;
Some(crate::NavigationTargets::single(NavigationTarget::new(
file, range,
)))
} else {
None
}
}
// For pattern match rest variables, they are their own definitions
GotoTarget::PatternMatchRest(pattern_mapping) => {
if let Some(rest_name) = &pattern_mapping.rest {
let range = rest_name.range;
Some(crate::NavigationTargets::single(NavigationTarget::new(
file, range,
)))
} else {
None
}
}
// For pattern match as names, they are their own definitions
GotoTarget::PatternMatchAsName(pattern_as) => {
if let Some(name) = &pattern_as.name {
let range = name.range;
Some(crate::NavigationTargets::single(NavigationTarget::new(
file, range,
)))
} else {
None
}
}
// TODO: Handle string literals that map to TypedDict fields
_ => None,
}
}
/// Returns the text representation of this goto target.
/// Returns `None` if no meaningful string representation can be provided.
/// This is used by the "references" feature, which looks for references
/// to this goto target.
pub(crate) fn to_string(&self) -> Option<Cow<str>> {
match self {
GotoTarget::Expression(expression) => match expression {
ast::ExprRef::Name(name) => Some(Cow::Borrowed(name.id.as_str())),
ast::ExprRef::Attribute(attr) => Some(Cow::Borrowed(attr.attr.as_str())),
_ => None,
},
GotoTarget::FunctionDef(function) => Some(Cow::Borrowed(function.name.as_str())),
GotoTarget::ClassDef(class) => Some(Cow::Borrowed(class.name.as_str())),
GotoTarget::Parameter(parameter) => Some(Cow::Borrowed(parameter.name.as_str())),
GotoTarget::ImportSymbolAlias { alias, .. } => {
if let Some(asname) = &alias.asname {
Some(Cow::Borrowed(asname.as_str()))
} else {
Some(Cow::Borrowed(alias.name.as_str()))
}
}
GotoTarget::ImportModuleComponent {
module_name,
component_index,
..
} => {
let components: Vec<&str> = module_name.split('.').collect();
if let Some(component) = components.get(*component_index) {
Some(Cow::Borrowed(*component))
} else {
Some(Cow::Borrowed(module_name))
}
}
GotoTarget::ImportModuleAlias { alias } => {
if let Some(asname) = &alias.asname {
Some(Cow::Borrowed(asname.as_str()))
} else {
Some(Cow::Borrowed(alias.name.as_str()))
}
}
GotoTarget::ExceptVariable(except) => {
Some(Cow::Borrowed(except.name.as_ref()?.as_str()))
}
GotoTarget::KeywordArgument { keyword, .. } => {
Some(Cow::Borrowed(keyword.arg.as_ref()?.as_str()))
}
GotoTarget::PatternMatchRest(rest) => Some(Cow::Borrowed(rest.rest.as_ref()?.as_str())),
GotoTarget::PatternKeywordArgument(keyword) => {
Some(Cow::Borrowed(keyword.attr.as_str()))
}
GotoTarget::PatternMatchStarName(star) => {
Some(Cow::Borrowed(star.name.as_ref()?.as_str()))
}
GotoTarget::PatternMatchAsName(as_name) => {
Some(Cow::Borrowed(as_name.name.as_ref()?.as_str()))
}
GotoTarget::TypeParamTypeVarName(type_var) => {
Some(Cow::Borrowed(type_var.name.as_str()))
}
GotoTarget::TypeParamParamSpecName(spec) => Some(Cow::Borrowed(spec.name.as_str())),
GotoTarget::TypeParamTypeVarTupleName(tuple) => {
Some(Cow::Borrowed(tuple.name.as_str()))
}
GotoTarget::NonLocal { identifier, .. } => Some(Cow::Borrowed(identifier.as_str())),
GotoTarget::Globals { identifier, .. } => Some(Cow::Borrowed(identifier.as_str())),
}
}
/// Creates a `GotoTarget` from a `CoveringNode` and an offset within the node
pub(crate) fn from_covering_node<'a>(
covering_node: &crate::find_node::CoveringNode<'a>,
offset: TextSize,
) -> Option<GotoTarget<'a>> {
tracing::trace!("Covering node is of kind {:?}", covering_node.node().kind());
match covering_node.node() {
AnyNodeRef::Identifier(identifier) => match covering_node.parent() {
Some(AnyNodeRef::StmtFunctionDef(function)) => {
Some(GotoTarget::FunctionDef(function))
}
Some(AnyNodeRef::StmtClassDef(class)) => Some(GotoTarget::ClassDef(class)),
Some(AnyNodeRef::Parameter(parameter)) => Some(GotoTarget::Parameter(parameter)),
Some(AnyNodeRef::Alias(alias)) => {
// Find the containing import statement to determine the type
let import_stmt = covering_node.ancestors().find(|node| {
matches!(
node,
AnyNodeRef::StmtImport(_) | AnyNodeRef::StmtImportFrom(_)
)
});
match import_stmt {
Some(AnyNodeRef::StmtImport(_)) => {
// Regular import statement like "import x.y as z"
// Is the offset within the alias name (asname) part?
if let Some(asname) = &alias.asname {
if asname.range.contains_inclusive(offset) {
return Some(GotoTarget::ImportModuleAlias { alias });
}
}
// Is the offset in the module name part?
if alias.name.range.contains_inclusive(offset) {
let full_name = alias.name.as_str();
if let Some((component_index, component_range)) =
find_module_component(
full_name,
alias.name.range.start(),
offset,
)
{
return Some(GotoTarget::ImportModuleComponent {
module_name: full_name.to_string(),
component_index,
component_range,
});
}
}
None
}
Some(AnyNodeRef::StmtImportFrom(import_from)) => {
// From import statement like "from x import y as z"
// Is the offset within the alias name (asname) part?
if let Some(asname) = &alias.asname {
if asname.range.contains_inclusive(offset) {
return Some(GotoTarget::ImportSymbolAlias {
alias,
range: asname.range,
import_from,
});
}
}
// Is the offset in the original name part?
if alias.name.range.contains_inclusive(offset) {
return Some(GotoTarget::ImportSymbolAlias {
alias,
range: alias.name.range,
import_from,
});
}
None
}
_ => None,
}
}
Some(AnyNodeRef::StmtImportFrom(from)) => {
// Handle offset within module name in from import statements
if let Some(module_expr) = &from.module {
let full_module_name = module_expr.to_string();
if let Some((component_index, component_range)) = find_module_component(
&full_module_name,
module_expr.range.start(),
offset,
) {
return Some(GotoTarget::ImportModuleComponent {
module_name: full_module_name,
component_index,
component_range,
});
}
}
None
}
Some(AnyNodeRef::ExceptHandlerExceptHandler(handler)) => {
Some(GotoTarget::ExceptVariable(handler))
}
Some(AnyNodeRef::Keyword(keyword)) => {
// Find the containing call expression from the ancestor chain
let call_expression = covering_node
.ancestors()
.find_map(ruff_python_ast::AnyNodeRef::expr_call)?;
Some(GotoTarget::KeywordArgument {
keyword,
call_expression,
})
}
Some(AnyNodeRef::PatternMatchMapping(mapping)) => {
Some(GotoTarget::PatternMatchRest(mapping))
}
Some(AnyNodeRef::PatternKeyword(keyword)) => {
Some(GotoTarget::PatternKeywordArgument(keyword))
}
Some(AnyNodeRef::PatternMatchStar(star)) => {
Some(GotoTarget::PatternMatchStarName(star))
}
Some(AnyNodeRef::PatternMatchAs(as_pattern)) => {
Some(GotoTarget::PatternMatchAsName(as_pattern))
}
Some(AnyNodeRef::TypeParamTypeVar(var)) => {
Some(GotoTarget::TypeParamTypeVarName(var))
}
Some(AnyNodeRef::TypeParamParamSpec(bound)) => {
Some(GotoTarget::TypeParamParamSpecName(bound))
}
Some(AnyNodeRef::TypeParamTypeVarTuple(var_tuple)) => {
Some(GotoTarget::TypeParamTypeVarTupleName(var_tuple))
}
Some(AnyNodeRef::ExprAttribute(attribute)) => {
Some(GotoTarget::Expression(attribute.into()))
}
Some(AnyNodeRef::StmtNonlocal(_)) => Some(GotoTarget::NonLocal { identifier }),
Some(AnyNodeRef::StmtGlobal(_)) => Some(GotoTarget::Globals { identifier }),
None => None,
Some(parent) => {
tracing::debug!(
"Missing `GoToTarget` for identifier with parent {:?}",
parent.kind()
);
None
}
},
node => node.as_expr_ref().map(GotoTarget::Expression),
}
}
}
impl Ranged for GotoTarget<'_> {
@@ -590,7 +328,11 @@ fn convert_resolved_definitions_to_targets(
}
ty_python_semantic::ResolvedDefinition::FileWithRange(file_range) => {
// For file ranges, navigate to the specific range within the file
crate::NavigationTarget::new(file_range.file(), file_range.range())
crate::NavigationTarget {
file: file_range.file(),
focus_range: file_range.range(),
full_range: file_range.range(),
}
}
})
.collect()
@@ -633,7 +375,145 @@ pub(crate) fn find_goto_target(
.find_first(|node| node.is_identifier() || node.is_expression())
.ok()?;
GotoTarget::from_covering_node(&covering_node, offset)
tracing::trace!("Covering node is of kind {:?}", covering_node.node().kind());
match covering_node.node() {
AnyNodeRef::Identifier(identifier) => match covering_node.parent() {
Some(AnyNodeRef::StmtFunctionDef(function)) => Some(GotoTarget::FunctionDef(function)),
Some(AnyNodeRef::StmtClassDef(class)) => Some(GotoTarget::ClassDef(class)),
Some(AnyNodeRef::Parameter(parameter)) => Some(GotoTarget::Parameter(parameter)),
Some(AnyNodeRef::Alias(alias)) => {
// Find the containing import statement to determine the type
let import_stmt = covering_node.ancestors().find(|node| {
matches!(
node,
AnyNodeRef::StmtImport(_) | AnyNodeRef::StmtImportFrom(_)
)
});
match import_stmt {
Some(AnyNodeRef::StmtImport(_)) => {
// Regular import statement like "import x.y as z"
// Is the offset within the alias name (asname) part?
if let Some(asname) = &alias.asname {
if asname.range.contains_inclusive(offset) {
return Some(GotoTarget::ImportModuleAlias { alias });
}
}
// Is the offset in the module name part?
if alias.name.range.contains_inclusive(offset) {
let full_name = alias.name.as_str();
if let Some((component_index, component_range)) =
find_module_component(full_name, alias.name.range.start(), offset)
{
return Some(GotoTarget::ImportModuleComponent {
module_name: full_name.to_string(),
component_index,
component_range,
});
}
}
None
}
Some(AnyNodeRef::StmtImportFrom(import_from)) => {
// From import statement like "from x import y as z"
// Is the offset within the alias name (asname) part?
if let Some(asname) = &alias.asname {
if asname.range.contains_inclusive(offset) {
return Some(GotoTarget::ImportSymbolAlias {
alias,
range: asname.range,
import_from,
});
}
}
// Is the offset in the original name part?
if alias.name.range.contains_inclusive(offset) {
return Some(GotoTarget::ImportSymbolAlias {
alias,
range: alias.name.range,
import_from,
});
}
None
}
_ => None,
}
}
Some(AnyNodeRef::StmtImportFrom(from)) => {
// Handle offset within module name in from import statements
if let Some(module_expr) = &from.module {
let full_module_name = module_expr.to_string();
if let Some((component_index, component_range)) =
find_module_component(&full_module_name, module_expr.range.start(), offset)
{
return Some(GotoTarget::ImportModuleComponent {
module_name: full_module_name,
component_index,
component_range,
});
}
}
None
}
Some(AnyNodeRef::ExceptHandlerExceptHandler(handler)) => {
Some(GotoTarget::ExceptVariable(handler))
}
Some(AnyNodeRef::Keyword(keyword)) => {
// Find the containing call expression from the ancestor chain
let call_expression = covering_node
.ancestors()
.find_map(ruff_python_ast::AnyNodeRef::expr_call)?;
Some(GotoTarget::KeywordArgument {
keyword,
call_expression,
})
}
Some(AnyNodeRef::PatternMatchMapping(mapping)) => {
Some(GotoTarget::PatternMatchRest(mapping))
}
Some(AnyNodeRef::PatternKeyword(keyword)) => {
Some(GotoTarget::PatternKeywordArgument(keyword))
}
Some(AnyNodeRef::PatternMatchStar(star)) => {
Some(GotoTarget::PatternMatchStarName(star))
}
Some(AnyNodeRef::PatternMatchAs(as_pattern)) => {
Some(GotoTarget::PatternMatchAsName(as_pattern))
}
Some(AnyNodeRef::TypeParamTypeVar(var)) => Some(GotoTarget::TypeParamTypeVarName(var)),
Some(AnyNodeRef::TypeParamParamSpec(bound)) => {
Some(GotoTarget::TypeParamParamSpecName(bound))
}
Some(AnyNodeRef::TypeParamTypeVarTuple(var_tuple)) => {
Some(GotoTarget::TypeParamTypeVarTupleName(var_tuple))
}
Some(AnyNodeRef::ExprAttribute(attribute)) => {
Some(GotoTarget::Expression(attribute.into()))
}
Some(AnyNodeRef::StmtNonlocal(_)) => Some(GotoTarget::NonLocal { identifier }),
Some(AnyNodeRef::StmtGlobal(_)) => Some(GotoTarget::Globals { identifier }),
None => None,
Some(parent) => {
tracing::debug!(
"Missing `GoToTarget` for identifier with parent {:?}",
parent.kind()
);
None
}
},
node => node.as_expr_ref().map(GotoTarget::Expression),
}
}
/// Helper function to resolve a module name and create a navigation target.
@@ -645,10 +525,12 @@ fn resolve_module_to_navigation_target(
if let Some(module_name) = ModuleName::new(module_name_str) {
if let Some(resolved_module) = resolve_module(db, &module_name) {
if let Some(module_file) = resolved_module.file(db) {
return Some(crate::NavigationTargets::single(
crate::NavigationTarget::new(module_file, TextRange::default()),
));
if let Some(module_file) = resolved_module.file() {
return Some(crate::NavigationTargets::single(crate::NavigationTarget {
file: module_file,
focus_range: TextRange::default(),
full_range: TextRange::default(),
}));
}
}
}

View File

@@ -32,7 +32,6 @@ mod tests {
use insta::assert_snapshot;
use ruff_db::diagnostic::{
Annotation, Diagnostic, DiagnosticId, LintName, Severity, Span, SubDiagnostic,
SubDiagnosticSeverity,
};
use ruff_db::files::FileRange;
use ruff_text_size::Ranged;
@@ -1350,7 +1349,7 @@ class MyClass:
impl IntoDiagnostic for GotoDeclarationDiagnostic {
fn into_diagnostic(self) -> Diagnostic {
let mut source = SubDiagnostic::new(SubDiagnosticSeverity::Info, "Source");
let mut source = SubDiagnostic::new(Severity::Info, "Source");
source.annotate(Annotation::primary(
Span::from(self.source.file()).with_range(self.source.range()),
));

View File

@@ -29,569 +29,3 @@ pub fn goto_definition(
value: definition_targets,
})
}
#[cfg(test)]
mod test {
use crate::tests::{CursorTest, IntoDiagnostic};
use crate::{NavigationTarget, goto_definition};
use insta::assert_snapshot;
use ruff_db::diagnostic::{
Annotation, Diagnostic, DiagnosticId, LintName, Severity, Span, SubDiagnostic,
SubDiagnosticSeverity,
};
use ruff_db::files::FileRange;
use ruff_text_size::Ranged;
/// goto-definition on a module should go to the .py not the .pyi
///
/// TODO: this currently doesn't work right! This is especially surprising
/// because [`goto_definition_stub_map_module_ref`] works fine.
#[test]
fn goto_definition_stub_map_module_import() {
let test = CursorTest::builder()
.source(
"main.py",
"
from mymo<CURSOR>dule import my_function
",
)
.source(
"mymodule.py",
r#"
def my_function():
return "hello"
"#,
)
.source(
"mymodule.pyi",
r#"
def my_function(): ...
"#,
)
.build();
assert_snapshot!(test.goto_definition(), @r"
info[goto-definition]: Definition
--> mymodule.pyi:1:1
|
1 |
| ^
2 | def my_function(): ...
|
info: Source
--> main.py:2:6
|
2 | from mymodule import my_function
| ^^^^^^^^
|
");
}
/// goto-definition on a module ref should go to the .py not the .pyi
#[test]
fn goto_definition_stub_map_module_ref() {
let test = CursorTest::builder()
.source(
"main.py",
"
import mymodule
x = mymo<CURSOR>dule
",
)
.source(
"mymodule.py",
r#"
def my_function():
return "hello"
"#,
)
.source(
"mymodule.pyi",
r#"
def my_function(): ...
"#,
)
.build();
assert_snapshot!(test.goto_definition(), @r#"
info[goto-definition]: Definition
--> mymodule.py:1:1
|
1 |
| ^
2 | def my_function():
3 | return "hello"
|
info: Source
--> main.py:3:5
|
2 | import mymodule
3 | x = mymodule
| ^^^^^^^^
|
"#);
}
/// goto-definition on a function call should go to the .py not the .pyi
#[test]
fn goto_definition_stub_map_function() {
let test = CursorTest::builder()
.source(
"main.py",
"
from mymodule import my_function
print(my_func<CURSOR>tion())
",
)
.source(
"mymodule.py",
r#"
def my_function():
return "hello"
def other_function():
return "other"
"#,
)
.source(
"mymodule.pyi",
r#"
def my_function(): ...
def other_function(): ...
"#,
)
.build();
assert_snapshot!(test.goto_definition(), @r#"
info[goto-definition]: Definition
--> mymodule.py:2:5
|
2 | def my_function():
| ^^^^^^^^^^^
3 | return "hello"
|
info: Source
--> main.py:3:7
|
2 | from mymodule import my_function
3 | print(my_function())
| ^^^^^^^^^^^
|
"#);
}
/// goto-definition on a function that's redefined many times in the impl .py
///
/// Currently this yields all instances. There's an argument for only yielding
/// the final one since that's the one "exported" but, this is consistent for
/// how we do file-local goto-definition.
#[test]
fn goto_definition_stub_map_function_redefine() {
let test = CursorTest::builder()
.source(
"main.py",
"
from mymodule import my_function
print(my_func<CURSOR>tion())
",
)
.source(
"mymodule.py",
r#"
def my_function():
return "hello"
def my_function():
return "hello again"
def my_function():
return "we can't keep doing this"
def other_function():
return "other"
"#,
)
.source(
"mymodule.pyi",
r#"
def my_function(): ...
def other_function(): ...
"#,
)
.build();
assert_snapshot!(test.goto_definition(), @r#"
info[goto-definition]: Definition
--> mymodule.py:2:5
|
2 | def my_function():
| ^^^^^^^^^^^
3 | return "hello"
|
info: Source
--> main.py:3:7
|
2 | from mymodule import my_function
3 | print(my_function())
| ^^^^^^^^^^^
|
info[goto-definition]: Definition
--> mymodule.py:5:5
|
3 | return "hello"
4 |
5 | def my_function():
| ^^^^^^^^^^^
6 | return "hello again"
|
info: Source
--> main.py:3:7
|
2 | from mymodule import my_function
3 | print(my_function())
| ^^^^^^^^^^^
|
info[goto-definition]: Definition
--> mymodule.py:8:5
|
6 | return "hello again"
7 |
8 | def my_function():
| ^^^^^^^^^^^
9 | return "we can't keep doing this"
|
info: Source
--> main.py:3:7
|
2 | from mymodule import my_function
3 | print(my_function())
| ^^^^^^^^^^^
|
"#);
}
/// goto-definition on a class ref go to the .py not the .pyi
#[test]
fn goto_definition_stub_map_class_ref() {
let test = CursorTest::builder()
.source(
"main.py",
"
from mymodule import MyClass
x = MyC<CURSOR>lass
",
)
.source(
"mymodule.py",
r#"
class MyClass:
def __init__(self, val):
self.val = val
class MyOtherClass:
def __init__(self, val):
self.val = val + 1
"#,
)
.source(
"mymodule.pyi",
r#"
class MyClass:
def __init__(self, val: bool): ...
class MyOtherClass:
def __init__(self, val: bool): ...
"#,
)
.build();
assert_snapshot!(test.goto_definition(), @r"
info[goto-definition]: Definition
--> mymodule.py:2:7
|
2 | class MyClass:
| ^^^^^^^
3 | def __init__(self, val):
4 | self.val = val
|
info: Source
--> main.py:3:5
|
2 | from mymodule import MyClass
3 | x = MyClass
| ^^^^^^^
|
");
}
/// goto-definition on a class init should go to the .py not the .pyi
#[test]
fn goto_definition_stub_map_class_init() {
let test = CursorTest::builder()
.source(
"main.py",
"
from mymodule import MyClass
x = MyCl<CURSOR>ass(0)
",
)
.source(
"mymodule.py",
r#"
class MyClass:
def __init__(self, val):
self.val = val
class MyOtherClass:
def __init__(self, val):
self.val = val + 1
"#,
)
.source(
"mymodule.pyi",
r#"
class MyClass:
def __init__(self, val: bool): ...
class MyOtherClass:
def __init__(self, val: bool): ...
"#,
)
.build();
assert_snapshot!(test.goto_definition(), @r"
info[goto-definition]: Definition
--> mymodule.py:2:7
|
2 | class MyClass:
| ^^^^^^^
3 | def __init__(self, val):
4 | self.val = val
|
info: Source
--> main.py:3:5
|
2 | from mymodule import MyClass
3 | x = MyClass(0)
| ^^^^^^^
|
");
}
/// goto-definition on a class method should go to the .py not the .pyi
#[test]
fn goto_definition_stub_map_class_method() {
let test = CursorTest::builder()
.source(
"main.py",
"
from mymodule import MyClass
x = MyClass(0)
x.act<CURSOR>ion()
",
)
.source(
"mymodule.py",
r#"
class MyClass:
def __init__(self, val):
self.val = val
def action(self):
print(self.val)
class MyOtherClass:
def __init__(self, val):
self.val = val + 1
"#,
)
.source(
"mymodule.pyi",
r#"
class MyClass:
def __init__(self, val: bool): ...
def action(self): ...
class MyOtherClass:
def __init__(self, val: bool): ...
"#,
)
.build();
assert_snapshot!(test.goto_definition(), @r"
info[goto-definition]: Definition
--> mymodule.py:5:9
|
3 | def __init__(self, val):
4 | self.val = val
5 | def action(self):
| ^^^^^^
6 | print(self.val)
|
info: Source
--> main.py:4:1
|
2 | from mymodule import MyClass
3 | x = MyClass(0)
4 | x.action()
| ^^^^^^^^
|
");
}
/// goto-definition on a class function should go to the .py not the .pyi
#[test]
fn goto_definition_stub_map_class_function() {
let test = CursorTest::builder()
.source(
"main.py",
"
from mymodule import MyClass
x = MyClass.act<CURSOR>ion()
",
)
.source(
"mymodule.py",
r#"
class MyClass:
def __init__(self, val):
self.val = val
def action():
print("hi!")
class MyOtherClass:
def __init__(self, val):
self.val = val + 1
"#,
)
.source(
"mymodule.pyi",
r#"
class MyClass:
def __init__(self, val: bool): ...
def action(): ...
class MyOtherClass:
def __init__(self, val: bool): ...
"#,
)
.build();
assert_snapshot!(test.goto_definition(), @r#"
info[goto-definition]: Definition
--> mymodule.py:5:9
|
3 | def __init__(self, val):
4 | self.val = val
5 | def action():
| ^^^^^^
6 | print("hi!")
|
info: Source
--> main.py:3:5
|
2 | from mymodule import MyClass
3 | x = MyClass.action()
| ^^^^^^^^^^^^^^
|
"#);
}
/// goto-definition on a class import should go to the .py not the .pyi
#[test]
fn goto_definition_stub_map_class_import() {
let test = CursorTest::builder()
.source(
"main.py",
"
from mymodule import MyC<CURSOR>lass
",
)
.source(
"mymodule.py",
r#"
class MyClass: ...
"#,
)
.source(
"mymodule.pyi",
r#"
class MyClass: ...
"#,
)
.build();
assert_snapshot!(test.goto_definition(), @r"
info[goto-definition]: Definition
--> mymodule.py:2:7
|
2 | class MyClass: ...
| ^^^^^^^
|
info: Source
--> main.py:2:22
|
2 | from mymodule import MyClass
| ^^^^^^^
|
");
}
impl CursorTest {
fn goto_definition(&self) -> String {
let Some(targets) = goto_definition(&self.db, self.cursor.file, self.cursor.offset)
else {
return "No goto target found".to_string();
};
if targets.is_empty() {
return "No definitions found".to_string();
}
let source = targets.range;
self.render_diagnostics(
targets
.into_iter()
.map(|target| GotoDefinitionDiagnostic::new(source, &target)),
)
}
}
struct GotoDefinitionDiagnostic {
source: FileRange,
target: FileRange,
}
impl GotoDefinitionDiagnostic {
fn new(source: FileRange, target: &NavigationTarget) -> Self {
Self {
source,
target: FileRange::new(target.file(), target.focus_range()),
}
}
}
impl IntoDiagnostic for GotoDefinitionDiagnostic {
fn into_diagnostic(self) -> Diagnostic {
let mut source = SubDiagnostic::new(SubDiagnosticSeverity::Info, "Source");
source.annotate(Annotation::primary(
Span::from(self.source.file()).with_range(self.source.range()),
));
let mut main = Diagnostic::new(
DiagnosticId::Lint(LintName::of("goto-definition")),
Severity::Info,
"Definition".to_string(),
);
main.annotate(Annotation::primary(
Span::from(self.target.file()).with_range(self.target.range()),
));
main.sub(source);
main
}
}
}

View File

@@ -33,7 +33,6 @@ mod tests {
use insta::assert_snapshot;
use ruff_db::diagnostic::{
Annotation, Diagnostic, DiagnosticId, LintName, Severity, Span, SubDiagnostic,
SubDiagnosticSeverity,
};
use ruff_db::files::FileRange;
use ruff_text_size::Ranged;
@@ -641,7 +640,7 @@ f(**kwargs<CURSOR>)
impl IntoDiagnostic for GotoTypeDefinitionDiagnostic {
fn into_diagnostic(self) -> Diagnostic {
let mut source = SubDiagnostic::new(SubDiagnosticSeverity::Info, "Source");
let mut source = SubDiagnostic::new(Severity::Info, "Source");
source.annotate(Annotation::primary(
Span::from(self.source.file()).with_range(self.source.range()),
));

View File

@@ -156,8 +156,9 @@ mod tests {
};
use ruff_text_size::TextSize;
use crate::db::tests::TestDb;
use ruff_db::system::{DbWithWritableSystem, SystemPathBuf};
use ty_project::ProjectMetadata;
use ty_python_semantic::{
Program, ProgramSettings, PythonPlatform, PythonVersionWithSource, SearchPathSettings,
};
@@ -166,10 +167,7 @@ mod tests {
const START: &str = "<START>";
const END: &str = "<END>";
let mut db = ty_project::TestDb::new(ProjectMetadata::new(
"test".into(),
SystemPathBuf::from("/"),
));
let mut db = TestDb::new();
let start = source.find(START);
let end = source
@@ -207,7 +205,7 @@ mod tests {
}
pub(super) struct InlayHintTest {
pub(super) db: ty_project::TestDb,
pub(super) db: TestDb,
pub(super) file: File,
pub(super) range: TextRange,
}

View File

@@ -1,4 +1,5 @@
mod completion;
mod db;
mod docstring;
mod find_node;
mod goto;
@@ -8,18 +9,17 @@ mod goto_type_definition;
mod hover;
mod inlay_hints;
mod markup;
mod references;
mod semantic_tokens;
mod signature_help;
mod stub_mapping;
pub use completion::completion;
pub use db::Db;
pub use docstring::get_parameter_documentation;
pub use goto::{goto_declaration, goto_definition, goto_type_definition};
pub use hover::hover;
pub use inlay_hints::inlay_hints;
pub use markup::MarkupKind;
pub use references::references;
pub use semantic_tokens::{
SemanticToken, SemanticTokenModifier, SemanticTokenType, SemanticTokens, semantic_tokens,
};
@@ -29,7 +29,6 @@ use ruff_db::files::{File, FileRange};
use ruff_text_size::{Ranged, TextRange};
use rustc_hash::FxHashSet;
use std::ops::{Deref, DerefMut};
use ty_project::Db;
use ty_python_semantic::types::{Type, TypeDefinition};
/// Information associated with a text range.
@@ -88,15 +87,6 @@ pub struct NavigationTarget {
}
impl NavigationTarget {
/// Creates a new `NavigationTarget` where the focus and full range are identical.
pub fn new(file: File, range: TextRange) -> Self {
Self {
file,
focus_range: range,
full_range: range,
}
}
pub fn file(&self) -> File {
self.file
}
@@ -221,13 +211,13 @@ impl HasNavigationTargets for TypeDefinition<'_> {
#[cfg(test)]
mod tests {
use crate::db::tests::TestDb;
use insta::internals::SettingsBindDropGuard;
use ruff_db::Db;
use ruff_db::diagnostic::{Diagnostic, DiagnosticFormat, DisplayDiagnosticConfig};
use ruff_db::files::{File, system_path_to_file};
use ruff_db::system::{DbWithWritableSystem, SystemPath, SystemPathBuf};
use ruff_text_size::TextSize;
use ty_project::ProjectMetadata;
use ty_python_semantic::{
Program, ProgramSettings, PythonPlatform, PythonVersionWithSource, SearchPathSettings,
};
@@ -241,7 +231,7 @@ mod tests {
}
pub(super) struct CursorTest {
pub(super) db: ty_project::TestDb,
pub(super) db: TestDb,
pub(super) cursor: Cursor,
_insta_settings_guard: SettingsBindDropGuard,
}
@@ -296,13 +286,8 @@ mod tests {
impl CursorTestBuilder {
pub(super) fn build(&self) -> CursorTest {
let mut db = ty_project::TestDb::new(ProjectMetadata::new(
"test".into(),
SystemPathBuf::from("/"),
));
let mut db = TestDb::new();
let mut cursor: Option<Cursor> = None;
for &Source {
ref path,
ref contents,
@@ -311,19 +296,19 @@ mod tests {
{
db.write_file(path, contents)
.expect("write to memory file system to be successful");
let Some(offset) = cursor_offset else {
continue;
};
let file = system_path_to_file(&db, path).expect("newly written file to existing");
if let Some(offset) = cursor_offset {
// This assert should generally never trip, since
// we have an assert on `CursorTestBuilder::source`
// to ensure we never have more than one marker.
assert!(
cursor.is_none(),
"found more than one source that contains `<CURSOR>`"
);
cursor = Some(Cursor { file, offset });
}
// This assert should generally never trip, since
// we have an assert on `CursorTestBuilder::source`
// to ensure we never have more than one marker.
assert!(
cursor.is_none(),
"found more than one source that contains `<CURSOR>`"
);
cursor = Some(Cursor { file, offset });
}
let search_paths = SearchPathSettings::new(vec![SystemPathBuf::from("/")])

File diff suppressed because it is too large Load Diff

View File

@@ -664,26 +664,6 @@ impl SourceOrderVisitor<'_> for SemanticTokenVisitor<'_> {
}
}
}
ast::Stmt::Nonlocal(nonlocal_stmt) => {
// Handle nonlocal statements - classify identifiers as variables
for identifier in &nonlocal_stmt.names {
self.add_token(
identifier.range(),
SemanticTokenType::Variable,
SemanticTokenModifier::empty(),
);
}
}
ast::Stmt::Global(global_stmt) => {
// Handle global statements - classify identifiers as variables
for identifier in &global_stmt.names {
self.add_token(
identifier.range(),
SemanticTokenType::Variable,
SemanticTokenModifier::empty(),
);
}
}
_ => {
// For all other statement types, let the default visitor handle them
walk_stmt(self, stmt);
@@ -851,71 +831,6 @@ impl SourceOrderVisitor<'_> for SemanticTokenVisitor<'_> {
}
}
}
fn visit_except_handler(&mut self, except_handler: &ast::ExceptHandler) {
match except_handler {
ast::ExceptHandler::ExceptHandler(handler) => {
// Visit the exception type expression if present
if let Some(type_expr) = &handler.type_ {
self.visit_expr(type_expr);
}
// Handle the exception variable name (after "as")
if let Some(name) = &handler.name {
self.add_token(
name.range(),
SemanticTokenType::Variable,
SemanticTokenModifier::empty(),
);
}
// Visit the handler body
self.visit_body(&handler.body);
}
}
}
fn visit_pattern(&mut self, pattern: &ast::Pattern) {
match pattern {
ast::Pattern::MatchAs(pattern_as) => {
// Visit the nested pattern first to maintain source order
if let Some(nested_pattern) = &pattern_as.pattern {
self.visit_pattern(nested_pattern);
}
// Now add the "as" variable name token
if let Some(name) = &pattern_as.name {
self.add_token(
name.range(),
SemanticTokenType::Variable,
SemanticTokenModifier::empty(),
);
}
}
ast::Pattern::MatchMapping(pattern_mapping) => {
// Visit keys and patterns in source order by interleaving them
for (key, nested_pattern) in
pattern_mapping.keys.iter().zip(&pattern_mapping.patterns)
{
self.visit_expr(key);
self.visit_pattern(nested_pattern);
}
// Handle the rest parameter (after "**") - this comes last
if let Some(rest_name) = &pattern_mapping.rest {
self.add_token(
rest_name.range(),
SemanticTokenType::Variable,
SemanticTokenModifier::empty(),
);
}
}
_ => {
// For all other pattern types, use the default walker
ruff_python_ast::visitor::source_order::walk_pattern(self, pattern);
}
}
}
}
#[cfg(test)]
@@ -2027,200 +1942,4 @@ complex_fstring = f"User: {name.upper()}, Count: {len(data)}, Hex: {value:x}"<CU
"x" @ 414..415: String
"#);
}
#[test]
fn test_nonlocal_and_global_statements() {
let test = cursor_test(
r#"
x = "global_value"
y = "another_global"
def outer():
x = "outer_value"
z = "outer_local"
def inner():
nonlocal x, z # These should be variable tokens
global y # This should be a variable token
x = "modified"
y = "modified_global"
z = "modified_local"
def deeper():
nonlocal x # Variable token
global y, x # Both should be variable tokens
return x + y
return deeper
return inner<CURSOR>
"#,
);
let tokens = semantic_tokens_full_file(&test.db, test.cursor.file);
assert_snapshot!(semantic_tokens_to_snapshot(&test.db, test.cursor.file, &tokens), @r#"
"x" @ 1..2: Variable
"/"global_value/"" @ 5..19: String
"y" @ 20..21: Variable
"/"another_global/"" @ 24..40: String
"outer" @ 46..51: Function [definition]
"x" @ 59..60: Variable
"/"outer_value/"" @ 63..76: String
"z" @ 81..82: Variable
"/"outer_local/"" @ 85..98: String
"inner" @ 112..117: Function [definition]
"x" @ 138..139: Variable
"z" @ 141..142: Variable
"y" @ 193..194: Variable
"x" @ 243..244: Variable
"/"modified/"" @ 247..257: String
"y" @ 266..267: Variable
"/"modified_global/"" @ 270..287: String
"z" @ 296..297: Variable
"/"modified_local/"" @ 300..316: String
"deeper" @ 338..344: Function [definition]
"x" @ 369..370: Variable
"y" @ 410..411: Variable
"x" @ 413..414: Variable
"x" @ 469..470: Variable
"y" @ 473..474: Variable
"deeper" @ 499..505: Function
"inner" @ 522..527: Function
"#);
}
#[test]
fn test_nonlocal_global_edge_cases() {
let test = cursor_test(
r#"
# Single variable statements
def test():
global x
nonlocal y
# Multiple variables in one statement
global a, b, c
nonlocal d, e, f
return x + y + a + b + c + d + e + f<CURSOR>
"#,
);
let tokens = semantic_tokens_full_file(&test.db, test.cursor.file);
assert_snapshot!(semantic_tokens_to_snapshot(&test.db, test.cursor.file, &tokens), @r#"
"test" @ 34..38: Function [definition]
"x" @ 53..54: Variable
"y" @ 68..69: Variable
"a" @ 128..129: Variable
"b" @ 131..132: Variable
"c" @ 134..135: Variable
"d" @ 149..150: Variable
"e" @ 152..153: Variable
"f" @ 155..156: Variable
"x" @ 173..174: Variable
"y" @ 177..178: Variable
"a" @ 181..182: Variable
"b" @ 185..186: Variable
"c" @ 189..190: Variable
"d" @ 193..194: Variable
"e" @ 197..198: Variable
"f" @ 201..202: Variable
"#);
}
#[test]
fn test_pattern_matching() {
let test = cursor_test(
r#"
def process_data(data):
match data:
case {"name": name, "age": age, **rest} as person:
print(f"Person {name}, age {age}, extra: {rest}")
return person
case [first, *remaining] as sequence:
print(f"First: {first}, remaining: {remaining}")
return sequence
case value as fallback:
print(f"Fallback: {fallback}")
return fallback<CURSOR>
"#,
);
let tokens = semantic_tokens_full_file(&test.db, test.cursor.file);
assert_snapshot!(semantic_tokens_to_snapshot(&test.db, test.cursor.file, &tokens), @r#"
"process_data" @ 5..17: Function [definition]
"data" @ 18..22: Parameter
"data" @ 35..39: Variable
"/"name/"" @ 55..61: String
"name" @ 63..67: Variable
"/"age/"" @ 69..74: String
"age" @ 76..79: Variable
"rest" @ 83..87: Variable
"person" @ 92..98: Variable
"print" @ 112..117: Function
"Person " @ 120..127: String
"name" @ 128..132: Variable
", age " @ 133..139: String
"age" @ 140..143: Variable
", extra: " @ 144..153: String
"rest" @ 154..158: Variable
"person" @ 181..187: Variable
"first" @ 202..207: Variable
"sequence" @ 224..232: Variable
"print" @ 246..251: Function
"First: " @ 254..261: String
"first" @ 262..267: Variable
", remaining: " @ 268..281: String
"remaining" @ 282..291: Variable
"sequence" @ 314..322: Variable
"value" @ 336..341: Variable
"fallback" @ 345..353: Variable
"print" @ 367..372: Function
"Fallback: " @ 375..385: String
"fallback" @ 386..394: Variable
"fallback" @ 417..425: Variable
"#);
}
#[test]
fn test_exception_handlers() {
let test = cursor_test(
r#"
try:
x = 1 / 0
except ValueError as ve:
print(ve)
except (TypeError, RuntimeError) as re:
print(re)
except Exception as e:
print(e)
finally:
pass<CURSOR>
"#,
);
let tokens = semantic_tokens_full_file(&test.db, test.cursor.file);
assert_snapshot!(semantic_tokens_to_snapshot(&test.db, test.cursor.file, &tokens), @r#"
"x" @ 10..11: Variable
"1" @ 14..15: Number
"0" @ 18..19: Number
"ValueError" @ 27..37: Class
"ve" @ 41..43: Variable
"print" @ 49..54: Function
"ve" @ 55..57: Variable
"TypeError" @ 67..76: Class
"RuntimeError" @ 78..90: Class
"re" @ 95..97: Variable
"print" @ 103..108: Function
"re" @ 109..111: Variable
"Exception" @ 120..129: Class
"e" @ 133..134: Variable
"print" @ 140..145: Function
"e" @ 146..147: Variable
"#);
}
}

View File

@@ -149,7 +149,7 @@ fn create_signature_details_from_call_signature_details(
details
.argument_to_parameter_mapping
.get(current_arg_index)
.and_then(|mapping| mapping.parameters.first().copied())
.and_then(|&param_index| param_index)
.or({
// If we can't find a mapping for this argument, but we have a current
// argument index, use that as the active parameter if it's within bounds.
@@ -242,11 +242,11 @@ fn find_active_signature_from_details(signature_details: &[CallSignatureDetails]
// First, try to find a signature where all arguments have valid parameter mappings.
let perfect_match = signature_details.iter().position(|details| {
// Check if all arguments have valid parameter mappings.
// Check if all arguments have valid parameter mappings (i.e., are not None).
details
.argument_to_parameter_mapping
.iter()
.all(|mapping| mapping.matched)
.all(Option::is_some)
});
if let Some(index) = perfect_match {
@@ -261,7 +261,7 @@ fn find_active_signature_from_details(signature_details: &[CallSignatureDetails]
details
.argument_to_parameter_mapping
.iter()
.filter(|mapping| mapping.matched)
.filter(|mapping| mapping.is_some())
.count()
})?;

View File

@@ -1,5 +1,4 @@
use itertools::Either;
use ty_python_semantic::{ResolvedDefinition, map_stub_definition};
use ty_python_semantic::ResolvedDefinition;
/// Maps `ResolvedDefinitions` from stub files to corresponding definitions in source files.
///
@@ -8,10 +7,12 @@ use ty_python_semantic::{ResolvedDefinition, map_stub_definition};
/// other language server providers (like hover, completion, and signature help) to find
/// docstrings for functions that resolve to stubs.
pub(crate) struct StubMapper<'db> {
#[allow(dead_code)] // Will be used when implementation is added
db: &'db dyn crate::Db,
}
impl<'db> StubMapper<'db> {
#[allow(dead_code)] // Will be used in the future
pub(crate) fn new(db: &'db dyn crate::Db) -> Self {
Self { db }
}
@@ -20,14 +21,15 @@ impl<'db> StubMapper<'db> {
///
/// If the definition is in a stub file and a corresponding source file definition exists,
/// returns the source file definition(s). Otherwise, returns the original definition.
#[allow(dead_code)] // Will be used when implementation is added
#[allow(clippy::unused_self)] // Will use self when implementation is added
pub(crate) fn map_definition(
&self,
def: ResolvedDefinition<'db>,
) -> impl Iterator<Item = ResolvedDefinition<'db>> {
if let Some(definitions) = map_stub_definition(self.db, &def) {
return Either::Left(definitions.into_iter());
}
Either::Right(std::iter::once(def))
) -> Vec<ResolvedDefinition<'db>> {
// TODO: Implement stub-to-source mapping logic
// For now, just return the original definition
vec![def]
}
/// Map multiple `ResolvedDefinitions`, applying stub-to-source mapping to each.

View File

@@ -19,6 +19,7 @@ ruff_options_metadata = { workspace = true }
ruff_python_ast = { workspace = true, features = ["serde"] }
ruff_python_formatter = { workspace = true, optional = true }
ruff_text_size = { workspace = true }
ty_ide = { workspace = true }
ty_python_semantic = { workspace = true, features = ["serde"] }
ty_vendored = { workspace = true }
@@ -52,7 +53,6 @@ deflate = ["ty_vendored/deflate"]
schemars = ["dep:schemars", "ruff_db/schemars", "ty_python_semantic/schemars"]
zstd = ["ty_vendored/zstd"]
format = ["ruff_python_formatter"]
testing = []
[lints]
workspace = true

View File

@@ -14,6 +14,7 @@ use ruff_db::system::System;
use ruff_db::vendored::VendoredFileSystem;
use salsa::plumbing::ZalsaDatabase;
use salsa::{Event, Setter};
use ty_ide::Db as IdeDb;
use ty_python_semantic::lint::{LintRegistry, RuleSelection};
use ty_python_semantic::{Db as SemanticDb, Program};
@@ -403,6 +404,9 @@ impl SalsaMemoryDump {
}
}
#[salsa::db]
impl IdeDb for ProjectDatabase {}
#[salsa::db]
impl SemanticDb for ProjectDatabase {
fn should_check_file(&self, file: File) -> bool {
@@ -464,7 +468,7 @@ mod format {
}
}
#[cfg(any(test, feature = "testing"))]
#[cfg(test)]
pub(crate) mod tests {
use std::sync::{Arc, Mutex};
@@ -483,7 +487,7 @@ pub(crate) mod tests {
#[salsa::db]
#[derive(Clone)]
pub struct TestDb {
pub(crate) struct TestDb {
storage: salsa::Storage<Self>,
events: Events,
files: Files,
@@ -493,7 +497,7 @@ pub(crate) mod tests {
}
impl TestDb {
pub fn new(project: ProjectMetadata) -> Self {
pub(crate) fn new(project: ProjectMetadata) -> Self {
let events = Events::default();
let mut db = Self {
storage: salsa::Storage::new(Some(Box::new({
@@ -518,7 +522,7 @@ pub(crate) mod tests {
impl TestDb {
/// Takes the salsa events.
pub fn take_salsa_events(&mut self) -> Vec<salsa::Event> {
pub(crate) fn take_salsa_events(&mut self) -> Vec<salsa::Event> {
let mut events = self.events.lock().unwrap();
std::mem::take(&mut *events)

View File

@@ -1,15 +1,11 @@
use crate::glob::{GlobFilterCheckMode, IncludeResult};
use crate::metadata::options::{OptionDiagnostic, ToSettingsError};
use crate::walk::{ProjectFilesFilter, ProjectFilesWalker};
#[cfg(feature = "testing")]
pub use db::tests::TestDb;
pub use db::{ChangeResult, CheckMode, Db, ProjectDatabase, SalsaMemoryDump};
use files::{Index, Indexed, IndexedFiles};
use metadata::settings::Settings;
pub use metadata::{ProjectMetadata, ProjectMetadataError};
use ruff_db::diagnostic::{
Annotation, Diagnostic, DiagnosticId, Severity, Span, SubDiagnostic, SubDiagnosticSeverity,
};
use ruff_db::diagnostic::{Annotation, Diagnostic, DiagnosticId, Severity, Span, SubDiagnostic};
use ruff_db::files::{File, FileRootKind};
use ruff_db::parsed::parsed_module;
use ruff_db::source::{SourceTextError, source_text};
@@ -678,17 +674,14 @@ where
let mut diagnostic = Diagnostic::new(DiagnosticId::Panic, Severity::Fatal, message);
diagnostic.sub(SubDiagnostic::new(
SubDiagnosticSeverity::Info,
Severity::Info,
"This indicates a bug in ty.",
));
let report_message = "If you could open an issue at https://github.com/astral-sh/ty/issues/new?title=%5Bpanic%5D, we'd be very appreciative!";
diagnostic.sub(SubDiagnostic::new(Severity::Info, report_message));
diagnostic.sub(SubDiagnostic::new(
SubDiagnosticSeverity::Info,
report_message,
));
diagnostic.sub(SubDiagnostic::new(
SubDiagnosticSeverity::Info,
Severity::Info,
format!(
"Platform: {os} {arch}",
os = std::env::consts::OS,
@@ -697,13 +690,13 @@ where
));
if let Some(version) = ruff_db::program_version() {
diagnostic.sub(SubDiagnostic::new(
SubDiagnosticSeverity::Info,
Severity::Info,
format!("Version: {version}"),
));
}
diagnostic.sub(SubDiagnostic::new(
SubDiagnosticSeverity::Info,
Severity::Info,
format!(
"Args: {args:?}",
args = std::env::args().collect::<Vec<_>>()
@@ -714,13 +707,13 @@ where
match backtrace.status() {
BacktraceStatus::Disabled => {
diagnostic.sub(SubDiagnostic::new(
SubDiagnosticSeverity::Info,
Severity::Info,
"run with `RUST_BACKTRACE=1` environment variable to show the full backtrace information",
));
}
BacktraceStatus::Captured => {
diagnostic.sub(SubDiagnostic::new(
SubDiagnosticSeverity::Info,
Severity::Info,
format!("Backtrace:\n{backtrace}"),
));
}
@@ -730,10 +723,7 @@ where
if let Some(backtrace) = error.salsa_backtrace {
salsa::attach(db, || {
diagnostic.sub(SubDiagnostic::new(
SubDiagnosticSeverity::Info,
backtrace.to_string(),
));
diagnostic.sub(SubDiagnostic::new(Severity::Info, backtrace.to_string()));
});
}

View File

@@ -12,7 +12,7 @@ use ordermap::OrderMap;
use ruff_db::RustDoc;
use ruff_db::diagnostic::{
Annotation, Diagnostic, DiagnosticFormat, DiagnosticId, DisplayDiagnosticConfig, Severity,
Span, SubDiagnostic, SubDiagnosticSeverity,
Span, SubDiagnostic,
};
use ruff_db::files::system_path_to_file;
use ruff_db::system::{System, SystemPath, SystemPathBuf};
@@ -318,7 +318,7 @@ impl Options {
if self.environment.or_default().root.is_some() {
diagnostic = diagnostic.sub(SubDiagnostic::new(
SubDiagnosticSeverity::Info,
Severity::Info,
"The `src.root` setting was ignored in favor of the `environment.root` setting",
));
}
@@ -811,7 +811,7 @@ fn build_include_filter(
Severity::Warning,
)
.sub(SubDiagnostic::new(
SubDiagnosticSeverity::Info,
Severity::Info,
"Remove the `include` option to match all files or add a pattern to match specific files",
));
@@ -853,13 +853,13 @@ fn build_include_filter(
))
} else {
diagnostic.sub(SubDiagnostic::new(
SubDiagnosticSeverity::Info,
Severity::Info,
format!("The pattern is defined in the `{}` option in your configuration file", context.include_name()),
))
}
}
ValueSource::Cli => diagnostic.sub(SubDiagnostic::new(
SubDiagnosticSeverity::Info,
Severity::Info,
"The pattern was specified on the CLI",
)),
ValueSource::PythonVSCodeExtension => unreachable!("Can't configure includes from the Python VSCode extension"),
@@ -883,7 +883,7 @@ fn build_include_filter(
Severity::Error,
);
Box::new(diagnostic.sub(SubDiagnostic::new(
SubDiagnosticSeverity::Info,
Severity::Info,
"Please open an issue on the ty repository and share the patterns that caused the error.",
)))
})
@@ -936,13 +936,13 @@ fn build_exclude_filter(
))
} else {
diagnostic.sub(SubDiagnostic::new(
SubDiagnosticSeverity::Info,
Severity::Info,
format!("The pattern is defined in the `{}` option in your configuration file", context.exclude_name()),
))
}
}
ValueSource::Cli => diagnostic.sub(SubDiagnostic::new(
SubDiagnosticSeverity::Info,
Severity::Info,
"The pattern was specified on the CLI",
)),
ValueSource::PythonVSCodeExtension => unreachable!(
@@ -960,7 +960,7 @@ fn build_exclude_filter(
Severity::Error,
);
Box::new(diagnostic.sub(SubDiagnostic::new(
SubDiagnosticSeverity::Info,
Severity::Info,
"Please open an issue on the ty repository and share the patterns that caused the error.",
)))
})
@@ -1197,26 +1197,26 @@ impl RangedValue<OverrideOptions> {
diagnostic = if self.rules.is_none() {
diagnostic = diagnostic.sub(SubDiagnostic::new(
SubDiagnosticSeverity::Info,
Severity::Info,
"It has no `rules` table",
));
diagnostic.sub(SubDiagnostic::new(
SubDiagnosticSeverity::Info,
Severity::Info,
"Add a `[overrides.rules]` table...",
))
} else {
diagnostic = diagnostic.sub(SubDiagnostic::new(
SubDiagnosticSeverity::Info,
Severity::Info,
"The rules table is empty",
));
diagnostic.sub(SubDiagnostic::new(
SubDiagnosticSeverity::Info,
Severity::Info,
"Add a rule to `[overrides.rules]` to override specific rules...",
))
};
diagnostic = diagnostic.sub(SubDiagnostic::new(
SubDiagnosticSeverity::Info,
Severity::Info,
"or remove the `[[overrides]]` section if there's nothing to override",
));
@@ -1251,23 +1251,23 @@ impl RangedValue<OverrideOptions> {
diagnostic = if self.exclude.is_none() {
diagnostic.sub(SubDiagnostic::new(
SubDiagnosticSeverity::Info,
Severity::Info,
"It has no `include` or `exclude` option restricting the files",
))
} else {
diagnostic.sub(SubDiagnostic::new(
SubDiagnosticSeverity::Info,
Severity::Info,
"It has no `include` option and `exclude` is empty",
))
};
diagnostic = diagnostic.sub(SubDiagnostic::new(
SubDiagnosticSeverity::Info,
Severity::Info,
"Restrict the files by adding a pattern to `include` or `exclude`...",
));
diagnostic = diagnostic.sub(SubDiagnostic::new(
SubDiagnosticSeverity::Info,
Severity::Info,
"or remove the `[[overrides]]` section and merge the configuration into the root `[rules]` table if the configuration should apply to all files",
));

View File

@@ -26,7 +26,6 @@ ty_static = { workspace = true }
anyhow = { workspace = true }
bitflags = { workspace = true }
bitvec = { workspace = true }
camino = { workspace = true }
colored = { workspace = true }
compact_str = { workspace = true }
@@ -36,7 +35,6 @@ indexmap = { workspace = true }
itertools = { workspace = true }
ordermap = { workspace = true }
salsa = { workspace = true, features = ["compact_str"] }
thin-vec = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
rustc-hash = { workspace = true }

View File

@@ -4,7 +4,7 @@ References:
- <https://typing.python.org/en/latest/spec/callables.html#callable>
Note that `typing.Callable` is deprecated at runtime, in favor of `collections.abc.Callable` (see:
Note that `typing.Callable` is deprecated at runtime, in favour of `collections.abc.Callable` (see:
<https://docs.python.org/3/library/typing.html#deprecated-aliases>). However, removal of
`typing.Callable` is not currently planned, and the canonical location of the stub for the symbol in
typeshed is still `typing.pyi`.

View File

@@ -25,9 +25,6 @@ class Color(Enum):
b1: Literal[Color.RED]
MissingT = Enum("MissingT", {"MISSING": "MISSING"})
b2: Literal[MissingT.MISSING]
def f():
reveal_type(mode) # revealed: Literal["w", "r"]
reveal_type(a1) # revealed: Literal[26]
@@ -54,16 +51,6 @@ invalid4: Literal[
hello, # error: [invalid-type-form]
(1, 2, 3), # error: [invalid-type-form]
]
class NotAnEnum:
x: int = 1
# error: [invalid-type-form]
invalid5: Literal[NotAnEnum.x]
a_list: list[int] = [1, 2, 3]
# error: [invalid-type-form]
invalid6: Literal[a_list[0]]
```
## Shortening unions of literals

View File

@@ -72,7 +72,7 @@ def f(x: Union) -> None:
## Implicit type aliases using new-style unions
We don't recognize these as type aliases yet, but we also don't emit false-positive diagnostics if
We don't recognise these as type aliases yet, but we also don't emit false-positive diagnostics if
you use them in type expressions:
```toml

View File

@@ -2355,13 +2355,12 @@ import enum
reveal_type(enum.Enum.__members__) # revealed: MappingProxyType[str, Unknown]
class Answer(enum.Enum):
NO = 0
YES = 1
class Foo(enum.Enum):
BAR = 1
reveal_type(Answer.NO) # revealed: Literal[Answer.NO]
reveal_type(Answer.NO.value) # revealed: Any
reveal_type(Answer.__members__) # revealed: MappingProxyType[str, Unknown]
reveal_type(Foo.BAR) # revealed: Literal[Foo.BAR]
reveal_type(Foo.BAR.value) # revealed: Any
reveal_type(Foo.__members__) # revealed: MappingProxyType[str, Unknown]
```
## References

View File

@@ -105,59 +105,3 @@ str("Müsli", "utf-8")
# error: [invalid-argument-type] "Argument to class `str` is incorrect: Expected `str`, found `Literal[b"utf-8"]`"
str(b"M\xc3\xbcsli", b"utf-8")
```
## Calls to `isinstance`
We infer `Literal[True]` for a limited set of cases where we can be sure that the answer is correct,
but fall back to `bool` otherwise.
```py
from enum import Enum
from types import FunctionType
class Answer(Enum):
NO = 0
YES = 1
reveal_type(isinstance(True, bool)) # revealed: Literal[True]
reveal_type(isinstance(True, int)) # revealed: Literal[True]
reveal_type(isinstance(True, object)) # revealed: Literal[True]
reveal_type(isinstance("", str)) # revealed: Literal[True]
reveal_type(isinstance(1, int)) # revealed: Literal[True]
reveal_type(isinstance(b"", bytes)) # revealed: Literal[True]
reveal_type(isinstance(Answer.NO, Answer)) # revealed: Literal[True]
reveal_type(isinstance((1, 2), tuple)) # revealed: Literal[True]
def f(): ...
reveal_type(isinstance(f, FunctionType)) # revealed: Literal[True]
reveal_type(isinstance("", int)) # revealed: bool
class A: ...
class SubclassOfA(A): ...
class B: ...
reveal_type(isinstance(A, type)) # revealed: Literal[True]
a = A()
reveal_type(isinstance(a, A)) # revealed: Literal[True]
reveal_type(isinstance(a, object)) # revealed: Literal[True]
reveal_type(isinstance(a, SubclassOfA)) # revealed: bool
reveal_type(isinstance(a, B)) # revealed: bool
s = SubclassOfA()
reveal_type(isinstance(s, SubclassOfA)) # revealed: Literal[True]
reveal_type(isinstance(s, A)) # revealed: Literal[True]
def _(x: A | B):
reveal_type(isinstance(x, A)) # revealed: bool
if isinstance(x, A):
pass
else:
reveal_type(x) # revealed: B & ~A
reveal_type(isinstance(x, B)) # revealed: Literal[True]
```

View File

@@ -69,321 +69,6 @@ def _(flag: bool):
reveal_type(foo()) # revealed: int
```
## Splatted arguments
### Unknown argument length
```py
def takes_zero() -> None: ...
def takes_one(x: int) -> None: ...
def takes_two(x: int, y: int) -> None: ...
def takes_two_positional_only(x: int, y: int, /) -> None: ...
def takes_two_different(x: int, y: str) -> None: ...
def takes_two_different_positional_only(x: int, y: str, /) -> None: ...
def takes_at_least_zero(*args) -> None: ...
def takes_at_least_one(x: int, *args) -> None: ...
def takes_at_least_two(x: int, y: int, *args) -> None: ...
def takes_at_least_two_positional_only(x: int, y: int, /, *args) -> None: ...
# Test all of the above with a number of different splatted argument types
def _(args: list[int]) -> None:
takes_zero(*args)
takes_one(*args)
takes_two(*args)
takes_two_positional_only(*args)
takes_two_different(*args) # error: [invalid-argument-type]
takes_two_different_positional_only(*args) # error: [invalid-argument-type]
takes_at_least_zero(*args)
takes_at_least_one(*args)
takes_at_least_two(*args)
takes_at_least_two_positional_only(*args)
def _(args: tuple[int, ...]) -> None:
takes_zero(*args)
takes_one(*args)
takes_two(*args)
takes_two_positional_only(*args)
takes_two_different(*args) # error: [invalid-argument-type]
takes_two_different_positional_only(*args) # error: [invalid-argument-type]
takes_at_least_zero(*args)
takes_at_least_one(*args)
takes_at_least_two(*args)
takes_at_least_two_positional_only(*args)
```
### Fixed-length tuple argument
```py
def takes_zero() -> None: ...
def takes_one(x: int) -> None: ...
def takes_two(x: int, y: int) -> None: ...
def takes_two_positional_only(x: int, y: int, /) -> None: ...
def takes_two_different(x: int, y: str) -> None: ...
def takes_two_different_positional_only(x: int, y: str, /) -> None: ...
def takes_at_least_zero(*args) -> None: ...
def takes_at_least_one(x: int, *args) -> None: ...
def takes_at_least_two(x: int, y: int, *args) -> None: ...
def takes_at_least_two_positional_only(x: int, y: int, /, *args) -> None: ...
# Test all of the above with a number of different splatted argument types
def _(args: tuple[int]) -> None:
takes_zero(*args) # error: [too-many-positional-arguments]
takes_one(*args)
takes_two(*args) # error: [missing-argument]
takes_two_positional_only(*args) # error: [missing-argument]
takes_two_different(*args) # error: [missing-argument]
takes_two_different_positional_only(*args) # error: [missing-argument]
takes_at_least_zero(*args)
takes_at_least_one(*args)
takes_at_least_two(*args) # error: [missing-argument]
takes_at_least_two_positional_only(*args) # error: [missing-argument]
def _(args: tuple[int, int]) -> None:
takes_zero(*args) # error: [too-many-positional-arguments]
takes_one(*args) # error: [too-many-positional-arguments]
takes_two(*args)
takes_two_positional_only(*args)
takes_two_different(*args) # error: [invalid-argument-type]
takes_two_different_positional_only(*args) # error: [invalid-argument-type]
takes_at_least_zero(*args)
takes_at_least_one(*args)
takes_at_least_two(*args)
takes_at_least_two_positional_only(*args)
def _(args: tuple[int, str]) -> None:
takes_zero(*args) # error: [too-many-positional-arguments]
takes_one(*args) # error: [too-many-positional-arguments]
takes_two(*args) # error: [invalid-argument-type]
takes_two_positional_only(*args) # error: [invalid-argument-type]
takes_two_different(*args)
takes_two_different_positional_only(*args)
takes_at_least_zero(*args)
takes_at_least_one(*args)
takes_at_least_two(*args) # error: [invalid-argument-type]
takes_at_least_two_positional_only(*args) # error: [invalid-argument-type]
```
### Mixed tuple argument
```toml
[environment]
python-version = "3.11"
```
```py
def takes_zero() -> None: ...
def takes_one(x: int) -> None: ...
def takes_two(x: int, y: int) -> None: ...
def takes_two_positional_only(x: int, y: int, /) -> None: ...
def takes_two_different(x: int, y: str) -> None: ...
def takes_two_different_positional_only(x: int, y: str, /) -> None: ...
def takes_at_least_zero(*args) -> None: ...
def takes_at_least_one(x: int, *args) -> None: ...
def takes_at_least_two(x: int, y: int, *args) -> None: ...
def takes_at_least_two_positional_only(x: int, y: int, /, *args) -> None: ...
# Test all of the above with a number of different splatted argument types
def _(args: tuple[int, *tuple[int, ...]]) -> None:
takes_zero(*args) # error: [too-many-positional-arguments]
takes_one(*args)
takes_two(*args)
takes_two_positional_only(*args)
takes_two_different(*args) # error: [invalid-argument-type]
takes_two_different_positional_only(*args) # error: [invalid-argument-type]
takes_at_least_zero(*args)
takes_at_least_one(*args)
takes_at_least_two(*args)
takes_at_least_two_positional_only(*args)
def _(args: tuple[int, *tuple[str, ...]]) -> None:
takes_zero(*args) # error: [too-many-positional-arguments]
takes_one(*args)
takes_two(*args) # error: [invalid-argument-type]
takes_two_positional_only(*args) # error: [invalid-argument-type]
takes_two_different(*args)
takes_two_different_positional_only(*args)
takes_at_least_zero(*args)
takes_at_least_one(*args)
takes_at_least_two(*args) # error: [invalid-argument-type]
takes_at_least_two_positional_only(*args) # error: [invalid-argument-type]
def _(args: tuple[int, int, *tuple[int, ...]]) -> None:
takes_zero(*args) # error: [too-many-positional-arguments]
takes_one(*args) # error: [too-many-positional-arguments]
takes_two(*args)
takes_two_positional_only(*args)
takes_two_different(*args) # error: [invalid-argument-type]
takes_two_different_positional_only(*args) # error: [invalid-argument-type]
takes_at_least_zero(*args)
takes_at_least_one(*args)
takes_at_least_two(*args)
takes_at_least_two_positional_only(*args)
def _(args: tuple[int, int, *tuple[str, ...]]) -> None:
takes_zero(*args) # error: [too-many-positional-arguments]
takes_one(*args) # error: [too-many-positional-arguments]
takes_two(*args)
takes_two_positional_only(*args)
takes_two_different(*args) # error: [invalid-argument-type]
takes_two_different_positional_only(*args) # error: [invalid-argument-type]
takes_at_least_zero(*args)
takes_at_least_one(*args)
takes_at_least_two(*args)
takes_at_least_two_positional_only(*args)
def _(args: tuple[int, *tuple[int, ...], int]) -> None:
takes_zero(*args) # error: [too-many-positional-arguments]
takes_one(*args) # error: [too-many-positional-arguments]
takes_two(*args)
takes_two_positional_only(*args)
takes_two_different(*args) # error: [invalid-argument-type]
takes_two_different_positional_only(*args) # error: [invalid-argument-type]
takes_at_least_zero(*args)
takes_at_least_one(*args)
takes_at_least_two(*args)
takes_at_least_two_positional_only(*args)
def _(args: tuple[int, *tuple[str, ...], int]) -> None:
takes_zero(*args) # error: [too-many-positional-arguments]
takes_one(*args) # error: [too-many-positional-arguments]
takes_two(*args) # error: [invalid-argument-type]
takes_two_positional_only(*args) # error: [invalid-argument-type]
takes_two_different(*args)
takes_two_different_positional_only(*args)
takes_at_least_zero(*args)
takes_at_least_one(*args)
takes_at_least_two(*args) # error: [invalid-argument-type]
takes_at_least_two_positional_only(*args) # error: [invalid-argument-type]
```
### String argument
```py
from typing import Literal
def takes_zero() -> None: ...
def takes_one(x: str) -> None: ...
def takes_two(x: str, y: str) -> None: ...
def takes_two_positional_only(x: str, y: str, /) -> None: ...
def takes_two_different(x: int, y: str) -> None: ...
def takes_two_different_positional_only(x: int, y: str, /) -> None: ...
def takes_at_least_zero(*args) -> None: ...
def takes_at_least_one(x: str, *args) -> None: ...
def takes_at_least_two(x: str, y: str, *args) -> None: ...
def takes_at_least_two_positional_only(x: str, y: str, /, *args) -> None: ...
# Test all of the above with a number of different splatted argument types
def _(args: Literal["a"]) -> None:
takes_zero(*args) # error: [too-many-positional-arguments]
takes_one(*args)
takes_two(*args) # error: [missing-argument]
takes_two_positional_only(*args) # error: [missing-argument]
# error: [invalid-argument-type]
# error: [missing-argument]
takes_two_different(*args)
# error: [invalid-argument-type]
# error: [missing-argument]
takes_two_different_positional_only(*args)
takes_at_least_zero(*args)
takes_at_least_one(*args)
takes_at_least_two(*args) # error: [missing-argument]
takes_at_least_two_positional_only(*args) # error: [missing-argument]
def _(args: Literal["ab"]) -> None:
takes_zero(*args) # error: [too-many-positional-arguments]
takes_one(*args) # error: [too-many-positional-arguments]
takes_two(*args)
takes_two_positional_only(*args)
takes_two_different(*args) # error: [invalid-argument-type]
takes_two_different_positional_only(*args) # error: [invalid-argument-type]
takes_at_least_zero(*args)
takes_at_least_one(*args)
takes_at_least_two(*args)
takes_at_least_two_positional_only(*args)
def _(args: Literal["abc"]) -> None:
takes_zero(*args) # error: [too-many-positional-arguments]
takes_one(*args) # error: [too-many-positional-arguments]
takes_two(*args) # error: [too-many-positional-arguments]
takes_two_positional_only(*args) # error: [too-many-positional-arguments]
# error: [invalid-argument-type]
# error: [too-many-positional-arguments]
takes_two_different(*args)
# error: [invalid-argument-type]
# error: [too-many-positional-arguments]
takes_two_different_positional_only(*args)
takes_at_least_zero(*args)
takes_at_least_one(*args)
takes_at_least_two(*args)
takes_at_least_two_positional_only(*args)
def _(args: str) -> None:
takes_zero(*args)
takes_one(*args)
takes_two(*args)
takes_two_positional_only(*args)
takes_two_different(*args) # error: [invalid-argument-type]
takes_two_different_positional_only(*args) # error: [invalid-argument-type]
takes_at_least_zero(*args)
takes_at_least_one(*args)
takes_at_least_two(*args)
takes_at_least_two_positional_only(*args)
```
### Argument expansion regression
This is a regression that was highlighted by the ecosystem check, which shows that we might need to
rethink how we perform argument expansion during overload resolution. In particular, we might need
to retry both `match_parameters` _and_ `check_types` for each expansion. Currently we only retry
`check_types`.
The issue is that argument expansion might produce a splatted value with a different arity than what
we originally inferred for the unexpanded value, and that in turn can affect which parameters the
splatted value is matched with.
The first example correctly produces an error. The `tuple[int, str]` union element has a precise
arity of two, and so parameter matching chooses the first overload. The second element of the tuple
does not match the second parameter type, which yielding an `invalid-argument-type` error.
The third example should produce the same error. However, because we have a union, we do not see the
precise arity of each union element during parameter matching. Instead, we infer an arity of "zero
or more" for the union as a whole, and use that less precise arity when matching parameters. We
therefore consider the second overload to still be a potential candidate for the `tuple[int, str]`
union element. During type checking, we have to force the arity of each union element to match the
inferred arity of the union as a whole (turning `tuple[int, str]` into `tuple[int | str, ...]`).
That less precise tuple type-checks successfully against the second overload, making us incorrectly
think that `tuple[int, str]` is a valid splatted call.
If we update argument expansion to retry parameter matching with the precise arity of each union
element, we will correctly rule out the second overload for `tuple[int, str]`, just like we do when
splatting that tuple directly (instead of as part of a union).
```py
from typing import overload
@overload
def f(x: int, y: int) -> None: ...
@overload
def f(x: int, y: str, z: int) -> None: ...
def f(*args): ...
# Test all of the above with a number of different splatted argument types
def _(t: tuple[int, str]) -> None:
f(*t) # error: [invalid-argument-type]
def _(t: tuple[int, str, int]) -> None:
f(*t)
def _(t: tuple[int, str] | tuple[int, str, int]) -> None:
# TODO: error: [invalid-argument-type]
f(*t)
```
## Wrong argument type
### Positional argument, positional-or-keyword parameter
@@ -625,7 +310,7 @@ len()
len([], 1)
```
### Type property predicates
### Type API predicates
```py
from ty_extensions import is_subtype_of

View File

@@ -204,7 +204,7 @@ reveal_type(IntOrStr.__or__) # revealed: bound method typing.TypeAliasType.__or
## Method calls on types not disjoint from `None`
Very few methods are defined on `object`, `None`, and other types not disjoint from `None`. However,
descriptor-binding behavior works on these types in exactly the same way as descriptor binding on
descriptor-binding behaviour works on these types in exactly the same way as descriptor binding on
other types. This is despite the fact that `None` is used as a sentinel internally by the descriptor
protocol to indicate that a method was accessed on the class itself rather than an instance of the
class:

View File

@@ -5,12 +5,6 @@ with one or more overloads. This document describes the algorithm that it uses f
matching, which is the same as the one mentioned in the
[spec](https://typing.python.org/en/latest/spec/overload.html#overload-call-evaluation).
Note that all of the examples that involve positional parameters are tested multiple times: once
with the parameters matched with individual positional arguments, and once with the parameters
matched with a single positional argument that is splatted into the argument list. Overload
resolution is performed _after_ splatted arguments have been expanded, and so both approaches (TODO:
should) produce the same results.
## Arity check
The first step is to perform arity check. The non-overloaded cases are described in the
@@ -32,15 +26,10 @@ from overloaded import f
# These match a single overload
reveal_type(f()) # revealed: None
reveal_type(f(*())) # revealed: None
reveal_type(f(1)) # revealed: int
reveal_type(f(*(1,))) # revealed: int
# error: [no-matching-overload] "No overload of function `f` matches arguments"
reveal_type(f("a", "b")) # revealed: Unknown
# error: [no-matching-overload] "No overload of function `f` matches arguments"
reveal_type(f(*("a", "b"))) # revealed: Unknown
```
## Type checking
@@ -70,13 +59,8 @@ which filters out all but the matching overload:
from overloaded import f
reveal_type(f(1)) # revealed: int
reveal_type(f(*(1,))) # revealed: int
reveal_type(f("a")) # revealed: str
reveal_type(f(*("a",))) # revealed: str
reveal_type(f(b"b")) # revealed: bytes
reveal_type(f(*(b"b",))) # revealed: bytes
```
### Single match error
@@ -104,12 +88,9 @@ from typing_extensions import reveal_type
from overloaded import f
reveal_type(f()) # revealed: None
reveal_type(f(*())) # revealed: None
# error: [invalid-argument-type] "Argument to function `f` is incorrect: Expected `int`, found `Literal["a"]`"
reveal_type(f("a")) # revealed: Unknown
# error: [invalid-argument-type] "Argument to function `f` is incorrect: Expected `int`, found `Literal["a"]`"
reveal_type(f(*("a",))) # revealed: Unknown
```
More examples of this diagnostic can be found in the
@@ -136,15 +117,10 @@ from overloaded import A, B, f
# These calls pass the arity check, and type checking matches both overloads:
reveal_type(f(A())) # revealed: A
reveal_type(f(*(A(),))) # revealed: A
reveal_type(f(B())) # revealed: A
# TODO: revealed: A
reveal_type(f(*(B(),))) # revealed: Unknown
# But, in this case, the arity check filters out the first overload, so we only have one match:
reveal_type(f(B(), 1)) # revealed: B
reveal_type(f(*(B(), 1))) # revealed: B
```
## Argument type expansion
@@ -179,13 +155,8 @@ from overloaded import A, B, C, f
def _(ab: A | B, ac: A | C, bc: B | C):
reveal_type(f(ab)) # revealed: A | B
reveal_type(f(*(ab,))) # revealed: A | B
reveal_type(f(bc)) # revealed: B | C
reveal_type(f(*(bc,))) # revealed: B | C
reveal_type(f(ac)) # revealed: A | C
reveal_type(f(*(ac,))) # revealed: A | C
```
### Expanding first argument
@@ -218,15 +189,11 @@ from overloaded import A, B, C, D, f
def _(a_b: A | B):
reveal_type(f(a_b, C())) # revealed: A | C
reveal_type(f(*(a_b, C()))) # revealed: A | C
reveal_type(f(a_b, D())) # revealed: B | D
reveal_type(f(*(a_b, D()))) # revealed: B | D
# But, if it doesn't, it should expand the second argument and try again:
def _(a_b: A | B, c_d: C | D):
reveal_type(f(a_b, c_d)) # revealed: A | B | C | D
reveal_type(f(*(a_b, c_d))) # revealed: A | B | C | D
```
### Expanding second argument
@@ -259,12 +226,9 @@ def _(a: A, bc: B | C, cd: C | D):
# This also tests that partial matching works correctly as the argument type expansion results
# in matching the first and second overloads, but not the third one.
reveal_type(f(a, bc)) # revealed: B | C
reveal_type(f(*(a, bc))) # revealed: B | C
# error: [no-matching-overload] "No overload of function `f` matches arguments"
reveal_type(f(a, cd)) # revealed: Unknown
# error: [no-matching-overload] "No overload of function `f` matches arguments"
reveal_type(f(*(a, cd))) # revealed: Unknown
```
### Generics (legacy)
@@ -290,16 +254,7 @@ from overloaded import A, f
def _(x: int, y: A | int):
reveal_type(f(x)) # revealed: int
# TODO: revealed: int
# TODO: no error
# error: [no-matching-overload]
reveal_type(f(*(x,))) # revealed: Unknown
reveal_type(f(y)) # revealed: A | int
# TODO: revealed: A | int
# TODO: no error
# error: [no-matching-overload]
reveal_type(f(*(y,))) # revealed: Unknown
```
### Generics (PEP 695)
@@ -328,16 +283,7 @@ from overloaded import B, f
def _(x: int, y: B | int):
reveal_type(f(x)) # revealed: int
# TODO: revealed: int
# TODO: no error
# error: [no-matching-overload]
reveal_type(f(*(x,))) # revealed: Unknown
reveal_type(f(y)) # revealed: B | int
# TODO: revealed: B | int
# TODO: no error
# error: [no-matching-overload]
reveal_type(f(*(y,))) # revealed: Unknown
```
### Expanding `bool`
@@ -361,13 +307,8 @@ from overloaded import f
def _(flag: bool):
reveal_type(f(True)) # revealed: T
reveal_type(f(*(True,))) # revealed: T
reveal_type(f(False)) # revealed: F
reveal_type(f(*(False,))) # revealed: F
reveal_type(f(flag)) # revealed: T | F
reveal_type(f(*(flag,))) # revealed: T | F
```
### Expanding `tuple`
@@ -397,7 +338,6 @@ from overloaded import A, B, f
def _(x: tuple[A | B, int], y: tuple[int, bool]):
reveal_type(f(x, y)) # revealed: A | B | C | D
reveal_type(f(*(x, y))) # revealed: A | B | C | D
```
### Expanding `type`
@@ -425,13 +365,10 @@ from overloaded import A, B, f
def _(x: type[A | B]):
reveal_type(x) # revealed: type[A] | type[B]
reveal_type(f(x)) # revealed: A | B
reveal_type(f(*(x,))) # revealed: A | B
```
### Expanding enums
#### Basic
`overloaded.pyi`:
```pyi
@@ -457,125 +394,15 @@ def f(x: Literal[SomeEnum.C]) -> C: ...
```
```py
from typing import Literal
from overloaded import SomeEnum, A, B, C, f
def _(x: SomeEnum, y: Literal[SomeEnum.A, SomeEnum.C]):
def _(x: SomeEnum):
reveal_type(f(SomeEnum.A)) # revealed: A
reveal_type(f(*(SomeEnum.A,))) # revealed: A
reveal_type(f(SomeEnum.B)) # revealed: B
reveal_type(f(*(SomeEnum.B,))) # revealed: B
reveal_type(f(SomeEnum.C)) # revealed: C
reveal_type(f(*(SomeEnum.C,))) # revealed: C
reveal_type(f(x)) # revealed: A | B | C
reveal_type(f(*(x,))) # revealed: A | B | C
reveal_type(f(y)) # revealed: A | C
reveal_type(f(*(y,))) # revealed: A | C
```
#### Enum with single member
This pattern appears in typeshed. Here, it is used to represent two optional, mutually exclusive
keyword parameters:
`overloaded.pyi`:
```pyi
from enum import Enum, auto
from typing import overload, Literal
class Missing(Enum):
Value = auto()
class OnlyASpecified: ...
class OnlyBSpecified: ...
class BothMissing: ...
@overload
def f(*, a: int, b: Literal[Missing.Value] = ...) -> OnlyASpecified: ...
@overload
def f(*, a: Literal[Missing.Value] = ..., b: int) -> OnlyBSpecified: ...
@overload
def f(*, a: Literal[Missing.Value] = ..., b: Literal[Missing.Value] = ...) -> BothMissing: ...
```
```py
from typing import Literal
from overloaded import f, Missing
reveal_type(f()) # revealed: BothMissing
reveal_type(f(a=0)) # revealed: OnlyASpecified
reveal_type(f(b=0)) # revealed: OnlyBSpecified
f(a=0, b=0) # error: [no-matching-overload]
def _(missing: Literal[Missing.Value], missing_or_present: Literal[Missing.Value] | int):
reveal_type(f(a=missing, b=missing)) # revealed: BothMissing
reveal_type(f(a=missing)) # revealed: BothMissing
reveal_type(f(b=missing)) # revealed: BothMissing
reveal_type(f(a=0, b=missing)) # revealed: OnlyASpecified
reveal_type(f(a=missing, b=0)) # revealed: OnlyBSpecified
reveal_type(f(a=missing_or_present)) # revealed: BothMissing | OnlyASpecified
reveal_type(f(b=missing_or_present)) # revealed: BothMissing | OnlyBSpecified
# Here, both could be present, so this should be an error
f(a=missing_or_present, b=missing_or_present) # error: [no-matching-overload]
```
#### Enum subclass without members
An `Enum` subclass without members should _not_ be expanded:
`overloaded.pyi`:
```pyi
from enum import Enum
from typing import overload, Literal
class MyEnumSubclass(Enum):
pass
class ActualEnum(MyEnumSubclass):
A = 1
B = 2
class OnlyA: ...
class OnlyB: ...
class Both: ...
@overload
def f(x: Literal[ActualEnum.A]) -> OnlyA: ...
@overload
def f(x: Literal[ActualEnum.B]) -> OnlyB: ...
@overload
def f(x: ActualEnum) -> Both: ...
@overload
def f(x: MyEnumSubclass) -> MyEnumSubclass: ...
```
```py
from overloaded import MyEnumSubclass, ActualEnum, f
def _(actual_enum: ActualEnum, my_enum_instance: MyEnumSubclass):
reveal_type(f(actual_enum)) # revealed: Both
# TODO: revealed: Both
reveal_type(f(*(actual_enum,))) # revealed: Unknown
reveal_type(f(ActualEnum.A)) # revealed: OnlyA
# TODO: revealed: OnlyA
reveal_type(f(*(ActualEnum.A,))) # revealed: Unknown
reveal_type(f(ActualEnum.B)) # revealed: OnlyB
# TODO: revealed: OnlyB
reveal_type(f(*(ActualEnum.B,))) # revealed: Unknown
reveal_type(f(my_enum_instance)) # revealed: MyEnumSubclass
reveal_type(f(*(my_enum_instance,))) # revealed: MyEnumSubclass
# TODO: This should not be an error. The return type should be `A | B | C` once enums are expanded
# error: [no-matching-overload]
reveal_type(f(x)) # revealed: Unknown
```
### No matching overloads
@@ -604,22 +431,21 @@ from overloaded import A, B, C, D, f
def _(ab: A | B, ac: A | C, cd: C | D):
reveal_type(f(ab)) # revealed: A | B
reveal_type(f(*(ab,))) # revealed: A | B
# The `[A | C]` argument list is expanded to `[A], [C]` where the first list matches the first
# overload while the second list doesn't match any of the overloads, so we generate an
# error: [no-matching-overload] "No overload of function `f` matches arguments"
reveal_type(f(ac)) # revealed: Unknown
# error: [no-matching-overload] "No overload of function `f` matches arguments"
reveal_type(f(*(ac,))) # revealed: Unknown
# None of the expanded argument lists (`[C], [D]`) match any of the overloads, so we generate an
# error: [no-matching-overload] "No overload of function `f` matches arguments"
reveal_type(f(cd)) # revealed: Unknown
# error: [no-matching-overload] "No overload of function `f` matches arguments"
reveal_type(f(*(cd,))) # revealed: Unknown
```
## Filtering overloads with variadic arguments and parameters
TODO
## Filtering based on `Any` / `Unknown`
This is the step 5 of the overload call evaluation algorithm which specifies that:
@@ -654,16 +480,10 @@ from overloaded import f
# Anything other than `list` should match the last overload
reveal_type(f(1)) # revealed: str
reveal_type(f(*(1,))) # revealed: str
def _(list_int: list[int], list_any: list[Any]):
reveal_type(f(list_int)) # revealed: int
# TODO: revealed: int
reveal_type(f(*(list_int,))) # revealed: Unknown
reveal_type(f(list_any)) # revealed: int
# TODO: revealed: int
reveal_type(f(*(list_any,))) # revealed: Unknown
```
### Single list argument (ambiguous)
@@ -691,20 +511,16 @@ from overloaded import f
# Anything other than `list` should match the last overload
reveal_type(f(1)) # revealed: str
reveal_type(f(*(1,))) # revealed: str
def _(list_int: list[int], list_any: list[Any]):
# All materializations of `list[int]` are assignable to `list[int]`, so it matches the first
# overload.
reveal_type(f(list_int)) # revealed: int
# TODO: revealed: int
reveal_type(f(*(list_int,))) # revealed: Unknown
# All materializations of `list[Any]` are assignable to `list[int]` and `list[Any]`, but the
# return type of first and second overloads are not equivalent, so the overload matching
# is ambiguous.
reveal_type(f(list_any)) # revealed: Unknown
reveal_type(f(*(list_any,))) # revealed: Unknown
```
### Single tuple argument
@@ -728,33 +544,21 @@ from typing import Any
from overloaded import f
reveal_type(f("a")) # revealed: str
reveal_type(f(*("a",))) # revealed: str
reveal_type(f((1, "b"))) # revealed: int
# TODO: revealed: int
reveal_type(f(*((1, "b"),))) # revealed: Unknown
reveal_type(f((1, 2))) # revealed: int
# TODO: revealed: int
reveal_type(f(*((1, 2),))) # revealed: Unknown
def _(int_str: tuple[int, str], int_any: tuple[int, Any], any_any: tuple[Any, Any]):
# All materializations are assignable to first overload, so second and third overloads are
# eliminated
reveal_type(f(int_str)) # revealed: int
# TODO: revealed: int
reveal_type(f(*(int_str,))) # revealed: Unknown
# All materializations are assignable to second overload, so the third overload is eliminated;
# the return type of first and second overload is equivalent
reveal_type(f(int_any)) # revealed: int
# TODO: revealed: int
reveal_type(f(*(int_any,))) # revealed: Unknown
# All materializations of `tuple[Any, Any]` are assignable to the parameters of all the
# overloads, but the return types aren't equivalent, so the overload matching is ambiguous
reveal_type(f(any_any)) # revealed: Unknown
reveal_type(f(*(any_any,))) # revealed: Unknown
```
### Multiple arguments
@@ -784,32 +588,23 @@ def _(list_int: list[int], list_any: list[Any], int_str: tuple[int, str], int_an
# All materializations of both argument types are assignable to the first overload, so the
# second and third overloads are filtered out
reveal_type(f(list_int, int_str)) # revealed: A
# TODO: revealed: A
reveal_type(f(*(list_int, int_str))) # revealed: Unknown
# All materialization of first argument is assignable to first overload and for the second
# argument, they're assignable to the second overload, so the third overload is filtered out
reveal_type(f(list_int, int_any)) # revealed: A
# TODO: revealed: A
reveal_type(f(*(list_int, int_any))) # revealed: Unknown
# All materialization of first argument is assignable to second overload and for the second
# argument, they're assignable to the first overload, so the third overload is filtered out
reveal_type(f(list_any, int_str)) # revealed: A
# TODO: revealed: A
reveal_type(f(*(list_any, int_str))) # revealed: Unknown
# All materializations of both arguments are assignable to the second overload, so the third
# overload is filtered out
reveal_type(f(list_any, int_any)) # revealed: A
# TODO: revealed: A
reveal_type(f(*(list_any, int_any))) # revealed: Unknown
# All materializations of first argument is assignable to the second overload and for the second
# argument, they're assignable to the third overload, so no overloads are filtered out; the
# return types of the remaining overloads are not equivalent, so overload matching is ambiguous
reveal_type(f(list_int, any_any)) # revealed: Unknown
reveal_type(f(*(list_int, any_any))) # revealed: Unknown
```
### `LiteralString` and `str`
@@ -834,16 +629,11 @@ from overloaded import f
def _(literal: LiteralString, string: str, any: Any):
reveal_type(f(literal)) # revealed: LiteralString
# TODO: revealed: LiteralString
reveal_type(f(*(literal,))) # revealed: Unknown
reveal_type(f(string)) # revealed: str
reveal_type(f(*(string,))) # revealed: str
# `Any` matches both overloads, but the return types are not equivalent.
# Pyright and mypy both reveal `str` here, contrary to the spec.
reveal_type(f(any)) # revealed: Unknown
reveal_type(f(*(any,))) # revealed: Unknown
```
### Generics
@@ -873,19 +663,10 @@ from overloaded import f
def _(list_int: list[int], list_str: list[str], list_any: list[Any], any: Any):
reveal_type(f(list_int)) # revealed: A
# TODO: revealed: A
reveal_type(f(*(list_int,))) # revealed: Unknown
# TODO: Should be `str`
reveal_type(f(list_str)) # revealed: Unknown
# TODO: Should be `str`
reveal_type(f(*(list_str,))) # revealed: Unknown
reveal_type(f(list_any)) # revealed: Unknown
reveal_type(f(*(list_any,))) # revealed: Unknown
reveal_type(f(any)) # revealed: Unknown
reveal_type(f(*(any,))) # revealed: Unknown
```
### Generics (multiple arguments)
@@ -910,24 +691,12 @@ from overloaded import f
def _(integer: int, string: str, any: Any, list_any: list[Any]):
reveal_type(f(integer, string)) # revealed: int
reveal_type(f(*(integer, string))) # revealed: int
reveal_type(f(string, integer)) # revealed: int
# TODO: revealed: int
# TODO: no error
# error: [no-matching-overload]
reveal_type(f(*(string, integer))) # revealed: Unknown
# This matches the second overload and is _not_ the case of ambiguous overload matching.
reveal_type(f(string, any)) # revealed: Any
# TODO: Any
reveal_type(f(*(string, any))) # revealed: tuple[str, Any]
reveal_type(f(string, list_any)) # revealed: list[Any]
# TODO: revealed: list[Any]
# TODO: no error
# error: [no-matching-overload]
reveal_type(f(*(string, list_any))) # revealed: Unknown
```
### Generic `self`
@@ -970,54 +739,7 @@ def _(b_int: B[int], b_str: B[str], b_any: B[Any]):
### Variadic argument
`overloaded.pyi`:
```pyi
from typing import Any, overload
class A: ...
class B: ...
@overload
def f1(x: int) -> A: ...
@overload
def f1(x: Any, y: Any) -> A: ...
@overload
def f2(x: int) -> A: ...
@overload
def f2(x: Any, y: Any) -> B: ...
@overload
def f3(x: int) -> A: ...
@overload
def f3(x: Any, y: Any) -> A: ...
@overload
def f3(x: Any, y: Any, *, z: str) -> B: ...
@overload
def f4(x: int) -> A: ...
@overload
def f4(x: Any, y: Any) -> B: ...
@overload
def f4(x: Any, y: Any, *, z: str) -> B: ...
```
```py
from typing import Any
from overloaded import f1, f2, f3, f4
def _(arg: list[Any]):
# Matches both overload and the return types are equivalent
reveal_type(f1(*arg)) # revealed: A
# Matches both overload but the return types aren't equivalent
reveal_type(f2(*arg)) # revealed: Unknown
# Filters out the final overload and the return types are equivalent
reveal_type(f3(*arg)) # revealed: A
# Filters out the final overload but the return types aren't equivalent
reveal_type(f4(*arg)) # revealed: Unknown
```
TODO: A variadic parameter is being assigned to a number of parameters of the same type
### Non-participating fully-static parameter
@@ -1053,10 +775,7 @@ from overloaded import f
def _(any: Any):
reveal_type(f(any, flag=True)) # revealed: int
reveal_type(f(*(any,), flag=True)) # revealed: int
reveal_type(f(any, flag=False)) # revealed: str
reveal_type(f(*(any,), flag=False)) # revealed: str
```
### Non-participating gradual parameter
@@ -1067,32 +786,21 @@ def _(any: Any):
from typing import Any, Literal, overload
@overload
def f(x: tuple[str, Any], flag: Literal[True]) -> int: ...
def f(x: tuple[str, Any], *, flag: Literal[True]) -> int: ...
@overload
def f(x: tuple[str, Any], flag: Literal[False] = ...) -> str: ...
def f(x: tuple[str, Any], *, flag: Literal[False] = ...) -> str: ...
@overload
def f(x: tuple[str, Any], flag: bool = ...) -> int | str: ...
def f(x: tuple[str, Any], *, flag: bool = ...) -> int | str: ...
```
```py
from typing import Any, Literal
from typing import Any
from overloaded import f
def _(any: Any):
reveal_type(f(any, flag=True)) # revealed: int
reveal_type(f(*(any,), flag=True)) # revealed: int
reveal_type(f(any, flag=False)) # revealed: str
reveal_type(f(*(any,), flag=False)) # revealed: str
def _(args: tuple[Any, Literal[True]]):
# TODO: revealed: int
reveal_type(f(*args)) # revealed: Unknown
def _(args: tuple[Any, Literal[False]]):
# TODO: revealed: str
reveal_type(f(*args)) # revealed: Unknown
```
### Argument type expansion
@@ -1137,7 +845,6 @@ from overloaded import A, B, f
def _(arg: tuple[A | B, Any]):
reveal_type(f(arg)) # revealed: A | B
reveal_type(f(*(arg,))) # revealed: A | B
```
#### One argument list ambiguous
@@ -1172,7 +879,6 @@ from overloaded import A, B, C, f
def _(arg: tuple[A | B, Any]):
reveal_type(f(arg)) # revealed: A | Unknown
reveal_type(f(*(arg,))) # revealed: A | Unknown
```
#### Both argument lists ambiguous
@@ -1206,5 +912,4 @@ from overloaded import A, B, C, f
def _(arg: tuple[A | B, Any]):
reveal_type(f(arg)) # revealed: Unknown
reveal_type(f(*(arg,))) # revealed: Unknown
```

View File

@@ -80,8 +80,6 @@ def _(subject: C):
A `case` branch with a class pattern is taken if the subject is an instance of the given class, and
all subpatterns in the class pattern match.
### Without arguments
```py
from typing import final
@@ -138,51 +136,6 @@ def _(target: FooSub | str):
reveal_type(y) # revealed: Literal[1, 3, 4]
```
### With arguments
```py
from typing_extensions import assert_never
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
class Other: ...
def _(target: Point):
y = 1
match target:
case Point(0, 0):
y = 2
case Point(x=0, y=1):
y = 3
case Point(x=1, y=0):
y = 4
reveal_type(y) # revealed: Literal[1, 2, 3, 4]
def _(target: Point):
match target:
case Point(x, y): # irrefutable sub-patterns
pass
case _:
assert_never(target)
def _(target: Point | Other):
match target:
case Point(0, 0):
reveal_type(target) # revealed: Point
case Point(x=0, y=1):
reveal_type(target) # revealed: Point
case Point(x=1, y=0):
reveal_type(target) # revealed: Point
case Other():
reveal_type(target) # revealed: Other
```
## Singleton match
Singleton patterns are matched based on identity, not equality comparisons or `isinstance()` checks.
@@ -201,7 +154,8 @@ def _(target: Literal[True, False]):
case None:
y = 4
reveal_type(y) # revealed: Literal[2, 3]
# TODO: with exhaustiveness checking, this should be Literal[2, 3]
reveal_type(y) # revealed: Literal[1, 2, 3]
def _(target: bool):
y = 1
@@ -214,7 +168,8 @@ def _(target: bool):
case None:
y = 4
reveal_type(y) # revealed: Literal[2, 3]
# TODO: with exhaustiveness checking, this should be Literal[2, 3]
reveal_type(y) # revealed: Literal[1, 2, 3]
def _(target: None):
y = 1
@@ -240,7 +195,8 @@ def _(target: None | Literal[True]):
case None:
y = 4
reveal_type(y) # revealed: Literal[2, 4]
# TODO: with exhaustiveness checking, this should be Literal[2, 4]
reveal_type(y) # revealed: Literal[1, 2, 4]
# bool is an int subclass
def _(target: int):
@@ -289,7 +245,7 @@ def _(answer: Answer):
reveal_type(answer) # revealed: Literal[Answer.NO]
y = 2
reveal_type(y) # revealed: Literal[1, 2]
reveal_type(y) # revealed: Literal[0, 1, 2]
```
## Or match
@@ -308,7 +264,8 @@ def _(target: Literal["foo", "baz"]):
case "baz":
y = 3
reveal_type(y) # revealed: Literal[2, 3]
# TODO: with exhaustiveness, this should be Literal[2, 3]
reveal_type(y) # revealed: Literal[1, 2, 3]
def _(target: None):
y = 1

View File

@@ -640,8 +640,6 @@ reveal_type(C.__init__) # revealed: (self: C, normal: int, conditionally_presen
python-version = "3.12"
```
### Basic
```py
from dataclasses import dataclass
@@ -660,34 +658,6 @@ reveal_type(d_int.description) # revealed: str
DataWithDescription[int](None, "description")
```
### Deriving from generic dataclasses
This is a regression test for <https://github.com/astral-sh/ty/issues/853>.
```py
from dataclasses import dataclass
@dataclass
class Wrap[T]:
data: T
reveal_type(Wrap[int].__init__) # revealed: (self: Wrap[int], data: int) -> None
@dataclass
class WrappedInt(Wrap[int]):
other_field: str
reveal_type(WrappedInt.__init__) # revealed: (self: WrappedInt, data: int, other_field: str) -> None
# Make sure that another generic type parameter does not affect the `data` field
@dataclass
class WrappedIntAndExtraData[T](Wrap[int]):
extra_data: T
# revealed: (self: WrappedIntAndExtraData[bytes], data: int, extra_data: bytes) -> None
reveal_type(WrappedIntAndExtraData[bytes].__init__)
```
## Descriptor-typed fields
### Same type in `__get__` and `__set__`

View File

@@ -54,7 +54,7 @@ class deprecated:
```
Only the mandatory message string is of interest to static analysis, the other two affect only
runtime behavior.
runtime behaviour.
```py
from typing_extensions import deprecated

View File

@@ -119,6 +119,8 @@ def match_singletons_success(obj: Literal[1, "a"] | None):
case None:
pass
case _ as obj:
# TODO: Ideally, we would not emit an error here
# error: [type-assertion-failure] "Argument does not have asserted type `Never`"
assert_never(obj)
def match_singletons_error(obj: Literal[1, "a"] | None):

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