Skip to content

Commit 9cbfbcb

Browse files
authored
Merge pull request swiftlang#150 from dylansturg/nuke_duplicate_imports
Delete duplicate imports.
2 parents 05ac891 + 7324c20 commit 9cbfbcb

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)