@@ -191,24 +191,52 @@ public final class OrderedImports: SyntaxFormatRule {
191
191
/// import lines should be assocaited with it, and move with the line during sorting. We also emit
192
192
/// a linter error if an import line is discovered to be out of order.
193
193
private func formatImports( _ imports: [ Line ] ) -> [ Line ] {
194
- var linesWithLeadingComments : [ ( Line , [ Line ] ) ] = [ ]
194
+ var linesWithLeadingComments : [ ( import: Line , comments: [ Line ] ) ] = [ ]
195
+ var visitedImports : [ String : Int ] = [ : ]
195
196
var commentBuffer : [ Line ] = [ ]
196
197
var previousImport : Line ? = nil
197
198
var diagnosed = false
198
199
199
200
for line in imports {
200
201
switch line. type {
201
202
case . regularImport, . declImport, . testableImport:
203
+ let fullyQualifiedImport = line. fullyQualifiedImport
204
+ // Check for duplicate imports and potentially remove them.
205
+ if let previousMatchingImportIndex = visitedImports [ fullyQualifiedImport] {
206
+ // Even if automatically removing this import is impossible, alert the user that this is a
207
+ // duplicate so they can manually fix it.
208
+ diagnose ( . removeDuplicateImport, on: line. firstToken)
209
+ var duplicateLine = linesWithLeadingComments [ previousMatchingImportIndex]
210
+
211
+ // We can combine multiple leading comments, but it's unsafe to combine trailing comments.
212
+ // Any extra comments must go on a new line, and would be grouped with the next import.
213
+ guard !duplicateLine. import. trailingTrivia. isEmpty && !line. trailingTrivia. isEmpty else {
214
+ duplicateLine. comments. append ( contentsOf: commentBuffer)
215
+ commentBuffer = [ ]
216
+ // Keep the Line that has the trailing comment, if there is one.
217
+ if !line. trailingTrivia. isEmpty {
218
+ duplicateLine. import = line
219
+ }
220
+ linesWithLeadingComments [ previousMatchingImportIndex] = duplicateLine
221
+ continue
222
+ }
223
+ // Otherwise, both lines have trailing trivia so it's not safe to automatically merge
224
+ // them. Leave this duplicate import.
225
+ }
202
226
if let previousImport = previousImport,
203
227
line. importName. lexicographicallyPrecedes ( previousImport. importName) && !diagnosed
228
+ // Only warn to sort imports that shouldn't be removed.
229
+ && visitedImports [ fullyQualifiedImport] == nil
204
230
{
205
231
diagnose ( . sortImports, on: line. firstToken)
206
232
diagnosed = true // Only emit one of these errors to avoid alert fatigue.
207
233
}
234
+
208
235
// Pack the import line and its associated comments into a tuple.
209
236
linesWithLeadingComments. append ( ( line, commentBuffer) )
210
237
commentBuffer = [ ]
211
238
previousImport = line
239
+ visitedImports [ fullyQualifiedImport] = linesWithLeadingComments. endIndex - 1
212
240
case . comment:
213
241
commentBuffer. append ( line)
214
242
default : ( )
@@ -449,13 +477,33 @@ fileprivate class Line {
449
477
return . blankLine
450
478
}
451
479
480
+ /// Returns a fully qualified description of this line's import if it's an import statement,
481
+ /// including any attributes, modifiers, the import kind, and the import path. When this line
482
+ /// isn't an import statement, returns an empty string.
483
+ var fullyQualifiedImport : String {
484
+ guard let syntaxNode = syntaxNode, case . importCodeBlock( let importCodeBlock, _) = syntaxNode,
485
+ let importDecl = importCodeBlock. item. as ( ImportDeclSyntax . self)
486
+ else {
487
+ return " "
488
+ }
489
+ // Using the description is a reliable way to include all content from the import, but
490
+ // description includes all leading and trailing trivia. It would be unusual to have any
491
+ // non-whitespace trivia on the components of the import. Trim off the leading trivia, where
492
+ // comments could be, and trim whitespace that might be after the import.
493
+ let leadingText = importDecl. leadingTrivia? . reduce ( into: " " ) { $1. write ( to: & $0) } ?? " "
494
+ return importDecl. description. dropFirst ( leadingText. count)
495
+ . trimmingCharacters ( in: . whitespacesAndNewlines)
496
+ }
497
+
498
+ /// Returns the path that is imported by this line's import statement if it's an import statement.
499
+ /// When this line isn't an import statement, returns an empty string.
452
500
var importName : String {
453
501
guard let syntaxNode = syntaxNode, case . importCodeBlock( let importCodeBlock, _) = syntaxNode,
454
502
let importDecl = importCodeBlock. item. as ( ImportDeclSyntax . self)
455
503
else {
456
504
return " "
457
505
}
458
- return importDecl. path. description
506
+ return importDecl. path. description. trimmingCharacters ( in : . whitespaces )
459
507
}
460
508
461
509
/// Returns the first `TokenSyntax` in the code block(s) from this Line, or nil when this Line
@@ -535,6 +583,8 @@ extension Diagnostic.Message {
535
583
return Diagnostic . Message ( . warning, " place \( before) imports before \( after) imports " )
536
584
}
537
585
586
+ public static let removeDuplicateImport = Diagnostic . Message ( . warning, " remove duplicate import " )
587
+
538
588
public static let sortImports =
539
589
Diagnostic . Message ( . warning, " sort import statements lexicographically " )
540
590
}
0 commit comments