diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_extend_from_shared_config.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_extend_from_shared_config.snap index 9d66be9a0b10a8..ae99f3c8c629f6 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_extend_from_shared_config.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_extend_from_shared_config.snap @@ -208,6 +208,7 @@ linter.isort.classes = [] linter.isort.constants = [] linter.isort.variables = [] linter.isort.no_lines_before = [] +linter.isort.import_headings = {} linter.isort.lines_after_imports = -1 linter.isort.lines_between_types = 0 linter.isort.forced_separate = [] diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool.snap index d417cb1a07e70b..b16352f78a0c31 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool.snap @@ -210,6 +210,7 @@ linter.isort.classes = [] linter.isort.constants = [] linter.isort.variables = [] linter.isort.no_lines_before = [] +linter.isort.import_headings = {} linter.isort.lines_after_imports = -1 linter.isort.lines_between_types = 0 linter.isort.forced_separate = [] diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_preview_enabled.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_preview_enabled.snap index dbb8144c8f583f..3590199c4ddc0c 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_preview_enabled.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_preview_enabled.snap @@ -212,6 +212,7 @@ linter.isort.classes = [] linter.isort.constants = [] linter.isort.variables = [] linter.isort.no_lines_before = [] +linter.isort.import_headings = {} linter.isort.lines_after_imports = -1 linter.isort.lines_between_types = 0 linter.isort.forced_separate = [] diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_target_version_override.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_target_version_override.snap index 6dfab2a36f3e1c..2df88653b10a37 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_target_version_override.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_target_version_override.snap @@ -212,6 +212,7 @@ linter.isort.classes = [] linter.isort.constants = [] linter.isort.variables = [] linter.isort.no_lines_before = [] +linter.isort.import_headings = {} linter.isort.lines_after_imports = -1 linter.isort.lines_between_types = 0 linter.isort.forced_separate = [] diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above.snap index e372f3b7e6d24f..1fd59014275611 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above.snap @@ -209,6 +209,7 @@ linter.isort.classes = [] linter.isort.constants = [] linter.isort.variables = [] linter.isort.no_lines_before = [] +linter.isort.import_headings = {} linter.isort.lines_after_imports = -1 linter.isort.lines_between_types = 0 linter.isort.forced_separate = [] diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above_with_tool.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above_with_tool.snap index 9d7ae54560d442..5a6b99f6701ceb 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above_with_tool.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above_with_tool.snap @@ -210,6 +210,7 @@ linter.isort.classes = [] linter.isort.constants = [] linter.isort.variables = [] linter.isort.no_lines_before = [] +linter.isort.import_headings = {} linter.isort.lines_after_imports = -1 linter.isort.lines_between_types = 0 linter.isort.forced_separate = [] diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above-2.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above-2.snap index 9af331105e6502..6c564974f5e767 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above-2.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above-2.snap @@ -208,6 +208,7 @@ linter.isort.classes = [] linter.isort.constants = [] linter.isort.variables = [] linter.isort.no_lines_before = [] +linter.isort.import_headings = {} linter.isort.lines_after_imports = -1 linter.isort.lines_between_types = 0 linter.isort.forced_separate = [] diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above.snap index d15fba3b5dade4..1496635d67ffa5 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above.snap @@ -208,6 +208,7 @@ linter.isort.classes = [] linter.isort.constants = [] linter.isort.variables = [] linter.isort.no_lines_before = [] +linter.isort.import_headings = {} linter.isort.lines_after_imports = -1 linter.isort.lines_between_types = 0 linter.isort.forced_separate = [] diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_no_target_fallback.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_no_target_fallback.snap index a8d98198546a59..6cac7aebf72791 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_no_target_fallback.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_no_target_fallback.snap @@ -208,6 +208,7 @@ linter.isort.classes = [] linter.isort.constants = [] linter.isort.variables = [] linter.isort.no_lines_before = [] +linter.isort.import_headings = {} linter.isort.lines_after_imports = -1 linter.isort.lines_between_types = 0 linter.isort.forced_separate = [] diff --git a/crates/ruff/tests/snapshots/show_settings__display_default_settings.snap b/crates/ruff/tests/snapshots/show_settings__display_default_settings.snap index ec36549bcda06c..2371d9050f45f3 100644 --- a/crates/ruff/tests/snapshots/show_settings__display_default_settings.snap +++ b/crates/ruff/tests/snapshots/show_settings__display_default_settings.snap @@ -321,6 +321,7 @@ linter.isort.classes = [] linter.isort.constants = [] linter.isort.variables = [] linter.isort.no_lines_before = [] +linter.isort.import_headings = {} linter.isort.lines_after_imports = -1 linter.isort.lines_between_types = 0 linter.isort.forced_separate = [] diff --git a/crates/ruff/tests/snapshots/show_settings__display_settings_from_nested_directory.snap b/crates/ruff/tests/snapshots/show_settings__display_settings_from_nested_directory.snap index 1141adb331863d..2c6025ccaea525 100644 --- a/crates/ruff/tests/snapshots/show_settings__display_settings_from_nested_directory.snap +++ b/crates/ruff/tests/snapshots/show_settings__display_settings_from_nested_directory.snap @@ -329,6 +329,7 @@ linter.isort.classes = [] linter.isort.constants = [] linter.isort.variables = [] linter.isort.no_lines_before = [] +linter.isort.import_headings = {} linter.isort.lines_after_imports = -1 linter.isort.lines_between_types = 0 linter.isort.forced_separate = [] diff --git a/crates/ruff_linter/resources/test/fixtures/isort/import_heading.py b/crates/ruff_linter/resources/test/fixtures/isort/import_heading.py new file mode 100644 index 00000000000000..ece0b4b99cb659 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/isort/import_heading.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +import os +import sys + +import requests +import pandas + +from my_first_party import my_first_party_object + +from . import my_local_folder_object diff --git a/crates/ruff_linter/resources/test/fixtures/isort/import_heading_already_correct.py b/crates/ruff_linter/resources/test/fixtures/isort/import_heading_already_correct.py new file mode 100644 index 00000000000000..27cba5e0a4a361 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/isort/import_heading_already_correct.py @@ -0,0 +1,16 @@ +# Future imports +from __future__ import annotations + +# Standard library imports +import os +import sys + +# Third party imports +import pandas +import requests + +# First party imports +from my_first_party import my_first_party_object + +# Local folder imports +from . import my_local_folder_object diff --git a/crates/ruff_linter/resources/test/fixtures/isort/import_heading_already_present.py b/crates/ruff_linter/resources/test/fixtures/isort/import_heading_already_present.py new file mode 100644 index 00000000000000..faa8b87be73190 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/isort/import_heading_already_present.py @@ -0,0 +1,15 @@ +# Future imports +from __future__ import annotations + +# Standard library imports +import os +import sys + +# Third party imports +import requests +import pandas + +# First party imports +from my_first_party import my_first_party_object + +from . import my_local_folder_object diff --git a/crates/ruff_linter/resources/test/fixtures/isort/import_heading_duplicate.py b/crates/ruff_linter/resources/test/fixtures/isort/import_heading_duplicate.py new file mode 100644 index 00000000000000..c870e8d3c17d22 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/isort/import_heading_duplicate.py @@ -0,0 +1,7 @@ +# Standard library imports +# Standard library imports +import os +import sys + +import requests +import pandas diff --git a/crates/ruff_linter/resources/test/fixtures/isort/import_heading_force_sort_within_sections.py b/crates/ruff_linter/resources/test/fixtures/isort/import_heading_force_sort_within_sections.py new file mode 100644 index 00000000000000..9887350b1720b7 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/isort/import_heading_force_sort_within_sections.py @@ -0,0 +1,5 @@ +from __future__ import annotations +from typing import Any + +import requests +import pandas diff --git a/crates/ruff_linter/resources/test/fixtures/isort/import_heading_partial.py b/crates/ruff_linter/resources/test/fixtures/isort/import_heading_partial.py new file mode 100644 index 00000000000000..8866a627dd65f9 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/isort/import_heading_partial.py @@ -0,0 +1,5 @@ +import os +import sys + +import requests +import pandas diff --git a/crates/ruff_linter/resources/test/fixtures/isort/import_heading_single_section.py b/crates/ruff_linter/resources/test/fixtures/isort/import_heading_single_section.py new file mode 100644 index 00000000000000..149b10ef5f13ba --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/isort/import_heading_single_section.py @@ -0,0 +1,2 @@ +import requests +import pandas diff --git a/crates/ruff_linter/resources/test/fixtures/isort/import_heading_unsorted.py b/crates/ruff_linter/resources/test/fixtures/isort/import_heading_unsorted.py new file mode 100644 index 00000000000000..49ad885bd6d9c1 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/isort/import_heading_unsorted.py @@ -0,0 +1,7 @@ +import pandas +import os +from __future__ import annotations +import sys +import requests +from my_first_party import my_first_party_object +from . import my_local_folder_object diff --git a/crates/ruff_linter/resources/test/fixtures/isort/import_heading_with_no_lines_before.py b/crates/ruff_linter/resources/test/fixtures/isort/import_heading_with_no_lines_before.py new file mode 100644 index 00000000000000..ece0b4b99cb659 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/isort/import_heading_with_no_lines_before.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +import os +import sys + +import requests +import pandas + +from my_first_party import my_first_party_object + +from . import my_local_folder_object diff --git a/crates/ruff_linter/resources/test/fixtures/isort/import_heading_wrong_heading.py b/crates/ruff_linter/resources/test/fixtures/isort/import_heading_wrong_heading.py new file mode 100644 index 00000000000000..59c81407b2158e --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/isort/import_heading_wrong_heading.py @@ -0,0 +1,9 @@ +# Wrong heading +import os +import sys + +# Also wrong heading +import pandas +import requests + +from my_first_party import my_first_party_object diff --git a/crates/ruff_linter/src/rules/isort/mod.rs b/crates/ruff_linter/src/rules/isort/mod.rs index abbff742d43972..76cb8314004f6d 100644 --- a/crates/ruff_linter/src/rules/isort/mod.rs +++ b/crates/ruff_linter/src/rules/isort/mod.rs @@ -196,7 +196,26 @@ fn format_import_block( continue; }; - let imports = order_imports(import_block, import_section, settings); + let mut imports = order_imports(import_block, import_section, settings); + + // Strip any heading comments from all imports in the section, + // as they will be re-added in the correct position. + // Heading comments may be on any import (not just the first) since + // sorting may have reordered them. + if !settings.import_headings.is_empty() { + for import in &mut imports { + let atop = match import { + Import((_, comments)) => &mut comments.atop, + ImportFrom((_, comments, _, _)) => &mut comments.atop, + }; + atop.retain(|comment| { + !settings + .import_headings + .values() + .any(|heading| comment == heading) + }); + } + } // Add a blank line between every section. if is_first_block { @@ -207,6 +226,12 @@ fn format_import_block( pending_lines_before = false; } + // Insert heading comment for this section, if configured. + if let Some(heading) = settings.import_headings.get(import_section) { + output.push_str(heading); + output.push_str(&stylist.line_ending()); + } + let mut line_insertion = None; let mut is_first_statement = true; let lines_between_types = settings.lines_between_types; @@ -1200,6 +1225,198 @@ mod tests { Ok(()) } + /// Helper to create a standard `import_headings` map for all sections. + fn all_section_headings() -> FxHashMap { + FxHashMap::from_iter([ + ( + ImportSection::Known(ImportType::Future), + "# Future imports".to_string(), + ), + ( + ImportSection::Known(ImportType::StandardLibrary), + "# Standard library imports".to_string(), + ), + ( + ImportSection::Known(ImportType::ThirdParty), + "# Third party imports".to_string(), + ), + ( + ImportSection::Known(ImportType::FirstParty), + "# First party imports".to_string(), + ), + ( + ImportSection::Known(ImportType::LocalFolder), + "# Local folder imports".to_string(), + ), + ]) + } + + /// Helper to create a standard isort Settings with all section headings + /// and `known_modules` configured for `my_first_party`. + fn isort_settings_with_all_headings() -> super::settings::Settings { + super::settings::Settings { + import_headings: all_section_headings(), + known_modules: KnownModules::new( + vec![pattern("my_first_party")], + vec![], + vec![], + vec![], + FxHashMap::default(), + ), + ..super::settings::Settings::default() + } + } + + #[test_case(Path::new("import_heading.py"))] + #[test_case(Path::new("import_heading_already_present.py"))] + #[test_case(Path::new("import_heading_unsorted.py"))] + #[test_case(Path::new("import_heading_already_correct.py"))] + #[test_case(Path::new("import_heading_duplicate.py"))] + fn import_heading(path: &Path) -> Result<()> { + let snapshot = format!("import_heading_{}", path.to_string_lossy()); + let diagnostics = test_path( + Path::new("isort").join(path).as_path(), + &LinterSettings { + isort: isort_settings_with_all_headings(), + src: vec![test_resource_path("fixtures/isort")], + ..LinterSettings::for_rule(Rule::UnsortedImports) + }, + )?; + assert_diagnostics!(snapshot, diagnostics); + Ok(()) + } + + #[test_case(Path::new("import_heading_with_no_lines_before.py"))] + fn import_heading_with_no_lines_before(path: &Path) -> Result<()> { + let snapshot = format!( + "import_heading_with_no_lines_before_{}", + path.to_string_lossy() + ); + let diagnostics = test_path( + Path::new("isort").join(path).as_path(), + &LinterSettings { + isort: super::settings::Settings { + no_lines_before: FxHashSet::from_iter([ImportSection::Known( + ImportType::LocalFolder, + )]), + ..isort_settings_with_all_headings() + }, + src: vec![test_resource_path("fixtures/isort")], + ..LinterSettings::for_rule(Rule::UnsortedImports) + }, + )?; + assert_diagnostics!(snapshot, diagnostics); + Ok(()) + } + + #[test_case(Path::new("import_heading_partial.py"))] + fn import_heading_partial(path: &Path) -> Result<()> { + let snapshot = format!("import_heading_partial_{}", path.to_string_lossy()); + let diagnostics = test_path( + Path::new("isort").join(path).as_path(), + &LinterSettings { + isort: super::settings::Settings { + import_headings: FxHashMap::from_iter([ + ( + ImportSection::Known(ImportType::StandardLibrary), + "# Standard library imports".to_string(), + ), + ( + ImportSection::Known(ImportType::ThirdParty), + "# Third party imports".to_string(), + ), + ]), + ..super::settings::Settings::default() + }, + src: vec![test_resource_path("fixtures/isort")], + ..LinterSettings::for_rule(Rule::UnsortedImports) + }, + )?; + assert_diagnostics!(snapshot, diagnostics); + Ok(()) + } + + #[test_case(Path::new("import_heading_wrong_heading.py"))] + fn import_heading_wrong_heading(path: &Path) -> Result<()> { + let snapshot = format!("import_heading_wrong_heading_{}", path.to_string_lossy()); + let diagnostics = test_path( + Path::new("isort").join(path).as_path(), + &LinterSettings { + isort: super::settings::Settings { + import_headings: FxHashMap::from_iter([ + ( + ImportSection::Known(ImportType::StandardLibrary), + "# Standard library imports".to_string(), + ), + ( + ImportSection::Known(ImportType::ThirdParty), + "# Third party imports".to_string(), + ), + ( + ImportSection::Known(ImportType::FirstParty), + "# First party imports".to_string(), + ), + ]), + known_modules: KnownModules::new( + vec![pattern("my_first_party")], + vec![], + vec![], + vec![], + FxHashMap::default(), + ), + ..super::settings::Settings::default() + }, + src: vec![test_resource_path("fixtures/isort")], + ..LinterSettings::for_rule(Rule::UnsortedImports) + }, + )?; + assert_diagnostics!(snapshot, diagnostics); + Ok(()) + } + + #[test_case(Path::new("import_heading_single_section.py"))] + fn import_heading_single_section(path: &Path) -> Result<()> { + let snapshot = format!("import_heading_single_section_{}", path.to_string_lossy()); + let diagnostics = test_path( + Path::new("isort").join(path).as_path(), + &LinterSettings { + isort: super::settings::Settings { + import_headings: FxHashMap::from_iter([( + ImportSection::Known(ImportType::ThirdParty), + "# Third party imports".to_string(), + )]), + ..super::settings::Settings::default() + }, + src: vec![test_resource_path("fixtures/isort")], + ..LinterSettings::for_rule(Rule::UnsortedImports) + }, + )?; + assert_diagnostics!(snapshot, diagnostics); + Ok(()) + } + + /// Test heading with `force_sort_within_sections`. + #[test_case(Path::new("import_heading_force_sort_within_sections.py"))] + fn import_heading_force_sort_within_sections(path: &Path) -> Result<()> { + let snapshot = format!( + "import_heading_force_sort_within_sections_{}", + path.to_string_lossy() + ); + let diagnostics = test_path( + Path::new("isort").join(path).as_path(), + &LinterSettings { + isort: super::settings::Settings { + force_sort_within_sections: true, + ..isort_settings_with_all_headings() + }, + src: vec![test_resource_path("fixtures/isort")], + ..LinterSettings::for_rule(Rule::UnsortedImports) + }, + )?; + assert_diagnostics!(snapshot, diagnostics); + Ok(()) + } + #[test_case(Path::new("lines_after_imports_nothing_after.py"))] #[test_case(Path::new("lines_after_imports.pyi"))] #[test_case(Path::new("lines_after_imports_func_after.py"))] diff --git a/crates/ruff_linter/src/rules/isort/rules/organize_imports.rs b/crates/ruff_linter/src/rules/isort/rules/organize_imports.rs index 2071555c8b58fe..43c0ed9b9014a3 100644 --- a/crates/ruff_linter/src/rules/isort/rules/organize_imports.rs +++ b/crates/ruff_linter/src/rules/isort/rules/organize_imports.rs @@ -110,8 +110,42 @@ pub(crate) fn organize_imports( } // Extract comments. Take care to grab any inline comments from the last line. + // Also extend the start backward to include any import heading comments above + // the first import, so they're collected and can be stripped/re-added correctly. + let import_headings = &settings.isort.import_headings; + let (comment_start, fix_start) = if import_headings.is_empty() { + // Preserve original behavior: comments from import start, fix range from line start. + (range.start(), locator.line_start(range.start())) + } else { + // Heading comments are already formatted as "# {heading}" in settings. + // Walk backward through comment ranges to find adjacent heading comments above the first import. + let comment_ranges: &[TextRange] = indexer.comment_ranges(); + let import_line_start = locator.line_start(range.start()); + let partition = + comment_ranges.partition_point(|comment| comment.start() < import_line_start); + + let mut earliest = import_line_start; + for comment_range in comment_ranges[..partition].iter().rev() { + // The comment's line must end right where 'earliest' starts (adjacent). + if locator.full_line_end(comment_range.end()) != earliest { + break; + } + + let comment_text = locator.slice(*comment_range); + if import_headings + .values() + .any(|header| comment_text == header.as_str()) + { + earliest = locator.line_start(comment_range.start()); + } else { + break; + } + } + (earliest, earliest) + }; + let comments = comments::collect_comments( - TextRange::new(range.start(), locator.full_line_end(range.end())), + TextRange::new(comment_start, locator.full_line_end(range.end())), locator, indexer.comment_ranges(), ); @@ -139,7 +173,7 @@ pub(crate) fn organize_imports( ); // Expand the span the entire range, including leading and trailing space. - let fix_range = TextRange::new(locator.line_start(range.start()), trailing_line_end); + let fix_range = TextRange::new(fix_start, trailing_line_end); let actual = locator.slice(fix_range); if matches_ignoring_indentation(actual, &expected) { return; diff --git a/crates/ruff_linter/src/rules/isort/settings.rs b/crates/ruff_linter/src/rules/isort/settings.rs index cab9ab35ed166e..ced2dfdcb7b34c 100644 --- a/crates/ruff_linter/src/rules/isort/settings.rs +++ b/crates/ruff_linter/src/rules/isort/settings.rs @@ -5,7 +5,7 @@ use std::error::Error; use std::fmt; use std::fmt::{Display, Formatter}; -use rustc_hash::FxHashSet; +use rustc_hash::{FxHashMap, FxHashSet}; use serde::{Deserialize, Serialize}; use strum::IntoEnumIterator; @@ -60,6 +60,7 @@ pub struct Settings { pub constants: FxHashSet, pub variables: FxHashSet, pub no_lines_before: FxHashSet, + pub import_headings: FxHashMap, pub lines_after_imports: isize, pub lines_between_types: usize, pub forced_separate: Vec, @@ -114,6 +115,7 @@ impl Default for Settings { constants: FxHashSet::default(), variables: FxHashSet::default(), no_lines_before: FxHashSet::default(), + import_headings: FxHashMap::default(), lines_after_imports: -1, lines_between_types: 0, forced_separate: Vec::new(), @@ -150,6 +152,7 @@ impl Display for Settings { self.constants | set, self.variables | set, self.no_lines_before | set, + self.import_headings | map, self.lines_after_imports, self.lines_between_types, self.forced_separate | array, diff --git a/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_force_sort_within_sections_import_heading_force_sort_within_sections.py.snap b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_force_sort_within_sections_import_heading_force_sort_within_sections.py.snap new file mode 100644 index 00000000000000..bb67a64156be30 --- /dev/null +++ b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_force_sort_within_sections_import_heading_force_sort_within_sections.py.snap @@ -0,0 +1,24 @@ +--- +source: crates/ruff_linter/src/rules/isort/mod.rs +--- +I001 [*] Import block is un-sorted or un-formatted + --> import_heading_force_sort_within_sections.py:1:1 + | +1 | / from __future__ import annotations +2 | | from typing import Any +3 | | +4 | | import requests +5 | | import pandas + | |_____________^ + | +help: Organize imports +1 + # Future imports +2 | from __future__ import annotations +3 + +4 + # Standard library imports +5 | from typing import Any +6 | + - import requests +7 + # Third party imports +8 | import pandas +9 + import requests diff --git a/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_import_heading.py.snap b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_import_heading.py.snap new file mode 100644 index 00000000000000..5df6b80e58d746 --- /dev/null +++ b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_import_heading.py.snap @@ -0,0 +1,37 @@ +--- +source: crates/ruff_linter/src/rules/isort/mod.rs +--- +I001 [*] Import block is un-sorted or un-formatted + --> import_heading.py:1:1 + | + 1 | / from __future__ import annotations + 2 | | + 3 | | import os + 4 | | import sys + 5 | | + 6 | | import requests + 7 | | import pandas + 8 | | + 9 | | from my_first_party import my_first_party_object +10 | | +11 | | from . import my_local_folder_object + | |____________________________________^ + | +help: Organize imports +1 + # Future imports +2 | from __future__ import annotations +3 | +4 + # Standard library imports +5 | import os +6 | import sys +7 | + - import requests +8 + # Third party imports +9 | import pandas +10 + import requests +11 | +12 + # First party imports +13 | from my_first_party import my_first_party_object +14 | +15 + # Local folder imports +16 | from . import my_local_folder_object diff --git a/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_import_heading_already_correct.py.snap b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_import_heading_already_correct.py.snap new file mode 100644 index 00000000000000..ed369f0fd61f02 --- /dev/null +++ b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_import_heading_already_correct.py.snap @@ -0,0 +1,4 @@ +--- +source: crates/ruff_linter/src/rules/isort/mod.rs +--- + diff --git a/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_import_heading_already_present.py.snap b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_import_heading_already_present.py.snap new file mode 100644 index 00000000000000..19d135acb65659 --- /dev/null +++ b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_import_heading_already_present.py.snap @@ -0,0 +1,37 @@ +--- +source: crates/ruff_linter/src/rules/isort/mod.rs +assertion_line: 1284 +--- +I001 [*] Import block is un-sorted or un-formatted + --> import_heading_already_present.py:2:1 + | + 1 | # Future imports + 2 | / from __future__ import annotations + 3 | | + 4 | | # Standard library imports + 5 | | import os + 6 | | import sys + 7 | | + 8 | | # Third party imports + 9 | | import requests +10 | | import pandas +11 | | +12 | | # First party imports +13 | | from my_first_party import my_first_party_object +14 | | +15 | | from . import my_local_folder_object + | |____________________________________^ + | +help: Organize imports +6 | import sys +7 | +8 | # Third party imports +9 + import pandas +10 | import requests + - import pandas +11 | +12 | # First party imports +13 | from my_first_party import my_first_party_object +14 | +15 + # Local folder imports +16 | from . import my_local_folder_object diff --git a/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_import_heading_duplicate.py.snap b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_import_heading_duplicate.py.snap new file mode 100644 index 00000000000000..50247617162c71 --- /dev/null +++ b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_import_heading_duplicate.py.snap @@ -0,0 +1,25 @@ +--- +source: crates/ruff_linter/src/rules/isort/mod.rs +--- +I001 [*] Import block is un-sorted or un-formatted + --> import_heading_duplicate.py:3:1 + | +1 | # Standard library imports +2 | # Standard library imports +3 | / import os +4 | | import sys +5 | | +6 | | import requests +7 | | import pandas + | |_____________^ + | +help: Organize imports +1 | # Standard library imports + - # Standard library imports +2 | import os +3 | import sys +4 | +5 + # Third party imports +6 + import pandas +7 | import requests + - import pandas diff --git a/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_import_heading_unsorted.py.snap b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_import_heading_unsorted.py.snap new file mode 100644 index 00000000000000..fd3b649b1a3b0f --- /dev/null +++ b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_import_heading_unsorted.py.snap @@ -0,0 +1,34 @@ +--- +source: crates/ruff_linter/src/rules/isort/mod.rs +--- +I001 [*] Import block is un-sorted or un-formatted + --> import_heading_unsorted.py:1:1 + | +1 | / import pandas +2 | | import os +3 | | from __future__ import annotations +4 | | import sys +5 | | import requests +6 | | from my_first_party import my_first_party_object +7 | | from . import my_local_folder_object + | |____________________________________^ + | +help: Organize imports + - import pandas + - import os +1 + # Future imports +2 | from __future__ import annotations +3 + +4 + # Standard library imports +5 + import os +6 | import sys +7 + +8 + # Third party imports +9 + import pandas +10 | import requests +11 + +12 + # First party imports +13 | from my_first_party import my_first_party_object +14 + +15 + # Local folder imports +16 | from . import my_local_folder_object diff --git a/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_partial_import_heading_partial.py.snap b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_partial_import_heading_partial.py.snap new file mode 100644 index 00000000000000..a04652f972fea5 --- /dev/null +++ b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_partial_import_heading_partial.py.snap @@ -0,0 +1,22 @@ +--- +source: crates/ruff_linter/src/rules/isort/mod.rs +--- +I001 [*] Import block is un-sorted or un-formatted + --> import_heading_partial.py:1:1 + | +1 | / import os +2 | | import sys +3 | | +4 | | import requests +5 | | import pandas + | |_____________^ + | +help: Organize imports +1 + # Standard library imports +2 | import os +3 | import sys +4 | +5 + # Third party imports +6 + import pandas +7 | import requests + - import pandas diff --git a/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_single_section_import_heading_single_section.py.snap b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_single_section_import_heading_single_section.py.snap new file mode 100644 index 00000000000000..6710dcdd4efe7b --- /dev/null +++ b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_single_section_import_heading_single_section.py.snap @@ -0,0 +1,15 @@ +--- +source: crates/ruff_linter/src/rules/isort/mod.rs +--- +I001 [*] Import block is un-sorted or un-formatted + --> import_heading_single_section.py:1:1 + | +1 | / import requests +2 | | import pandas + | |_____________^ + | +help: Organize imports + - import requests +1 + # Third party imports +2 | import pandas +3 + import requests diff --git a/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_with_no_lines_before_import_heading_with_no_lines_before.py.snap b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_with_no_lines_before_import_heading_with_no_lines_before.py.snap new file mode 100644 index 00000000000000..297319792a2b6d --- /dev/null +++ b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_with_no_lines_before_import_heading_with_no_lines_before.py.snap @@ -0,0 +1,37 @@ +--- +source: crates/ruff_linter/src/rules/isort/mod.rs +--- +I001 [*] Import block is un-sorted or un-formatted + --> import_heading_with_no_lines_before.py:1:1 + | + 1 | / from __future__ import annotations + 2 | | + 3 | | import os + 4 | | import sys + 5 | | + 6 | | import requests + 7 | | import pandas + 8 | | + 9 | | from my_first_party import my_first_party_object +10 | | +11 | | from . import my_local_folder_object + | |____________________________________^ + | +help: Organize imports +1 + # Future imports +2 | from __future__ import annotations +3 | +4 + # Standard library imports +5 | import os +6 | import sys +7 | +8 + # Third party imports +9 + import pandas +10 | import requests + - import pandas +11 | +12 + # First party imports +13 | from my_first_party import my_first_party_object + - +14 + # Local folder imports +15 | from . import my_local_folder_object diff --git a/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_wrong_heading_import_heading_wrong_heading.py.snap b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_wrong_heading_import_heading_wrong_heading.py.snap new file mode 100644 index 00000000000000..86dc06b3e61a8b --- /dev/null +++ b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__import_heading_wrong_heading_import_heading_wrong_heading.py.snap @@ -0,0 +1,30 @@ +--- +source: crates/ruff_linter/src/rules/isort/mod.rs +--- +I001 [*] Import block is un-sorted or un-formatted + --> import_heading_wrong_heading.py:2:1 + | +1 | # Wrong heading +2 | / import os +3 | | import sys +4 | | +5 | | # Also wrong heading +6 | | import pandas +7 | | import requests +8 | | +9 | | from my_first_party import my_first_party_object + | |________________________________________________^ + | +help: Organize imports +1 | # Wrong heading +2 + # Standard library imports +3 | import os +4 | import sys +5 | +6 + # Third party imports +7 | # Also wrong heading +8 | import pandas +9 | import requests +10 | +11 + # First party imports +12 | from my_first_party import my_first_party_object diff --git a/crates/ruff_workspace/src/options.rs b/crates/ruff_workspace/src/options.rs index 7ef20e620c1010..07011ad66026c4 100644 --- a/crates/ruff_workspace/src/options.rs +++ b/crates/ruff_workspace/src/options.rs @@ -2497,6 +2497,26 @@ pub struct IsortOptions { )] pub no_lines_before: Option>, + /// A mapping from import section names to their heading comments. + /// + /// When set, a comment with the specified text will be added above imports + /// in the corresponding section. If a heading comment already exists, it + /// will be replaced. + /// + /// Compatible with isort's `import_heading_{section_name}` settings. + #[option( + default = r#"{}"#, + value_type = r#"dict["future" | "standard-library" | "third-party" | "first-party" | "local-folder" | str, str]"#, + example = r#" + future = "Future imports" + standard-library = "Standard library imports" + third-party = "Third party imports" + first-party = "First party imports" + local-folder = "Local folder imports" + "# + )] + pub import_heading: Option>, + /// The number of blank lines to place after imports. /// Use `-1` for automatic determination. /// @@ -2843,6 +2863,17 @@ impl IsortOptions { } } + let import_heading = self.import_heading.unwrap_or_default(); + + // Verify that all sections listed in `import_heading` are defined in `sections`. + for section in import_heading.keys() { + if let ImportSection::UserDefined(section_name) = section { + if !sections.contains_key(section_name) { + warn_user_once!("`import-heading` contains unknown section: `{:?}`", section,); + } + } + } + // Verify that `default_section` is in `section_order`. if !section_order.contains(&default_section) { warn_user_once!( @@ -2883,6 +2914,10 @@ impl IsortOptions { constants: FxHashSet::from_iter(self.constants.unwrap_or_default()), variables: FxHashSet::from_iter(self.variables.unwrap_or_default()), no_lines_before: FxHashSet::from_iter(no_lines_before), + import_headings: import_heading + .into_iter() + .map(|(section, heading)| (section, format!("# {heading}"))) + .collect(), lines_after_imports: self.lines_after_imports.unwrap_or(-1), lines_between_types, forced_separate: Vec::from_iter(self.forced_separate.unwrap_or_default()), diff --git a/ruff.schema.json b/ruff.schema.json index afc3fd07fb8f3e..1de42c71a9ba7e 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -1758,6 +1758,33 @@ "null" ] }, + "import-heading": { + "description": "A mapping from import section names to their heading comments.\n\nWhen set, a comment with the specified text will be added above imports\nin the corresponding section. If a heading comment already exists, it\nwill be replaced.\n\nCompatible with isort's `import_heading_{section_name}` settings.", + "type": [ + "object", + "null" + ], + "properties": { + "first-party": { + "type": "string" + }, + "future": { + "type": "string" + }, + "local-folder": { + "type": "string" + }, + "standard-library": { + "type": "string" + }, + "third-party": { + "type": "string" + } + }, + "additionalProperties": { + "type": "string" + } + }, "known-first-party": { "description": "A list of modules to consider first-party, regardless of whether they\ncan be identified as such via introspection of the local filesystem.\n\nSupports glob patterns. For more information on the glob syntax, refer\nto the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", "type": [