From 534d8d049cd349da66246aafd149eaf54967005f Mon Sep 17 00:00:00 2001 From: Reiner Gerecke Date: Tue, 27 Dec 2022 14:51:32 +0100 Subject: [PATCH] Support isort's force-single-line option (#1366) --- README.md | 34 ++++++++ playground/src/ruff_options.ts | 10 +++ pyproject.toml | 2 + .../test/fixtures/isort/force_single_line.py | 18 ++++ ruff.schema.json | 17 ++++ src/isort/mod.rs | 82 ++++++++++++++++++- src/isort/plugins.rs | 2 + src/isort/settings.rs | 25 ++++++ ...orce_single_line_force_single_line.py.snap | 20 +++++ src/isort/types.rs | 4 +- 10 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 resources/test/fixtures/isort/force_single_line.py create mode 100644 src/isort/snapshots/ruff__isort__tests__force_single_line_force_single_line.py.snap diff --git a/README.md b/README.md index f66e818eb9..cfbc73a03e 100644 --- a/README.md +++ b/README.md @@ -2468,6 +2468,23 @@ extra-standard-library = ["path"] --- +#### [`force-single-line`](#force-single-line) + +Forces all from imports to appear on their own line. + +**Default value**: `false` + +**Type**: `bool` + +**Example usage**: + +```toml +[tool.ruff.isort] +force-single-line = true +``` + +--- + #### [`force-wrap-aliases`](#force-wrap-aliases) Force `import from` statements with multiple members and at least one @@ -2537,6 +2554,23 @@ known-third-party = ["src"] --- +#### [`single-line-exclusions`](#single-line-exclusions) + +One or more modules to exclude from the single line rule. + +**Default value**: `[]` + +**Type**: `Vec` + +**Example usage**: + +```toml +[tool.ruff.isort] +single-line-exclusions = ["os", "json"] +``` + +--- + #### [`split-on-trailing-comma`](#split-on-trailing-comma) If a comma is placed after the last member in a multi-line import, then diff --git a/playground/src/ruff_options.ts b/playground/src/ruff_options.ts index 4f66dab44c..8c2d663e0e 100644 --- a/playground/src/ruff_options.ts +++ b/playground/src/ruff_options.ts @@ -167,6 +167,11 @@ export const AVAILABLE_OPTIONS: OptionGroup[] = [ "default": '[]', "type": 'Vec', }, + { + "name": "force-single-line", + "default": 'false', + "type": 'bool', + }, { "name": "force-wrap-aliases", "default": 'false', @@ -182,6 +187,11 @@ export const AVAILABLE_OPTIONS: OptionGroup[] = [ "default": '[]', "type": 'Vec', }, + { + "name": "single-line-exclusions", + "default": '[]', + "type": 'Vec', + }, { "name": "split-on-trailing-comma", "default": 'true', diff --git a/pyproject.toml b/pyproject.toml index cd69b1abe8..989cb757f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,3 +38,5 @@ strip = true [tool.ruff.isort] force-wrap-aliases = true combine-as-imports = true +force-single-line = true +single-line-exclusions = ["os", "logging.handlers"] diff --git a/resources/test/fixtures/isort/force_single_line.py b/resources/test/fixtures/isort/force_single_line.py new file mode 100644 index 0000000000..3aadd3e436 --- /dev/null +++ b/resources/test/fixtures/isort/force_single_line.py @@ -0,0 +1,18 @@ +import sys, math +from os import path, uname +from logging.handlers import StreamHandler, FileHandler + +# comment 1 +from third_party import lib1, lib2, \ + lib3, lib7, lib5, lib6 +# comment 2 +from third_party import lib4 + +from foo import bar # comment 3 +from foo2 import bar2 # comment 4 + +# comment 5 +from bar import ( + a, # comment 6 + b, # comment 7 +) \ No newline at end of file diff --git a/ruff.schema.json b/ruff.schema.json index e81c1ee098..018b60fa16 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -1080,6 +1080,13 @@ "type": "string" } }, + "force-single-line": { + "description": "Forces all from imports to appear on their own line.", + "type": [ + "boolean", + "null" + ] + }, "force-wrap-aliases": { "description": "Force `import from` statements with multiple members and at least one alias (e.g., `import A as B`) to wrap such that every line contains exactly one member. For example, this formatting would be retained, rather than condensing to a single line:\n\n```py from .utils import ( test_directory as test_directory, test_id as test_id ) ```\n\nNote that this setting is only effective when combined with `combine-as-imports = true`. When `combine-as-imports` isn't enabled, every aliased `import from` will be given its own line, in which case, wrapping is not necessary.", "type": [ @@ -1107,6 +1114,16 @@ "type": "string" } }, + "single-line-exclusions": { + "description": "One or more modules to exclude from the single line rule.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, "split-on-trailing-comma": { "description": "If a comma is placed after the last member in a multi-line import, then the imports will never be folded into one line.\n\nSee isort's [`split-on-trailing-comma`](https://pycqa.github.io/isort/docs/configuration/options.html#split-on-trailing-comma) option.", "type": [ diff --git a/src/isort/mod.rs b/src/isort/mod.rs index 92a737a766..8b76ba1557 100644 --- a/src/isort/mod.rs +++ b/src/isort/mod.rs @@ -2,6 +2,7 @@ use std::cmp::Ordering; use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; +use itertools::Either::{Left, Right}; use itertools::Itertools; use ropey::RopeBuilder; use rustc_hash::FxHashMap; @@ -495,7 +496,54 @@ fn sort_imports(block: ImportBlock) -> OrderedImportBlock { ordered } -#[allow(clippy::too_many_arguments)] +fn force_single_line_imports<'a>( + block: OrderedImportBlock<'a>, + single_line_exclusions: &BTreeSet, +) -> OrderedImportBlock<'a> { + OrderedImportBlock { + import: block.import, + import_from: block + .import_from + .into_iter() + .flat_map(|(from_data, comment_set, trailing_comma, alias_data)| { + if from_data + .module + .map_or(false, |module| single_line_exclusions.contains(module)) + { + Left(std::iter::once(( + from_data, + comment_set, + trailing_comma, + alias_data, + ))) + } else { + Right( + alias_data + .into_iter() + .enumerate() + .map(move |(index, alias_data)| { + ( + from_data.clone(), + if index == 0 { + comment_set.clone() + } else { + CommentSet { + atop: vec![], + inline: vec![], + } + }, + TrailingComma::Absent, + vec![alias_data], + ) + }), + ) + } + }) + .collect(), + } +} + +#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)] pub fn format_imports( block: &Block, comments: Vec, @@ -509,6 +557,8 @@ pub fn format_imports( combine_as_imports: bool, force_wrap_aliases: bool, split_on_trailing_comma: bool, + force_single_line: bool, + single_line_exclusions: &BTreeSet, ) -> String { let trailer = &block.trailer; let block = annotate_imports(&block.imports, comments, locator, split_on_trailing_comma); @@ -531,7 +581,10 @@ pub fn format_imports( // Generate replacement source code. let mut is_first_block = true; for import_block in block_by_type.into_values() { - let import_block = sort_imports(import_block); + let mut import_block = sort_imports(import_block); + if force_single_line { + import_block = force_single_line_imports(import_block, single_line_exclusions); + } // Add a blank line between every section. if is_first_block { @@ -577,6 +630,7 @@ pub fn format_imports( #[cfg(test)] mod tests { + use std::collections::BTreeSet; use std::path::Path; use anyhow::Result; @@ -697,4 +751,28 @@ mod tests { insta::assert_yaml_snapshot!(snapshot, checks); Ok(()) } + + #[test_case(Path::new("force_single_line.py"))] + fn force_single_line(path: &Path) -> Result<()> { + let snapshot = format!("force_single_line_{}", path.to_string_lossy()); + let mut checks = test_path( + Path::new("./resources/test/fixtures/isort") + .join(path) + .as_path(), + &Settings { + isort: isort::settings::Settings { + force_single_line: true, + single_line_exclusions: vec!["os".to_string(), "logging.handlers".to_string()] + .into_iter() + .collect::>(), + ..isort::settings::Settings::default() + }, + src: vec![Path::new("resources/test/fixtures/isort").to_path_buf()], + ..Settings::for_rule(CheckCode::I001) + }, + )?; + checks.sort_by_key(|check| check.location); + insta::assert_yaml_snapshot!(snapshot, checks); + Ok(()) + } } diff --git a/src/isort/plugins.rs b/src/isort/plugins.rs index 07caa485fc..603b69a933 100644 --- a/src/isort/plugins.rs +++ b/src/isort/plugins.rs @@ -82,6 +82,8 @@ pub fn check_imports( settings.isort.combine_as_imports, settings.isort.force_wrap_aliases, settings.isort.split_on_trailing_comma, + settings.isort.force_single_line, + &settings.isort.single_line_exclusions, ); // Expand the span the entire range, including leading and trailing space. diff --git a/src/isort/settings.rs b/src/isort/settings.rs index c4389f7a81..bda1d33d2c 100644 --- a/src/isort/settings.rs +++ b/src/isort/settings.rs @@ -40,6 +40,22 @@ pub struct Options { /// enabled, every aliased `import from` will be given its own line, in /// which case, wrapping is not necessary. pub force_wrap_aliases: Option, + #[option( + default = r#"false"#, + value_type = "bool", + example = r#"force-single-line = true"# + )] + /// Forces all from imports to appear on their own line. + pub force_single_line: Option, + #[option( + default = r#"[]"#, + value_type = "Vec", + example = r#" + single-line-exclusions = ["os", "json"] + "# + )] + /// One or more modules to exclude from the single line rule. + pub single_line_exclusions: Option>, #[option( default = r#"false"#, value_type = "bool", @@ -95,10 +111,13 @@ pub struct Options { } #[derive(Debug, Hash)] +#[allow(clippy::struct_excessive_bools)] pub struct Settings { pub combine_as_imports: bool, pub force_wrap_aliases: bool, pub split_on_trailing_comma: bool, + pub force_single_line: bool, + pub single_line_exclusions: BTreeSet, pub known_first_party: BTreeSet, pub known_third_party: BTreeSet, pub extra_standard_library: BTreeSet, @@ -110,6 +129,10 @@ impl Settings { combine_as_imports: options.combine_as_imports.unwrap_or(false), force_wrap_aliases: options.force_wrap_aliases.unwrap_or(false), split_on_trailing_comma: options.split_on_trailing_comma.unwrap_or(true), + force_single_line: options.force_single_line.unwrap_or(false), + single_line_exclusions: BTreeSet::from_iter( + options.single_line_exclusions.unwrap_or_default(), + ), known_first_party: BTreeSet::from_iter(options.known_first_party.unwrap_or_default()), known_third_party: BTreeSet::from_iter(options.known_third_party.unwrap_or_default()), extra_standard_library: BTreeSet::from_iter( @@ -125,6 +148,8 @@ impl Default for Settings { combine_as_imports: false, force_wrap_aliases: false, split_on_trailing_comma: true, + force_single_line: false, + single_line_exclusions: BTreeSet::new(), known_first_party: BTreeSet::new(), known_third_party: BTreeSet::new(), extra_standard_library: BTreeSet::new(), diff --git a/src/isort/snapshots/ruff__isort__tests__force_single_line_force_single_line.py.snap b/src/isort/snapshots/ruff__isort__tests__force_single_line_force_single_line.py.snap new file mode 100644 index 0000000000..a4b224326b --- /dev/null +++ b/src/isort/snapshots/ruff__isort__tests__force_single_line_force_single_line.py.snap @@ -0,0 +1,20 @@ +--- +source: src/isort/mod.rs +expression: checks +--- +- kind: UnsortedImports + location: + row: 1 + column: 0 + end_location: + row: 19 + column: 0 + fix: + content: "import math\nimport sys\nfrom logging.handlers import FileHandler, StreamHandler\nfrom os import path, uname\n\n# comment 5\nfrom bar import a # comment 6\nfrom bar import b # comment 7\nfrom foo import bar # comment 3\nfrom foo2 import bar2 # comment 4\n\n# comment 1\n# comment 2\nfrom third_party import lib1\nfrom third_party import lib2\nfrom third_party import lib3\nfrom third_party import lib4\nfrom third_party import lib5\nfrom third_party import lib6\nfrom third_party import lib7\n" + location: + row: 1 + column: 0 + end_location: + row: 19 + column: 0 + diff --git a/src/isort/types.rs b/src/isort/types.rs index 357b1edf73..5700a025b2 100644 --- a/src/isort/types.rs +++ b/src/isort/types.rs @@ -16,7 +16,7 @@ impl Default for TrailingComma { } } -#[derive(Debug, Hash, Ord, PartialOrd, Eq, PartialEq)] +#[derive(Debug, Hash, Ord, PartialOrd, Eq, PartialEq, Clone)] pub struct ImportFromData<'a> { pub module: Option<&'a String>, pub level: Option<&'a usize>, @@ -28,7 +28,7 @@ pub struct AliasData<'a> { pub asname: Option<&'a String>, } -#[derive(Debug, Default)] +#[derive(Debug, Default, Clone)] pub struct CommentSet<'a> { pub atop: Vec>, pub inline: Vec>,