diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/statement/import.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/statement/import.py index 7a172955b928e..68bf5e812a66e 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/statement/import.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/statement/import.py @@ -2,6 +2,13 @@ from a import aksjdhflsakhdflkjsadlfajkslhfdkjsaldajlahflashdfljahlfksajlhfajfjfsaahflakjslhdfkjalhdskjfa, aksjdhflsakhdflkjsadlfajkslhfdkjsaldajlahflashdfljahlfksajlhfajfjfsaahflakjslhdfkjalhdskjfa from a import aksjdhflsakhdflkjsadlfajkslhfdkjsaldajlahflashdfljahlfksajlhfajfjfsaahflakjslhdfkjalhdskjfa as dfgsdfgsd, aksjdhflsakhdflkjsadlfajkslhfdkjsaldajlahflashdfljahlfksajlhfajfjfsaahflakjslhdfkjalhdskjfa as sdkjflsdjlahlfd +# Continuations. +import foo\ + .bar + +from foo\ + .bar import baz + # At the top-level, force one empty line after an import, but allow up to two empty # lines. import os diff --git a/crates/ruff_python_formatter/src/other/alias.rs b/crates/ruff_python_formatter/src/other/alias.rs index 0020c872cc454..f8532d22d4b20 100644 --- a/crates/ruff_python_formatter/src/other/alias.rs +++ b/crates/ruff_python_formatter/src/other/alias.rs @@ -1,6 +1,7 @@ use ruff_formatter::write; use ruff_python_ast::Alias; +use crate::other::identifier::DotDelimitedIdentifier; use crate::prelude::*; #[derive(Default)] @@ -13,7 +14,7 @@ impl FormatNodeRule for FormatAlias { name, asname, } = item; - name.format().fmt(f)?; + DotDelimitedIdentifier::new(name).fmt(f)?; if let Some(asname) = asname { write!(f, [space(), token("as"), space(), asname.format()])?; } diff --git a/crates/ruff_python_formatter/src/other/identifier.rs b/crates/ruff_python_formatter/src/other/identifier.rs index ae3dd98f67000..03d674635c688 100644 --- a/crates/ruff_python_formatter/src/other/identifier.rs +++ b/crates/ruff_python_formatter/src/other/identifier.rs @@ -27,3 +27,43 @@ impl<'ast> IntoFormat> for Identifier { FormatOwnedWithRule::new(self, FormatIdentifier) } } + +/// A formatter for a dot-delimited identifier, as seen in import statements: +/// ```python +/// import foo.bar +/// ``` +/// +/// Dot-delimited identifiers can contain newlines via continuations (backslashes) after the +/// dot-delimited segment, as in: +/// ```python +/// import foo\ +/// .bar +/// ``` +/// +/// While identifiers can typically be formatted via verbatim source code slices, dot-delimited +/// identifiers with newlines must be formatted via `text`. This struct implements both the fast +/// and slow paths for such identifiers. +#[derive(Debug)] +pub(crate) struct DotDelimitedIdentifier<'a>(&'a Identifier); + +impl<'a> DotDelimitedIdentifier<'a> { + pub(crate) fn new(identifier: &'a Identifier) -> Self { + Self(identifier) + } +} + +impl Format> for DotDelimitedIdentifier<'_> { + fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { + // An import identifier can contain newlines by inserting continuations (backslashes) after + // a dot-delimited segment, as in: + // ```python + // import foo\ + // .bar + // ``` + if memchr::memchr(b'\\', f.context().source()[self.0.range()].as_bytes()).is_some() { + text(self.0.as_str(), Some(self.0.start())).fmt(f) + } else { + source_text_slice(self.0.range()).fmt(f) + } + } +} diff --git a/crates/ruff_python_formatter/src/statement/stmt_import_from.rs b/crates/ruff_python_formatter/src/statement/stmt_import_from.rs index 377d34ef6211e..29c1738d04866 100644 --- a/crates/ruff_python_formatter/src/statement/stmt_import_from.rs +++ b/crates/ruff_python_formatter/src/statement/stmt_import_from.rs @@ -6,6 +6,7 @@ use ruff_text_size::Ranged; use crate::builders::{parenthesize_if_expands, PyFormatterExtensions, TrailingComma}; use crate::comments::{SourceComment, SuppressionKind}; use crate::expression::parentheses::parenthesized; +use crate::other::identifier::DotDelimitedIdentifier; use crate::prelude::*; #[derive(Default)] @@ -31,7 +32,7 @@ impl FormatNodeRule for FormatStmtImportFrom { } Ok(()) }), - module.as_ref().map(AsFormat::format), + module.as_ref().map(DotDelimitedIdentifier::new), space(), token("import"), space(), diff --git a/crates/ruff_python_formatter/tests/snapshots/format@statement__import.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@statement__import.py.snap index 7f993f5b49fd8..1118a8378f574 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@statement__import.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@statement__import.py.snap @@ -8,6 +8,13 @@ from a import aksjdhflsakhdflkjsadlfajkslhfdkjsaldajlahflashdfljahlfksajlhfajfjf from a import aksjdhflsakhdflkjsadlfajkslhfdkjsaldajlahflashdfljahlfksajlhfajfjfsaahflakjslhdfkjalhdskjfa, aksjdhflsakhdflkjsadlfajkslhfdkjsaldajlahflashdfljahlfksajlhfajfjfsaahflakjslhdfkjalhdskjfa from a import aksjdhflsakhdflkjsadlfajkslhfdkjsaldajlahflashdfljahlfksajlhfajfjfsaahflakjslhdfkjalhdskjfa as dfgsdfgsd, aksjdhflsakhdflkjsadlfajkslhfdkjsaldajlahflashdfljahlfksajlhfajfjfsaahflakjslhdfkjalhdskjfa as sdkjflsdjlahlfd +# Continuations. +import foo\ + .bar + +from foo\ + .bar import baz + # At the top-level, force one empty line after an import, but allow up to two empty # lines. import os @@ -98,6 +105,11 @@ from a import ( aksjdhflsakhdflkjsadlfajkslhfdkjsaldajlahflashdfljahlfksajlhfajfjfsaahflakjslhdfkjalhdskjfa as sdkjflsdjlahlfd, ) +# Continuations. +import foo.bar + +from foo.bar import baz + # At the top-level, force one empty line after an import, but allow up to two empty # lines. import os