diff --git a/crates/ruff_linter/resources/test/fixtures/pycodestyle/E122.py b/crates/ruff_linter/resources/test/fixtures/pycodestyle/E122.py new file mode 100644 index 0000000000000..eb69d9f4ba1e5 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pycodestyle/E122.py @@ -0,0 +1,143 @@ +#: E122 +print("E122", ( +"str")) + +# OK +print("E122", ( + "str")) + +#: E122:6:5 E122:7:5 E122:8:1 +print(dedent( + ''' + mkdir -p ./{build}/ + mv ./build/ ./{build}/%(revision)s/ + '''.format( + build='build', + # more stuff +) +)) + +# OK +print(dedent( + ''' + mkdir -p ./{build}/ + mv ./build/ ./{build}/%(revision)s/ + '''.format( + build='build', + # more stuff + ) +)) + +#: E122 +if True: + result = some_function_that_takes_arguments( + 'a', 'b', 'c', + 'd', 'e', 'f', +) + +# OK +if True: + result = some_function_that_takes_arguments( + 'a', 'b', 'c', + 'd', 'e', 'f', + ) + +#: E122 +if some_very_very_very_long_variable_name or var \ +or another_very_long_variable_name: + raise Exception() + +# OK +if some_very_very_very_long_variable_name or var \ + or another_very_long_variable_name: + raise Exception() + +#: E122 +if some_very_very_very_long_variable_name or var[0] \ +or another_very_long_variable_name: + raise Exception() + +# OK +if some_very_very_very_long_variable_name or var[0] \ + or another_very_long_variable_name: + raise Exception() + +#: E122 +if True: + if some_very_very_very_long_variable_name or var \ + or another_very_long_variable_name: + raise Exception() + +# OK +if True: + if some_very_very_very_long_variable_name or var \ + or another_very_long_variable_name: + raise Exception() + +#: E122 +if True: + if some_very_very_very_long_variable_name or var[0] \ + or another_very_long_variable_name: + raise Exception() + +#: OK +if True: + if some_very_very_very_long_variable_name or var[0] \ + or another_very_long_variable_name: + raise Exception() + +#: E122 +dictionary = { + "is": { + "nested": yes(), + }, +} + +# OK +dictionary = { + "is": { + "nested": yes(), + }, +} + +#: E122 +setup('', + scripts=[''], + classifiers=[ + 'Development Status :: 4 - Beta', + 'Environment :: Console', + 'Intended Audience :: Developers', + ]) + +# OK +setup('', + scripts=[''], + classifiers=[ + 'Development Status :: 4 - Beta', + 'Environment :: Console', + 'Intended Audience :: Developers', + ]) + +#: E122:2:1 +if True:\ +print(True) + +# OK +if True:\ + print(True) + +# E122 +def f(): + x = (( + ( + ) + ) + + 2) + +# OK +def f(): + x = (( + ( + ) + ) + + 2) diff --git a/crates/ruff_linter/src/checkers/logical_lines.rs b/crates/ruff_linter/src/checkers/logical_lines.rs index 4044e6c18a67b..a7b152a9511be 100644 --- a/crates/ruff_linter/src/checkers/logical_lines.rs +++ b/crates/ruff_linter/src/checkers/logical_lines.rs @@ -1,4 +1,5 @@ use crate::line_width::IndentWidth; +use crate::registry::Rule; use ruff_diagnostics::Diagnostic; use ruff_python_codegen::Stylist; use ruff_python_index::Indexer; @@ -9,10 +10,11 @@ use ruff_text_size::{Ranged, TextRange}; use crate::registry::AsRule; use crate::rules::pycodestyle::rules::logical_lines::{ - extraneous_whitespace, indentation, missing_whitespace, missing_whitespace_after_keyword, - missing_whitespace_around_operator, redundant_backslash, space_after_comma, - space_around_operator, whitespace_around_keywords, whitespace_around_named_parameter_equals, - whitespace_before_comment, whitespace_before_parameters, LogicalLines, TokenFlags, + extraneous_whitespace, indentation, missing_or_outdented_indentation, missing_whitespace, + missing_whitespace_after_keyword, missing_whitespace_around_operator, redundant_backslash, + space_after_comma, space_around_operator, whitespace_around_keywords, + whitespace_around_named_parameter_equals, whitespace_before_comment, + whitespace_before_parameters, LogicalLines, TokenFlags, }; use crate::settings::LinterSettings; @@ -106,6 +108,16 @@ pub(crate) fn check_logical_lines( } } + if settings.rules.enabled(Rule::MissingOrOutdentedIndentation) { + missing_or_outdented_indentation( + &line, + indent_level, + settings.tab_size, + locator, + &mut context, + ); + } + if !line.is_comment_only() { prev_line = Some(line); prev_indent_level = Some(indent_level); diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index 05906db3c906b..d03e542139a6f 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -85,6 +85,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Pycodestyle, "E116") => (RuleGroup::Nursery, rules::pycodestyle::rules::logical_lines::UnexpectedIndentationComment), #[allow(deprecated)] (Pycodestyle, "E117") => (RuleGroup::Nursery, rules::pycodestyle::rules::logical_lines::OverIndented), + (Pycodestyle, "E122") => (RuleGroup::Preview, rules::pycodestyle::rules::logical_lines::MissingOrOutdentedIndentation), #[allow(deprecated)] (Pycodestyle, "E201") => (RuleGroup::Nursery, rules::pycodestyle::rules::logical_lines::WhitespaceAfterOpenBracket), #[allow(deprecated)] diff --git a/crates/ruff_linter/src/registry.rs b/crates/ruff_linter/src/registry.rs index 77b5d97df557f..4dd7e1a844d1d 100644 --- a/crates/ruff_linter/src/registry.rs +++ b/crates/ruff_linter/src/registry.rs @@ -305,6 +305,7 @@ impl Rule { Rule::ImplicitNamespacePackage | Rule::InvalidModuleName => LintSource::Filesystem, Rule::IndentationWithInvalidMultiple | Rule::IndentationWithInvalidMultipleComment + | Rule::MissingOrOutdentedIndentation | Rule::MissingWhitespace | Rule::MissingWhitespaceAfterKeyword | Rule::MissingWhitespaceAroundArithmeticOperator diff --git a/crates/ruff_linter/src/rules/pycodestyle/helpers.rs b/crates/ruff_linter/src/rules/pycodestyle/helpers.rs index 2b39a6dad110b..5a7220d1d63a2 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/helpers.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/helpers.rs @@ -1,4 +1,23 @@ +use crate::line_width::IndentWidth; + /// Returns `true` if the name should be considered "ambiguous". pub(super) fn is_ambiguous_name(name: &str) -> bool { name == "l" || name == "I" || name == "O" } + +/// Return the amount of indentation, expanding tabs to the next multiple of the settings' tab size. +pub(crate) fn expand_indent(line: &str, indent_width: IndentWidth) -> usize { + let line = line.trim_end_matches(['\n', '\r']); + + let mut indent = 0; + let tab_size = indent_width.as_usize(); + for c in line.bytes() { + match c { + b'\t' => indent += (indent / tab_size) * tab_size + tab_size, + b' ' => indent += 1, + _ => break, + } + } + + indent +} diff --git a/crates/ruff_linter/src/rules/pycodestyle/mod.rs b/crates/ruff_linter/src/rules/pycodestyle/mod.rs index dbb9a293b8735..6e65a911e24a1 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/mod.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/mod.rs @@ -77,6 +77,7 @@ mod tests { #[test_case(Rule::TooManyNewlinesAtEndOfFile, Path::new("W391_2.py"))] #[test_case(Rule::TooManyNewlinesAtEndOfFile, Path::new("W391_3.py"))] #[test_case(Rule::TooManyNewlinesAtEndOfFile, Path::new("W391_4.py"))] + #[test_case(Rule::MissingOrOutdentedIndentation, Path::new("E122.py"))] fn preview_rules(rule_code: Rule, path: &Path) -> Result<()> { let snapshot = format!( "preview__{}_{}", diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/missing_or_outdented_indentation.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/missing_or_outdented_indentation.rs new file mode 100644 index 0000000000000..21f0b0e8804c3 --- /dev/null +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/missing_or_outdented_indentation.rs @@ -0,0 +1,136 @@ +use super::{LogicalLine, LogicalLineToken}; +use crate::checkers::logical_lines::LogicalLinesContext; +use crate::line_width::IndentWidth; +use ruff_diagnostics::{Diagnostic, Violation}; +use ruff_macros::{derive_message_formats, violation}; +use ruff_python_parser::TokenKind; +use ruff_source_file::Locator; +use ruff_text_size::{Ranged, TextRange}; + +use crate::rules::pycodestyle::helpers::expand_indent; + +/// ## What it does +/// Checks for continuation lines not indented as far as they should be or indented too far. +/// +/// ## Why is this bad? +/// This makes distinguishing continuation line harder. +/// +/// ## Example +/// ```python +/// print("Python", ( +/// "Rules")) +/// ``` +/// +/// Use instead: +/// ```python +/// print("Python", ( +/// "Rules")) +/// ``` +/// +/// [PEP 8]: https://www.python.org/dev/peps/pep-0008/#indentation +#[violation] +pub struct MissingOrOutdentedIndentation; + +impl Violation for MissingOrOutdentedIndentation { + #[derive_message_formats] + fn message(&self) -> String { + format!("Continuation line missing indentation or outdented.") + } +} + +/// E122 +pub(crate) fn missing_or_outdented_indentation( + line: &LogicalLine, + indent_level: usize, + indent_width: IndentWidth, + locator: &Locator, + context: &mut LogicalLinesContext, +) { + if line.tokens().len() <= 1 { + return; + } + + let first_token = line.first_token().unwrap(); + let mut line_end = locator.full_line_end(first_token.start()); + + let tab_size = indent_width.as_usize(); + let mut indentation = indent_level; + // Start by increasing indent on any continuation line + let mut desired_indentation = indentation + tab_size; + let mut indent_increased = true; + let mut indentation_stack: std::vec::Vec<usize> = Vec::new(); + + let mut iter = line.tokens().iter().peekable(); + while let Some(token) = iter.next() { + // If continuation line + if token.start() >= line_end { + // Reset and calculate current indentation + indent_increased = false; + indentation = expand_indent(locator.line(token.start()), indent_width); + + // Calculate correct indentation + let correct_indentation = if first_token_is_closing_bracket(token, iter.peek().copied()) + { + // If first non-indent token is a closing bracket + // then the correct indentation is the one on top of the stack + // unless we are back at the starting indentation in which case + // the initial indentation is correct. + if desired_indentation == indent_level + tab_size { + indent_level + } else { + *indentation_stack + .last() + .expect("Closing brackets should always be preceded by opening brackets") + } + } else { + desired_indentation + }; + + if indentation < correct_indentation { + let diagnostic = Diagnostic::new( + MissingOrOutdentedIndentation, + TextRange::new(locator.line_start(token.start()), token.start()), + ); + context.push_diagnostic(diagnostic); + } + + line_end = locator.full_line_end(token.start()); + } + + match token.kind() { + TokenKind::Lpar | TokenKind::Lsqb | TokenKind::Lbrace => { + // Store indent to return to once bracket closes + indentation_stack.push(desired_indentation); + // Only increase the indent once per continuation line + if !indent_increased { + desired_indentation += tab_size; + indent_increased = true; + } + } + TokenKind::Rpar | TokenKind::Rsqb | TokenKind::Rbrace => { + // Return to previous indent + desired_indentation = indentation_stack + .pop() + .expect("Closing brackets should always be preceded by opening brackets"); + } + _ => {} + } + } +} + +fn first_token_is_closing_bracket( + first_token: &LogicalLineToken, + second_token: Option<&LogicalLineToken>, +) -> bool { + match first_token.kind { + TokenKind::Rpar | TokenKind::Rsqb | TokenKind::Rbrace => true, + TokenKind::Indent => { + second_token.is_some() + && matches!( + second_token.unwrap().kind, + TokenKind::Rpar | TokenKind::Rsqb | TokenKind::Rbrace + ) + } + _ => false, + } +} diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/mod.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/mod.rs index 606972bcf0c38..cd76249d4a42d 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/mod.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/rules/logical_lines/mod.rs @@ -1,5 +1,6 @@ pub(crate) use extraneous_whitespace::*; pub(crate) use indentation::*; +pub(crate) use missing_or_outdented_indentation::*; pub(crate) use missing_whitespace::*; pub(crate) use missing_whitespace_after_keyword::*; pub(crate) use missing_whitespace_around_operator::*; @@ -23,6 +24,7 @@ use ruff_source_file::Locator; mod extraneous_whitespace; mod indentation; +mod missing_or_outdented_indentation; mod missing_whitespace; mod missing_whitespace_after_keyword; mod missing_whitespace_around_operator; diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E122_E122.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E122_E122.py.snap new file mode 100644 index 0000000000000..ff54952e68375 --- /dev/null +++ b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__preview__E122_E122.py.snap @@ -0,0 +1,127 @@ +--- +source: crates/ruff_linter/src/rules/pycodestyle/mod.rs +--- +E122.py:3:1: E122 Continuation line missing indentation or outdented. + | +1 | #: E122 +2 | print("E122", ( +3 | "str")) + | E122 +4 | +5 | # OK + | + +E122.py:15:1: E122 Continuation line missing indentation or outdented. + | +13 | mv ./build/ ./{build}/%(revision)s/ +14 | '''.format( +15 | build='build', + | ^^^^ E122 +16 | # more stuff +17 | ) + | + +E122.py:16:1: E122 Continuation line missing indentation or outdented. + | +14 | '''.format( +15 | build='build', +16 | # more stuff + | ^^^^ E122 +17 | ) +18 | )) + | + +E122.py:17:1: E122 Continuation line missing indentation or outdented. + | +15 | build='build', +16 | # more stuff +17 | ) + | E122 +18 | )) + | + +E122.py:36:1: E122 Continuation line missing indentation or outdented. + | +34 | 'a', 'b', 'c', +35 | 'd', 'e', 'f', +36 | ) + | E122 +37 | +38 | # OK + | + +E122.py:47:1: E122 Continuation line missing indentation or outdented. + | +45 | #: E122 +46 | if some_very_very_very_long_variable_name or var \ +47 | or another_very_long_variable_name: + | E122 +48 | raise Exception() + | + +E122.py:57:1: E122 Continuation line missing indentation or outdented. + | +55 | #: E122 +56 | if some_very_very_very_long_variable_name or var[0] \ +57 | or another_very_long_variable_name: + | E122 +58 | raise Exception() + | + +E122.py:68:1: E122 Continuation line missing indentation or outdented. + | +66 | if True: +67 | if some_very_very_very_long_variable_name or var \ +68 | or another_very_long_variable_name: + | ^^^^ E122 +69 | raise Exception() + | + +E122.py:80:1: E122 Continuation line missing indentation or outdented. + | +78 | if True: +79 | if some_very_very_very_long_variable_name or var[0] \ +80 | or another_very_long_variable_name: + | ^^^^ E122 +81 | raise Exception() + | + +E122.py:92:1: E122 Continuation line missing indentation or outdented. + | +90 | dictionary = { +91 | "is": { +92 | "nested": yes(), + | ^^^^ E122 +93 | }, +94 | } + | + +E122.py:107:1: E122 Continuation line missing indentation or outdented. + | +105 | scripts=[''], +106 | classifiers=[ +107 | 'Development Status :: 4 - Beta', + | ^^^^ E122 +108 | 'Environment :: Console', +109 | 'Intended Audience :: Developers', + | + +E122.py:123:1: E122 Continuation line missing indentation or outdented. + | +121 | #: E122:2:1 +122 | if True:\ +123 | print(True) + | E122 +124 | +125 | # OK + | + +E122.py:135:1: E122 Continuation line missing indentation or outdented. + | +133 | ) +134 | ) +135 | + 2) + | ^^^^ E122 +136 | +137 | # OK + | diff --git a/ruff.schema.json b/ruff.schema.json index 33cc16342c78f..d246f712f23de 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -2860,6 +2860,8 @@ "E115", "E116", "E117", + "E12", + "E122", "E2", "E20", "E201", diff --git a/scripts/check_docs_formatted.py b/scripts/check_docs_formatted.py index 96f6ec3a7476a..272885e0a9169 100755 --- a/scripts/check_docs_formatted.py +++ b/scripts/check_docs_formatted.py @@ -42,6 +42,7 @@ "indent-with-spaces", "indentation-with-invalid-multiple", "line-too-long", + "missing-or-outdented-indentation", "missing-trailing-comma", "missing-whitespace", "missing-whitespace-after-keyword",