diff --git a/crates/ruff_linter/resources/test/fixtures/pycodestyle/E204.py b/crates/ruff_linter/resources/test/fixtures/pycodestyle/E204.py new file mode 100644 index 0000000000000..60d218a17bf49 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pycodestyle/E204.py @@ -0,0 +1,34 @@ +def foo(fun): + def wrapper(): + print('before') + fun() + print('after') + return wrapper + +# No error +@foo +def bar(): + print('bar') + +# E204 +@ foo +def baz(): + print('baz') + +class Test: + # No error + @foo + def bar(self): + print('bar') + + # E204 + @ foo + def baz(self): + print('baz') + + +# E204 +@ \ +foo +def baz(): + print('baz') diff --git a/crates/ruff_linter/src/checkers/ast/analyze/statement.rs b/crates/ruff_linter/src/checkers/ast/analyze/statement.rs index 030cfe2449041..e92fee7e8d45e 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/statement.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/statement.rs @@ -368,6 +368,9 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) { if checker.enabled(Rule::UnusedAsync) { ruff::rules::unused_async(checker, function_def); } + if checker.enabled(Rule::WhitespaceAfterDecorator) { + pycodestyle::rules::whitespace_after_decorator(checker, decorator_list); + } } Stmt::Return(_) => { if checker.enabled(Rule::ReturnOutsideFunction) { @@ -531,6 +534,9 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) { if checker.enabled(Rule::MetaClassABCMeta) { refurb::rules::metaclass_abcmeta(checker, class_def); } + if checker.enabled(Rule::WhitespaceAfterDecorator) { + pycodestyle::rules::whitespace_after_decorator(checker, decorator_list); + } } Stmt::Import(ast::StmtImport { names, range: _ }) => { if checker.enabled(Rule::MultipleImportsOnOneLine) { diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index 70bbaab4ebf94..69449360e34cc 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -78,6 +78,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Pycodestyle, "E201") => (RuleGroup::Preview, rules::pycodestyle::rules::logical_lines::WhitespaceAfterOpenBracket), (Pycodestyle, "E202") => (RuleGroup::Preview, rules::pycodestyle::rules::logical_lines::WhitespaceBeforeCloseBracket), (Pycodestyle, "E203") => (RuleGroup::Preview, rules::pycodestyle::rules::logical_lines::WhitespaceBeforePunctuation), + (Pycodestyle, "E204") => (RuleGroup::Preview, rules::pycodestyle::rules::WhitespaceAfterDecorator), (Pycodestyle, "E211") => (RuleGroup::Preview, rules::pycodestyle::rules::logical_lines::WhitespaceBeforeParameters), (Pycodestyle, "E221") => (RuleGroup::Preview, rules::pycodestyle::rules::logical_lines::MultipleSpacesBeforeOperator), (Pycodestyle, "E222") => (RuleGroup::Preview, rules::pycodestyle::rules::logical_lines::MultipleSpacesAfterOperator), diff --git a/crates/ruff_linter/src/rules/pycodestyle/mod.rs b/crates/ruff_linter/src/rules/pycodestyle/mod.rs index f493cdf71b9a4..4c1900f625c66 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/mod.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/mod.rs @@ -58,6 +58,7 @@ mod tests { #[test_case(Rule::TypeComparison, Path::new("E721.py"))] #[test_case(Rule::UselessSemicolon, Path::new("E70.py"))] #[test_case(Rule::UselessSemicolon, Path::new("E703.ipynb"))] + #[test_case(Rule::WhitespaceAfterDecorator, Path::new("E204.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); let diagnostics = test_path( diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/mod.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/mod.rs index 8d5914d8a4ec6..419337608d436 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/mod.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/mod.rs @@ -20,6 +20,7 @@ pub(crate) use tab_indentation::*; pub(crate) use too_many_newlines_at_end_of_file::*; pub(crate) use trailing_whitespace::*; pub(crate) use type_comparison::*; +pub(crate) use whitespace_after_decorator::*; mod ambiguous_class_name; mod ambiguous_function_name; @@ -43,3 +44,4 @@ mod tab_indentation; mod too_many_newlines_at_end_of_file; mod trailing_whitespace; mod type_comparison; +mod whitespace_after_decorator; diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/whitespace_after_decorator.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/whitespace_after_decorator.rs new file mode 100644 index 0000000000000..f820b5f1ecd4b --- /dev/null +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/whitespace_after_decorator.rs @@ -0,0 +1,71 @@ +use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix}; +use ruff_macros::{derive_message_formats, violation}; +use ruff_python_ast::Decorator; +use ruff_python_trivia::is_python_whitespace; +use ruff_text_size::{Ranged, TextRange, TextSize}; + +use crate::checkers::ast::Checker; + +/// ## What it does +/// Checks for trailing whitespace after a decorator's opening `@`. +/// +/// ## Why is this bad? +/// Including whitespace after the `@` symbol is not compliant with +/// [PEP 8]. +/// +/// ## Example +/// +/// ```python +/// @ decorator +/// def func(): +/// pass +/// ``` +/// +/// Use instead: +/// ```python +/// @decorator +/// def func(): +/// pass +/// ``` +/// +/// [PEP 8]: https://peps.python.org/pep-0008/#maximum-line-length + +#[violation] +pub struct WhitespaceAfterDecorator; + +impl AlwaysFixableViolation for WhitespaceAfterDecorator { + #[derive_message_formats] + fn message(&self) -> String { + format!("Whitespace after decorator") + } + + fn fix_title(&self) -> String { + "Remove whitespace".to_string() + } +} + +/// E204 +pub(crate) fn whitespace_after_decorator(checker: &mut Checker, decorator_list: &[Decorator]) { + for decorator in decorator_list { + let decorator_text = checker.locator().slice(decorator); + + // Determine whether the `@` is followed by whitespace. + if let Some(trailing) = decorator_text.strip_prefix('@') { + // Collect the whitespace characters after the `@`. + if trailing.chars().next().is_some_and(is_python_whitespace) { + let end = trailing + .chars() + .position(|c| !(is_python_whitespace(c) || matches!(c, '\n' | '\r' | '\\'))) + .unwrap_or(trailing.len()); + + let start = decorator.start() + TextSize::from(1); + let end = start + TextSize::try_from(end).unwrap(); + let range = TextRange::new(start, end); + + let mut diagnostic = Diagnostic::new(WhitespaceAfterDecorator, range); + diagnostic.set_fix(Fix::safe_edit(Edit::range_deletion(range))); + checker.diagnostics.push(diagnostic); + } + } + } +} diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E204_E204.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E204_E204.py.snap new file mode 100644 index 0000000000000..050562950c21b --- /dev/null +++ b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E204_E204.py.snap @@ -0,0 +1,64 @@ +--- +source: crates/ruff_linter/src/rules/pycodestyle/mod.rs +--- +E204.py:14:2: E204 [*] Whitespace after decorator + | +13 | # E204 +14 | @ foo + | ^ E204 +15 | def baz(): +16 | print('baz') + | + = help: Remove whitespace + +ℹ Safe fix +11 11 | print('bar') +12 12 | +13 13 | # E204 +14 |-@ foo + 14 |+@foo +15 15 | def baz(): +16 16 | print('baz') +17 17 | + +E204.py:25:6: E204 [*] Whitespace after decorator + | +24 | # E204 +25 | @ foo + | ^ E204 +26 | def baz(self): +27 | print('baz') + | + = help: Remove whitespace + +ℹ Safe fix +22 22 | print('bar') +23 23 | +24 24 | # E204 +25 |- @ foo + 25 |+ @foo +26 26 | def baz(self): +27 27 | print('baz') +28 28 | + +E204.py:31:2: E204 [*] Whitespace after decorator + | +30 | # E204 +31 | @ \ + | __^ +32 | | foo + | |_^ E204 +33 | def baz(): +34 | print('baz') + | + = help: Remove whitespace + +ℹ Safe fix +28 28 | +29 29 | +30 30 | # E204 +31 |-@ \ +32 |-foo + 31 |+@foo +33 32 | def baz(): +34 33 | print('baz') diff --git a/ruff.schema.json b/ruff.schema.json index ce44ea3f8efd3..ab7bad65446f5 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -2904,6 +2904,7 @@ "E201", "E202", "E203", + "E204", "E21", "E211", "E22", diff --git a/scripts/check_docs_formatted.py b/scripts/check_docs_formatted.py index 208cf6ea108e4..1a904711f0cfe 100755 --- a/scripts/check_docs_formatted.py +++ b/scripts/check_docs_formatted.py @@ -86,6 +86,7 @@ "unnecessary-class-parentheses", "unnecessary-escaped-quote", "useless-semicolon", + "whitespace-after-decorator", "whitespace-after-open-bracket", "whitespace-before-close-bracket", "whitespace-before-parameters",