mirror of
https://github.com/realm/SwiftLint.git
synced 2026-06-06 20:18:40 +00:00
263 lines
11 KiB
Swift
263 lines
11 KiB
Swift
import Foundation
|
|
import SourceKittenFramework
|
|
import SwiftSyntax
|
|
|
|
@SwiftSyntaxRule(explicitRewriter: true, optIn: true)
|
|
struct SortedImportsRule: Rule {
|
|
var configuration = SortedImportsConfiguration()
|
|
|
|
static let description = RuleDescription(
|
|
identifier: "sorted_imports",
|
|
name: "Sorted Imports",
|
|
description: "Imports should be sorted",
|
|
kind: .style,
|
|
nonTriggeringExamples: SortedImportsRuleExamples.nonTriggeringExamples,
|
|
triggeringExamples: SortedImportsRuleExamples.triggeringExamples,
|
|
corrections: SortedImportsRuleExamples.corrections
|
|
)
|
|
}
|
|
|
|
private extension SortedImportsRule {
|
|
final class Visitor: ViolationsSyntaxVisitor<ConfigurationType> {
|
|
private var imports = [Import]()
|
|
|
|
override var skippableDeclarations: [any DeclSyntaxProtocol.Type] { .all }
|
|
|
|
override func visitPost(_ node: ImportDeclSyntax) {
|
|
imports.append(
|
|
Import.from(importDecl: node, grouping: configuration.grouping, locationConverter: locationConverter)
|
|
)
|
|
}
|
|
|
|
override func visitPost(_: SourceFileSyntax) {
|
|
// Group imports that are adjacent to each other.
|
|
var importBlocks = [[Import]]()
|
|
for `import` in imports {
|
|
if let lastBlock = importBlocks.last, let lastImport = lastBlock.last {
|
|
if `import`.isDirectlyAfter(previous: lastImport, in: file) {
|
|
importBlocks[importBlocks.count - 1].append(`import`)
|
|
} else {
|
|
importBlocks.append([`import`])
|
|
}
|
|
} else {
|
|
importBlocks.append([`import`])
|
|
}
|
|
}
|
|
|
|
// For every block, check that the imports are sorted.
|
|
for block in importBlocks {
|
|
for (previous, current) in zip(block, block.dropFirst()) where previous > current {
|
|
violations.append(current.violationPosition)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
final class Rewriter: ViolationsSyntaxRewriter<ConfigurationType> {
|
|
override func visit(_ node: SourceFileSyntax) -> SourceFileSyntax {
|
|
var statements = rewrite(statements: node.statements)
|
|
if numberOfCorrections == 0 {
|
|
return super.visit(node)
|
|
}
|
|
if let leadingTrivia = statements.first?.leadingTrivia {
|
|
statements[0] = statements[0].with(\.leadingTrivia, leadingTrivia.skippingLeadingNewline)
|
|
}
|
|
return super.visit(node.with(\.statements, CodeBlockItemListSyntax(statements)))
|
|
}
|
|
|
|
private func rewrite(statements: CodeBlockItemListSyntax) -> [CodeBlockItemSyntax] {
|
|
var rewrittenStatements = [CodeBlockItemSyntax]()
|
|
var imports = [Import]()
|
|
|
|
for stmt in statements {
|
|
if let importDecl = stmt.item.as(ImportDeclSyntax.self) {
|
|
if importDecl.isContainedIn(regions: disabledRegions, locationConverter: locationConverter) {
|
|
rewrittenStatements.append(stmt)
|
|
continue
|
|
}
|
|
let `import` = Import.from(
|
|
importDecl: importDecl,
|
|
grouping: configuration.grouping,
|
|
locationConverter: locationConverter
|
|
)
|
|
if let lastImport = imports.last, !`import`.isDirectlyAfter(previous: lastImport, in: file) {
|
|
rewrittenStatements.append(contentsOf: sort(imports))
|
|
imports = [`import`]
|
|
continue
|
|
}
|
|
imports.append(`import`)
|
|
} else if let ifConfigDecl = stmt.item.as(IfConfigDeclSyntax.self) {
|
|
// Recursively rewrite imports inside `#if` blocks.
|
|
let rewrittenClauses = ifConfigDecl.clauses.map { clause in
|
|
let rewrittenClause = clause.elements.map { elements in
|
|
let rewrittenElements = rewrite(statements: elements.as(CodeBlockItemListSyntax.self) ?? [])
|
|
return CodeBlockItemListSyntax(rewrittenElements)
|
|
}
|
|
return clause.with(\.elements, .statements(rewrittenClause ?? []))
|
|
}
|
|
let rewrittenIfConfig = ifConfigDecl.with(\.clauses, IfConfigClauseListSyntax(rewrittenClauses))
|
|
if !imports.isEmpty {
|
|
rewrittenStatements.append(contentsOf: sort(imports))
|
|
imports = []
|
|
}
|
|
rewrittenStatements.append(CodeBlockItemSyntax(item: .decl(DeclSyntax(rewrittenIfConfig))))
|
|
} else {
|
|
rewrittenStatements.append(contentsOf: sort(imports))
|
|
imports = []
|
|
rewrittenStatements.append(stmt)
|
|
}
|
|
}
|
|
rewrittenStatements.append(contentsOf: sort(imports))
|
|
return rewrittenStatements
|
|
}
|
|
|
|
private func sort(_ imports: [Import]) -> [CodeBlockItemSyntax] {
|
|
guard imports.count > 1, let firstImport = imports.first else {
|
|
return imports.map(\.importDecl.asCodeBlockItem)
|
|
}
|
|
let leadingTrivia = firstImport.importDecl.leadingTrivia.splitBlocks
|
|
if leadingTrivia.foundSplit {
|
|
// Comment with extra newlines before first import.
|
|
let firstImportWithoutComment = Import.from(
|
|
importDecl: firstImport.importDecl.with(\.leadingTrivia, leadingTrivia.second),
|
|
grouping: configuration.grouping,
|
|
locationConverter: locationConverter
|
|
)
|
|
let imports = [firstImportWithoutComment] + imports.dropFirst()
|
|
let sorted = imports.sorted(by: { $0 < $1 })
|
|
numberOfCorrections += imports.difference(from: sorted).count
|
|
let first = sorted.first!.importDecl
|
|
let firstWithTrivia = firstImportWithoutComment == sorted.first
|
|
? first.with(\.leadingTrivia, firstImport.importDecl.leadingTrivia)
|
|
: first.with(\.leadingTrivia, leadingTrivia.first + first.leadingTrivia)
|
|
return [firstWithTrivia.asCodeBlockItem] + sorted.dropFirst().map(\.importDecl.asCodeBlockItem)
|
|
}
|
|
let sorted = imports.sorted(by: { $0 < $1 })
|
|
numberOfCorrections += imports.difference(from: sorted).count
|
|
return sorted.map(\.importDecl.asCodeBlockItem)
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension Trivia {
|
|
var skippingLeadingNewline: Self {
|
|
if pieces.onlyElement?.isNewline == true {
|
|
Trivia(pieces: dropLast())
|
|
} else if containsComments, pieces.first?.isNewline == true {
|
|
Trivia(pieces: dropFirst())
|
|
} else {
|
|
self
|
|
}
|
|
}
|
|
|
|
var splitBlocks: (first: Trivia, second: Trivia, foundSplit: Bool) {
|
|
var leading = [TriviaPiece]()
|
|
var trailing = [TriviaPiece]()
|
|
var foundSplit = false
|
|
|
|
for piece in pieces {
|
|
if case let .newlines(count) = piece, count > 1 {
|
|
leading.append(.newlines(count - 1))
|
|
foundSplit = true
|
|
} else if foundSplit {
|
|
trailing.append(piece)
|
|
} else {
|
|
leading.append(piece)
|
|
}
|
|
}
|
|
return (Trivia(pieces: leading), Trivia(pieces: trailing), foundSplit)
|
|
}
|
|
}
|
|
private extension ImportDeclSyntax {
|
|
var asCodeBlockItem: CodeBlockItemSyntax {
|
|
let item = CodeBlockItemSyntax(item: .decl(DeclSyntax(self)))
|
|
return leadingTrivia.pieces.first?.isNewline == true
|
|
? item
|
|
: item.with(\.leadingTrivia, [.newlines(1)] + leadingTrivia)
|
|
}
|
|
}
|
|
|
|
private struct Import: Comparable {
|
|
let importDecl: ImportDeclSyntax
|
|
let line: Int
|
|
let offset: Int
|
|
let attributes: String
|
|
let modifier: UInt8
|
|
|
|
var violationPosition: AbsolutePosition {
|
|
importDecl.path.positionAfterSkippingLeadingTrivia
|
|
}
|
|
|
|
var symbol: String {
|
|
importDecl.path.map(\.name.text).joined(separator: ".")
|
|
}
|
|
|
|
static func from(importDecl: ImportDeclSyntax,
|
|
grouping: SortedImportsConfiguration.Grouping,
|
|
locationConverter: SourceLocationConverter) -> Self {
|
|
let attributes: [String] =
|
|
if grouping == .attributes {
|
|
importDecl.attributes.compactMap {
|
|
$0.as(AttributeSyntax.self)?.attributeName.as(IdentifierTypeSyntax.self)?.name.text
|
|
}
|
|
} else {
|
|
[]
|
|
}
|
|
let modifier: UInt8 =
|
|
if grouping == .attributes {
|
|
// As of (swift-syntax@604.0.0-prerelease-2025-12-15) the `importDecl.modifiers` documentation states:
|
|
// Modifiers that are attached to the import declaration.
|
|
// Currently, no modifiers are supported by Swift.
|
|
//
|
|
// This is incorrect, a single access level modifier is allowed when either or both:
|
|
// - Experimental Feature `AccessLevelOnImport` is enabled
|
|
// - Upcoming Feature `InternalImportsByDefault` is enabled
|
|
//
|
|
// https://github.com/swiftlang/swift-syntax/issues/3213
|
|
switch importDecl.modifiers.first?.name.text {
|
|
case "public": 1
|
|
case "package": 2
|
|
case "internal": 3
|
|
case "fileprivate": 4
|
|
case "private": 5
|
|
default: 0
|
|
}
|
|
} else {
|
|
0
|
|
}
|
|
let startLine = locationConverter.location(for: importDecl.positionAfterSkippingLeadingTrivia).line
|
|
return Self(
|
|
importDecl: importDecl,
|
|
line: startLine,
|
|
offset: locationConverter.location(for: importDecl.path.endPositionBeforeTrailingTrivia).line - startLine,
|
|
attributes: attributes.joined(),
|
|
modifier: modifier
|
|
)
|
|
}
|
|
|
|
func isDirectlyAfter(previous: Self, in file: SwiftLintFile) -> Bool {
|
|
let lineAfterPrevious = previous.line + previous.offset + 1
|
|
|
|
// Import is either directly after the previous import ...
|
|
return lineAfterPrevious == line
|
|
// ... or there are only comment lines between them.
|
|
|| file.commentLines.isSuperset(of: lineAfterPrevious..<line)
|
|
}
|
|
|
|
static func < (lhs: Self, rhs: Self) -> Bool {
|
|
if lhs.attributes != rhs.attributes {
|
|
if lhs.attributes.isEmpty {
|
|
return false
|
|
}
|
|
return rhs.attributes.isEmpty || lhs.attributes < rhs.attributes
|
|
}
|
|
if lhs.modifier != rhs.modifier {
|
|
if lhs.modifier == 0 {
|
|
return false
|
|
}
|
|
return rhs.modifier == 0 || lhs.modifier < rhs.modifier
|
|
}
|
|
return lhs.symbol.caseInsensitiveCompare(rhs.symbol) == .orderedAscending
|
|
}
|
|
}
|