Skip to content

Commit d102b15

Browse files
committed
OrganizeImports: re-emit already-organized wrapped imports verbatim
A wrapped import the rule leaves untouched is currently collapsed onto one line by the pretty-printer, fighting scalafmt over the same lines. Re-emit such an import from its own source text instead, so its layout (indentation, alignment, trailing comma) is the formatter's business, not this rule's. The signal for "untouched" is position presence, the same invariant the leaf-name emission already relies on: a node the pipeline rewrote is a `copy()` and carries no position, so an `Importer` that still has its parsed `Position.Range` was not restructured -- same qualifier, same importees, same order. `isUnchangedFromSource` = has a range position, spans multiple lines (single-line imports are still normalized, e.g. `import a.{ B }` -> `import a.B`), and needs no dialect migration (`=>`/`as`, `_`/`*`). No structural comparison, no `originalPrototype()` navigation. The reconstruction branches are untouched: anything the rule rewrites -- reordered, merged, coalesced, exploded, expanded -- still goes through the pretty-printer as before. A coalesced multi-line import, for instance, re-flows to one line (see CoalesceMultiLineTrailingComma) and the formatter re-wraps it. Fixtures adapted from #2507.
1 parent b1b436d commit d102b15

11 files changed

Lines changed: 277 additions & 59 deletions

File tree

scalafix-rules/src/main/scala/scalafix/internal/rule/OrganizeImports.scala

Lines changed: 115 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -779,57 +779,79 @@ class OrganizeImports(
779779

780780
ps.foreach(_.begComment.foreach(appendBegComment))
781781
i.begComment.foreach(appendBegComment)
782-
if (single != null) single.begComment.foreach(appendBegComment)
783-
sb.append("import ").append(refSyntax(i.ref)).append('.')
784-
if (single != null) {
785-
val isCurly = single.isCurlyBraced
786-
val useOuterSpace = isCurly && single
787-
.originalPrototype()
788-
.parent
789-
.exists(_.hasSpaceInCurly)
790-
if (isCurly) {
791-
sb.append('{')
792-
if (useOuterSpace) sb.append(' ')
793-
}
794-
sb.append(importeeSyntax(single))
795-
if (isCurly) {
796-
if (useOuterSpace) sb.append(' ')
797-
sb.append('}')
782+
if (i.isUnchangedFromSource) {
783+
// An already-organized wrapped import: re-emit its source text as is,
784+
// leaving its layout to the formatter. Comments within the source
785+
// span are part of it; those attached around it are printed as usual.
786+
def isWithin(pc: Tree.Comments): Boolean = pc.values.forall { c =>
787+
c.pos.start >= i.pos.start && c.pos.end <= i.pos.end
798788
}
799-
single.endComment.foreach(appendEndComment)
800-
} else {
801-
val lines = i.importees.iterator.map(_.pos.startLine).filter(_ >= 0)
802-
val isMultiline = lines.hasNext && {
803-
val line = lines.next()
804-
lines.exists(_ != line)
805-
}
806-
val origImporters = i.importees
807-
.flatMap(_.originalPrototype().parent)
808-
.distinct
809-
val useOuterSpace =
810-
!isMultiline && origImporters.exists(_.hasSpaceInCurly)
811-
// Preserve a trailing comma if the (single) source importer had one.
812-
val trailingComma = isMultiline && (origImporters match {
813-
case Seq(origImporter: Importer) => origImporter.hasTrailingComma
814-
case _ => false
789+
i.importees.foreach(_.begComment.foreach { pc =>
790+
if (isWithin(pc)) commentsPrinted.add(pc) else appendBegComment(pc)
791+
})
792+
// Continuation lines are re-indented on insertion, like printed ones.
793+
val srcIndent = " " * i.parent.fold(0)(_.pos.startColumn)
794+
val srcLines = i.tokens.syntax.linesIterator
795+
sb.append("import ").append(srcLines.next())
796+
srcLines.foreach(l =>
797+
sb.append('\n').append(l.stripPrefix(srcIndent))
798+
)
799+
i.importees.foreach(_.endComment.foreach { pc =>
800+
if (isWithin(pc)) commentsPrinted.add(pc) else appendEndComment(pc)
815801
})
816-
sb.append('{')
817-
val sep = if (isMultiline) "\n " else " "
818-
if (isMultiline) sb.append(sep)
819-
else if (useOuterSpace) sb.append(' ')
820-
val sblen = sb.length
821-
i.importees.foreach { i2 =>
822-
if (sb.length > sblen) sb.append(',').append(sep)
823-
val proto = i2.originalPrototype()
824-
proto.begComment.foreach(appendBegComment)
825-
sb.append(importeeSyntax(i2))
826-
proto.endComment.foreach(appendEndComment)
802+
} else {
803+
if (single != null) single.begComment.foreach(appendBegComment)
804+
sb.append("import ").append(refSyntax(i.ref)).append('.')
805+
if (single != null) {
806+
val isCurly = single.isCurlyBraced
807+
val useOuterSpace = isCurly && single
808+
.originalPrototype()
809+
.parent
810+
.exists(_.hasSpaceInCurly)
811+
if (isCurly) {
812+
sb.append('{')
813+
if (useOuterSpace) sb.append(' ')
814+
}
815+
sb.append(importeeSyntax(single))
816+
if (isCurly) {
817+
if (useOuterSpace) sb.append(' ')
818+
sb.append('}')
819+
}
820+
single.endComment.foreach(appendEndComment)
821+
} else {
822+
val lines = i.importees.iterator.map(_.pos.startLine).filter(_ >= 0)
823+
val isMultiline = lines.hasNext && {
824+
val line = lines.next()
825+
lines.exists(_ != line)
826+
}
827+
val origImporters = i.importees
828+
.flatMap(_.originalPrototype().parent)
829+
.distinct
830+
val useOuterSpace =
831+
!isMultiline && origImporters.exists(_.hasSpaceInCurly)
832+
// Preserve a trailing comma if the (single) source importer had one.
833+
val trailingComma = isMultiline && (origImporters match {
834+
case Seq(origImporter: Importer) => origImporter.hasTrailingComma
835+
case _ => false
836+
})
837+
sb.append('{')
838+
val sep = if (isMultiline) "\n " else " "
839+
if (isMultiline) sb.append(sep)
840+
else if (useOuterSpace) sb.append(' ')
841+
val sblen = sb.length
842+
i.importees.foreach { i2 =>
843+
if (sb.length > sblen) sb.append(',').append(sep)
844+
val proto = i2.originalPrototype()
845+
proto.begComment.foreach(appendBegComment)
846+
sb.append(importeeSyntax(i2))
847+
proto.endComment.foreach(appendEndComment)
848+
}
849+
if (isMultiline) {
850+
if (trailingComma) sb.append(',')
851+
sb.append('\n')
852+
} else if (useOuterSpace) sb.append(' ')
853+
sb.append('}')
827854
}
828-
if (isMultiline) {
829-
if (trailingComma) sb.append(',')
830-
sb.append('\n')
831-
} else if (useOuterSpace) sb.append(' ')
832-
sb.append('}')
833855
}
834856
i.endComment.foreach(appendEndComment)
835857
ps.foreach(_.endComment.foreach(appendEndComment))
@@ -1158,16 +1180,17 @@ object OrganizeImports {
11581180
* produce the same key regardless of how they were spelled in the source, so
11591181
* ordering here deliberately follows the dialect-normalized spelling rather
11601182
* than e.g. an author's backticks (see `refSyntax`/`importeeSyntax` for
1161-
* output, and https://github.com/scalacenter/scalafix/pull/2500 for why
1162-
* these two must not be conflated).
1183+
* output, and https://github.com/scalacenter/scalafix/pull/2500 for why these
1184+
* two must not be conflated).
11631185
*/
11641186
private def treeSyntax(tree: Tree)(implicit dialect: Dialect): String =
11651187
tree.reprint()
11661188

11671189
/**
11681190
* Emits a leaf `Name` as it was spelled in the source, so that backquotes
11691191
* around identifiers the target dialect does not consider keywords are not
1170-
* dropped (soft keywords, e.g. `` `export` ``, https://github.com/scalacenter/scalafix/issues/2480).
1192+
* dropped (soft keywords, e.g. `` `export` ``,
1193+
* https://github.com/scalacenter/scalafix/issues/2480).
11711194
*
11721195
* NB: always reads the name's own position, never `originalPrototype()` --
11731196
* the latter would resurrect pre-rewrite text for a node produced by
@@ -1180,9 +1203,9 @@ object OrganizeImports {
11801203
}
11811204

11821205
/**
1183-
* Emits an importer ref segment by segment: only leaf names can be taken
1184-
* from the source, so inter-segment trivia (`a . b`, a ref wrapped over
1185-
* several lines) can never leak into the output.
1206+
* Emits an importer ref segment by segment: only leaf names can be taken from
1207+
* the source, so inter-segment trivia (`a . b`, a ref wrapped over several
1208+
* lines) can never leak into the output.
11861209
*/
11871210
private def refSyntax(ref: Term)(implicit dialect: Dialect): String =
11881211
ref match {
@@ -1199,11 +1222,10 @@ object OrganizeImports {
11991222
}
12001223

12011224
/**
1202-
* Emits an importee: leaf names come from the source, every connective
1203-
* token (`=>` vs `as`, `given`) is produced for the target dialect, so a
1204-
* dialect migration keeps the source spelling of the names it moves.
1205-
* Separators match the pretty-printer's, so output is unchanged for
1206-
* unescaped names.
1225+
* Emits an importee: leaf names come from the source, every connective token
1226+
* (`=>` vs `as`, `given`) is produced for the target dialect, so a dialect
1227+
* migration keeps the source spelling of the names it moves. Separators match
1228+
* the pretty-printer's, so output is unchanged for unescaped names.
12071229
*/
12081230
private def importeeSyntax(importee: Importee)(implicit
12091231
dialect: Dialect
@@ -1287,6 +1309,40 @@ object OrganizeImports {
12871309
tokens.getWideOpt(idx).exists(_.is[Token.Comma])
12881310
}
12891311

1312+
def spansMultipleLines: Boolean =
1313+
importer.pos.startLine != importer.pos.endLine
1314+
1315+
/**
1316+
* Would pretty-printing under `dialect` rewrite `=>` to `as` or `_` to `*`?
1317+
*/
1318+
def needsDialectRewrite(implicit dialect: Dialect): Boolean =
1319+
importer.importees.exists {
1320+
case i: Importee.Wildcard =>
1321+
i.tokens.exists(_.is[Token.Underscore]) ==
1322+
dialect.allowStarWildcardImport
1323+
case i @ (_: Importee.Rename | _: Importee.Unimport) =>
1324+
i.tokens.exists(_.is[Token.RightArrow]) ==
1325+
dialect.allowAsForImportRename
1326+
case _ => false
1327+
}
1328+
1329+
/**
1330+
* Checks whether this `Importer` can be re-emitted verbatim from its own
1331+
* source text, so its layout (indentation, alignment, trailing comma) is
1332+
* left to the formatter rather than re-flowed by this rule.
1333+
*
1334+
* The signal is position presence: a node the pipeline rewrote is a
1335+
* `copy()` and carries no position (same invariant `nameSyntax` relies on),
1336+
* so an `Importer` that still has its parsed `Position.Range` was not
1337+
* restructured -- same qualifier, same importees, same order. Only wrapped
1338+
* imports qualify: single-line ones are still normalized (`import a.{ B }`
1339+
* -> `import a.B`), and one needing a dialect migration is being rewritten.
1340+
*/
1341+
def isUnchangedFromSource(implicit dialect: Dialect): Boolean =
1342+
importer.pos.isInstanceOf[Position.Range] &&
1343+
importer.spansMultipleLines &&
1344+
!importer.needsDialectRewrite
1345+
12901346
}
12911347

12921348
implicit private class TreeExtension(val tree: Tree) extends AnyVal {
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
rules = [OrganizeImports]
3+
OrganizeImports {
4+
groupedImports = Keep
5+
removeUnused = false
6+
coalesceToWildcardImportThreshold = 2
7+
}
8+
*/
9+
package test.organizeImports
10+
11+
// Coalescing rewrites the importees, so the import no longer counts as
12+
// already-organized: it is pretty-printed (re-flowed to one line) rather than
13+
// re-emitted verbatim, and the formatter re-wraps it afterwards.
14+
import scala.collection.immutable.{
15+
Map => M,
16+
Set,
17+
Seq,
18+
}
19+
20+
object CoalesceMultiLineTrailingComma {
21+
val m: M[Int, Int] = M.empty
22+
val s: Set[Int] = Set.empty
23+
val q: Seq[Int] = Seq.empty
24+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/*
2+
rules = [OrganizeImports]
3+
OrganizeImports {
4+
groupedImports = Explode
5+
removeUnused = false
6+
}
7+
*/
8+
package test.organizeImports
9+
10+
// Importees split out of a wrapped multi-importee import are printed inline:
11+
// the wrapping belonged to the source import, not to each importee.
12+
import scala.collection.immutable.{
13+
Set,
14+
Map => M,
15+
}
16+
17+
object SingleImporteeSplitInline {
18+
val m: M[Int, Int] = M.empty
19+
val s: Set[Int] = Set.empty
20+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/*
2+
rules = [OrganizeImports]
3+
OrganizeImports {
4+
groupedImports = Keep
5+
removeUnused = false
6+
}
7+
*/
8+
package test.organizeImports
9+
10+
// A wrapped single importee keeps its multi-line layout.
11+
import scala.collection.{
12+
immutable => imm,
13+
}
14+
15+
object SingleImporteeWrapped {
16+
val m: imm.Map[Int, Int] = imm.Map.empty
17+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/*
2+
rules = [OrganizeImports]
3+
OrganizeImports {
4+
groupedImports = Keep
5+
removeUnused = false
6+
}
7+
*/
8+
package test.organizeImports
9+
10+
// A wrapped single importee without a trailing comma keeps its layout, and
11+
// does not gain a comma.
12+
import scala.collection.{
13+
immutable => imm
14+
}
15+
16+
object SingleImporteeWrappedNoComma {
17+
val m: imm.Map[Int, Int] = imm.Map.empty
18+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*
2+
rules = [OrganizeImports]
3+
OrganizeImports {
4+
groupedImports = Keep
5+
removeUnused = false
6+
}
7+
*/
8+
package test.organizeImports.nested {
9+
// Wrapped imports inside an indented block keep their relative indentation.
10+
import scala.collection.immutable.{
11+
Map => M,
12+
Set,
13+
}
14+
import scala.collection.{
15+
immutable => imm,
16+
}
17+
18+
object NestedPackageWrapped {
19+
val m: M[Int, Int] = M.empty
20+
val s: Set[Int] = Set.empty
21+
val i: imm.Map[Int, Int] = imm.Map.empty
22+
}
23+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package test.organizeImports
2+
3+
// Coalescing rewrites the importees, so the import no longer counts as
4+
// already-organized: it is pretty-printed (re-flowed to one line) rather than
5+
// re-emitted verbatim, and the formatter re-wraps it afterwards.
6+
import scala.collection.immutable.{Map => M, _}
7+
8+
object CoalesceMultiLineTrailingComma {
9+
val m: M[Int, Int] = M.empty
10+
val s: Set[Int] = Set.empty
11+
val q: Seq[Int] = Seq.empty
12+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package test.organizeImports
2+
3+
// Importees split out of a wrapped multi-importee import are printed inline:
4+
// the wrapping belonged to the source import, not to each importee.
5+
import scala.collection.immutable.Set
6+
import scala.collection.immutable.{Map => M}
7+
8+
object SingleImporteeSplitInline {
9+
val m: M[Int, Int] = M.empty
10+
val s: Set[Int] = Set.empty
11+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package test.organizeImports
2+
3+
// A wrapped single importee keeps its multi-line layout.
4+
import scala.collection.{
5+
immutable => imm,
6+
}
7+
8+
object SingleImporteeWrapped {
9+
val m: imm.Map[Int, Int] = imm.Map.empty
10+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package test.organizeImports
2+
3+
// A wrapped single importee without a trailing comma keeps its layout, and
4+
// does not gain a comma.
5+
import scala.collection.{
6+
immutable => imm
7+
}
8+
9+
object SingleImporteeWrappedNoComma {
10+
val m: imm.Map[Int, Int] = imm.Map.empty
11+
}

0 commit comments

Comments
 (0)