Skip to content

Commit 7324c20

Browse files
committed
Delete duplicate imports.
The criteria for deletion is: - Has an identical path, excluding whitespace, as an existing import - No more than 1 of the matching imports have a trailing comment The trailing comment rule is necessary because it's impossible to safely combine multiple trailing comments such that they all stay grouped to the relevant import. Leading comments are safe to combine, because OrderedImports keeps multiple lines of leading comments grouped to the next import. Rather than trying to keep the trailing comments on 1 line together or promote 1 trailing comment to a leading comment, it's safer to raise the diagnostic and keep both imports.
1 parent 05ac891 commit 7324c20

File tree

3 files changed

+202
-2
lines changed

3 files changed

+202
-2
lines changed

Sources/SwiftFormatRules/OrderedImports.swift

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,24 +191,52 @@ public final class OrderedImports: SyntaxFormatRule {
191191
/// import lines should be assocaited with it, and move with the line during sorting. We also emit
192192
/// a linter error if an import line is discovered to be out of order.
193193
private func formatImports(_ imports: [Line]) -> [Line] {
194-
var linesWithLeadingComments: [(Line, [Line])] = []
194+
var linesWithLeadingComments: [(import: Line, comments: [Line])] = []
195+
var visitedImports: [String: Int] = [:]
195196
var commentBuffer: [Line] = []
196197
var previousImport: Line? = nil
197198
var diagnosed = false
198199

199200
for line in imports {
200201
switch line.type {
201202
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+
}
202226
if let previousImport = previousImport,
203227
line.importName.lexicographicallyPrecedes(previousImport.importName) && !diagnosed
228+
// Only warn to sort imports that shouldn't be removed.
229+
&& visitedImports[fullyQualifiedImport] == nil
204230
{
205231
diagnose(.sortImports, on: line.firstToken)
206232
diagnosed = true // Only emit one of these errors to avoid alert fatigue.
207233
}
234+
208235
// Pack the import line and its associated comments into a tuple.
209236
linesWithLeadingComments.append((line, commentBuffer))
210237
commentBuffer = []
211238
previousImport = line
239+
visitedImports[fullyQualifiedImport] = linesWithLeadingComments.endIndex - 1
212240
case .comment:
213241
commentBuffer.append(line)
214242
default: ()
@@ -449,13 +477,33 @@ fileprivate class Line {
449477
return .blankLine
450478
}
451479

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.
452500
var importName: String {
453501
guard let syntaxNode = syntaxNode, case .importCodeBlock(let importCodeBlock, _) = syntaxNode,
454502
let importDecl = importCodeBlock.item.as(ImportDeclSyntax.self)
455503
else {
456504
return ""
457505
}
458-
return importDecl.path.description
506+
return importDecl.path.description.trimmingCharacters(in: .whitespaces)
459507
}
460508

461509
/// Returns the first `TokenSyntax` in the code block(s) from this Line, or nil when this Line
@@ -535,6 +583,8 @@ extension Diagnostic.Message {
535583
return Diagnostic.Message(.warning, "place \(before) imports before \(after) imports")
536584
}
537585

586+
public static let removeDuplicateImport = Diagnostic.Message(.warning, "remove duplicate import")
587+
538588
public static let sortImports =
539589
Diagnostic.Message(.warning, "sort import statements lexicographically")
540590
}

Tests/SwiftFormatRulesTests/OrderedImportsTests.swift

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,4 +395,150 @@ final class OrderedImportsTests: LintOrFormatRuleTestCase {
395395
checkForUnassertedDiagnostics: true
396396
)
397397
}
398+
399+
func testRemovesDuplicateImports() {
400+
let input =
401+
"""
402+
import CoreLocation
403+
import UIKit
404+
import CoreLocation
405+
import ZeeFramework
406+
bar()
407+
"""
408+
409+
let expected =
410+
"""
411+
import CoreLocation
412+
import UIKit
413+
import ZeeFramework
414+
415+
bar()
416+
"""
417+
418+
XCTAssertFormatting(
419+
OrderedImports.self, input: input, expected: expected, checkForUnassertedDiagnostics: true)
420+
XCTAssertDiagnosed(.removeDuplicateImport, line: 3, column: 1)
421+
}
422+
423+
func testDuplicateCommentedImports() {
424+
let input =
425+
"""
426+
import AppKit
427+
// CoreLocation is necessary to get location stuff.
428+
import CoreLocation // This import must stay.
429+
// UIKit does UI Stuff?
430+
import UIKit
431+
// This is the second CoreLocation import.
432+
import CoreLocation // The 2nd CL import has a comment here too.
433+
// Comment about ZeeFramework.
434+
import ZeeFramework
435+
import foo
436+
// Second comment about ZeeFramework.
437+
import ZeeFramework // This one has a trailing comment too.
438+
foo()
439+
"""
440+
441+
let expected =
442+
"""
443+
import AppKit
444+
// CoreLocation is necessary to get location stuff.
445+
import CoreLocation // This import must stay.
446+
// This is the second CoreLocation import.
447+
import CoreLocation // The 2nd CL import has a comment here too.
448+
// UIKit does UI Stuff?
449+
import UIKit
450+
// Comment about ZeeFramework.
451+
// Second comment about ZeeFramework.
452+
import ZeeFramework // This one has a trailing comment too.
453+
import foo
454+
455+
foo()
456+
"""
457+
458+
XCTAssertFormatting(
459+
OrderedImports.self, input: input, expected: expected, checkForUnassertedDiagnostics: true)
460+
461+
// Even though this import is technically also not sorted, that won't matter if the import is
462+
// removed so there should only be a warning to remove it.
463+
XCTAssertDiagnosed(.removeDuplicateImport, line: 7, column: 1)
464+
XCTAssertDiagnosed(.removeDuplicateImport, line: 12, column: 1)
465+
}
466+
467+
func testDuplicateIgnoredImports() {
468+
let input =
469+
"""
470+
import AppKit
471+
// swift-format-ignore
472+
import CoreLocation
473+
// Second CoreLocation import here.
474+
import CoreLocation
475+
// Comment about ZeeFramework.
476+
import ZeeFramework
477+
// swift-format-ignore
478+
import ZeeFramework // trailing comment
479+
foo()
480+
"""
481+
482+
let expected =
483+
"""
484+
import AppKit
485+
486+
// swift-format-ignore
487+
import CoreLocation
488+
489+
// Second CoreLocation import here.
490+
import CoreLocation
491+
// Comment about ZeeFramework.
492+
import ZeeFramework
493+
494+
// swift-format-ignore
495+
import ZeeFramework // trailing comment
496+
497+
foo()
498+
"""
499+
500+
XCTAssertFormatting(
501+
OrderedImports.self, input: input, expected: expected, checkForUnassertedDiagnostics: true)
502+
}
503+
504+
func testDuplicateAttributedImports() {
505+
let input =
506+
"""
507+
// imports an enum
508+
import enum Darwin.D.isatty
509+
// this is a dup
510+
import enum Darwin.D.isatty
511+
import foo
512+
import a
513+
@testable import foo
514+
// exported import of bar
515+
@_exported import bar
516+
@_implementationOnly import bar
517+
import bar
518+
// second import of foo
519+
import foo
520+
baz()
521+
"""
522+
523+
let expected =
524+
"""
525+
import a
526+
// exported import of bar
527+
@_exported import bar
528+
@_implementationOnly import bar
529+
import bar
530+
// second import of foo
531+
import foo
532+
533+
// imports an enum
534+
// this is a dup
535+
import enum Darwin.D.isatty
536+
537+
@testable import foo
538+
539+
baz()
540+
"""
541+
542+
XCTAssertFormatting(OrderedImports.self, input: input, expected: expected)
543+
}
398544
}

