Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Separate statement-level and alias-level comments that isort merges onto one line
# when collapsing. The merged comment token is 89 columns with the `# explain` prefix
# counted, so the import must not end up on an overlong single line.
from aaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaa import ( # explain
x # noqa: TID251
)

# A single mixed comment on an already-collapsed 102-column line. The code plus the
# non-pragma prefix is 86 columns.
from bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb import x # explain # noqa: TID251

# Separate comments with the pragma first: when isort collapses this import in preview,
# the merged comment token is pragma-prefixed, so E501 strips it entirely and the
# collapsed 104-column line is fine.
from ccccccccccccccccccccccccccccccc.ccccccccccccccccccccccccccccccc import ( # noqa: TID251
x # explain
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# The next import fits on one line once the trailing pragma is excluded from the width
# (the `# keep this` prefix still counts); in preview it should not be wrapped.
from aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa import x # keep this # noqa: TID251
# The next import exceeds the line length even without the trailing pragma
# (code plus the `# keep this` prefix is 89 columns); it must always be wrapped.
from bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb import x # keep this # noqa: TID251
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# The next import fits on one line once the pragma comment is excluded from the width;
# in preview it should not be wrapped (the `# noqa` must stay effective).
from aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa import x # noqa: TID251
# The next import exceeds the line length even without the pragma comment;
# it must still be wrapped.
from bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb import x # noqa: TID251


def f():
# The next import fits on one line once the pragma comment is excluded from the
# width, so in preview it should not be wrapped.
from cccccccccccccccccccccccccccccc.ccccccccccccccccccccccccccccccccccccc import bar # noqa: PLC0415
bar()
65 changes: 64 additions & 1 deletion crates/ruff_linter/src/line_width.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,35 @@ use unicode_width::UnicodeWidthChar;

use ruff_cache::{CacheKey, CacheKeyHasher};
use ruff_macros::CacheKey;
use ruff_python_trivia::tab_offset;
use ruff_python_trivia::{find_trailing_pragma_offset, is_pragma_comment, tab_offset};

use crate::preview::{
is_pragma_excluded_from_import_width_enabled, is_trailing_pragma_in_line_length_enabled,
};
use crate::settings::types::PreviewMode;

/// Returns the offset within `comment` at which the pragma comment excluded from line-length
/// measurement begins, or `None` if the comment contains no such pragma.
///
/// This is the shared policy for how pragma comments (e.g., `# noqa: F401` or `# type: ignore`)
/// are excluded when measuring line width, used by `line-too-long` (E501) and
/// `doc-line-too-long` (W505), and, in preview mode, by isort's (I001) decision of whether an
/// import fits on one line (see [`LineWidthBuilder::add_comment`]). The formatter applies the
/// equivalent policy when measuring comment widths.
///
/// In stable mode, only comments that are pragmas in their entirety are excluded (the returned
/// offset is `0`). In preview mode, a trailing pragma within a mixed comment (e.g.,
/// `# explanation # noqa: F401`) is also excluded, in which case the offset points at the `#`
/// that begins the pragma.
pub(crate) fn pragma_offset_for_line_length(comment: &str, preview: PreviewMode) -> Option<usize> {
if is_trailing_pragma_in_line_length_enabled(preview) {
find_trailing_pragma_offset(comment)
} else if is_pragma_comment(comment) {
Some(0)
} else {
None
}
}

/// The length of a line of text that is considered too long.
///
Expand Down Expand Up @@ -237,6 +265,41 @@ impl LineWidthBuilder {
self.column += width;
self
}

/// Adds the width of a trailing comment, including the standard two-space separator that
/// precedes it. In preview mode, any pragma comment is excluded per
/// [`pragma_offset_for_line_length`].
///
/// Pragma comments are excluded so that adding one to a line never affects whether the line
/// is considered to fit, consistent with how `line-too-long` (E501) measures lines. For
/// example, counting a `# noqa` comment towards an import's width could cause isort to wrap
/// an import that otherwise fits on one line, moving the pragma to a position where it no
/// longer applies to the import statement:
///
/// ```python
/// from module import (
/// member, # noqa: PLC0415
/// )
/// ```
///
/// Unlike E501, which has always stripped whole-pragma comments on stable, the exclusion
/// changes how imports are formatted, so it is preview-gated in its entirety: on stable, the
/// full comment width is counted.
#[must_use]
pub(crate) fn add_comment(self, comment: &str, preview: PreviewMode) -> Self {
if !is_pragma_excluded_from_import_width_enabled(preview) {
return self.add_width(2).add_str(comment);
}
let counted = match pragma_offset_for_line_length(comment, preview) {
Some(offset) => comment[..offset].trim_end(),
None => comment,
};
if counted.is_empty() {
self
} else {
self.add_width(2).add_str(counted)
}
}
}

impl PartialEq<LineLength> for LineWidthBuilder {
Expand Down
5 changes: 5 additions & 0 deletions crates/ruff_linter/src/preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,11 @@ pub const fn is_warn_on_unknown_selectors_enabled(preview: PreviewMode) -> bool
preview.is_enabled()
}

