Skip to content

Commit f512fa8

Browse files
atamez31allevato
authored andcommitted
Implementation of OrderedImports (swiftlang#81)
1 parent 29ec14d commit f512fa8

File tree

3 files changed

+386
-0
lines changed

3 files changed

+386
-0
lines changed

tools/swift-format/Sources/Rules/OrderedImports.swift

+232
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,237 @@ import SwiftSyntax
1212
///
1313
/// - SeeAlso: https://google.github.io/swift#import-statements
1414
public final class OrderedImports: SyntaxFormatRule {
15+
public override func visit(_ node: SourceFileSyntax) -> Syntax {
16+
var fileElements = [CodeBlockItemSyntax]()
17+
fileElements.append(contentsOf: orderStatements(node))
18+
let statements = SyntaxFactory.makeCodeBlockItemList(fileElements)
19+
20+
return node.withStatements(statements)
21+
}
22+
23+
/// Gathers all the statements of the sourcefile, separates the imports in their appropriate
24+
/// group and sorts each group by lexicographically order.
25+
private func orderStatements(_ node: SourceFileSyntax) -> [CodeBlockItemSyntax] {
26+
var fileComment = Trivia()
27+
var impComment = Trivia()
28+
var (allImports, allCode) = getAllImports(node.statements)
29+
30+
if let firstImport = allImports.first,
31+
let firstStatement = node.statements.first,
32+
firstImport.statement == firstStatement {
33+
// Extracts the comments of the imports and separates them into file comments
34+
// and specific comments for the first import statement.
35+
(fileComment, impComment) = getComments(node.statements.first!)
36+
allImports[0].statement = replaceTrivia(
37+
on: node.statements.first!,
38+
token: node.statements.first?.firstToken,
39+
leadingTrivia: .newlines(1) + impComment
40+
) as! CodeBlockItemSyntax
41+
}
1542

43+
let importGroups = groupImports(allImports)
44+
let sortedImportGroups = sortImports(importGroups)
45+
var allStatements = joinsImports(sortedImportGroups)
46+
allStatements.append(contentsOf: allCode)
47+
48+
// After all the imports have been grouped and sorted, the leading trivia of the new first
49+
// import has to be rewrite to delete any extra newlines and append any file comment.
50+
if let firstImport = allStatements.first{
51+
allStatements[0] = replaceTrivia(
52+
on: firstImport,
53+
token: firstImport.firstToken,
54+
leadingTrivia: fileComment + firstImport.leadingTrivia!.withoutLeadingNewLines()
55+
) as! CodeBlockItemSyntax
56+
}
57+
58+
return allStatements
59+
}
60+
61+
/// Groups the imports in their appropiate group and returns a collection that contains
62+
/// the three types of imports groups
63+
func groupImports(_ imports: ([(statement: CodeBlockItemSyntax, type: ImportType)])) ->
64+
[[CodeBlockItemSyntax]] {
65+
var importGroups = [[CodeBlockItemSyntax](), [CodeBlockItemSyntax](), [CodeBlockItemSyntax]()]
66+
for imp in imports {
67+
// Remove all extra blank lines from the import trivia.
68+
let importWithCleanTrivia = replaceTrivia(
69+
on: imp.statement,
70+
token: imp.statement.firstToken,
71+
leadingTrivia: removeExtraBlankLines(imp.statement.leadingTrivia!.withoutTrailingSpaces())
72+
) as! CodeBlockItemSyntax
73+
74+
importGroups[imp.type.rawValue].append(importWithCleanTrivia)
75+
}
76+
return importGroups
77+
}
78+
79+
/// Joins the three types of imports into one single colletion and separates
80+
/// the import groups with one blank line between them.
81+
func joinsImports(_ statements: [[CodeBlockItemSyntax]]) -> [CodeBlockItemSyntax] {
82+
return statements.flatMap { (imports: [CodeBlockItemSyntax]) -> [CodeBlockItemSyntax] in
83+
// Ensures only the first import of each group has the
84+
// blank line separator.
85+
if statements.first != imports {
86+
var newImports = imports
87+
newImports[0] = replaceTrivia(
88+
on: imports.first!,
89+
token: imports.first!.firstToken,
90+
leadingTrivia: .newlines(1) + imports.first!.leadingTrivia!
91+
) as! CodeBlockItemSyntax
92+
return newImports
93+
}
94+
return imports
95+
}
96+
}
97+
98+
/// Sorts all the import groups by lelexicographically order.
99+
func sortImports(_ imports: [[CodeBlockItemSyntax]]) -> [[CodeBlockItemSyntax]] {
100+
return imports.filter { !$0.isEmpty }.map { (imports: [CodeBlockItemSyntax]) ->
101+
[CodeBlockItemSyntax] in
102+
if !imports.isEmpty && !isSorted(imports) {
103+
diagnose(.sortImports, on: imports.first)
104+
return imports.sorted(by: { (l, r) -> Bool in
105+
return (l.item as! ImportDeclSyntax).path.description <
106+
(r.item as! ImportDeclSyntax).path.description
107+
})
108+
}
109+
else {
110+
return imports
111+
}
112+
}
113+
}
114+
115+
/// Separates all the statements of a file into a collection of the imports
116+
/// and all the rest of the code.
117+
func getAllImports(_ statements: CodeBlockItemListSyntax) ->
118+
([(statement: CodeBlockItemSyntax, type: ImportType)],
119+
[CodeBlockItemSyntax]) {
120+
var readerMode: ImportType?
121+
var codeEncountered = false
122+
var allCode = [CodeBlockItemSyntax]()
123+
// It's assume that most of the staments are not imports.
124+
allCode.reserveCapacity(statements.count)
125+
126+
let allImports = statements.compactMap { (statement: CodeBlockItemSyntax) ->
127+
(statement: CodeBlockItemSyntax, type: ImportType)? in
128+
if statement.item is ImportDeclSyntax {
129+
if codeEncountered {
130+
diagnose(.placeAtTheBeginning(importName: statement.description), on: statement)
131+
}
132+
133+
let newReaderMode = classifyImport(statement)
134+
if readerMode != nil && newReaderMode!.rawValue < readerMode!.rawValue {
135+
diagnose(
136+
.orderImportsGroups(
137+
firstImpType: importTypeName(readerMode!.rawValue),
138+
secondImpType: importTypeName(newReaderMode!.rawValue)
139+
),
140+
on: statement
141+
)
142+
}
143+
readerMode = newReaderMode
144+
return (statement, newReaderMode!)
145+
}
146+
codeEncountered = true
147+
allCode.append(statement)
148+
return nil
149+
}
150+
return (allImports, allCode)
151+
}
152+
153+
/// If the given trivia contains a blank line between comments, it returns
154+
/// two trivias, one with all the pieces before the blankline and the other
155+
/// with the pieces after it
156+
func getComments(_ firstImp: CodeBlockItemSyntax) -> (fileComments: Trivia, impComments: Trivia) {
157+
var fileComments = [TriviaPiece]()
158+
var impComments = [TriviaPiece]()
159+
guard let firstTokenTrivia = firstImp.leadingTrivia else {
160+
return (Trivia(pieces: fileComments), Trivia(pieces: impComments))
161+
}
162+
var hasFoundBlankLine = false
163+
164+
for piece in firstTokenTrivia.withoutTrailingSpaces() {
165+
if !hasFoundBlankLine, case .newlines(let num) = piece, num > 1 {
166+
hasFoundBlankLine = true
167+
fileComments.append(piece)
168+
}
169+
else if !hasFoundBlankLine {
170+
fileComments.append(piece)
171+
}
172+
else {
173+
impComments.append(piece)
174+
}
175+
}
176+
return (Trivia(pieces: fileComments), Trivia(pieces: impComments))
177+
}
178+
179+
/// Return the given trivia without any set of consecutive blank lines
180+
func removeExtraBlankLines(_ trivia: Trivia) -> Trivia {
181+
var pieces = [TriviaPiece]()
182+
for piece in trivia.withoutTrailingSpaces() {
183+
if case .newlines(let num) = piece, num > 1 {
184+
pieces.append(.newlines(1))
185+
}
186+
else {
187+
pieces.append(piece)
188+
}
189+
}
190+
return Trivia(pieces: pieces)
191+
}
192+
193+
enum ImportType: Int {
194+
case regularImport = 0
195+
case individualImport
196+
case testableImport
197+
}
198+
199+
/// Indicates to which import type those the given import belongs.
200+
func classifyImport(_ impStatement: CodeBlockItemSyntax) -> ImportType? {
201+
guard let importToken = impStatement.firstToken else {return nil}
202+
guard let nextToken = importToken.nextToken else {return nil}
203+
204+
if importToken.tokenKind == .atSign && nextToken.text == "testable" {
205+
return ImportType.testableImport
206+
}
207+
if nextToken.tokenKind != .identifier(nextToken.text) {
208+
return ImportType.individualImport
209+
}
210+
return ImportType.regularImport
211+
}
212+
213+
/// Return the name of the given import group type.
214+
func importTypeName(_ type: Int) -> String {
215+
switch type {
216+
case 0:
217+
return "regular imports"
218+
case 1:
219+
return "Individual declaration imports"
220+
default:
221+
return "@testable imports"
222+
}
223+
}
224+
225+
/// Returns a bool indicating if the given collection of imports is ordered by
226+
/// lexicographically order.
227+
func isSorted(_ imports: [CodeBlockItemSyntax]) -> Bool {
228+
for index in 1..<imports.count {
229+
if (imports[index - 1].item as! ImportDeclSyntax).path.description > (imports[index].item as! ImportDeclSyntax).path.description {
230+
return false
231+
}
232+
}
233+
return true
234+
}
235+
}
236+
237+
extension Diagnostic.Message {
238+
static func placeAtTheBeginning(importName: String) -> Diagnostic.Message {
239+
return Diagnostic.Message(.warning, "Place the \(importName) at the beginning of the file.")
240+
}
241+
242+
static func orderImportsGroups(firstImpType: String, secondImpType: String) -> Diagnostic.Message {
243+
return Diagnostic.Message(.warning, "Place the \(secondImpType) after the \(firstImpType).")
244+
}
245+
246+
static let sortImports =
247+
Diagnostic.Message(.warning, "Sort the imports by lexicographically order.")
16248
}

tools/swift-format/Sources/Rules/Trivia+Convenience.swift

+16
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,22 @@ extension Trivia {
5050
return Trivia(pieces: pieces).condensed()
5151
}
5252

53+
/// Returns this set of trivia, without any trailing whitespace characters.
54+
func withoutLeadingNewLines() -> Trivia {
55+
let triviaCondensed = self.condensed()
56+
guard let firstPieceOfTrivia = triviaCondensed.first else { return self }
57+
if case .newlines(_) = firstPieceOfTrivia {
58+
var pieces = [TriviaPiece]()
59+
for piece in triviaCondensed.dropFirst() {
60+
pieces.append(piece)
61+
}
62+
return Trivia(pieces: pieces)
63+
}
64+
else {
65+
return self
66+
}
67+
}
68+
5369
/// Returns this set of trivia, without any newlines.
5470
func withoutNewlines() -> Trivia {
5571
return Trivia(pieces: filter {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import SwiftSyntax
2+
import XCTest
3+
4+
@testable import Rules
5+
6+
public class OrderedImportsTests: DiagnosingTestCase {
7+
public func testInvalidImportsOrder() {
8+
XCTAssertFormatting(
9+
OrderedImports.self,
10+
input: """
11+
import Foundation
12+
// Starts Imports
13+
import Core
14+
15+
16+
// Comment with new lines
17+
import UIKit
18+
19+
@testable import Rules
20+
import enum Darwin.D.isatty
21+
// Starts Test
22+
@testable import MyModuleUnderTest
23+
// Starts Ind
24+
import func Darwin.C.isatty
25+
26+
let a = 3
27+
import SwiftSyntax
28+
""",
29+
expected: """
30+
// Starts Imports
31+
import Core
32+
import Foundation
33+
import SwiftSyntax
34+
// Comment with new lines
35+
import UIKit
36+
37+
// Starts Ind
38+
import func Darwin.C.isatty
39+
import enum Darwin.D.isatty
40+
41+
// Starts Test
42+
@testable import MyModuleUnderTest
43+
@testable import Rules
44+
45+
let a = 3
46+
""")
47+
}
48+
49+
public func testImportsOrderWithoutModuleType() {
50+
XCTAssertFormatting(
51+
OrderedImports.self,
52+
input: """
53+
@testable import Rules
54+
import func Darwin.D.isatty
55+
@testable import MyModuleUnderTest
56+
import func Darwin.C.isatty
57+
58+
let a = 3
59+
""",
60+
expected: """
61+
import func Darwin.C.isatty
62+
import func Darwin.D.isatty
63+
64+
@testable import MyModuleUnderTest
65+
@testable import Rules
66+
67+
let a = 3
68+
""")
69+
}
70+
71+
public func testImportsOrderWithDocComment() {
72+
XCTAssertFormatting(
73+
OrderedImports.self,
74+
input: """
75+
/// Test imports with comments.
76+
///
77+
/// Comments at the top of the file
78+
/// should be preserved.
79+
80+
// Line comment for import
81+
// Foundation.
82+
import Foundation
83+
// Line comment for Core
84+
import Core
85+
import UIKit
86+
87+
let a = 3
88+
""",
89+
expected: """
90+
/// Test imports with comments.
91+
///
92+
/// Comments at the top of the file
93+
/// should be preserved.
94+
95+
// Line comment for Core
96+
import Core
97+
// Line comment for import
98+
// Foundation.
99+
import Foundation
100+
import UIKit
101+
102+
let a = 3
103+
""")
104+
}
105+
106+
public func testValidOrderedImport() {
107+
XCTAssertFormatting(
108+
OrderedImports.self,
109+
input: """
110+
import CoreLocation
111+
import MyThirdPartyModule
112+
import SpriteKit
113+
import UIKit
114+
115+
import func Darwin.C.isatty
116+
117+
@testable import MyModuleUnderTest
118+
""",
119+
expected: """
120+
import CoreLocation
121+
import MyThirdPartyModule
122+
import SpriteKit
123+
import UIKit
124+
125+
import func Darwin.C.isatty
126+
127+
@testable import MyModuleUnderTest
128+
""")
129+
}
130+
#if !os(macOS)
131+
static let allTests = [
132+
OrderedImportsTests.testInvalidImportsOrder,
133+
OrderedImportsTests.testImportsOrderWithoutModuleType,
134+
OrderedImportsTests.testImportsOrderWithDocComment,
135+
OrderedImportsTests.testValidOrderedImport
136+
]
137+
#endif
138+
}

0 commit comments

Comments
 (0)