From feabc5357180fc4e369702b7cd1500ebc0f91993 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:39:37 -0700 Subject: [PATCH 1/3] [ty] Preserve blank lines between comments and imports in add-import code action When the add-import code action inserts a new import after a comment block (e.g., PEP 723 script metadata), it dropped the blank line between the comment and the existing imports. This caused the comment to visually merge with the import block after formatting. Fix this by tracking blank lines encountered after the last comment in the start-of-file insertion logic. When blank lines exist between the final comment and the first statement, advance the insertion point past them to preserve the gap. Closes astral-sh/ty#2990 --- crates/ruff_python_importer/src/insertion.rs | 46 +++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/ruff_python_importer/src/insertion.rs b/crates/ruff_python_importer/src/insertion.rs index 15b0f8d67429f9..0cbdaf154c8d03 100644 --- a/crates/ruff_python_importer/src/insertion.rs +++ b/crates/ruff_python_importer/src/insertion.rs @@ -93,21 +93,36 @@ impl<'a> Insertion<'a> { contents.bom_start_offset() }; - // Skip over commented lines, with whitespace separation. + // Skip over commented lines, with whitespace separation. Track blank + // lines after comments so we can preserve them between comments and + // the first statement. + let mut seen_comment = false; + let mut blank_lines_end = None; for line in UniversalNewlineIterator::with_offset(&contents[location.to_usize()..], location) { let trimmed_line = line.trim_whitespace_start(); if trimmed_line.is_empty() { + if seen_comment { + blank_lines_end = Some(line.full_end()); + } continue; } if trimmed_line.starts_with('#') { location = line.full_end(); + seen_comment = true; + blank_lines_end = None; } else { break; } } + // If there were blank lines between the last comment and the first + // statement, advance past them so the insertion preserves the gap. + if let Some(end) = blank_lines_end { + location = end; + } + Insertion::own_line("", location, stylist.line_ending().as_str()) } @@ -525,6 +540,35 @@ x = 1 Insertion::inline(" ", TextSize::from(20), ";") ); + // Script metadata comments followed by a blank line and imports. + // The blank line between the comments and the import should be preserved. + let contents = r" +# /// script +# dependencies = ['anyio'] +# /// + +import datetime as dt +" + .trim_start(); + assert_eq!( + insert(contents)?, + Insertion::own_line("", TextSize::from(47), "\n") + ); + + // Comments without a blank line before imports should insert right + // after the comments (no blank line to preserve). + let contents = r" +# /// script +# dependencies = ['anyio'] +# /// +import datetime as dt +" + .trim_start(); + assert_eq!( + insert(contents)?, + Insertion::own_line("", TextSize::from(46), "\n") + ); + Ok(()) } From aa7aba95eff0a22bf76cd35007dc01723dd213b0 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Fri, 20 Mar 2026 09:47:58 +0100 Subject: [PATCH 2/3] Simplify state tracking --- crates/ruff_python_importer/src/insertion.rs | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/crates/ruff_python_importer/src/insertion.rs b/crates/ruff_python_importer/src/insertion.rs index 0cbdaf154c8d03..0f695b392ee979 100644 --- a/crates/ruff_python_importer/src/insertion.rs +++ b/crates/ruff_python_importer/src/insertion.rs @@ -97,32 +97,25 @@ impl<'a> Insertion<'a> { // lines after comments so we can preserve them between comments and // the first statement. let mut seen_comment = false; - let mut blank_lines_end = None; for line in UniversalNewlineIterator::with_offset(&contents[location.to_usize()..], location) { let trimmed_line = line.trim_whitespace_start(); if trimmed_line.is_empty() { if seen_comment { - blank_lines_end = Some(line.full_end()); + location = line.full_end(); } continue; } + if trimmed_line.starts_with('#') { location = line.full_end(); seen_comment = true; - blank_lines_end = None; } else { break; } } - // If there were blank lines between the last comment and the first - // statement, advance past them so the insertion preserves the gap. - if let Some(end) = blank_lines_end { - location = end; - } - Insertion::own_line("", location, stylist.line_ending().as_str()) } From 4df5cb823526327bb7e8719fc24bb3c4d762c7f4 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Fri, 20 Mar 2026 10:29:59 +0100 Subject: [PATCH 3/3] Update Ruff snapshots --- ...rt__tests__required_import_comment.py.snap | 4 +- ...uired_import_comments_and_newlines.py.snap | 5 +- ..._isort__tests__required_import_off.py.snap | 4 +- ...required_import_with_alias_comment.py.snap | 4 +- ...t_with_alias_comments_and_newlines.py.snap | 5 +- ...ts__required_import_with_alias_off.py.snap | 4 +- ...__refurb__tests__FURB101_FURB101_2.py.snap | 1 - ...es__refurb__tests__FURB156_FURB156.py.snap | 65 ++++++++++--------- ..._ruff__tests__PY39_RUF013_RUF013_1.py.snap | 4 +- ..._tests__add_future_import_RUF013_1.py.snap | 4 +- ..._tests__add_future_import_RUF013_4.py.snap | 5 +- 11 files changed, 55 insertions(+), 50 deletions(-) diff --git a/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__required_import_comment.py.snap b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__required_import_comment.py.snap index 9a2d25a230736a..d06b9d2ecf9b79 100644 --- a/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__required_import_comment.py.snap +++ b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__required_import_comment.py.snap @@ -5,6 +5,6 @@ I002 [*] Missing required import: `from __future__ import annotations` --> comment.py:1:1 help: Insert required import: `from __future__ import annotations` 1 | #!/usr/bin/env python3 -2 + from __future__ import annotations -3 | +2 | +3 + from __future__ import annotations 4 | x = 1 diff --git a/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__required_import_comments_and_newlines.py.snap b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__required_import_comments_and_newlines.py.snap index d1911923c2e2d3..eade912ca17241 100644 --- a/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__required_import_comments_and_newlines.py.snap +++ b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__required_import_comments_and_newlines.py.snap @@ -4,9 +4,8 @@ source: crates/ruff_linter/src/rules/isort/mod.rs I002 [*] Missing required import: `from __future__ import annotations` --> comments_and_newlines.py:1:1 help: Insert required import: `from __future__ import annotations` -2 | # A copyright notice could go here 3 | 4 | # A linter directive could go here -5 + from __future__ import annotations -6 | +5 | +6 + from __future__ import annotations 7 | x = 1 diff --git a/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__required_import_off.py.snap b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__required_import_off.py.snap index ea3ab8c8973992..ac98c2981c09aa 100644 --- a/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__required_import_off.py.snap +++ b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__required_import_off.py.snap @@ -5,7 +5,7 @@ I002 [*] Missing required import: `from __future__ import annotations` --> off.py:1:1 help: Insert required import: `from __future__ import annotations` 1 | # isort: off -2 + from __future__ import annotations -3 | +2 | +3 + from __future__ import annotations 4 | x = 1 5 | # isort: on diff --git a/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__required_import_with_alias_comment.py.snap b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__required_import_with_alias_comment.py.snap index dcfb94ea27f473..c4d7139308bdc6 100644 --- a/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__required_import_with_alias_comment.py.snap +++ b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__required_import_with_alias_comment.py.snap @@ -5,6 +5,6 @@ I002 [*] Missing required import: `from __future__ import annotations as _annota --> comment.py:1:1 help: Insert required import: `from __future__ import annotations as _annotations` 1 | #!/usr/bin/env python3 -2 + from __future__ import annotations as _annotations -3 | +2 | +3 + from __future__ import annotations as _annotations 4 | x = 1 diff --git a/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__required_import_with_alias_comments_and_newlines.py.snap b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__required_import_with_alias_comments_and_newlines.py.snap index 0253eee38e4a54..a2655714d56203 100644 --- a/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__required_import_with_alias_comments_and_newlines.py.snap +++ b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__required_import_with_alias_comments_and_newlines.py.snap @@ -4,9 +4,8 @@ source: crates/ruff_linter/src/rules/isort/mod.rs I002 [*] Missing required import: `from __future__ import annotations as _annotations` --> comments_and_newlines.py:1:1 help: Insert required import: `from __future__ import annotations as _annotations` -2 | # A copyright notice could go here 3 | 4 | # A linter directive could go here -5 + from __future__ import annotations as _annotations -6 | +5 | +6 + from __future__ import annotations as _annotations 7 | x = 1 diff --git a/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__required_import_with_alias_off.py.snap b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__required_import_with_alias_off.py.snap index 886d740520bb10..f1e73e33b4e410 100644 --- a/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__required_import_with_alias_off.py.snap +++ b/crates/ruff_linter/src/rules/isort/snapshots/ruff_linter__rules__isort__tests__required_import_with_alias_off.py.snap @@ -5,7 +5,7 @@ I002 [*] Missing required import: `from __future__ import annotations as _annota --> off.py:1:1 help: Insert required import: `from __future__ import annotations as _annotations` 1 | # isort: off -2 + from __future__ import annotations as _annotations -3 | +2 | +3 + from __future__ import annotations as _annotations 4 | x = 1 5 | # isort: on diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB101_FURB101_2.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB101_FURB101_2.py.snap index 00d2098e90620f..db27c4ab5d36af 100644 --- a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB101_FURB101_2.py.snap +++ b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB101_FURB101_2.py.snap @@ -1,6 +1,5 @@ --- source: crates/ruff_linter/src/rules/refurb/mod.rs -assertion_line: 65 --- FURB101 [*] `open` and `read` should be replaced by `Path("file.txt").read_text(encoding="utf-8")` --> FURB101_2.py:2:6 diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB156_FURB156.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB156_FURB156.py.snap index dee2d3685acbf5..28da8cde0d2d73 100644 --- a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB156_FURB156.py.snap +++ b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB156_FURB156.py.snap @@ -13,9 +13,9 @@ FURB156 [*] Use of hardcoded string charset | help: Replace hardcoded charset with `string.digits` 1 | # Errors -2 + import string -3 | +2 | - _ = "0123456789" +3 + import string 4 + _ = string.digits 5 | _ = "01234567" 6 | _ = "0123456789abcdefABCDEF" @@ -32,8 +32,8 @@ FURB156 [*] Use of hardcoded string charset | help: Replace hardcoded charset with `string.octdigits` 1 | # Errors -2 + import string -3 | +2 | +3 + import string 4 | _ = "0123456789" - _ = "01234567" 5 + _ = string.octdigits @@ -53,8 +53,8 @@ FURB156 [*] Use of hardcoded string charset | help: Replace hardcoded charset with `string.hexdigits` 1 | # Errors -2 + import string -3 | +2 | +3 + import string 4 | _ = "0123456789" 5 | _ = "01234567" - _ = "0123456789abcdefABCDEF" @@ -75,8 +75,8 @@ FURB156 [*] Use of hardcoded string charset | help: Replace hardcoded charset with `string.ascii_lowercase` 1 | # Errors -2 + import string -3 | +2 | +3 + import string 4 | _ = "0123456789" 5 | _ = "01234567" 6 | _ = "0123456789abcdefABCDEF" @@ -98,8 +98,8 @@ FURB156 [*] Use of hardcoded string charset | help: Replace hardcoded charset with `string.ascii_uppercase` 1 | # Errors -2 + import string -3 | +2 | +3 + import string 4 | _ = "0123456789" 5 | _ = "01234567" 6 | _ = "0123456789abcdefABCDEF" @@ -122,8 +122,8 @@ FURB156 [*] Use of hardcoded string charset | help: Replace hardcoded charset with `string.ascii_letters` 1 | # Errors -2 + import string -3 | +2 | +3 + import string 4 | _ = "0123456789" 5 | _ = "01234567" 6 | _ = "0123456789abcdefABCDEF" @@ -146,11 +146,11 @@ FURB156 [*] Use of hardcoded string charset | help: Replace hardcoded charset with `string.punctuation` 1 | # Errors -2 + import string -3 | +2 | +3 + import string 4 | _ = "0123456789" 5 | _ = "01234567" --------------------------------------------------------------------------------- +6 | _ = "0123456789abcdefABCDEF" 7 | _ = "abcdefghijklmnopqrstuvwxyz" 8 | _ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" 9 | _ = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" @@ -172,10 +172,11 @@ FURB156 [*] Use of hardcoded string charset | help: Replace hardcoded charset with `string.whitespace` 1 | # Errors -2 + import string -3 | +2 | +3 + import string 4 | _ = "0123456789" 5 | _ = "01234567" +6 | _ = "0123456789abcdefABCDEF" -------------------------------------------------------------------------------- 8 | _ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" 9 | _ = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" @@ -198,10 +199,11 @@ FURB156 [*] Use of hardcoded string charset | help: Replace hardcoded charset with `string.printable` 1 | # Errors -2 + import string -3 | +2 | +3 + import string 4 | _ = "0123456789" 5 | _ = "01234567" +6 | _ = "0123456789abcdefABCDEF" -------------------------------------------------------------------------------- 10 | _ = r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~""" 11 | _ = " \t\n\r\v\f" @@ -225,10 +227,11 @@ FURB156 [*] Use of hardcoded string charset | help: Replace hardcoded charset with `string.printable` 1 | # Errors -2 + import string -3 | +2 | +3 + import string 4 | _ = "0123456789" 5 | _ = "01234567" +6 | _ = "0123456789abcdefABCDEF" -------------------------------------------------------------------------------- 12 | 13 | _ = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c' @@ -255,10 +258,11 @@ FURB156 [*] Use of hardcoded string charset | help: Replace hardcoded charset with `string.digits` 1 | # Errors -2 + import string -3 | +2 | +3 + import string 4 | _ = "0123456789" 5 | _ = "01234567" +6 | _ = "0123456789abcdefABCDEF" -------------------------------------------------------------------------------- 15 | '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&' 16 | "'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c" @@ -281,10 +285,11 @@ FURB156 [*] Use of hardcoded string charset | help: Replace hardcoded charset with `string.digits` 1 | # Errors -2 + import string -3 | +2 | +3 + import string 4 | _ = "0123456789" 5 | _ = "01234567" +6 | _ = "0123456789abcdefABCDEF" -------------------------------------------------------------------------------- 20 | "89") 21 | @@ -306,10 +311,11 @@ FURB156 [*] Use of hardcoded string charset | help: Replace hardcoded charset with `string.digits` 1 | # Errors -2 + import string -3 | +2 | +3 + import string 4 | _ = "0123456789" 5 | _ = "01234567" +6 | _ = "0123456789abcdefABCDEF" -------------------------------------------------------------------------------- 24 | ).capitalize() 25 | @@ -331,10 +337,11 @@ FURB156 [*] Use of hardcoded string charset | help: Replace hardcoded charset with `string.digits` 1 | # Errors -2 + import string -3 | +2 | +3 + import string 4 | _ = "0123456789" 5 | _ = "01234567" +6 | _ = "0123456789abcdefABCDEF" -------------------------------------------------------------------------------- 29 | ).capitalize() 30 | diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__PY39_RUF013_RUF013_1.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__PY39_RUF013_RUF013_1.py.snap index 63e5b089eb89fc..c0507ebdf61bb8 100644 --- a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__PY39_RUF013_RUF013_1.py.snap +++ b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__PY39_RUF013_RUF013_1.py.snap @@ -10,10 +10,10 @@ RUF013 [*] PEP 484 prohibits implicit `Optional` | help: Convert to `Optional[T]` 1 | # No `typing.Optional` import -2 + from typing import Optional +2 | 3 | -4 | - def f(arg: int = None): # RUF013 +4 + from typing import Optional 5 + def f(arg: Optional[int] = None): # RUF013 6 | pass note: This is an unsafe fix and may change runtime behavior diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__add_future_import_RUF013_1.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__add_future_import_RUF013_1.py.snap index dfcdf2c124ab9f..3b2e6595b5412f 100644 --- a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__add_future_import_RUF013_1.py.snap +++ b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__add_future_import_RUF013_1.py.snap @@ -10,10 +10,10 @@ RUF013 [*] PEP 484 prohibits implicit `Optional` | help: Convert to `T | None` 1 | # No `typing.Optional` import -2 + from __future__ import annotations +2 | 3 | -4 | - def f(arg: int = None): # RUF013 +4 + from __future__ import annotations 5 + def f(arg: int | None = None): # RUF013 6 | pass note: This is an unsafe fix and may change runtime behavior diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__add_future_import_RUF013_4.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__add_future_import_RUF013_4.py.snap index d1a4078a128748..053d7f6d944bdf 100644 --- a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__add_future_import_RUF013_4.py.snap +++ b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__add_future_import_RUF013_4.py.snap @@ -9,10 +9,11 @@ RUF013 [*] PEP 484 prohibits implicit `Optional` | help: Convert to `T | None` 1 | # https://github.com/astral-sh/ruff/issues/13833 -2 + from __future__ import annotations -3 | +2 | +3 + from __future__ import annotations 4 | from typing import Optional 5 | +6 | -------------------------------------------------------------------------------- 13 | def multiple_1(arg1: Optional, arg2: Optional = None): ... 14 |