// https://github.com/astral-sh/ruff/pull/27313
pub(crate) const fn is_pragma_excluded_from_import_width_enabled(preview: PreviewMode) -> bool {
preview.is_enabled()
}

// https://github.com/astral-sh/ruff/pull/27666
pub(crate) const fn is_rule_categories_enabled(preview: PreviewMode) -> bool {
preview.is_enabled()
Expand Down
15 changes: 10 additions & 5 deletions crates/ruff_linter/src/rules/isort/format.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use ruff_python_codegen::Stylist;

use crate::line_width::{LineLength, LineWidthBuilder};
use crate::settings::types::PreviewMode;

use super::types::{AliasData, ImportCommentSet, ImportFromCommentSet, ImportFromData, Importable};

Expand Down Expand Up @@ -54,6 +55,7 @@ pub(crate) fn format_import_from(
force_wrap_aliases: bool,
is_first: bool,
trailing_comma: bool,
preview: PreviewMode,
) -> String {
if aliases.len() == 1
&& aliases
Expand All @@ -67,6 +69,7 @@ pub(crate) fn format_import_from(
is_first,
stylist,
indentation_width,
preview,
);
return single_line;
}
Expand Down Expand Up @@ -95,6 +98,7 @@ pub(crate) fn format_import_from(
is_first,
stylist,
indentation_width,
preview,
);
if import_width <= line_length || aliases.iter().any(|(alias, _)| alias.name == "*") {
return single_line;
Expand All @@ -114,6 +118,7 @@ fn format_single_line(
is_first: bool,
stylist: &Stylist,
indentation_width: LineWidthBuilder,
preview: PreviewMode,
) -> (String, LineWidthBuilder) {
let mut output = String::with_capacity(CAPACITY);
let mut line_width = indentation_width;
Expand Down Expand Up @@ -156,37 +161,37 @@ fn format_single_line(
output.push(' ');
output.push(' ');
output.push_str(comment);
line_width = line_width.add_width(2).add_str(comment);
line_width = line_width.add_comment(comment, preview);
Comment thread
ntBre marked this conversation as resolved.
}

for (_, comments) in aliases {
for comment in &comments.atop {
output.push(' ');
output.push(' ');
output.push_str(comment);
line_width = line_width.add_width(2).add_str(comment);
line_width = line_width.add_comment(comment, preview);
}

for comment in &comments.inline {
output.push(' ');
output.push(' ');
output.push_str(comment);
line_width = line_width.add_width(2).add_str(comment);
line_width = line_width.add_comment(comment, preview);
}

for comment in &comments.trailing {
output.push(' ');
output.push(' ');
output.push_str(comment);
line_width = line_width.add_width(2).add_str(comment);
line_width = line_width.add_comment(comment, preview);
}
}

for comment in &comments.trailing {
output.push(' ');
output.push(' ');
output.push_str(comment);
line_width = line_width.add_width(2).add_str(comment);
line_width = line_width.add_comment(comment, preview);
}

output.push_str(&stylist.line_ending());
Expand Down
66 changes: 64 additions & 2 deletions crates/ruff_linter/src/rules/isort/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use types::{AliasData, ImportBlock, TrailingComma};
use crate::Locator;
use crate::line_width::{LineLength, LineWidthBuilder};
use crate::package::PackageRoot;
use crate::settings::types::PreviewMode;
use ruff_python_ast::PythonVersion;

mod annotate;
Expand Down Expand Up @@ -77,6 +78,7 @@ pub(crate) fn format_imports(
source_type: PySourceType,
target_version: PythonVersion,
settings: &Settings,
preview: PreviewMode,
tokens: &Tokens,
) -> String {
let trailer = &block.trailer;
Expand Down Expand Up @@ -104,6 +106,7 @@ pub(crate) fn format_imports(
package,
target_version,
settings,
preview,
);

if !block_output.is_empty() && !output.is_empty() {
Expand Down Expand Up @@ -160,6 +163,7 @@ fn format_import_block(
package: Option<PackageRoot<'_>>,
target_version: PythonVersion,
settings: &Settings,
preview: PreviewMode,
) -> String {
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
enum LineInsertion {
Expand Down Expand Up @@ -289,6 +293,7 @@ fn format_import_block(
is_first_statement,
settings.split_on_trailing_comma
&& matches!(trailing_comma, TrailingComma::Present),
preview,
));

if settings.from_first {
Expand All @@ -311,14 +316,16 @@ mod tests {
use rustc_hash::{FxHashMap, FxHashSet};
use test_case::test_case;

use ruff_python_ast::{PySourceType, SourceType};
use ruff_python_semantic::{MemberNameImport, ModuleNameImport, NameImport};

use crate::assert_diagnostics;
use crate::registry::Rule;
use crate::rules::isort::categorize::{ImportSection, KnownModules};
use crate::settings::LinterSettings;
use crate::settings::types::IdentifierPattern;
use crate::test::{test_path, test_resource_path};
use crate::settings::types::{IdentifierPattern, PreviewMode};
use crate::source_kind::SourceKind;
use crate::test::{test_contents, test_path, test_resource_path};

use super::categorize::ImportType;
use super::settings::RelativeImportsOrder;
Expand All @@ -333,6 +340,8 @@ mod tests {
#[test_case(Path::new("deduplicate_imports.py"))]
#[test_case(Path::new("fit_line_length.py"))]
#[test_case(Path::new("fit_line_length_comment.py"))]
#[test_case(Path::new("fit_line_length_mixed_pragma.py"))]
#[test_case(Path::new("fit_line_length_pragma.py"))]
#[test_case(Path::new("force_sort_within_sections.py"))]
#[test_case(Path::new("force_to_top.py"))]
#[test_case(Path::new("force_wrap_aliases.py"))]
Expand Down Expand Up @@ -390,6 +399,59 @@ mod tests {
Ok(())
}

#[test_case(Path::new("fit_line_length_mixed_pragma.py"))]
#[test_case(Path::new("fit_line_length_pragma.py"))]
fn preview(path: &Path) -> Result<()> {
let snapshot = format!("preview__{}", path.to_string_lossy());
let diagnostics = test_path(
Path::new("isort").join(path).as_path(),
&LinterSettings {
preview: PreviewMode::Enabled,
src: vec![test_resource_path("fixtures/isort")],
..LinterSettings::for_rule(Rule::UnsortedImports)
},
)?;
assert_diagnostics!(snapshot, diagnostics);
Ok(())
}

/// Fixing I001 must never leave behind a line that E501 then flags.
///
/// isort excludes pragma comments (e.g., `# noqa: TID251`) from its width computation
/// per comment, while E501 measures the emitted line's single comment token as a whole.
/// When isort merges separate comments onto one line (e.g., a statement-level `# explain`
/// and an alias-level `# noqa`), the two computations could disagree; this test verifies
/// that the fix nonetheless converges to output that E501 accepts, in both stable and
/// preview modes.
#[test_case(PreviewMode::Disabled, "stable")]
#[test_case(PreviewMode::Enabled, "preview")]
fn no_line_too_long_after_fix(preview: PreviewMode, label: &str) -> Result<()> {
let path = test_resource_path("fixtures").join("isort/fit_line_length_merged_pragma.py");
let source_type = SourceType::Python(PySourceType::from(&path));
let source_kind = SourceKind::from_path(&path, source_type)?.expect("valid source");
let settings = LinterSettings {
preview,
src: vec![test_resource_path("fixtures/isort")],
..LinterSettings::for_rules([Rule::UnsortedImports, Rule::LineTooLong])
};

// `test_contents` applies fixes to convergence (and panics if they fail to converge).
let (_, transformed) = test_contents(&source_kind, &path, &settings);
insta::assert_snapshot!(
format!("fit_line_length_merged_pragma_fixed_{label}"),
transformed.source_code()
);

// Re-linting the converged output must produce no diagnostics: I001 is fully fixed
// and, in particular, the fix must not have introduced any E501 violations.
let (diagnostics, _) = test_contents(&transformed, &path, &settings);
assert!(
diagnostics.is_empty(),
"expected no diagnostics after applying fixes, found:\n{diagnostics:#?}"
);
Ok(())
}

fn pattern(pattern: &str) -> IdentifierPattern {
IdentifierPattern::new(pattern).unwrap()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ pub(crate) fn organize_imports(
source_type,
target_version,
&settings.isort,
settings.preview,
tokens,
);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
source: crates/ruff_linter/src/rules/isort/mod.rs
expression: transformed.source_code()
---
# Separate statement-level and alias-level comments that isort merges onto one line
# when collapsing. The merged comment token is 89 columns with the `# explain` prefix
# counted, so the import must not end up on an overlong single line.
from aaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaa import x # explain # noqa: TID251

# A single mixed comment on an already-collapsed 102-column line. The code plus the
# non-pragma prefix is 86 columns.
from bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb import x # explain # noqa: TID251

# Separate comments with the pragma first: when isort collapses this import in preview,
# the merged comment token is pragma-prefixed, so E501 strips it entirely and the
# collapsed 104-column line is fine.
from ccccccccccccccccccccccccccccccc.ccccccccccccccccccccccccccccccc import x # noqa: TID251 # explain
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
source: crates/ruff_linter/src/rules/isort/mod.rs
expression: transformed.source_code()
---
# Separate statement-level and alias-level comments that isort merges onto one line
# when collapsing. The merged comment token is 89 columns with the `# explain` prefix
# counted, so the import must not end up on an overlong single line.
from aaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaa import ( # explain
x, # noqa: TID251
)

# A single mixed comment on an already-collapsed 102-column line. The code plus the
# non-pragma prefix is 86 columns.
from bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb import (
x, # explain # noqa: TID251
)

# Separate comments with the pragma first: when isort collapses this import in preview,
# the merged comment token is pragma-prefixed, so E501 strips it entirely and the
# collapsed 104-column line is fine.
from ccccccccccccccccccccccccccccccc.ccccccccccccccccccccccccccccccc import ( # noqa: TID251
x, # explain
)
Loading
Loading