Tests/SwiftFormatRulesTests/XCTestManifests.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,13 +266,17 @@ extension OrderedImportsTests {
266266
static let __allTests__OrderedImportsTests = [
267267
("testDisableOrderedImports", testDisableOrderedImports),
268268
("testDisableOrderedImportsMovingComments", testDisableOrderedImportsMovingComments),
269+
("testDuplicateAttributedImports", testDuplicateAttributedImports),
270+
("testDuplicateCommentedImports", testDuplicateCommentedImports),
271+
("testDuplicateIgnoredImports", testDuplicateIgnoredImports),
269272
("testEmptyFile", testEmptyFile),
270273
("testImportsOrderWithDocComment", testImportsOrderWithDocComment),
271274
("testImportsOrderWithoutModuleType", testImportsOrderWithoutModuleType),
272275
("testInvalidImportsOrder", testInvalidImportsOrder),
273276
("testMultipleCodeBlocksPerLine", testMultipleCodeBlocksPerLine),
274277
("testMultipleCodeBlocksWithImportsPerLine", testMultipleCodeBlocksWithImportsPerLine),
275278
("testNonHeaderComment", testNonHeaderComment),
279+
("testRemovesDuplicateImports", testRemovesDuplicateImports),
276280
("testSeparatedFileHeader", testSeparatedFileHeader),
277281
("testValidOrderedImport", testValidOrderedImport),
278282
]

0 commit comments

Comments
 (0)