diff --git a/README.md b/README.md index 4a03917ad6..29c348b609 100644 --- a/README.md +++ b/README.md @@ -3500,6 +3500,25 @@ known-third-party = ["src"] --- +#### [`lines-after-imports`](#lines-after-imports) + +The number of blank lines to place after imports. +-1 for automatic determination. + +**Default value**: `-1` + +**Type**: `int` + +**Example usage**: + +```toml +[tool.ruff.isort] +# Use a single line after each import block. +lines-after-imports = 1 +``` + +--- + #### [`no-lines-before`](#no-lines-before) A list of sections that should _not_ be delineated from the previous diff --git a/resources/test/fixtures/isort/lines_after_imports_class_after.py b/resources/test/fixtures/isort/lines_after_imports_class_after.py new file mode 100644 index 0000000000..535e0b9f92 --- /dev/null +++ b/resources/test/fixtures/isort/lines_after_imports_class_after.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import Any + +from requests import Session + +from my_first_party import my_first_party_object + +from . import my_local_folder_object +class Thing(object): + name: str + def __init__(self, name: str): + self.name = name diff --git a/resources/test/fixtures/isort/lines_after_imports_func_after.py b/resources/test/fixtures/isort/lines_after_imports_func_after.py new file mode 100644 index 0000000000..aa1f252d7c --- /dev/null +++ b/resources/test/fixtures/isort/lines_after_imports_func_after.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from typing import Any + +from requests import Session + +from my_first_party import my_first_party_object + +from . import my_local_folder_object + + + + + + + + + + + +def main(): + my_local_folder_object.get() diff --git a/resources/test/fixtures/isort/lines_after_imports_nothing_after.py b/resources/test/fixtures/isort/lines_after_imports_nothing_after.py new file mode 100644 index 0000000000..b4cc530175 --- /dev/null +++ b/resources/test/fixtures/isort/lines_after_imports_nothing_after.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from typing import Any + +from requests import Session + +from my_first_party import my_first_party_object + +from . import my_local_folder_object diff --git a/resources/test/fixtures/isort/pyproject.toml b/resources/test/fixtures/isort/pyproject.toml index 97fda9d62e..f9ec77c97f 100644 --- a/resources/test/fixtures/isort/pyproject.toml +++ b/resources/test/fixtures/isort/pyproject.toml @@ -1,2 +1,5 @@ [tool.ruff] line-length = 88 + +[tool.ruff.isort] +lines-after-imports = 3 diff --git a/ruff.schema.json b/ruff.schema.json index 90404f3784..11755b354b 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -957,6 +957,14 @@ "type": "string" } }, + "lines-after-imports": { + "description": "The number of blank lines to place after imports. -1 for automatic determination.", + "type": [ + "integer", + "null" + ], + "format": "int" + }, "no-lines-before": { "description": "A list of sections that should _not_ be delineated from the previous section via empty lines.", "type": [ diff --git a/src/rules/isort/mod.rs b/src/rules/isort/mod.rs index 50721d753b..256a6279f2 100644 --- a/src/rules/isort/mod.rs +++ b/src/rules/isort/mod.rs @@ -3,11 +3,12 @@ use std::collections::BTreeSet; use std::path::{Path, PathBuf}; +use itertools::Either::{Left, Right}; + use annotate::annotate_imports; use categorize::categorize_imports; pub use categorize::{categorize, ImportType}; use comments::Comment; -use itertools::Either::{Left, Right}; use normalize::normalize_imports; use order::order_imports; use settings::RelativeImportsOrder; @@ -127,6 +128,7 @@ pub fn format_imports( constants: &BTreeSet, variables: &BTreeSet, no_lines_before: &BTreeSet, + lines_after_imports: isize, ) -> String { let trailer = &block.trailer; let block = annotate_imports(&block.imports, comments, locator, split_on_trailing_comma); @@ -214,11 +216,23 @@ pub fn format_imports( match trailer { None => {} Some(Trailer::Sibling) => { - output.push_str(stylist.line_ending()); + if lines_after_imports >= 0 { + for _ in 0..lines_after_imports { + output.push_str(stylist.line_ending()); + } + } else { + output.push_str(stylist.line_ending()); + } } Some(Trailer::FunctionDef | Trailer::ClassDef) => { - output.push_str(stylist.line_ending()); - output.push_str(stylist.line_ending()); + if lines_after_imports >= 0 { + for _ in 0..lines_after_imports { + output.push_str(stylist.line_ending()); + } + } else { + output.push_str(stylist.line_ending()); + output.push_str(stylist.line_ending()); + } } } output @@ -232,13 +246,14 @@ mod tests { use anyhow::Result; use test_case::test_case; - use super::categorize::ImportType; - use super::settings::RelativeImportsOrder; use crate::assert_yaml_snapshot; use crate::registry::Rule; use crate::settings::Settings; use crate::test::{test_path, test_resource_path}; + use super::categorize::ImportType; + use super::settings::RelativeImportsOrder; + #[test_case(Path::new("add_newline_before_comments.py"))] #[test_case(Path::new("combine_as_imports.py"))] #[test_case(Path::new("combine_import_from.py"))] @@ -641,4 +656,25 @@ mod tests { assert_yaml_snapshot!(snapshot, diagnostics); Ok(()) } + + #[test_case(Path::new("lines_after_imports_nothing_after.py"))] + #[test_case(Path::new("lines_after_imports_func_after.py"))] + #[test_case(Path::new("lines_after_imports_class_after.py"))] + fn lines_after_imports(path: &Path) -> Result<()> { + let snapshot = format!("lines_after_imports_{}", path.to_string_lossy()); + let mut diagnostics = test_path( + Path::new("isort").join(path).as_path(), + &Settings { + isort: super::settings::Settings { + lines_after_imports: 3, + ..super::settings::Settings::default() + }, + src: vec![test_resource_path("fixtures/isort")], + ..Settings::for_rule(Rule::UnsortedImports) + }, + )?; + diagnostics.sort_by_key(|diagnostic| diagnostic.location); + assert_yaml_snapshot!(snapshot, diagnostics); + Ok(()) + } } diff --git a/src/rules/isort/rules/organize_imports.rs b/src/rules/isort/rules/organize_imports.rs index 74d2a7e2cf..482f855459 100644 --- a/src/rules/isort/rules/organize_imports.rs +++ b/src/rules/isort/rules/organize_imports.rs @@ -55,6 +55,7 @@ fn matches_ignoring_indentation(val1: &str, val2: &str) -> bool { }) } +#[allow(clippy::cast_sign_loss)] /// I001 pub fn organize_imports( block: &Block, @@ -117,6 +118,7 @@ pub fn organize_imports( &settings.isort.constants, &settings.isort.variables, &settings.isort.no_lines_before, + settings.isort.lines_after_imports, ); // Expand the span the entire range, including leading and trailing space. diff --git a/src/rules/isort/settings.rs b/src/rules/isort/settings.rs index 48efc62cbc..dcf56c6e96 100644 --- a/src/rules/isort/settings.rs +++ b/src/rules/isort/settings.rs @@ -213,6 +213,17 @@ pub struct Options { /// A list of sections that should _not_ be delineated from the previous /// section via empty lines. pub no_lines_before: Option>, + #[option( + default = r#"-1"#, + value_type = "int", + example = r#" + # Use a single line after each import block. + lines-after-imports = 1 + "# + )] + /// The number of blank lines to place after imports. + /// -1 for automatic determination. + pub lines_after_imports: Option, } #[derive(Debug, Hash)] @@ -234,6 +245,7 @@ pub struct Settings { pub constants: BTreeSet, pub variables: BTreeSet, pub no_lines_before: BTreeSet, + pub lines_after_imports: isize, } impl Default for Settings { @@ -255,6 +267,7 @@ impl Default for Settings { constants: BTreeSet::new(), variables: BTreeSet::new(), no_lines_before: BTreeSet::new(), + lines_after_imports: -1, } } } @@ -282,6 +295,7 @@ impl From for Settings { constants: BTreeSet::from_iter(options.constants.unwrap_or_default()), variables: BTreeSet::from_iter(options.variables.unwrap_or_default()), no_lines_before: BTreeSet::from_iter(options.no_lines_before.unwrap_or_default()), + lines_after_imports: options.lines_after_imports.unwrap_or(-1), } } } @@ -305,6 +319,7 @@ impl From for Options { constants: Some(settings.constants.into_iter().collect()), variables: Some(settings.variables.into_iter().collect()), no_lines_before: Some(settings.no_lines_before.into_iter().collect()), + lines_after_imports: Some(settings.lines_after_imports), } } } diff --git a/src/rules/isort/snapshots/ruff__rules__isort__tests__lines_after_imports_lines_after_imports_class_after.py.snap b/src/rules/isort/snapshots/ruff__rules__isort__tests__lines_after_imports_lines_after_imports_class_after.py.snap new file mode 100644 index 0000000000..0fa9e64492 --- /dev/null +++ b/src/rules/isort/snapshots/ruff__rules__isort__tests__lines_after_imports_lines_after_imports_class_after.py.snap @@ -0,0 +1,34 @@ +--- +source: src/rules/isort/mod.rs +expression: diagnostics +--- +- kind: + UnsortedImports: ~ + location: + row: 1 + column: 0 + end_location: + row: 10 + column: 0 + fix: + content: + - from __future__ import annotations + - "" + - from typing import Any + - "" + - from my_first_party import my_first_party_object + - from requests import Session + - "" + - from . import my_local_folder_object + - "" + - "" + - "" + - "" + location: + row: 1 + column: 0 + end_location: + row: 10 + column: 0 + parent: ~ + diff --git a/src/rules/isort/snapshots/ruff__rules__isort__tests__lines_after_imports_lines_after_imports_func_after.py.snap b/src/rules/isort/snapshots/ruff__rules__isort__tests__lines_after_imports_lines_after_imports_func_after.py.snap new file mode 100644 index 0000000000..ff6d5535f2 --- /dev/null +++ b/src/rules/isort/snapshots/ruff__rules__isort__tests__lines_after_imports_lines_after_imports_func_after.py.snap @@ -0,0 +1,34 @@ +--- +source: src/rules/isort/mod.rs +expression: diagnostics +--- +- kind: + UnsortedImports: ~ + location: + row: 1 + column: 0 + end_location: + row: 21 + column: 0 + fix: + content: + - from __future__ import annotations + - "" + - from typing import Any + - "" + - from my_first_party import my_first_party_object + - from requests import Session + - "" + - from . import my_local_folder_object + - "" + - "" + - "" + - "" + location: + row: 1 + column: 0 + end_location: + row: 21 + column: 0 + parent: ~ + diff --git a/src/rules/isort/snapshots/ruff__rules__isort__tests__lines_after_imports_lines_after_imports_nothing_after.py.snap b/src/rules/isort/snapshots/ruff__rules__isort__tests__lines_after_imports_lines_after_imports_nothing_after.py.snap new file mode 100644 index 0000000000..e3faea75f7 --- /dev/null +++ b/src/rules/isort/snapshots/ruff__rules__isort__tests__lines_after_imports_lines_after_imports_nothing_after.py.snap @@ -0,0 +1,31 @@ +--- +source: src/rules/isort/mod.rs +expression: diagnostics +--- +- kind: + UnsortedImports: ~ + location: + row: 1 + column: 0 + end_location: + row: 10 + column: 0 + fix: + content: + - from __future__ import annotations + - "" + - from typing import Any + - "" + - from my_first_party import my_first_party_object + - from requests import Session + - "" + - from . import my_local_folder_object + - "" + location: + row: 1 + column: 0 + end_location: + row: 10 + column: 0 + parent: ~ +