diff --git a/Cargo.lock b/Cargo.lock index 6fd797d51e..571d79c854 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2300,6 +2300,7 @@ dependencies = [ "red_knot_vendored", "ruff_db", "ruff_index", + "ruff_macros", "ruff_python_ast", "ruff_python_literal", "ruff_python_parser", diff --git a/crates/red_knot_python_semantic/Cargo.toml b/crates/red_knot_python_semantic/Cargo.toml index 981b72c169..74b7fe81d1 100644 --- a/crates/red_knot_python_semantic/Cargo.toml +++ b/crates/red_knot_python_semantic/Cargo.toml @@ -13,6 +13,7 @@ license = { workspace = true } [dependencies] ruff_db = { workspace = true } ruff_index = { workspace = true } +ruff_macros = { workspace = true } ruff_python_ast = { workspace = true } ruff_python_parser = { workspace = true } ruff_python_stdlib = { workspace = true } diff --git a/crates/red_knot_python_semantic/resources/mdtest/annotations/string.md b/crates/red_knot_python_semantic/resources/mdtest/annotations/string.md index b72b542529..4ad2b7e4f3 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/annotations/string.md +++ b/crates/red_knot_python_semantic/resources/mdtest/annotations/string.md @@ -71,21 +71,21 @@ class Foo: ... ```py def f1( - # error: [annotation-raw-string] "Type expressions cannot use raw string literal" + # error: [raw-string-type-annotation] "Type expressions cannot use raw string literal" a: r"int", - # error: [annotation-f-string] "Type expressions cannot use f-strings" + # error: [fstring-type-annotation] "Type expressions cannot use f-strings" b: f"int", - # error: [annotation-byte-string] "Type expressions cannot use bytes literal" + # error: [byte-string-type-annotation] "Type expressions cannot use bytes literal" c: b"int", d: "int", - # error: [annotation-implicit-concat] "Type expressions cannot span multiple string literals" + # error: [implicit-concatenated-string-type-annotation] "Type expressions cannot span multiple string literals" e: "in" "t", - # error: [annotation-escape-character] "Type expressions cannot contain escape characters" + # error: [escape-character-in-forward-annotation] "Type expressions cannot contain escape characters" f: "\N{LATIN SMALL LETTER I}nt", - # error: [annotation-escape-character] "Type expressions cannot contain escape characters" + # error: [escape-character-in-forward-annotation] "Type expressions cannot contain escape characters" g: "\x69nt", h: """int""", - # error: [annotation-byte-string] "Type expressions cannot use bytes literal" + # error: [byte-string-type-annotation] "Type expressions cannot use bytes literal" i: "b'int'", ): reveal_type(a) # revealed: Unknown @@ -164,9 +164,9 @@ i: "{i for i in range(5)}" j: "{i: i for i in range(5)}" k: "(i for i in range(5))" l: "await 1" -# error: [forward-annotation-syntax-error] +# error: [invalid-syntax-in-forward-annotation] m: "yield 1" -# error: [forward-annotation-syntax-error] +# error: [invalid-syntax-in-forward-annotation] n: "yield from 1" o: "1 < 2" p: "call()" diff --git a/crates/red_knot_python_semantic/resources/mdtest/exception/basic.md b/crates/red_knot_python_semantic/resources/mdtest/exception/basic.md index 167692d422..9610d403c8 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/exception/basic.md +++ b/crates/red_knot_python_semantic/resources/mdtest/exception/basic.md @@ -62,14 +62,14 @@ def foo( ```py try: pass -# error: [invalid-exception] "Cannot catch object of type `Literal[3]` in an exception handler (must be a `BaseException` subclass or a tuple of `BaseException` subclasses)" +# error: [invalid-exception-caught] "Cannot catch object of type `Literal[3]` in an exception handler (must be a `BaseException` subclass or a tuple of `BaseException` subclasses)" except 3 as e: reveal_type(e) # revealed: Unknown try: pass -# error: [invalid-exception] "Cannot catch object of type `Literal["foo"]` in an exception handler (must be a `BaseException` subclass or a tuple of `BaseException` subclasses)" -# error: [invalid-exception] "Cannot catch object of type `Literal[b"bar"]` in an exception handler (must be a `BaseException` subclass or a tuple of `BaseException` subclasses)" +# error: [invalid-exception-caught] "Cannot catch object of type `Literal["foo"]` in an exception handler (must be a `BaseException` subclass or a tuple of `BaseException` subclasses)" +# error: [invalid-exception-caught] "Cannot catch object of type `Literal[b"bar"]` in an exception handler (must be a `BaseException` subclass or a tuple of `BaseException` subclasses)" except (ValueError, OSError, "foo", b"bar") as e: reveal_type(e) # revealed: ValueError | OSError | Unknown @@ -80,10 +80,10 @@ def foo( ): try: help() - # error: [invalid-exception] + # error: [invalid-exception-caught] except x as e: reveal_type(e) # revealed: Unknown - # error: [invalid-exception] + # error: [invalid-exception-caught] except y as f: reveal_type(f) # revealed: OSError | RuntimeError | Unknown except z as g: diff --git a/crates/red_knot_python_semantic/resources/mdtest/exception/except_star.md b/crates/red_knot_python_semantic/resources/mdtest/exception/except_star.md index 47f6f2d97b..062e285c46 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/exception/except_star.md +++ b/crates/red_knot_python_semantic/resources/mdtest/exception/except_star.md @@ -47,13 +47,13 @@ except* (KeyboardInterrupt, AttributeError) as e: ```py try: help() -except* 3 as e: # error: [invalid-exception] +except* 3 as e: # error: [invalid-exception-caught] # TODO: Should be `BaseExceptionGroup[Unknown]` --Alex reveal_type(e) # revealed: BaseExceptionGroup try: help() -except* (AttributeError, 42) as e: # error: [invalid-exception] +except* (AttributeError, 42) as e: # error: [invalid-exception-caught] # TODO: Should be `BaseExceptionGroup[AttributeError | Unknown]` --Alex reveal_type(e) # revealed: BaseExceptionGroup ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/generics.md b/crates/red_knot_python_semantic/resources/mdtest/generics.md index b65f2c7c94..c8f80eb076 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/generics.md +++ b/crates/red_knot_python_semantic/resources/mdtest/generics.md @@ -73,7 +73,7 @@ def f[T](): A typevar with less than two constraints emits a diagnostic: ```py -# error: [invalid-typevar-constraints] "TypeVar must have at least two constrained types" +# error: [invalid-type-variable-constraints] "TypeVar must have at least two constrained types" def f[T: (int,)](): pass ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/metaclass.md b/crates/red_knot_python_semantic/resources/mdtest/metaclass.md index e6bcd6e43c..f8f408789c 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/metaclass.md +++ b/crates/red_knot_python_semantic/resources/mdtest/metaclass.md @@ -179,9 +179,9 @@ reveal_type(A.__class__) # revealed: @Todo(metaclass not a class) Retrieving the metaclass of a cyclically defined class should not cause an infinite loop. ```py path=a.pyi -class A(B): ... # error: [cyclic-class-def] -class B(C): ... # error: [cyclic-class-def] -class C(A): ... # error: [cyclic-class-def] +class A(B): ... # error: [cyclic-class-definition] +class B(C): ... # error: [cyclic-class-definition] +class C(A): ... # error: [cyclic-class-definition] reveal_type(A.__class__) # revealed: Unknown ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/mro.md b/crates/red_knot_python_semantic/resources/mdtest/mro.md index dc0abf9e26..4ca16760ca 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/mro.md +++ b/crates/red_knot_python_semantic/resources/mdtest/mro.md @@ -348,14 +348,14 @@ reveal_type(unknown_object.__mro__) # revealed: Unknown These are invalid, but we need to be able to handle them gracefully without panicking. ```py path=a.pyi -class Foo(Foo): ... # error: [cyclic-class-def] +class Foo(Foo): ... # error: [cyclic-class-definition] reveal_type(Foo) # revealed: Literal[Foo] reveal_type(Foo.__mro__) # revealed: tuple[Literal[Foo], Unknown, Literal[object]] class Bar: ... class Baz: ... -class Boz(Bar, Baz, Boz): ... # error: [cyclic-class-def] +class Boz(Bar, Baz, Boz): ... # error: [cyclic-class-definition] reveal_type(Boz) # revealed: Literal[Boz] reveal_type(Boz.__mro__) # revealed: tuple[Literal[Boz], Unknown, Literal[object]] @@ -366,9 +366,9 @@ reveal_type(Boz.__mro__) # revealed: tuple[Literal[Boz], Unknown, Literal[objec These are similarly unlikely, but we still shouldn't crash: ```py path=a.pyi -class Foo(Bar): ... # error: [cyclic-class-def] -class Bar(Baz): ... # error: [cyclic-class-def] -class Baz(Foo): ... # error: [cyclic-class-def] +class Foo(Bar): ... # error: [cyclic-class-definition] +class Bar(Baz): ... # error: [cyclic-class-definition] +class Baz(Foo): ... # error: [cyclic-class-definition] reveal_type(Foo.__mro__) # revealed: tuple[Literal[Foo], Unknown, Literal[object]] reveal_type(Bar.__mro__) # revealed: tuple[Literal[Bar], Unknown, Literal[object]] @@ -379,9 +379,9 @@ reveal_type(Baz.__mro__) # revealed: tuple[Literal[Baz], Unknown, Literal[objec ```py path=a.pyi class Spam: ... -class Foo(Bar): ... # error: [cyclic-class-def] -class Bar(Baz): ... # error: [cyclic-class-def] -class Baz(Foo, Spam): ... # error: [cyclic-class-def] +class Foo(Bar): ... # error: [cyclic-class-definition] +class Bar(Baz): ... # error: [cyclic-class-definition] +class Baz(Foo, Spam): ... # error: [cyclic-class-definition] reveal_type(Foo.__mro__) # revealed: tuple[Literal[Foo], Unknown, Literal[object]] reveal_type(Bar.__mro__) # revealed: tuple[Literal[Bar], Unknown, Literal[object]] @@ -391,16 +391,16 @@ reveal_type(Baz.__mro__) # revealed: tuple[Literal[Baz], Unknown, Literal[objec ## Classes with cycles in their MRO, and a sub-graph ```py path=a.pyi -class FooCycle(BarCycle): ... # error: [cyclic-class-def] +class FooCycle(BarCycle): ... # error: [cyclic-class-definition] class Foo: ... -class BarCycle(FooCycle): ... # error: [cyclic-class-def] +class BarCycle(FooCycle): ... # error: [cyclic-class-definition] class Bar(Foo): ... # TODO: can we avoid emitting the errors for these? # The classes have cyclic superclasses, # but are not themselves cyclic... -class Baz(Bar, BarCycle): ... # error: [cyclic-class-def] -class Spam(Baz): ... # error: [cyclic-class-def] +class Baz(Bar, BarCycle): ... # error: [cyclic-class-definition] +class Spam(Baz): ... # error: [cyclic-class-definition] reveal_type(FooCycle.__mro__) # revealed: tuple[Literal[FooCycle], Unknown, Literal[object]] reveal_type(BarCycle.__mro__) # revealed: tuple[Literal[BarCycle], Unknown, Literal[object]] diff --git a/crates/red_knot_python_semantic/src/lib.rs b/crates/red_knot_python_semantic/src/lib.rs index 013dec6219..29a3b177c3 100644 --- a/crates/red_knot_python_semantic/src/lib.rs +++ b/crates/red_knot_python_semantic/src/lib.rs @@ -11,6 +11,7 @@ pub use semantic_model::{HasTy, SemanticModel}; pub mod ast_node_ref; mod db; +pub mod lint; mod module_name; mod module_resolver; mod node_key; diff --git a/crates/red_knot_python_semantic/src/lint.rs b/crates/red_knot_python_semantic/src/lint.rs new file mode 100644 index 0000000000..b6471cc25a --- /dev/null +++ b/crates/red_knot_python_semantic/src/lint.rs @@ -0,0 +1,226 @@ +use itertools::Itertools; +use ruff_db::diagnostic::{LintName, Severity}; + +#[derive(Debug, Clone)] +pub struct LintMetadata { + /// The unique identifier for the lint. + pub name: LintName, + + /// A one-sentence summary of what the lint catches. + pub summary: &'static str, + + /// An in depth explanation of the lint in markdown. Covers what the lint does, why it's bad and possible fixes. + /// + /// The documentation may require post-processing to be rendered correctly. For example, lines + /// might have leading or trailing whitespace that should be removed. + pub raw_documentation: &'static str, + + /// The default level of the lint if the user doesn't specify one. + pub default_level: Level, + + pub status: LintStatus, + + /// The source file in which the lint is declared. + pub file: &'static str, + + /// The 1-based line number in the source `file` where the lint is declared. + pub line: u32, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub enum Level { + /// The lint is disabled and should not run. + Ignore, + + /// The lint is enabled and diagnostic should have a warning severity. + Warn, + + /// The lint is enabled and diagnostics have an error severity. + Error, +} + +impl Level { + pub const fn is_error(self) -> bool { + matches!(self, Level::Error) + } + + pub const fn is_warn(self) -> bool { + matches!(self, Level::Warn) + } + + pub const fn is_ignore(self) -> bool { + matches!(self, Level::Ignore) + } +} + +impl TryFrom for Severity { + type Error = (); + + fn try_from(level: Level) -> Result { + match level { + Level::Ignore => Err(()), + Level::Warn => Ok(Severity::Warning), + Level::Error => Ok(Severity::Error), + } + } +} + +impl LintMetadata { + pub fn name(&self) -> LintName { + self.name + } + + pub fn summary(&self) -> &str { + self.summary + } + + /// Returns the documentation line by line with one leading space and all trailing whitespace removed. + pub fn documentation_lines(&self) -> impl Iterator { + self.raw_documentation + .lines() + .map(|line| line.strip_prefix(' ').unwrap_or(line).trim_end()) + } + + /// Returns the documentation as a single string. + pub fn documentation(&self) -> String { + self.documentation_lines().join("\n") + } + + pub fn default_level(&self) -> Level { + self.default_level + } + + pub fn status(&self) -> &LintStatus { + &self.status + } + + pub fn file(&self) -> &str { + self.file + } + + pub fn line(&self) -> u32 { + self.line + } +} + +#[doc(hidden)] +pub const fn lint_metadata_defaults() -> LintMetadata { + LintMetadata { + name: LintName::of(""), + summary: "", + raw_documentation: "", + default_level: Level::Error, + status: LintStatus::preview("0.0.0"), + file: "", + line: 1, + } +} + +#[derive(Copy, Clone, Debug)] +pub enum LintStatus { + /// The lint has been added to the linter, but is not yet stable. + Preview { + /// The version in which the lint was added. + since: &'static str, + }, + + /// The lint is stable. + Stable { + /// The version in which the lint was stabilized. + since: &'static str, + }, + + /// The lint is deprecated and no longer recommended for use. + Deprecated { + /// The version in which the lint was deprecated. + since: &'static str, + + /// The reason why the lint has been deprecated. + /// + /// This should explain why the lint has been deprecated and if there's a replacement lint that users + /// can use instead. + reason: &'static str, + }, + + /// The lint has been removed and can no longer be used. + Removed { + /// The version in which the lint was removed. + since: &'static str, + + /// The reason why the lint has been removed. + reason: &'static str, + }, +} + +impl LintStatus { + pub const fn preview(since: &'static str) -> Self { + LintStatus::Preview { since } + } + + pub const fn stable(since: &'static str) -> Self { + LintStatus::Stable { since } + } + + pub const fn deprecated(since: &'static str, reason: &'static str) -> Self { + LintStatus::Deprecated { since, reason } + } + + pub const fn removed(since: &'static str, reason: &'static str) -> Self { + LintStatus::Removed { since, reason } + } + + pub const fn is_removed(&self) -> bool { + matches!(self, LintStatus::Removed { .. }) + } +} + +/// Declares a lint rule with the given metadata. +/// +/// ```rust +/// use red_knot_python_semantic::declare_lint; +/// use red_knot_python_semantic::lint::{LintStatus, Level}; +/// +/// declare_lint! { +/// /// ## What it does +/// /// Checks for references to names that are not defined. +/// /// +/// /// ## Why is this bad? +/// /// Using an undefined variable will raise a `NameError` at runtime. +/// /// +/// /// ## Example +/// /// +/// /// ```python +/// /// print(x) # NameError: name 'x' is not defined +/// /// ``` +/// pub(crate) static UNRESOLVED_REFERENCE = { +/// summary: "detects references to names that are not defined", +/// status: LintStatus::preview("1.0.0"), +/// default_level: Level::Warn, +/// } +/// } +/// ``` +#[macro_export] +macro_rules! declare_lint { + ( + $(#[doc = $doc:literal])+ + $vis: vis static $name: ident = { + summary: $summary: literal, + status: $status: expr, + // Optional properties + $( $key:ident: $value:expr, )* + } + ) => { + $( #[doc = $doc] )+ + #[allow(clippy::needless_update)] + $vis static $name: $crate::lint::LintMetadata = $crate::lint::LintMetadata { + name: ruff_db::diagnostic::LintName::of(ruff_macros::kebab_case!($name)), + summary: $summary, + raw_documentation: concat!($($doc,)+ "\n"), + status: $status, + file: file!(), + line: line!(), + $( $key: $value, )* + ..$crate::lint::lint_metadata_defaults() + }; + }; +} diff --git a/crates/red_knot_python_semantic/src/types.rs b/crates/red_knot_python_semantic/src/types.rs index 2de6f25321..308ef49aeb 100644 --- a/crates/red_knot_python_semantic/src/types.rs +++ b/crates/red_knot_python_semantic/src/types.rs @@ -2,7 +2,7 @@ use std::hash::Hash; use indexmap::IndexSet; use itertools::Itertools; -use ruff_db::diagnostic::DiagnosticId; +use ruff_db::diagnostic::{DiagnosticId, Severity}; use ruff_db::files::File; use ruff_python_ast as ast; @@ -25,7 +25,7 @@ use crate::stdlib::{ builtins_symbol, core_module_symbol, typing_extensions_symbol, CoreStdlibModule, }; use crate::symbol::{Boundness, Symbol}; -use crate::types::diagnostic::TypeCheckDiagnosticsBuilder; +use crate::types::diagnostic::{TypeCheckDiagnosticsBuilder, CALL_NON_CALLABLE}; use crate::types::mro::{ClassBase, Mro, MroError, MroIterator}; use crate::types::narrow::narrowing_constraint; use crate::{Db, FxOrderSet, Module, Program, PythonVersion}; @@ -2308,9 +2308,9 @@ impl<'db> CallOutcome<'db> { not_callable_ty, return_ty, }) => { - diagnostics.add( + diagnostics.add_lint( + &CALL_NON_CALLABLE, node, - DiagnosticId::lint("call-non-callable"), format_args!( "Object of type `{}` is not callable", not_callable_ty.display(db) @@ -2323,9 +2323,9 @@ impl<'db> CallOutcome<'db> { called_ty, return_ty, }) => { - diagnostics.add( + diagnostics.add_lint( + &CALL_NON_CALLABLE, node, - DiagnosticId::lint("call-non-callable"), format_args!( "Object of type `{}` is not callable (due to union element `{}`)", called_ty.display(db), @@ -2339,9 +2339,9 @@ impl<'db> CallOutcome<'db> { called_ty, return_ty, }) => { - diagnostics.add( + diagnostics.add_lint( + &CALL_NON_CALLABLE, node, - DiagnosticId::lint("call-non-callable"), format_args!( "Object of type `{}` is not callable (due to union elements {})", called_ty.display(db), @@ -2354,9 +2354,9 @@ impl<'db> CallOutcome<'db> { callable_ty: called_ty, return_ty, }) => { - diagnostics.add( + diagnostics.add_lint( + &CALL_NON_CALLABLE, node, - DiagnosticId::lint("call-non-callable"), format_args!( "Object of type `{}` is not callable (possibly unbound `__call__` method)", called_ty.display(db) @@ -2383,6 +2383,7 @@ impl<'db> CallOutcome<'db> { diagnostics.add( node, DiagnosticId::RevealedType, + Severity::Info, format_args!("Revealed type is `{}`", revealed_ty.display(db)), ); Ok(*return_ty) diff --git a/crates/red_knot_python_semantic/src/types/diagnostic.rs b/crates/red_knot_python_semantic/src/types/diagnostic.rs index 9756ab79af..662ec0daa8 100644 --- a/crates/red_knot_python_semantic/src/types/diagnostic.rs +++ b/crates/red_knot_python_semantic/src/types/diagnostic.rs @@ -1,5 +1,6 @@ +use crate::lint::{Level, LintMetadata, LintStatus}; use crate::types::{ClassLiteralType, Type}; -use crate::Db; +use crate::{declare_lint, Db}; use ruff_db::diagnostic::{Diagnostic, DiagnosticId, Severity}; use ruff_db::files::File; use ruff_python_ast::{self as ast, AnyNodeRef}; @@ -9,11 +10,418 @@ use std::fmt::Formatter; use std::ops::Deref; use std::sync::Arc; +declare_lint! { + /// ## What it does + /// Checks for references to names that are not defined. + /// + /// ## Why is this bad? + /// Using an undefined variable will raise a `NameError` at runtime. + /// + /// ## Example + /// + /// ```python + /// print(x) # NameError: name 'x' is not defined + /// ``` + pub(crate) static UNRESOLVED_REFERENCE = { + summary: "detects references to names that are not defined", + status: LintStatus::preview("1.0.0"), + default_level: Level::Warn, + } +} + +declare_lint! { + /// ## What it does + /// Checks for references to names that are possibly not defined. + /// + /// ## Why is this bad? + /// Using an undefined variable will raise a `NameError` at runtime. + /// + /// ## Example + /// + /// ```python + /// for i in range(0): + /// x = i + /// + /// print(x) # NameError: name 'x' is not defined + /// ``` + pub(crate) static POSSIBLY_UNRESOLVED_REFERENCE = { + summary: "detects references to possibly undefined names", + status: LintStatus::preview("1.0.0"), + default_level: Level::Warn, + } +} + +declare_lint! { + /// ## What it does + /// Checks for objects that are not iterable but are used in a context that requires them to be. + /// + /// ## Why is this bad? + /// Iterating over an object that is not iterable will raise a `TypeError` at runtime. + /// + /// ## Examples + /// + /// ```python + /// for i in 34: # TypeError: 'int' object is not iterable + /// pass + /// ``` + pub(crate) static NOT_ITERABLE = { + summary: "detects iteration over an object that is not iterable", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + +declare_lint! { + /// ## What it does + /// TODO #14889 + pub(crate) static INDEX_OUT_OF_BOUNDS = { + summary: "detects index out of bounds errors", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + +declare_lint! { + /// ## What it does + /// Checks for subscripting objects that do not support subscripting. + /// + /// ## Why is this bad? + /// Subscripting an object that does not support it will raise a `TypeError` at runtime. + /// + /// ## Examples + /// ```python + /// 4[1] # TypeError: 'int' object is not subscriptable + /// ``` + pub(crate) static NON_SUBSCRIPTABLE = { + summary: "detects subscripting objects that do not support subscripting", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + +declare_lint! { + /// ## What it does + /// Checks for import statements for which the module cannot be resolved. + /// + /// ## Why is this bad? + /// Importing a module that cannot be resolved will raise an `ImportError` at runtime. + pub(crate) static UNRESOLVED_IMPORT = { + summary: "detects unresolved imports", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + +declare_lint! { + /// TODO #14889 + pub(crate) static POSSIBLY_UNBOUND_IMPORT = { + summary: "detects possibly unbound imports", + status: LintStatus::preview("1.0.0"), + default_level: Level::Warn, + } +} + +declare_lint! { + /// ## What it does + /// Checks for step size 0 in slices. + /// + /// ## Why is this bad? + /// A slice with a step size of zero will raise a `ValueError` at runtime. + /// + /// ## Examples + /// ```python + /// l = list(range(10)) + /// l[1:10:0] # ValueError: slice step cannot be zero + pub(crate) static ZERO_STEPSIZE_IN_SLICE = { + summary: "detects a slice step size of zero", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + +declare_lint! { + /// TODO #14889 + pub(crate) static INVALID_ASSIGNMENT = { + summary: "detects invalid assignments", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + +declare_lint! { + /// TODO #14889 + pub(crate) static INVALID_DECLARATION = { + summary: "detects invalid declarations", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + +declare_lint! { + /// TODO #14889 + pub(crate) static CONFLICTING_DECLARATIONS = { + summary: "detects conflicting declarations", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + +declare_lint! { + /// ## What it does + /// It detects division by zero. + /// + /// ## Why is this bad? + /// Dividing by zero raises a `ZeroDivisionError` at runtime. + /// + /// ## Examples + /// ```python + /// 5 / 0 + /// ``` + pub(crate) static DIVISION_BY_ZERO = { + summary: "detects division by zero", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + +declare_lint! { + /// ## What it does + /// Checks for calls to non-callable objects. + /// + /// ## Why is this bad? + /// Calling a non-callable object will raise a `TypeError` at runtime. + /// + /// ## Examples + /// ```python + /// 4() # TypeError: 'int' object is not callable + /// ``` + pub(crate) static CALL_NON_CALLABLE = { + summary: "detects calls to non-callable objects", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + +declare_lint! { + /// ## What it does + /// TODO #14889 + pub(crate) static INVALID_TYPE_PARAMETER = { + summary: "detects invalid type parameters", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + +declare_lint! { + /// TODO #14889 + pub(crate) static INVALID_TYPE_VARIABLE_CONSTRAINTS = { + summary: "detects invalid type variable constraints", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + +declare_lint! { + /// ## What it does + /// Checks for class definitions with a cyclic inheritance chain. + /// + /// ## Why is it bad? + /// TODO #14889 + pub(crate) static CYCLIC_CLASS_DEFINITION = { + summary: "detects cyclic class definitions", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + +declare_lint! { + /// TODO #14889 + pub(crate) static DUPLICATE_BASE = { + summary: "detects class definitions with duplicate bases", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + +declare_lint! { + /// TODO #14889 + pub(crate) static INVALID_BASE = { + summary: "detects class definitions with an invalid base", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + +declare_lint! { + /// TODO #14889 + pub(crate) static INCONSISTENT_MRO = { + summary: "detects class definitions with an inconsistent MRO", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + +declare_lint! { + /// ## What it does + /// Checks for invalid parameters to `typing.Literal`. + /// + /// TODO #14889 + pub(crate) static INVALID_LITERAL_PARAMETER = { + summary: "detects invalid literal parameters", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + +declare_lint! { + /// ## What it does + /// Checks for calls to possibly unbound methods. + /// + /// TODO #14889 + pub(crate) static CALL_POSSIBLY_UNBOUND_METHOD = { + summary: "detects calls to possibly unbound methods", + status: LintStatus::preview("1.0.0"), + default_level: Level::Warn, + } +} + +declare_lint! { + /// ## What it does + /// Checks for possibly unbound attributes. + /// + /// TODO #14889 + pub(crate) static POSSIBLY_UNBOUND_ATTRIBUTE = { + summary: "detects references to possibly unbound attributes", + status: LintStatus::preview("1.0.0"), + default_level: Level::Warn, + } +} + +declare_lint! { + /// ## What it does + /// Checks for unresolved attributes. + /// + /// TODO #14889 + pub(crate) static UNRESOLVED_ATTRIBUTE = { + summary: "detects references to unresolved attributes", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + +declare_lint! { + /// TODO #14889 + pub(crate) static CONFLICTING_METACLASS = { + summary: "detects conflicting metaclasses", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + +declare_lint! { + /// ## What it does + /// Checks for binary expressions, comparisons, and unary expressions where the operands don't support the operator. + /// + /// TODO #14889 + pub(crate) static UNSUPPORTED_OPERATOR = { + summary: "detects binary, unary, or comparison expressions where the operands don't support the operator", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + +declare_lint! { + /// TODO #14889 + pub(crate) static INVALID_CONTEXT_MANAGER = { + summary: "detects expressions used in with statements that don't implement the context manager protocol", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + +declare_lint! { + /// ## What it does + /// Checks for calls to `reveal_type` without importing it. + /// + /// ## Why is this bad? + /// Using `reveal_type` without importing it will raise a `NameError` at runtime. + /// + /// ## Examples + /// TODO #14889 + pub(crate) static UNDEFINED_REVEAL = { + summary: "detects usages of `reveal_type` without importing it", + status: LintStatus::preview("1.0.0"), + default_level: Level::Warn, + } +} + +declare_lint! { + /// ## What it does + /// Checks for default values that can't be assigned to the parameter's annotated type. + /// + /// ## Why is this bad? + /// TODO #14889 + pub(crate) static INVALID_PARAMETER_DEFAULT = { + summary: "detects default values that can't be assigned to the parameter's annotated type", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + +declare_lint! { + /// ## What it does + /// Checks for invalid type expressions. + /// + /// ## Why is this bad? + /// TODO #14889 + pub(crate) static INVALID_TYPE_FORM = { + summary: "detects invalid type forms", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + +declare_lint! { + /// Checks for exception handlers that catch non-exception classes. + /// + /// ## Why is this bad? + /// Catching classes that do not inherit from `BaseException` will raise a TypeError at runtime. + /// + /// ## Example + /// ```python + /// try: + /// 1 / 0 + /// except 1: + /// ... + /// ``` + /// + /// Use instead: + /// ```python + /// try: + /// 1 / 0 + /// except ZeroDivisionError: + /// ... + /// ``` + /// + /// ## References + /// - [Python documentation: except clause](https://docs.python.org/3/reference/compound_stmts.html#except-clause) + /// - [Python documentation: Built-in Exceptions](https://docs.python.org/3/library/exceptions.html#built-in-exceptions) + /// + /// ## Ruff rule + /// This rule corresponds to Ruff's [`except-with-non-exception-classes` (`B030`)](https://docs.astral.sh/ruff/rules/except-with-non-exception-classes) + pub(crate) static INVALID_EXCEPTION_CAUGHT = { + summary: "detects exception handlers that catch classes that do not inherit from `BaseException`", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + #[derive(Debug, Eq, PartialEq, Clone)] pub struct TypeCheckDiagnostic { pub(super) id: DiagnosticId, pub(super) message: String, pub(super) range: TextRange, + pub(super) severity: Severity, pub(super) file: File, } @@ -49,7 +457,7 @@ impl Diagnostic for TypeCheckDiagnostic { } fn severity(&self) -> Severity { - Severity::Error + self.severity } } @@ -149,9 +557,9 @@ impl<'db> TypeCheckDiagnosticsBuilder<'db> { /// Emit a diagnostic declaring that the object represented by `node` is not iterable pub(super) fn add_not_iterable(&mut self, node: AnyNodeRef, not_iterable_ty: Type<'db>) { - self.add( + self.add_lint( + &NOT_ITERABLE, node, - DiagnosticId::lint("not-iterable"), format_args!( "Object of type `{}` is not iterable", not_iterable_ty.display(self.db) @@ -166,9 +574,9 @@ impl<'db> TypeCheckDiagnosticsBuilder<'db> { node: AnyNodeRef, element_ty: Type<'db>, ) { - self.add( + self.add_lint( + &NOT_ITERABLE, node, - DiagnosticId::lint("not-iterable"), format_args!( "Object of type `{}` is not iterable because its `__iter__` method is possibly unbound", element_ty.display(self.db) @@ -185,9 +593,9 @@ impl<'db> TypeCheckDiagnosticsBuilder<'db> { length: usize, index: i64, ) { - self.add( + self.add_lint( + &INDEX_OUT_OF_BOUNDS, node, - DiagnosticId::lint("index-out-of-bounds"), format_args!( "Index {index} is out of bounds for {kind} `{}` with length {length}", tuple_ty.display(self.db) @@ -202,9 +610,9 @@ impl<'db> TypeCheckDiagnosticsBuilder<'db> { non_subscriptable_ty: Type<'db>, method: &str, ) { - self.add( + self.add_lint( + &NON_SUBSCRIPTABLE, node, - DiagnosticId::lint("non-subscriptable"), format_args!( "Cannot subscript object of type `{}` with no `{method}` method", non_subscriptable_ty.display(self.db) @@ -218,9 +626,9 @@ impl<'db> TypeCheckDiagnosticsBuilder<'db> { level: u32, module: Option<&str>, ) { - self.add( + self.add_lint( + &UNRESOLVED_IMPORT, import_node.into(), - DiagnosticId::lint("unresolved-import"), format_args!( "Cannot resolve import `{}{}`", ".".repeat(level as usize), @@ -230,9 +638,9 @@ impl<'db> TypeCheckDiagnosticsBuilder<'db> { } pub(super) fn add_slice_step_size_zero(&mut self, node: AnyNodeRef) { - self.add( + self.add_lint( + &ZERO_STEPSIZE_IN_SLICE, node, - DiagnosticId::lint("zero-stepsize-in-slice"), format_args!("Slice step size can not be zero"), ); } @@ -245,19 +653,19 @@ impl<'db> TypeCheckDiagnosticsBuilder<'db> { ) { match declared_ty { Type::ClassLiteral(ClassLiteralType { class }) => { - self.add(node, DiagnosticId::lint("invalid-assignment"), format_args!( + self.add_lint(&INVALID_ASSIGNMENT, node, format_args!( "Implicit shadowing of class `{}`; annotate to make it explicit if this is intentional", class.name(self.db))); } Type::FunctionLiteral(function) => { - self.add(node, DiagnosticId::lint("invalid-assignment"), format_args!( + self.add_lint(&INVALID_ASSIGNMENT, node, format_args!( "Implicit shadowing of function `{}`; annotate to make it explicit if this is intentional", function.name(self.db))); } _ => { - self.add( + self.add_lint( + &INVALID_ASSIGNMENT, node, - DiagnosticId::lint("invalid-assignment"), format_args!( "Object of type `{}` is not assignable to `{}`", assigned_ty.display(self.db), @@ -271,9 +679,9 @@ impl<'db> TypeCheckDiagnosticsBuilder<'db> { pub(super) fn add_possibly_unresolved_reference(&mut self, expr_name_node: &ast::ExprName) { let ast::ExprName { id, .. } = expr_name_node; - self.add( + self.add_lint( + &POSSIBLY_UNRESOLVED_REFERENCE, expr_name_node.into(), - DiagnosticId::lint("possibly-unresolved-reference"), format_args!("Name `{id}` used when possibly not defined"), ); } @@ -281,17 +689,17 @@ impl<'db> TypeCheckDiagnosticsBuilder<'db> { pub(super) fn add_unresolved_reference(&mut self, expr_name_node: &ast::ExprName) { let ast::ExprName { id, .. } = expr_name_node; - self.add( + self.add_lint( + &UNRESOLVED_REFERENCE, expr_name_node.into(), - DiagnosticId::lint("unresolved-reference"), format_args!("Name `{id}` used when not defined"), ); } - pub(super) fn add_invalid_exception(&mut self, db: &dyn Db, node: &ast::Expr, ty: Type) { - self.add( + pub(super) fn add_invalid_exception_caught(&mut self, db: &dyn Db, node: &ast::Expr, ty: Type) { + self.add_lint( + &INVALID_EXCEPTION_CAUGHT, node.into(), - DiagnosticId::lint("invalid-exception"), format_args!( "Cannot catch object of type `{}` in an exception handler \ (must be a `BaseException` subclass or a tuple of `BaseException` subclasses)", @@ -300,10 +708,29 @@ impl<'db> TypeCheckDiagnosticsBuilder<'db> { ); } + pub(super) fn add_lint( + &mut self, + lint: &LintMetadata, + node: AnyNodeRef, + message: std::fmt::Arguments, + ) { + let Ok(severity) = Severity::try_from(lint.default_level()) else { + return; + }; + + self.add(node, DiagnosticId::Lint(lint.name()), severity, message); + } + /// Adds a new diagnostic. /// /// The diagnostic does not get added if the rule isn't enabled for this file. - pub(super) fn add(&mut self, node: AnyNodeRef, id: DiagnosticId, message: std::fmt::Arguments) { + pub(super) fn add( + &mut self, + node: AnyNodeRef, + id: DiagnosticId, + severity: Severity, + message: std::fmt::Arguments, + ) { if !self.db.is_file_open(self.file) { return; } @@ -319,6 +746,7 @@ impl<'db> TypeCheckDiagnosticsBuilder<'db> { id, message: message.to_string(), range: node.range(), + severity, }); } diff --git a/crates/red_knot_python_semantic/src/types/infer.rs b/crates/red_knot_python_semantic/src/types/infer.rs index 4de8486ab4..acdc28571d 100644 --- a/crates/red_knot_python_semantic/src/types/infer.rs +++ b/crates/red_knot_python_semantic/src/types/infer.rs @@ -29,10 +29,9 @@ use std::num::NonZeroU32; use itertools::Itertools; -use ruff_db::diagnostic::DiagnosticId; use ruff_db::files::File; use ruff_db::parsed::parsed_module; -use ruff_python_ast::{self as ast, AnyNodeRef, Expr, ExprContext, UnaryOp}; +use ruff_python_ast::{self as ast, AnyNodeRef, ExprContext, UnaryOp}; use rustc_hash::{FxHashMap, FxHashSet}; use salsa; use salsa::plumbing::AsId; @@ -49,7 +48,15 @@ use crate::semantic_index::semantic_index; use crate::semantic_index::symbol::{NodeWithScopeKind, NodeWithScopeRef, ScopeId}; use crate::semantic_index::SemanticIndex; use crate::stdlib::builtins_module_scope; -use crate::types::diagnostic::{TypeCheckDiagnostics, TypeCheckDiagnosticsBuilder}; +use crate::types::diagnostic::{ + TypeCheckDiagnostics, TypeCheckDiagnosticsBuilder, CALL_NON_CALLABLE, + CALL_POSSIBLY_UNBOUND_METHOD, CONFLICTING_DECLARATIONS, CONFLICTING_METACLASS, + CYCLIC_CLASS_DEFINITION, DIVISION_BY_ZERO, DUPLICATE_BASE, INCONSISTENT_MRO, INVALID_BASE, + INVALID_CONTEXT_MANAGER, INVALID_DECLARATION, INVALID_LITERAL_PARAMETER, + INVALID_PARAMETER_DEFAULT, INVALID_TYPE_FORM, INVALID_TYPE_PARAMETER, + INVALID_TYPE_VARIABLE_CONSTRAINTS, POSSIBLY_UNBOUND_ATTRIBUTE, POSSIBLY_UNBOUND_IMPORT, + UNDEFINED_REVEAL, UNRESOLVED_ATTRIBUTE, UNRESOLVED_IMPORT, UNSUPPORTED_OPERATOR, +}; use crate::types::mro::MroErrorKind; use crate::types::unpacker::{UnpackResult, Unpacker}; use crate::types::{ @@ -64,7 +71,9 @@ use crate::unpack::Unpack; use crate::util::subscript::{PyIndex, PySlice}; use crate::Db; -use super::string_annotation::parse_string_annotation; +use super::string_annotation::{ + parse_string_annotation, BYTE_STRING_TYPE_ANNOTATION, FSTRING_TYPE_ANNOTATION, +}; /// Infer all types for a [`ScopeId`], including all definitions and expressions in that scope. /// Use when checking a scope, or needing to provide a type for an arbitrary expression in the @@ -526,9 +535,9 @@ impl<'db> TypeInferenceBuilder<'db> { for (class, class_node) in class_definitions { // (1) Check that the class does not have a cyclic definition if class.is_cyclically_defined(self.db) { - self.diagnostics.add( + self.diagnostics.add_lint( + &CYCLIC_CLASS_DEFINITION, class_node.into(), - DiagnosticId::lint("cyclic-class-def"), format_args!( "Cyclic definition of `{}` or bases of `{}` (class cannot inherit from itself)", class.name(self.db), @@ -546,9 +555,9 @@ impl<'db> TypeInferenceBuilder<'db> { MroErrorKind::DuplicateBases(duplicates) => { let base_nodes = class_node.bases(); for (index, duplicate) in duplicates { - self.diagnostics.add( + self.diagnostics.add_lint( + &DUPLICATE_BASE, (&base_nodes[*index]).into(), - DiagnosticId::lint("duplicate-base"), format_args!("Duplicate base class `{}`", duplicate.name(self.db)), ); } @@ -556,9 +565,9 @@ impl<'db> TypeInferenceBuilder<'db> { MroErrorKind::InvalidBases(bases) => { let base_nodes = class_node.bases(); for (index, base_ty) in bases { - self.diagnostics.add( + self.diagnostics.add_lint( + &INVALID_BASE, (&base_nodes[*index]).into(), - DiagnosticId::lint("invalid-base"), format_args!( "Invalid class base with type `{}` (all bases must be a class, `Any`, `Unknown` or `Todo`)", base_ty.display(self.db) @@ -566,9 +575,9 @@ impl<'db> TypeInferenceBuilder<'db> { ); } } - MroErrorKind::UnresolvableMro { bases_list } => self.diagnostics.add( + MroErrorKind::UnresolvableMro { bases_list } => self.diagnostics.add_lint( + &INCONSISTENT_MRO, class_node.into(), - DiagnosticId::lint("inconsistent-mro"), format_args!( "Cannot create a consistent method resolution order (MRO) for class `{}` with bases list `[{}]`", class.name(self.db), @@ -596,9 +605,9 @@ impl<'db> TypeInferenceBuilder<'db> { } => { let node = class_node.into(); if *candidate1_is_base_class { - self.diagnostics.add( + self.diagnostics.add_lint( + &CONFLICTING_METACLASS, node, - DiagnosticId::lint("conflicting-metaclass"), format_args!( "The metaclass of a derived class (`{class}`) must be a subclass of the metaclasses of all its bases, \ but `{metaclass1}` (metaclass of base class `{base1}`) and `{metaclass2}` (metaclass of base class `{base2}`) \ @@ -608,12 +617,12 @@ impl<'db> TypeInferenceBuilder<'db> { base1 = class1.name(self.db), metaclass2 = metaclass2.name(self.db), base2 = class2.name(self.db), - ) + ), ); } else { - self.diagnostics.add( + self.diagnostics.add_lint( + &CONFLICTING_METACLASS, node, - DiagnosticId::lint("conflicting-metaclass"), format_args!( "The metaclass of a derived class (`{class}`) must be a subclass of the metaclasses of all its bases, \ but `{metaclass_of_class}` (metaclass of `{class}`) and `{metaclass_of_base}` (metaclass of base class `{base}`) \ @@ -622,7 +631,7 @@ impl<'db> TypeInferenceBuilder<'db> { metaclass_of_class = metaclass1.name(self.db), metaclass_of_base = metaclass2.name(self.db), base = class2.name(self.db), - ) + ), ); } } @@ -760,9 +769,9 @@ impl<'db> TypeInferenceBuilder<'db> { _ => return, }; - self.diagnostics.add( + self.diagnostics.add_lint( + &DIVISION_BY_ZERO, expr.into(), - DiagnosticId::lint("division-by-zero"), format_args!( "Cannot {op} object of type `{}` {by_zero}", left.display(self.db) @@ -785,9 +794,9 @@ impl<'db> TypeInferenceBuilder<'db> { // TODO point out the conflicting declarations in the diagnostic? let symbol_table = self.index.symbol_table(binding.file_scope(self.db)); let symbol_name = symbol_table.symbol(binding.symbol(self.db)).name(); - self.diagnostics.add( + self.diagnostics.add_lint( + &CONFLICTING_DECLARATIONS, node, - DiagnosticId::lint("conflicting-declarations"), format_args!( "Conflicting declared types for `{symbol_name}`: {}", conflicting.display(self.db) @@ -815,9 +824,9 @@ impl<'db> TypeInferenceBuilder<'db> { let ty = if inferred_ty.is_assignable_to(self.db, ty) { ty } else { - self.diagnostics.add( + self.diagnostics.add_lint( + &INVALID_DECLARATION, node, - DiagnosticId::lint("invalid-declaration"), format_args!( "Cannot declare type `{}` for inferred type `{}`", ty.display(self.db), @@ -1112,12 +1121,12 @@ impl<'db> TypeInferenceBuilder<'db> { if default_ty.is_assignable_to(self.db, declared_ty) { UnionType::from_elements(self.db, [declared_ty, default_ty]) } else { - self.diagnostics.add( + self.diagnostics.add_lint( + &INVALID_PARAMETER_DEFAULT, parameter_with_default.into(), - DiagnosticId::lint("invalid-parameter-default"), format_args!( "Default value of type `{}` is not assignable to annotated parameter type `{}`", - default_ty.display(self.db), declared_ty.display(self.db)) + default_ty.display(self.db), declared_ty.display(self.db)), ); declared_ty } @@ -1424,9 +1433,9 @@ impl<'db> TypeInferenceBuilder<'db> { // TODO: Make use of Protocols when we support it (the manager be assignable to `contextlib.AbstractContextManager`). match (enter, exit) { (Symbol::Unbound, Symbol::Unbound) => { - self.diagnostics.add( + self.diagnostics.add_lint( + &INVALID_CONTEXT_MANAGER, context_expression.into(), - DiagnosticId::lint("invalid-context-manager"), format_args!( "Object of type `{}` cannot be used with `with` because it doesn't implement `__enter__` and `__exit__`", context_expression_ty.display(self.db) @@ -1435,9 +1444,9 @@ impl<'db> TypeInferenceBuilder<'db> { Type::Unknown } (Symbol::Unbound, _) => { - self.diagnostics.add( + self.diagnostics.add_lint( + &INVALID_CONTEXT_MANAGER, context_expression.into(), - DiagnosticId::lint("invalid-context-manager"), format_args!( "Object of type `{}` cannot be used with `with` because it doesn't implement `__enter__`", context_expression_ty.display(self.db) @@ -1447,9 +1456,9 @@ impl<'db> TypeInferenceBuilder<'db> { } (Symbol::Type(enter_ty, enter_boundness), exit) => { if enter_boundness == Boundness::PossiblyUnbound { - self.diagnostics.add( + self.diagnostics.add_lint( + &INVALID_CONTEXT_MANAGER, context_expression.into(), - DiagnosticId::lint("invalid-context-manager"), format_args!( "Object of type `{context_expression}` cannot be used with `with` because the method `__enter__` is possibly unbound", context_expression = context_expression_ty.display(self.db), @@ -1461,9 +1470,9 @@ impl<'db> TypeInferenceBuilder<'db> { .call(self.db, &[context_expression_ty]) .return_ty_result(self.db, context_expression.into(), &mut self.diagnostics) .unwrap_or_else(|err| { - self.diagnostics.add( + self.diagnostics.add_lint( + &INVALID_CONTEXT_MANAGER, context_expression.into(), - DiagnosticId::lint("invalid-context-manager"), format_args!(" Object of type `{context_expression}` cannot be used with `with` because the method `__enter__` of type `{enter_ty}` is not callable", context_expression = context_expression_ty.display(self.db), enter_ty = enter_ty.display(self.db) ), @@ -1473,9 +1482,9 @@ impl<'db> TypeInferenceBuilder<'db> { match exit { Symbol::Unbound => { - self.diagnostics.add( + self.diagnostics.add_lint( + &INVALID_CONTEXT_MANAGER, context_expression.into(), - DiagnosticId::lint("invalid-context-manager"), format_args!( "Object of type `{}` cannot be used with `with` because it doesn't implement `__exit__`", context_expression_ty.display(self.db) @@ -1486,9 +1495,9 @@ impl<'db> TypeInferenceBuilder<'db> { // TODO: Use the `exit_ty` to determine if any raised exception is suppressed. if exit_boundness == Boundness::PossiblyUnbound { - self.diagnostics.add( + self.diagnostics.add_lint( + &INVALID_CONTEXT_MANAGER, context_expression.into(), - DiagnosticId::lint("invalid-context-manager"), format_args!( "Object of type `{context_expression}` cannot be used with `with` because the method `__exit__` is possibly unbound", context_expression = context_expression_ty.display(self.db), @@ -1513,9 +1522,9 @@ impl<'db> TypeInferenceBuilder<'db> { ) .is_err() { - self.diagnostics.add( + self.diagnostics.add_lint( + &INVALID_CONTEXT_MANAGER, context_expression.into(), - DiagnosticId::lint("invalid-context-manager"), format_args!( "Object of type `{context_expression}` cannot be used with `with` because the method `__exit__` of type `{exit_ty}` is not callable", context_expression = context_expression_ty.display(self.db), @@ -1555,7 +1564,7 @@ impl<'db> TypeInferenceBuilder<'db> { } else { if let Some(node) = node { self.diagnostics - .add_invalid_exception(self.db, node, element); + .add_invalid_exception_caught(self.db, node, element); } Type::Unknown }); @@ -1572,7 +1581,7 @@ impl<'db> TypeInferenceBuilder<'db> { } else { if let Some(node) = node { self.diagnostics - .add_invalid_exception(self.db, node, node_ty); + .add_invalid_exception_caught(self.db, node, node_ty); } Type::Unknown } @@ -1609,9 +1618,9 @@ impl<'db> TypeInferenceBuilder<'db> { let bound_or_constraint = match bound.as_deref() { Some(expr @ ast::Expr::Tuple(ast::ExprTuple { elts, .. })) => { if elts.len() < 2 { - self.diagnostics.add( + self.diagnostics.add_lint( + &INVALID_TYPE_VARIABLE_CONSTRAINTS, expr.into(), - DiagnosticId::lint("invalid-typevar-constraints"), format_args!("TypeVar must have at least two constrained types"), ); self.infer_expression(expr); @@ -1932,9 +1941,9 @@ impl<'db> TypeInferenceBuilder<'db> { ) { Ok(t) => t, Err(e) => { - self.diagnostics.add( + self.diagnostics.add_lint( + &UNSUPPORTED_OPERATOR, assignment.into(), - DiagnosticId::lint("unsupported-operator"), format_args!( "Operator `{op}=` is unsupported between objects of type `{}` and `{}`", target_type.display(self.db), @@ -1953,9 +1962,9 @@ impl<'db> TypeInferenceBuilder<'db> { let binary_return_ty = self.infer_binary_expression_type(left_ty, right_ty, op) .unwrap_or_else(|| { - self.diagnostics.add( + self.diagnostics.add_lint( + &UNSUPPORTED_OPERATOR, assignment.into(), - DiagnosticId::lint("unsupported-operator"), format_args!( "Operator `{op}=` is unsupported between objects of type `{}` and `{}`", left_ty.display(self.db), @@ -1982,9 +1991,9 @@ impl<'db> TypeInferenceBuilder<'db> { self.infer_binary_expression_type(left_ty, right_ty, op) .unwrap_or_else(|| { - self.diagnostics.add( + self.diagnostics.add_lint( + &UNSUPPORTED_OPERATOR, assignment.into(), - DiagnosticId::lint("unsupported-operator"), format_args!( "Operator `{op}=` is unsupported between objects of type `{}` and `{}`", left_ty.display(self.db), @@ -2014,11 +2023,11 @@ impl<'db> TypeInferenceBuilder<'db> { // Resolve the target type, assuming a load context. let target_type = match &**target { - Expr::Name(name) => { + ast::Expr::Name(name) => { self.store_expression_type(target, Type::Never); self.infer_name_load(name) } - Expr::Attribute(attr) => { + ast::Expr::Attribute(attr) => { self.store_expression_type(target, Type::Never); self.infer_attribute_load(attr) } @@ -2235,19 +2244,19 @@ impl<'db> TypeInferenceBuilder<'db> { match module_ty.member(self.db, &ast::name::Name::new(&name.id)) { Symbol::Type(ty, boundness) => { if boundness == Boundness::PossiblyUnbound { - self.diagnostics.add( + self.diagnostics.add_lint( + &POSSIBLY_UNBOUND_IMPORT, AnyNodeRef::Alias(alias), - DiagnosticId::lint("possibly-unbound-import"), - format_args!("Member `{name}` of module `{module_name}` is possibly unbound",), + format_args!("Member `{name}` of module `{module_name}` is possibly unbound", ), ); } ty } Symbol::Unbound => { - self.diagnostics.add( + self.diagnostics.add_lint( + &UNRESOLVED_IMPORT, AnyNodeRef::Alias(alias), - DiagnosticId::lint("unresolved-import"), format_args!("Module `{module_name}` has no member `{name}`",), ); Type::Unknown @@ -2952,9 +2961,9 @@ impl<'db> TypeInferenceBuilder<'db> { { let mut builtins_symbol = builtins_symbol(self.db, name); if builtins_symbol.is_unbound() && name == "reveal_type" { - self.diagnostics.add( + self.diagnostics.add_lint( + &UNDEFINED_REVEAL, name_node.into(), - DiagnosticId::lint("undefined-reveal"), format_args!( "`reveal_type` used without importing it; this is allowed for debugging convenience but will fail at runtime"), ); @@ -3049,9 +3058,9 @@ impl<'db> TypeInferenceBuilder<'db> { match value_ty.member(self.db, &attr.id) { Symbol::Type(member_ty, boundness) => { if boundness == Boundness::PossiblyUnbound { - self.diagnostics.add( + self.diagnostics.add_lint( + &POSSIBLY_UNBOUND_ATTRIBUTE, attribute.into(), - DiagnosticId::lint("possibly-unbound-attribute"), format_args!( "Attribute `{}` on type `{}` is possibly unbound", attr.id, @@ -3063,9 +3072,9 @@ impl<'db> TypeInferenceBuilder<'db> { member_ty } Symbol::Unbound => { - self.diagnostics.add( + self.diagnostics.add_lint( + &UNRESOLVED_ATTRIBUTE, attribute.into(), - DiagnosticId::lint("unresolved-attribute"), format_args!( "Type `{}` has no attribute `{}`", value_ty.display(self.db), @@ -3144,9 +3153,9 @@ impl<'db> TypeInferenceBuilder<'db> { ) { Ok(t) => t, Err(e) => { - self.diagnostics.add( + self.diagnostics.add_lint( + &UNSUPPORTED_OPERATOR, unary.into(), - DiagnosticId::lint("unsupported-operator"), format_args!( "Unary operator `{op}` is unsupported for type `{}`", operand_type.display(self.db), @@ -3156,9 +3165,9 @@ impl<'db> TypeInferenceBuilder<'db> { } } } else { - self.diagnostics.add( + self.diagnostics.add_lint( + &UNSUPPORTED_OPERATOR, unary.into(), - DiagnosticId::lint("unsupported-operator"), format_args!( "Unary operator `{op}` is unsupported for type `{}`", operand_type.display(self.db), @@ -3197,9 +3206,9 @@ impl<'db> TypeInferenceBuilder<'db> { self.infer_binary_expression_type(left_ty, right_ty, *op) .unwrap_or_else(|| { - self.diagnostics.add( + self.diagnostics.add_lint( + &UNSUPPORTED_OPERATOR, binary.into(), - DiagnosticId::lint("unsupported-operator"), format_args!( "Operator `{op}` is unsupported between objects of type `{}` and `{}`", left_ty.display(self.db), @@ -3507,9 +3516,9 @@ impl<'db> TypeInferenceBuilder<'db> { self.infer_binary_type_comparison(left_ty, *op, right_ty) .unwrap_or_else(|error| { // Handle unsupported operators (diagnostic, `bool`/`Unknown` outcome) - self.diagnostics.add( + self.diagnostics.add_lint( + &UNSUPPORTED_OPERATOR, AnyNodeRef::ExprCompare(compare), - DiagnosticId::lint("unsupported-operator"), format_args!( "Operator `{}` is not supported for types `{}` and `{}`{}", error.op, @@ -4164,9 +4173,9 @@ impl<'db> TypeInferenceBuilder<'db> { Symbol::Unbound => {} Symbol::Type(dunder_getitem_method, boundness) => { if boundness == Boundness::PossiblyUnbound { - self.diagnostics.add( + self.diagnostics.add_lint( + &CALL_POSSIBLY_UNBOUND_METHOD, value_node.into(), - DiagnosticId::lint("call-possibly-unbound-method"), format_args!( "Method `__getitem__` of type `{}` is possibly unbound", value_ty.display(self.db), @@ -4178,9 +4187,9 @@ impl<'db> TypeInferenceBuilder<'db> { .call(self.db, &[slice_ty]) .return_ty_result(self.db, value_node.into(), &mut self.diagnostics) .unwrap_or_else(|err| { - self.diagnostics.add( + self.diagnostics.add_lint( + &CALL_NON_CALLABLE, value_node.into(), - DiagnosticId::lint("call-non-callable"), format_args!( "Method `__getitem__` of type `{}` is not callable on object of type `{}`", err.called_ty().display(self.db), @@ -4208,9 +4217,9 @@ impl<'db> TypeInferenceBuilder<'db> { Symbol::Unbound => {} Symbol::Type(ty, boundness) => { if boundness == Boundness::PossiblyUnbound { - self.diagnostics.add( + self.diagnostics.add_lint( + &CALL_POSSIBLY_UNBOUND_METHOD, value_node.into(), - DiagnosticId::lint("call-possibly-unbound-method"), format_args!( "Method `__class_getitem__` of type `{}` is possibly unbound", value_ty.display(self.db), @@ -4222,9 +4231,9 @@ impl<'db> TypeInferenceBuilder<'db> { .call(self.db, &[slice_ty]) .return_ty_result(self.db, value_node.into(), &mut self.diagnostics) .unwrap_or_else(|err| { - self.diagnostics.add( + self.diagnostics.add_lint( + &CALL_NON_CALLABLE, value_node.into(), - DiagnosticId::lint("call-non-callable"), format_args!( "Method `__class_getitem__` of type `{}` is not callable on object of type `{}`", err.called_ty().display(self.db), @@ -4364,18 +4373,18 @@ impl<'db> TypeInferenceBuilder<'db> { ast::Expr::Starred(starred) => self.infer_starred_expression(starred), ast::Expr::BytesLiteral(bytes) => { - self.diagnostics.add( + self.diagnostics.add_lint( + &BYTE_STRING_TYPE_ANNOTATION, bytes.into(), - DiagnosticId::lint("annotation-byte-string"), format_args!("Type expressions cannot use bytes literal"), ); Type::Unknown } ast::Expr::FString(fstring) => { - self.diagnostics.add( + self.diagnostics.add_lint( + &FSTRING_TYPE_ANNOTATION, fstring.into(), - DiagnosticId::lint("annotation-f-string"), format_args!("Type expressions cannot use f-strings"), ); self.infer_fstring_expression(fstring); @@ -4717,9 +4726,9 @@ impl<'db> TypeInferenceBuilder<'db> { } ast::Expr::Tuple(_) => { self.infer_type_expression(slice); - self.diagnostics.add( + self.diagnostics.add_lint( + &INVALID_TYPE_FORM, slice.into(), - DiagnosticId::lint("invalid-type-form"), format_args!("type[...] must have exactly one type argument"), ); Type::Unknown @@ -4793,9 +4802,9 @@ impl<'db> TypeInferenceBuilder<'db> { Ok(ty) => ty, Err(nodes) => { for node in nodes { - self.diagnostics.add( + self.diagnostics.add_lint( + &INVALID_LITERAL_PARAMETER, node.into(), - DiagnosticId::lint("invalid-literal-parameter"), format_args!( "Type arguments for `Literal` must be `None`, \ a literal value (int, bool, str, or bytes), or an enum value" @@ -4829,9 +4838,9 @@ impl<'db> TypeInferenceBuilder<'db> { todo_type!("generic type alias") } KnownInstanceType::NoReturn | KnownInstanceType::Never => { - self.diagnostics.add( + self.diagnostics.add_lint( + &INVALID_TYPE_PARAMETER, subscript.into(), - DiagnosticId::lint("invalid-type-parameter"), format_args!( "Type `{}` expected no type parameter", known_instance.repr(self.db) @@ -4840,9 +4849,9 @@ impl<'db> TypeInferenceBuilder<'db> { Type::Unknown } KnownInstanceType::LiteralString => { - self.diagnostics.add( + self.diagnostics.add_lint( + &INVALID_TYPE_PARAMETER, subscript.into(), - DiagnosticId::lint("invalid-type-parameter"), format_args!( "Type `{}` expected no type parameter. Did you mean to use `Literal[...]` instead?", known_instance.repr(self.db) diff --git a/crates/red_knot_python_semantic/src/types/string_annotation.rs b/crates/red_knot_python_semantic/src/types/string_annotation.rs index 2a03391716..6ae6b66581 100644 --- a/crates/red_knot_python_semantic/src/types/string_annotation.rs +++ b/crates/red_knot_python_semantic/src/types/string_annotation.rs @@ -1,4 +1,3 @@ -use ruff_db::diagnostic::DiagnosticId; use ruff_db::files::File; use ruff_db::source::source_text; use ruff_python_ast::str::raw_contents; @@ -6,8 +5,127 @@ use ruff_python_ast::{self as ast, ModExpression, StringFlags}; use ruff_python_parser::{parse_expression_range, Parsed}; use ruff_text_size::Ranged; +use crate::lint::{Level, LintStatus}; use crate::types::diagnostic::{TypeCheckDiagnostics, TypeCheckDiagnosticsBuilder}; -use crate::Db; +use crate::{declare_lint, Db}; + +declare_lint! { + /// ## What it does + /// Checks for f-strings in type annotation positions. + /// + /// ## Why is this bad? + /// Static analysis tools like Red Knot can't analyse type annotations that use f-string notation. + /// + /// ## Examples + /// ```python + /// def test(): -> f"int": + /// ... + /// ``` + /// + /// Use instead: + /// ```python + /// def test(): -> "int": + /// ... + /// ``` + pub(crate) static FSTRING_TYPE_ANNOTATION = { + summary: "detects F-strings in type annotation positions", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + +declare_lint! { + /// ## What it does + /// Checks for byte-strings in type annotation positions. + /// + /// ## Why is this bad? + /// Static analysis tools like Red Knot can't analyse type annotations that use byte-string notation. + /// + /// ## Examples + /// ```python + /// def test(): -> b"int": + /// ... + /// ``` + /// + /// Use instead: + /// ```python + /// def test(): -> "int": + /// ... + /// ``` + pub(crate) static BYTE_STRING_TYPE_ANNOTATION = { + summary: "detects byte strings in type annotation positions", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + +declare_lint! { + /// ## What it does + /// Checks for raw-strings in type annotation positions. + /// + /// ## Why is this bad? + /// Static analysis tools like Red Knot can't analyse type annotations that use raw-string notation. + /// + /// ## Examples + /// ```python + /// def test(): -> r"int": + /// ... + /// ``` + /// + /// Use instead: + /// ```python + /// def test(): -> "int": + /// ... + /// ``` + pub(crate) static RAW_STRING_TYPE_ANNOTATION = { + summary: "detects raw strings in type annotation positions", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + +declare_lint! { + /// ## What it does + /// Checks for implicit concatenated strings in type annotation positions. + /// + /// ## Why is this bad? + /// Static analysis tools like Red Knot can't analyse type annotations that use implicit concatenated strings. + /// + /// ## Examples + /// ```python + /// def test(): -> "Literal[" "5" "]": + /// ... + /// ``` + /// + /// Use instead: + /// ```python + /// def test(): -> "Literal[5]": + /// ... + /// ``` + pub(crate) static IMPLICIT_CONCATENATED_STRING_TYPE_ANNOTATION = { + summary: "detects implicit concatenated strings in type annotations", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + +declare_lint! { + /// TODO #14889 + pub(crate) static INVALID_SYNTAX_IN_FORWARD_ANNOTATION = { + summary: "detects invalid syntax in forward annotations", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} + +declare_lint! { + /// TODO #14889 + pub(crate) static ESCAPE_CHARACTER_IN_FORWARD_ANNOTATION = { + summary: "detects forward type annotations with escape characters", + status: LintStatus::preview("1.0.0"), + default_level: Level::Error, + } +} type AnnotationParseResult = Result, TypeCheckDiagnostics>; @@ -26,9 +144,9 @@ pub(crate) fn parse_string_annotation( if let [string_literal] = string_expr.value.as_slice() { let prefix = string_literal.flags.prefix(); if prefix.is_raw() { - diagnostics.add( + diagnostics.add_lint( + &RAW_STRING_TYPE_ANNOTATION, string_literal.into(), - DiagnosticId::lint("annotation-raw-string"), format_args!("Type expressions cannot use raw string literal"), ); // Compare the raw contents (without quotes) of the expression with the parsed contents @@ -50,26 +168,26 @@ pub(crate) fn parse_string_annotation( // ``` match parse_expression_range(source.as_str(), range_excluding_quotes) { Ok(parsed) => return Ok(parsed), - Err(parse_error) => diagnostics.add( + Err(parse_error) => diagnostics.add_lint( + &INVALID_SYNTAX_IN_FORWARD_ANNOTATION, string_literal.into(), - DiagnosticId::lint("forward-annotation-syntax-error"), format_args!("Syntax error in forward annotation: {}", parse_error.error), ), } } else { // The raw contents of the string doesn't match the parsed content. This could be the // case for annotations that contain escape sequences. - diagnostics.add( + diagnostics.add_lint( + &ESCAPE_CHARACTER_IN_FORWARD_ANNOTATION, string_expr.into(), - DiagnosticId::lint("annotation-escape-character"), format_args!("Type expressions cannot contain escape characters"), ); } } else { // String is implicitly concatenated. - diagnostics.add( + diagnostics.add_lint( + &IMPLICIT_CONCATENATED_STRING_TYPE_ANNOTATION, string_expr.into(), - DiagnosticId::lint("annotation-implicit-concat"), format_args!("Type expressions cannot span multiple string literals"), ); } diff --git a/crates/ruff_benchmark/benches/red_knot.rs b/crates/ruff_benchmark/benches/red_knot.rs index c7a9366518..1949151f91 100644 --- a/crates/ruff_benchmark/benches/red_knot.rs +++ b/crates/ruff_benchmark/benches/red_knot.rs @@ -27,28 +27,28 @@ static EXPECTED_DIAGNOSTICS: &[&str] = &[ // We don't support `*` imports yet: "error[lint:unresolved-import] /src/tomllib/_parser.py:7:29 Module `collections.abc` has no member `Iterable`", // We don't support terminal statements in control flow yet: - "error[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:66:18 Name `s` used when possibly not defined", - "error[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:98:12 Name `char` used when possibly not defined", - "error[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:101:12 Name `char` used when possibly not defined", - "error[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:104:14 Name `char` used when possibly not defined", + "warning[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:66:18 Name `s` used when possibly not defined", + "warning[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:98:12 Name `char` used when possibly not defined", + "warning[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:101:12 Name `char` used when possibly not defined", + "warning[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:104:14 Name `char` used when possibly not defined", "error[lint:conflicting-declarations] /src/tomllib/_parser.py:108:17 Conflicting declared types for `second_char`: Unknown, str | None", - "error[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:115:14 Name `char` used when possibly not defined", - "error[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:126:12 Name `char` used when possibly not defined", + "warning[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:115:14 Name `char` used when possibly not defined", + "warning[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:126:12 Name `char` used when possibly not defined", "error[lint:conflicting-declarations] /src/tomllib/_parser.py:267:9 Conflicting declared types for `char`: Unknown, str | None", - "error[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:348:20 Name `nest` used when possibly not defined", - "error[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:353:5 Name `nest` used when possibly not defined", + "warning[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:348:20 Name `nest` used when possibly not defined", + "warning[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:353:5 Name `nest` used when possibly not defined", "error[lint:conflicting-declarations] /src/tomllib/_parser.py:364:9 Conflicting declared types for `char`: Unknown, str | None", "error[lint:conflicting-declarations] /src/tomllib/_parser.py:381:13 Conflicting declared types for `char`: Unknown, str | None", "error[lint:conflicting-declarations] /src/tomllib/_parser.py:395:9 Conflicting declared types for `char`: Unknown, str | None", - "error[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:453:24 Name `nest` used when possibly not defined", - "error[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:455:9 Name `nest` used when possibly not defined", - "error[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:482:16 Name `char` used when possibly not defined", - "error[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:566:12 Name `char` used when possibly not defined", - "error[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:573:12 Name `char` used when possibly not defined", - "error[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:579:12 Name `char` used when possibly not defined", - "error[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:580:63 Name `char` used when possibly not defined", + "warning[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:453:24 Name `nest` used when possibly not defined", + "warning[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:455:9 Name `nest` used when possibly not defined", + "warning[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:482:16 Name `char` used when possibly not defined", + "warning[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:566:12 Name `char` used when possibly not defined", + "warning[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:573:12 Name `char` used when possibly not defined", + "warning[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:579:12 Name `char` used when possibly not defined", + "warning[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:580:63 Name `char` used when possibly not defined", "error[lint:conflicting-declarations] /src/tomllib/_parser.py:590:9 Conflicting declared types for `char`: Unknown, str | None", - "error[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:629:38 Name `datetime_obj` used when possibly not defined", + "warning[lint:possibly-unresolved-reference] /src/tomllib/_parser.py:629:38 Name `datetime_obj` used when possibly not defined", ]; fn get_test_file(name: &str) -> TestFile { diff --git a/crates/ruff_macros/src/kebab_case.rs b/crates/ruff_macros/src/kebab_case.rs new file mode 100644 index 0000000000..1f6dcf42fd --- /dev/null +++ b/crates/ruff_macros/src/kebab_case.rs @@ -0,0 +1,19 @@ +use proc_macro2::TokenStream; + +pub(crate) fn kebab_case(input: &syn::Ident) -> TokenStream { + let screaming_snake_case = input.to_string(); + + let mut kebab_case = String::with_capacity(screaming_snake_case.len()); + + for (i, word) in screaming_snake_case.split('_').enumerate() { + if i > 0 { + kebab_case.push('-'); + } + + kebab_case.push_str(&word.to_lowercase()); + } + + let kebab_case_lit = syn::LitStr::new(&kebab_case, input.span()); + + quote::quote!(#kebab_case_lit) +} diff --git a/crates/ruff_macros/src/lib.rs b/crates/ruff_macros/src/lib.rs index 00bd4018a4..1d34a5617b 100644 --- a/crates/ruff_macros/src/lib.rs +++ b/crates/ruff_macros/src/lib.rs @@ -10,6 +10,7 @@ mod cache_key; mod combine_options; mod config; mod derive_message_formats; +mod kebab_case; mod map_codes; mod newtype_index; mod rule_code_prefix; @@ -34,6 +35,14 @@ pub fn derive_combine_options(input: TokenStream) -> TokenStream { .into() } +/// Converts a screaming snake case identifier to a kebab case string. +#[proc_macro] +pub fn kebab_case(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as syn::Ident); + + kebab_case::kebab_case(&input).into() +} + /// Generates a [`CacheKey`] implementation for the attributed type. /// /// Struct fields can be attributed with the `cache_key` field-attribute that supports: