Skip to content

Commit 311b99a

Browse files
authored
Merge pull request #2509 from bjaglin/fix/organize-imports-source-preserving-emit
fix(organizeImports): preserve source spelling and layout of already-organized imports
2 parents 344bb40 + 9394697 commit 311b99a

15 files changed

Lines changed: 373 additions & 58 deletions

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

Lines changed: 156 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -779,57 +779,71 @@ 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) {
782+
if (i.isUnchangedFromSource) {
783+
// An already-organized wrapped import: re-emit its source text as is,
784+
// leaving its layout to the formatter. Interior comments come along
785+
// in the token stream; comments around the import are printed as
786+
// usual, above and below this branch.
787+
// Continuation lines are re-indented on insertion, like printed ones.
788+
val srcIndent = " " * i.parent.fold(0)(_.pos.startColumn)
789+
val srcLines = i.tokens.syntax.linesIterator
790+
sb.append("import ").append(srcLines.next())
791+
srcLines.foreach(l =>
792+
sb.append('\n').append(l.stripPrefix(srcIndent))
793+
)
794+
} else {
795+
if (single != null) single.begComment.foreach(appendBegComment)
796+
sb.append("import ").append(refSyntax(i.ref)).append('.')
797+
if (single != null) {
798+
val isCurly = single.isCurlyBraced
799+
val useOuterSpace = isCurly && single
800+
.originalPrototype()
801+
.parent
802+
.exists(_.hasSpaceInCurly)
803+
if (isCurly) {
804+
sb.append('{')
805+
if (useOuterSpace) sb.append(' ')
806+
}
807+
sb.append(importeeSyntax(single))
808+
if (isCurly) {
809+
if (useOuterSpace) sb.append(' ')
810+
sb.append('}')
811+
}
812+
single.endComment.foreach(appendEndComment)
813+
} else {
814+
val lines = i.importees.iterator.map(_.pos.startLine).filter(_ >= 0)
815+
val isMultiline = lines.hasNext && {
816+
val line = lines.next()
817+
lines.exists(_ != line)
818+
}
819+
val origImporters = i.importees
820+
.flatMap(_.originalPrototype().parent)
821+
.distinct
822+
val useOuterSpace =
823+
!isMultiline && origImporters.exists(_.hasSpaceInCurly)
824+
// Preserve a trailing comma if the (single) source importer had one.
825+
val trailingComma = isMultiline && (origImporters match {
826+
case Seq(origImporter: Importer) => origImporter.hasTrailingComma
827+
case _ => false
828+
})
791829
sb.append('{')
792-
if (useOuterSpace) sb.append(' ')
793-
}
794-
sb.append(treeSyntax(single))
795-
if (isCurly) {
796-
if (useOuterSpace) sb.append(' ')
830+
val sep = if (isMultiline) "\n " else " "
831+
if (isMultiline) sb.append(sep)
832+
else if (useOuterSpace) sb.append(' ')
833+
val sblen = sb.length
834+
i.importees.foreach { i2 =>
835+
if (sb.length > sblen) sb.append(',').append(sep)
836+
val proto = i2.originalPrototype()
837+
proto.begComment.foreach(appendBegComment)
838+
sb.append(importeeSyntax(i2))
839+
proto.endComment.foreach(appendEndComment)
840+
}
841+
if (isMultiline) {
842+
if (trailingComma) sb.append(',')
843+
sb.append('\n')
844+
} else if (useOuterSpace) sb.append(' ')
797845
sb.append('}')
798846
}
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
815-
})
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(treeSyntax(i2))
826-
proto.endComment.foreach(appendEndComment)
827-
}
828-
if (isMultiline) {
829-
if (trailingComma) sb.append(',')
830-
sb.append('\n')
831-
} else if (useOuterSpace) sb.append(' ')
832-
sb.append('}')
833847
}
834848
i.endComment.foreach(appendEndComment)
835849
ps.foreach(_.endComment.foreach(appendEndComment))
@@ -1152,23 +1166,73 @@ object OrganizeImports {
11521166
Try(symbol.info).toOption.flatten
11531167
}
11541168

1155-
@inline
1169+
/**
1170+
* Emits an importer ref segment by segment: only leaf names can be taken from
1171+
* the source, so inter-segment trivia (`a . b`, a ref wrapped over several
1172+
* lines) can never leak into the output.
1173+
*/
11561174
private def refSyntax(ref: Term)(implicit dialect: Dialect): String =
11571175
ref match {
1158-
case Term.Select(qual, name) if !name.pos.isEmpty =>
1159-
refSyntax(qual) + "." + name.pos.text
1160-
case _ if !ref.pos.isEmpty =>
1161-
ref.pos.text
1162-
case Term.Select(qual, name) =>
1163-
refSyntax(qual) + "." + treeSyntax(name)
1164-
case _ =>
1165-
treeSyntax(ref)
1176+
case t: Term.Name => nameSyntax(t)
1177+
case Term.Select(qual, name) => refSyntax(qual) + "." + nameSyntax(name)
1178+
case _ => treeSyntax(ref)
11661179
}
11671180

1168-
@inline
1181+
/**
1182+
* Canonical, dialect-normalized syntax. Use for comparison keys ONLY (dedup,
1183+
* grouping, sorting): two importers/importees that mean the same thing must
1184+
* produce the same key regardless of how they were spelled in the source, so
1185+
* ordering here deliberately follows the dialect-normalized spelling rather
1186+
* than e.g. an author's backticks (see `refSyntax`/`importeeSyntax` for
1187+
* output, and https://github.com/scalacenter/scalafix/pull/2500 for why these
1188+
* two must not be conflated).
1189+
*/
11691190
private def treeSyntax(tree: Tree)(implicit dialect: Dialect): String =
11701191
tree.reprint()
11711192

1193+
/**
1194+
* Emits a leaf `Name` as it was spelled in the source, so that backquotes
1195+
* around identifiers the target dialect does not consider keywords are not
1196+
* dropped (soft keywords, e.g. `` `export` ``,
1197+
* https://github.com/scalacenter/scalafix/issues/2480).
1198+
*
1199+
* NB: always reads the name's own position, never `originalPrototype()` --
1200+
* the latter would resurrect pre-rewrite text for a node produced by
1201+
* `copy(field = ...)` (e.g. `expandRelative`'s `replaceTopQualifier`).
1202+
*/
1203+
private def nameSyntax(name: Name)(implicit dialect: Dialect): String =
1204+
name.pos match {
1205+
case p: Position.Range => p.text
1206+
case _ => treeSyntax(name)
1207+
}
1208+
1209+
private def typeSyntax(tpe: Type)(implicit dialect: Dialect): String =
1210+
tpe match {
1211+
case t: Type.Name => nameSyntax(t)
1212+
case Type.Select(qual, name) => refSyntax(qual) + "." + nameSyntax(name)
1213+
case _ => treeSyntax(tpe) // Type.Apply, Type.Project, ...
1214+
}
1215+
1216+
/**
1217+
* Emits an importee: leaf names come from the source, every connective token
1218+
* (`=>` vs `as`, `given`) is produced for the target dialect, so a dialect
1219+
* migration keeps the source spelling of the names it moves. Separators match
1220+
* the pretty-printer's, so output is unchanged for unescaped names.
1221+
*/
1222+
private def importeeSyntax(importee: Importee)(implicit
1223+
dialect: Dialect
1224+
): String = {
1225+
def arrow = if (dialect.allowAsForImportRename) "as" else "=>"
1226+
importee match {
1227+
case Importee.Name(name) => nameSyntax(name)
1228+
case Importee.Rename(name, rename) =>
1229+
s"${nameSyntax(name)} $arrow ${nameSyntax(rename)}"
1230+
case Importee.Unimport(name) => s"${nameSyntax(name)} $arrow _"
1231+
case Importee.Given(tpe) => "given " + typeSyntax(tpe)
1232+
case _ => treeSyntax(importee) // Wildcard, GivenAll
1233+
}
1234+
}
1235+
11721236
implicit private class ImporteeExtension(val importee: Importee)
11731237
extends AnyVal {
11741238

@@ -1237,6 +1301,40 @@ object OrganizeImports {
12371301
tokens.getWideOpt(idx).exists(_.is[Token.Comma])
12381302
}
12391303

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

12421340
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: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/*
2+
rules = [OrganizeImports]
3+
OrganizeImports.removeUnused = false
4+
OrganizeImports.targetDialect = StandardLayout
5+
OrganizeImports.groupedImports = Merge
6+
*/
7+
package test.organizeImports
8+
9+
// `export` is a plain member name here, not a qualifier; merging two imports
10+
// from the same prefix into one importer must not drop its backticks.
11+
import test.organizeImports.QuotedIdent.ea
12+
import test.organizeImports.QuotedIdent.`export`
13+
14+
object MergeScala3KeywordImportee
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: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/*
2+
rules = [OrganizeImports]
3+
OrganizeImports {
4+
groupedImports = Keep
5+
removeUnused = false
6+
}
7+
*/
8+
package test.organizeImports
9+
10+
import scala.collection.immutable.{
11+
// pick a map
12+
Map,
13+
Set,
14+
}
15+
16+
object VerbatimInteriorComment {
17+
val m: Map[Int, Int] = Map.empty
18+
val s: Set[Int] = Set.empty
19+
}
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+
}

0 commit comments

Comments
 (0)