fix missing-required-imports introducing syntax error after dosctring ending with backslash (#19505)

Issue: https://github.com/astral-sh/ruff/issues/19498

## Summary


[missing-required-import](https://docs.astral.sh/ruff/rules/missing-required-import/)
inserts the missing import on the line immediately following the last
line of the docstring. However, if the dosctring is immediately followed
by a continuation token (i.e. backslash) then this leads to a syntax
error because Python interprets the docstring and the inserted import to
be on the same line.

The proposed solution in this PR is to check if the first token after a
file docstring is a continuation character, and if so, to advance an
additional line before inserting the missing import.

## Test Plan

Added a unit test, and the following example was verified manually:

Given this simple test Python file:

```python
"Hello, World!"\

print(__doc__)
```

and this ruff linting configuration in the `pyproject.toml` file:

```toml
[tool.ruff.lint]
select = ["I"]

[tool.ruff.lint.isort]
required-imports = ["import sys"]
```

Without the changes in this PR, the ruff linter would try to insert the
missing import in line 2, resulting in a syntax error, and report the
following:

`error: Fix introduced a syntax error. Reverting all changes.`

With the changes in this PR, ruff correctly advances one more line
before adding the missing import, resulting in the following output:

```python
"Hello, World!"\

import sys

print(__doc__)
```

---------

Co-authored-by: Jim Hoekstra <jim.hoekstra@pacmed.nl>
This commit is contained in:
Jim Hoekstra 2025-07-30 18:12:46 +02:00 committed by GitHub
parent ec3d5ebda2
commit 38049aae12
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 40 additions and 1 deletions

View File

@ -0,0 +1,3 @@
"""Hello, world!"""\
x = 1; y = 2

View File

@ -56,13 +56,19 @@ impl<'a> Insertion<'a> {
stylist: &Stylist,
) -> Insertion<'static> {
// Skip over any docstrings.
let mut location = if let Some(location) = match_docstring_end(body) {
let mut location = if let Some(mut location) = match_docstring_end(body) {
// If the first token after the docstring is a semicolon, insert after the semicolon as
// an inline statement.
if let Some(offset) = match_semicolon(locator.after(location)) {
return Insertion::inline(" ", location.add(offset).add(TextSize::of(';')), ";");
}
// If the first token after the docstring is a continuation character (i.e. "\"), advance
// an additional row to prevent inserting in the same logical line.
if match_continuation(locator.after(location)).is_some() {
location = locator.full_line_end(location);
}
// Otherwise, advance to the next row.
locator.full_line_end(location)
} else {
@ -363,6 +369,16 @@ mod tests {
Insertion::own_line("", TextSize::from(20), "\n")
);
let contents = r#"
"""Hello, world!"""\
"#
.trim_start();
assert_eq!(
insert(contents)?,
Insertion::own_line("", TextSize::from(22), "\n")
);
let contents = r"
x = 1
"

View File

@ -794,6 +794,7 @@ mod tests {
#[test_case(Path::new("comments_and_newlines.py"))]
#[test_case(Path::new("docstring.py"))]
#[test_case(Path::new("docstring.pyi"))]
#[test_case(Path::new("docstring_followed_by_continuation.py"))]
#[test_case(Path::new("docstring_only.py"))]
#[test_case(Path::new("docstring_with_continuation.py"))]
#[test_case(Path::new("docstring_with_semicolon.py"))]
@ -828,6 +829,7 @@ mod tests {
#[test_case(Path::new("comments_and_newlines.py"))]
#[test_case(Path::new("docstring.py"))]
#[test_case(Path::new("docstring.pyi"))]
#[test_case(Path::new("docstring_followed_by_continuation.py"))]
#[test_case(Path::new("docstring_only.py"))]
#[test_case(Path::new("docstring_with_continuation.py"))]
#[test_case(Path::new("docstring_with_semicolon.py"))]

View File

@ -0,0 +1,9 @@
---
source: crates/ruff_linter/src/rules/isort/mod.rs
---
docstring_followed_by_continuation.py:1:1: I002 [*] Missing required import: `from __future__ import annotations`
Safe fix
1 1 | """Hello, world!"""\
2 2 |
3 |+from __future__ import annotations
3 4 | x = 1; y = 2

View File

@ -0,0 +1,9 @@
---
source: crates/ruff_linter/src/rules/isort/mod.rs
---
docstring_followed_by_continuation.py:1:1: I002 [*] Missing required import: `from __future__ import annotations as _annotations`
Safe fix
1 1 | """Hello, world!"""\
2 2 |
3 |+from __future__ import annotations as _annotations
3 4 | x = 1; y = 2