diff --git a/crates/ruff_linter/src/checkers/ast/analyze/string_like.rs b/crates/ruff_linter/src/checkers/ast/analyze/string_like.rs index 5af18e67adf54..52f481a91a0a8 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/string_like.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/string_like.rs @@ -2,7 +2,7 @@ use ruff_python_ast::StringLike; use crate::checkers::ast::Checker; use crate::codes::Rule; -use crate::rules::{flake8_bandit, flake8_pyi, ruff}; +use crate::rules::{flake8_bandit, flake8_pyi, flake8_quotes, ruff}; /// Run lint rules over a [`StringLike`] syntax nodes. pub(crate) fn string_like(string_like: StringLike, checker: &mut Checker) { @@ -23,4 +23,11 @@ pub(crate) fn string_like(string_like: StringLike, checker: &mut Checker) { flake8_pyi::rules::string_or_bytes_too_long(checker, string_like); } } + if checker.any_enabled(&[ + Rule::BadQuotesInlineString, + Rule::BadQuotesMultilineString, + Rule::BadQuotesDocstring, + ]) { + flake8_quotes::rules::check_string_quotes(checker, string_like); + } } diff --git a/crates/ruff_linter/src/checkers/tokens.rs b/crates/ruff_linter/src/checkers/tokens.rs index fa801a3284c74..431472093fc86 100644 --- a/crates/ruff_linter/src/checkers/tokens.rs +++ b/crates/ruff_linter/src/checkers/tokens.rs @@ -130,14 +130,6 @@ pub(crate) fn check_tokens( flake8_quotes::rules::unnecessary_escaped_quote(&mut diagnostics, tokens, locator); } - if settings.rules.any_enabled(&[ - Rule::BadQuotesInlineString, - Rule::BadQuotesMultilineString, - Rule::BadQuotesDocstring, - ]) { - flake8_quotes::rules::check_string_quotes(&mut diagnostics, tokens, locator, settings); - } - if settings.rules.any_enabled(&[ Rule::SingleLineImplicitStringConcatenation, Rule::MultiLineImplicitStringConcatenation, diff --git a/crates/ruff_linter/src/registry.rs b/crates/ruff_linter/src/registry.rs index 7f07227fc386f..e156e2900c75d 100644 --- a/crates/ruff_linter/src/registry.rs +++ b/crates/ruff_linter/src/registry.rs @@ -257,9 +257,6 @@ impl Rule { | Rule::TrailingWhitespace => LintSource::PhysicalLines, Rule::AmbiguousUnicodeCharacterComment | Rule::AvoidableEscapedQuote - | Rule::BadQuotesDocstring - | Rule::BadQuotesInlineString - | Rule::BadQuotesMultilineString | Rule::BlanketNOQA | Rule::BlanketTypeIgnore | Rule::BlankLineAfterDecorator diff --git a/crates/ruff_linter/src/rules/flake8_quotes/rules/check_string_quotes.rs b/crates/ruff_linter/src/rules/flake8_quotes/rules/check_string_quotes.rs index 449fdcfd2feef..a3850a854a33f 100644 --- a/crates/ruff_linter/src/rules/flake8_quotes/rules/check_string_quotes.rs +++ b/crates/ruff_linter/src/rules/flake8_quotes/rules/check_string_quotes.rs @@ -1,14 +1,10 @@ -use ruff_python_parser::lexer::LexResult; -use ruff_python_parser::Tok; -use ruff_text_size::{TextRange, TextSize}; - use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix, FixAvailability, Violation}; use ruff_macros::{derive_message_formats, violation}; +use ruff_python_ast::StringLike; use ruff_source_file::Locator; +use ruff_text_size::{Ranged, TextRange}; -use crate::lex::docstring_detection::StateMachine; - -use crate::settings::LinterSettings; +use crate::checkers::ast::Checker; use super::super::settings::Quote; @@ -256,29 +252,31 @@ fn text_ends_at_quote(locator: &Locator, range: TextRange, quote: Quote) -> bool } /// Q002 -fn docstring(locator: &Locator, range: TextRange, settings: &LinterSettings) -> Option { - let quotes_settings = &settings.flake8_quotes; +fn docstring(checker: &mut Checker, range: TextRange) { + let quotes_settings = &checker.settings.flake8_quotes; + let locator = checker.locator(); let text = locator.slice(range); let trivia: Trivia = text.into(); if trivia.has_empty_text() - && text_ends_at_quote(locator, range, settings.flake8_quotes.docstring_quotes) + && text_ends_at_quote(locator, range, quotes_settings.docstring_quotes) { // Fixing this would result in a one-sided multi-line docstring, which would // introduce a syntax error. - return Some(Diagnostic::new( + checker.diagnostics.push(Diagnostic::new( BadQuotesDocstring { preferred_quote: quotes_settings.docstring_quotes, }, range, )); + return; } if trivia .raw_text .contains(good_docstring(quotes_settings.docstring_quotes)) { - return None; + return; } let mut diagnostic = Diagnostic::new( @@ -302,18 +300,13 @@ fn docstring(locator: &Locator, range: TextRange, settings: &LinterSettings) -> fixed_contents, range, ))); - Some(diagnostic) + checker.diagnostics.push(diagnostic); } /// Q000, Q001 -fn strings( - locator: &Locator, - sequence: &[TextRange], - settings: &LinterSettings, -) -> Vec { - let mut diagnostics = vec![]; - - let quotes_settings = &settings.flake8_quotes; +fn strings(checker: &mut Checker, sequence: &[TextRange]) { + let quotes_settings = &checker.settings.flake8_quotes; + let locator = checker.locator(); let trivia = sequence .iter() @@ -377,20 +370,20 @@ fn strings( fixed_contents, *range, ))); - diagnostics.push(diagnostic); + checker.diagnostics.push(diagnostic); } else if trivia.last_quote_char != quotes_settings.inline_quotes.as_char() // If we're not using the preferred type, only allow use to avoid escapes. && !relax_quote { if trivia.has_empty_text() - && text_ends_at_quote(locator, *range, settings.flake8_quotes.inline_quotes) + && text_ends_at_quote(locator, *range, quotes_settings.inline_quotes) { // Fixing this would introduce a syntax error. For example, changing the initial // single quotes to double quotes would result in a syntax error: // ```python // ''"assert" ' SAM macro definitions ''' // ``` - diagnostics.push(Diagnostic::new( + checker.diagnostics.push(Diagnostic::new( BadQuotesInlineString { preferred_quote: quotes_settings.inline_quotes, }, @@ -399,17 +392,13 @@ fn strings( continue; } - if text_starts_at_consecutive_quote( - locator, - *range, - settings.flake8_quotes.inline_quotes, - ) { + if text_starts_at_consecutive_quote(locator, *range, quotes_settings.inline_quotes) { // Fixing this would introduce a syntax error. For example, changing the double // doubles to single quotes would result in a syntax error: // ```python // ''"assert" ' SAM macro definitions ''' // ``` - diagnostics.push(Diagnostic::new( + checker.diagnostics.push(Diagnostic::new( BadQuotesInlineString { preferred_quote: quotes_settings.inline_quotes, }, @@ -436,120 +425,34 @@ fn strings( fixed_contents, *range, ))); - diagnostics.push(diagnostic); + checker.diagnostics.push(diagnostic); } } - - diagnostics -} - -/// A builder for the f-string range. -/// -/// For now, this is limited to the outermost f-string and doesn't support -/// nested f-strings. -#[derive(Debug, Default)] -struct FStringRangeBuilder { - start_location: TextSize, - end_location: TextSize, - nesting: u32, -} - -impl FStringRangeBuilder { - fn visit_token(&mut self, token: &Tok, range: TextRange) { - match token { - Tok::FStringStart(_) => { - if self.nesting == 0 { - self.start_location = range.start(); - } - self.nesting += 1; - } - Tok::FStringEnd => { - self.nesting = self.nesting.saturating_sub(1); - if self.nesting == 0 { - self.end_location = range.end(); - } - } - _ => {} - } - } - - /// Returns `true` if the lexer is currently inside of a f-string. - /// - /// It'll return `false` once the `FStringEnd` token for the outermost - /// f-string is visited. - const fn in_fstring(&self) -> bool { - self.nesting > 0 - } - - /// Returns the complete range of the previously visited f-string. - /// - /// This method should only be called once the lexer is outside of any - /// f-string otherwise it might return an invalid range. - /// - /// It doesn't consume the builder because there can be multiple f-strings - /// throughout the source code. - fn finish(&self) -> TextRange { - debug_assert!(!self.in_fstring()); - TextRange::new(self.start_location, self.end_location) - } } /// Generate `flake8-quote` diagnostics from a token stream. -pub(crate) fn check_string_quotes( - diagnostics: &mut Vec, - lxr: &[LexResult], - locator: &Locator, - settings: &LinterSettings, -) { - // Keep track of sequences of strings, which represent implicit string - // concatenation, and should thus be handled as a single unit. - let mut sequence = vec![]; - let mut state_machine = StateMachine::default(); - let mut fstring_range_builder = FStringRangeBuilder::default(); - for &(ref tok, range) in lxr.iter().flatten() { - fstring_range_builder.visit_token(tok, range); - if fstring_range_builder.in_fstring() { - continue; - } +pub(crate) fn check_string_quotes(checker: &mut Checker, string_like: StringLike) { + // If the string is part of a f-string, ignore it. + if checker + .indexer() + .fstring_ranges() + .outermost(string_like.start()) + .is_some_and(|outer| outer.start() < string_like.start() && string_like.end() < outer.end()) + { + return; + } - let is_docstring = state_machine.consume(tok); + let ranges: Vec = match string_like { + StringLike::String(node) => node.value.iter().map(Ranged::range).collect(), + StringLike::Bytes(node) => node.value.iter().map(Ranged::range).collect(), + StringLike::FString(node) => node.value.iter().map(Ranged::range).collect(), + }; - // If this is a docstring, consume the existing sequence, then consume the - // docstring, then move on. - if is_docstring { - if !sequence.is_empty() { - diagnostics.extend(strings(locator, &sequence, settings)); - sequence.clear(); - } - if let Some(diagnostic) = docstring(locator, range, settings) { - diagnostics.push(diagnostic); - } - } else { - match tok { - Tok::String { .. } => { - // If this is a string, add it to the sequence. - sequence.push(range); - } - Tok::FStringEnd => { - // If this is the end of an f-string, add the entire f-string - // range to the sequence. - sequence.push(fstring_range_builder.finish()); - } - Tok::Comment(..) | Tok::NonLogicalNewline => continue, - _ => { - // Otherwise, consume the sequence. - if !sequence.is_empty() { - diagnostics.extend(strings(locator, &sequence, settings)); - sequence.clear(); - } - } - } + if checker.semantic().in_docstring() { + for range in ranges { + docstring(checker, range); } - } - - // If we have an unterminated sequence, consume it. - if !sequence.is_empty() { - diagnostics.extend(strings(locator, &sequence, settings)); - sequence.clear(); + } else { + strings(checker, &ranges); } } diff --git a/crates/ruff_linter/src/rules/flake8_quotes/snapshots/ruff_linter__rules__flake8_quotes__tests__require_docstring_doubles_over_docstring_singles_mixed_quotes_class_var_1.py.snap b/crates/ruff_linter/src/rules/flake8_quotes/snapshots/ruff_linter__rules__flake8_quotes__tests__require_docstring_doubles_over_docstring_singles_mixed_quotes_class_var_1.py.snap index ead01a887e0ca..6d3aeaab93d71 100644 --- a/crates/ruff_linter/src/rules/flake8_quotes/snapshots/ruff_linter__rules__flake8_quotes__tests__require_docstring_doubles_over_docstring_singles_mixed_quotes_class_var_1.py.snap +++ b/crates/ruff_linter/src/rules/flake8_quotes/snapshots/ruff_linter__rules__flake8_quotes__tests__require_docstring_doubles_over_docstring_singles_mixed_quotes_class_var_1.py.snap @@ -10,14 +10,22 @@ docstring_singles_mixed_quotes_class_var_1.py:2:5: Q002 Single quote docstring f | = help: Replace single quotes docstring with double quotes -docstring_singles_mixed_quotes_class_var_1.py:2:7: Q000 Double quotes found but single quotes preferred +docstring_singles_mixed_quotes_class_var_1.py:2:33: Q002 [*] Single quote docstring found but double quotes preferred | 1 | class SingleLineDocstrings(): 2 | ''"Start with empty string" ' and lint docstring safely' - | ^^^^^^^^^^^^^^^^^^^^^^^^^ Q000 + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Q002 3 | ''' Not a docstring ''' | - = help: Replace double quotes with single quotes + = help: Replace single quotes docstring with double quotes + +ℹ Safe fix +1 1 | class SingleLineDocstrings(): +2 |- ''"Start with empty string" ' and lint docstring safely' + 2 |+ ''"Start with empty string" " and lint docstring safely" +3 3 | ''' Not a docstring ''' +4 4 | +5 5 | def foo(self, bar='''not a docstring'''): docstring_singles_mixed_quotes_class_var_1.py:6:9: Q002 Single quote docstring found but double quotes preferred | @@ -28,14 +36,24 @@ docstring_singles_mixed_quotes_class_var_1.py:6:9: Q002 Single quote docstring f | = help: Replace single quotes docstring with double quotes -docstring_singles_mixed_quotes_class_var_1.py:6:11: Q000 Double quotes found but single quotes preferred +docstring_singles_mixed_quotes_class_var_1.py:6:37: Q002 [*] Single quote docstring found but double quotes preferred | 5 | def foo(self, bar='''not a docstring'''): 6 | ''"Start with empty string" ' and lint docstring safely' - | ^^^^^^^^^^^^^^^^^^^^^^^^^ Q000 + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Q002 7 | pass | - = help: Replace double quotes with single quotes + = help: Replace single quotes docstring with double quotes + +ℹ Safe fix +3 3 | ''' Not a docstring ''' +4 4 | +5 5 | def foo(self, bar='''not a docstring'''): +6 |- ''"Start with empty string" ' and lint docstring safely' + 6 |+ ''"Start with empty string" " and lint docstring safely" +7 7 | pass +8 8 | +9 9 | class Nested(foo()[:]): ''"Start with empty string" ' and lint docstring safely'; pass docstring_singles_mixed_quotes_class_var_1.py:9:29: Q002 Single quote docstring found but double quotes preferred | @@ -46,11 +64,18 @@ docstring_singles_mixed_quotes_class_var_1.py:9:29: Q002 Single quote docstring | = help: Replace single quotes docstring with double quotes -docstring_singles_mixed_quotes_class_var_1.py:9:31: Q000 Double quotes found but single quotes preferred +docstring_singles_mixed_quotes_class_var_1.py:9:57: Q002 [*] Single quote docstring found but double quotes preferred | 7 | pass 8 | 9 | class Nested(foo()[:]): ''"Start with empty string" ' and lint docstring safely'; pass - | ^^^^^^^^^^^^^^^^^^^^^^^^^ Q000 + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Q002 | - = help: Replace double quotes with single quotes + = help: Replace single quotes docstring with double quotes + +ℹ Safe fix +6 6 | ''"Start with empty string" ' and lint docstring safely' +7 7 | pass +8 8 | +9 |- class Nested(foo()[:]): ''"Start with empty string" ' and lint docstring safely'; pass + 9 |+ class Nested(foo()[:]): ''"Start with empty string" " and lint docstring safely"; pass diff --git a/crates/ruff_linter/src/rules/flake8_quotes/snapshots/ruff_linter__rules__flake8_quotes__tests__require_docstring_doubles_over_docstring_singles_mixed_quotes_class_var_2.py.snap b/crates/ruff_linter/src/rules/flake8_quotes/snapshots/ruff_linter__rules__flake8_quotes__tests__require_docstring_doubles_over_docstring_singles_mixed_quotes_class_var_2.py.snap index 67203cf8ff337..60ecc511e3e13 100644 --- a/crates/ruff_linter/src/rules/flake8_quotes/snapshots/ruff_linter__rules__flake8_quotes__tests__require_docstring_doubles_over_docstring_singles_mixed_quotes_class_var_2.py.snap +++ b/crates/ruff_linter/src/rules/flake8_quotes/snapshots/ruff_linter__rules__flake8_quotes__tests__require_docstring_doubles_over_docstring_singles_mixed_quotes_class_var_2.py.snap @@ -18,19 +18,19 @@ docstring_singles_mixed_quotes_class_var_2.py:2:5: Q002 [*] Single quote docstri 4 4 | 5 5 | def foo(self, bar='''not a docstring'''): -docstring_singles_mixed_quotes_class_var_2.py:2:13: Q000 [*] Double quotes found but single quotes preferred +docstring_singles_mixed_quotes_class_var_2.py:2:40: Q002 [*] Single quote docstring found but double quotes preferred | 1 | class SingleLineDocstrings(): 2 | 'Do not'" start with empty string" ' and lint docstring safely' - | ^^^^^^^^^^^^^^^^^^^^^^^^^^ Q000 + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Q002 3 | ''' Not a docstring ''' | - = help: Replace double quotes with single quotes + = help: Replace single quotes docstring with double quotes ℹ Safe fix 1 1 | class SingleLineDocstrings(): 2 |- 'Do not'" start with empty string" ' and lint docstring safely' - 2 |+ 'Do not'' start with empty string' ' and lint docstring safely' + 2 |+ 'Do not'" start with empty string" " and lint docstring safely" 3 3 | ''' Not a docstring ''' 4 4 | 5 5 | def foo(self, bar='''not a docstring'''): @@ -54,21 +54,21 @@ docstring_singles_mixed_quotes_class_var_2.py:6:9: Q002 [*] Single quote docstri 8 8 | 9 9 | class Nested(foo()[:]): 'Do not'" start with empty string" ' and lint docstring safely'; pass -docstring_singles_mixed_quotes_class_var_2.py:6:17: Q000 [*] Double quotes found but single quotes preferred +docstring_singles_mixed_quotes_class_var_2.py:6:44: Q002 [*] Single quote docstring found but double quotes preferred | 5 | def foo(self, bar='''not a docstring'''): 6 | 'Do not'" start with empty string" ' and lint docstring safely' - | ^^^^^^^^^^^^^^^^^^^^^^^^^^ Q000 + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Q002 7 | pass | - = help: Replace double quotes with single quotes + = help: Replace single quotes docstring with double quotes ℹ Safe fix 3 3 | ''' Not a docstring ''' 4 4 | 5 5 | def foo(self, bar='''not a docstring'''): 6 |- 'Do not'" start with empty string" ' and lint docstring safely' - 6 |+ 'Do not'' start with empty string' ' and lint docstring safely' + 6 |+ 'Do not'" start with empty string" " and lint docstring safely" 7 7 | pass 8 8 | 9 9 | class Nested(foo()[:]): 'Do not'" start with empty string" ' and lint docstring safely'; pass @@ -89,18 +89,18 @@ docstring_singles_mixed_quotes_class_var_2.py:9:29: Q002 [*] Single quote docstr 9 |- class Nested(foo()[:]): 'Do not'" start with empty string" ' and lint docstring safely'; pass 9 |+ class Nested(foo()[:]): "Do not"" start with empty string" ' and lint docstring safely'; pass -docstring_singles_mixed_quotes_class_var_2.py:9:37: Q000 [*] Double quotes found but single quotes preferred +docstring_singles_mixed_quotes_class_var_2.py:9:64: Q002 [*] Single quote docstring found but double quotes preferred | 7 | pass 8 | 9 | class Nested(foo()[:]): 'Do not'" start with empty string" ' and lint docstring safely'; pass - | ^^^^^^^^^^^^^^^^^^^^^^^^^^ Q000 + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Q002 | - = help: Replace double quotes with single quotes + = help: Replace single quotes docstring with double quotes ℹ Safe fix 6 6 | 'Do not'" start with empty string" ' and lint docstring safely' 7 7 | pass 8 8 | 9 |- class Nested(foo()[:]): 'Do not'" start with empty string" ' and lint docstring safely'; pass - 9 |+ class Nested(foo()[:]): 'Do not'' start with empty string' ' and lint docstring safely'; pass + 9 |+ class Nested(foo()[:]): 'Do not'" start with empty string" " and lint docstring safely"; pass diff --git a/crates/ruff_linter/src/rules/flake8_quotes/snapshots/ruff_linter__rules__flake8_quotes__tests__require_docstring_doubles_over_docstring_singles_mixed_quotes_module_singleline_var_1.py.snap b/crates/ruff_linter/src/rules/flake8_quotes/snapshots/ruff_linter__rules__flake8_quotes__tests__require_docstring_doubles_over_docstring_singles_mixed_quotes_module_singleline_var_1.py.snap index b12ef7e5b6e9c..d8a027bcd0919 100644 --- a/crates/ruff_linter/src/rules/flake8_quotes/snapshots/ruff_linter__rules__flake8_quotes__tests__require_docstring_doubles_over_docstring_singles_mixed_quotes_module_singleline_var_1.py.snap +++ b/crates/ruff_linter/src/rules/flake8_quotes/snapshots/ruff_linter__rules__flake8_quotes__tests__require_docstring_doubles_over_docstring_singles_mixed_quotes_module_singleline_var_1.py.snap @@ -10,14 +10,21 @@ docstring_singles_mixed_quotes_module_singleline_var_1.py:1:1: Q002 Single quote | = help: Replace single quotes docstring with double quotes -docstring_singles_mixed_quotes_module_singleline_var_1.py:1:3: Q000 Double quotes found but single quotes preferred +docstring_singles_mixed_quotes_module_singleline_var_1.py:1:29: Q002 [*] Single quote docstring found but double quotes preferred | 1 | ''"Start with empty string" ' and lint docstring safely' - | ^^^^^^^^^^^^^^^^^^^^^^^^^ Q000 + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Q002 2 | 3 | def foo(): | - = help: Replace double quotes with single quotes + = help: Replace single quotes docstring with double quotes + +ℹ Safe fix +1 |-''"Start with empty string" ' and lint docstring safely' + 1 |+''"Start with empty string" " and lint docstring safely" +2 2 | +3 3 | def foo(): +4 4 | pass docstring_singles_mixed_quotes_module_singleline_var_1.py:5:1: Q001 [*] Double quote multiline found but single quotes preferred | diff --git a/crates/ruff_linter/src/rules/flake8_quotes/snapshots/ruff_linter__rules__flake8_quotes__tests__require_docstring_doubles_over_docstring_singles_mixed_quotes_module_singleline_var_2.py.snap b/crates/ruff_linter/src/rules/flake8_quotes/snapshots/ruff_linter__rules__flake8_quotes__tests__require_docstring_doubles_over_docstring_singles_mixed_quotes_module_singleline_var_2.py.snap index a0f9cc158c40a..5ffafe0ffd2b6 100644 --- a/crates/ruff_linter/src/rules/flake8_quotes/snapshots/ruff_linter__rules__flake8_quotes__tests__require_docstring_doubles_over_docstring_singles_mixed_quotes_module_singleline_var_2.py.snap +++ b/crates/ruff_linter/src/rules/flake8_quotes/snapshots/ruff_linter__rules__flake8_quotes__tests__require_docstring_doubles_over_docstring_singles_mixed_quotes_module_singleline_var_2.py.snap @@ -17,18 +17,18 @@ docstring_singles_mixed_quotes_module_singleline_var_2.py:1:1: Q002 [*] Single q 3 3 | def foo(): 4 4 | pass -docstring_singles_mixed_quotes_module_singleline_var_2.py:1:9: Q000 [*] Double quotes found but single quotes preferred +docstring_singles_mixed_quotes_module_singleline_var_2.py:1:36: Q002 [*] Single quote docstring found but double quotes preferred | 1 | 'Do not'" start with empty string" ' and lint docstring safely' - | ^^^^^^^^^^^^^^^^^^^^^^^^^^ Q000 + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Q002 2 | 3 | def foo(): | - = help: Replace double quotes with single quotes + = help: Replace single quotes docstring with double quotes ℹ Safe fix 1 |-'Do not'" start with empty string" ' and lint docstring safely' - 1 |+'Do not'' start with empty string' ' and lint docstring safely' + 1 |+'Do not'" start with empty string" " and lint docstring safely" 2 2 | 3 3 | def foo(): 4 4 | pass