diff --git a/Documentation/Configuration.md b/Documentation/Configuration.md index 658f8a8d..193a9f80 100644 --- a/Documentation/Configuration.md +++ b/Documentation/Configuration.md @@ -296,6 +296,19 @@ too long. **default:** `false` +--- + +### `orderedImports` +**type:** object + +**description:** Configuration for the `OrderedImports` rule. + +- `includeConditionalImports` _(boolean)_: Determines whether imports within conditional compilation blocks (`#if`, `#elseif`, `#else`) should be ordered. When `true`, imports inside conditional blocks will be sorted and organized according to the same rules as top-level imports. When `false`, imports within conditional blocks are left in their original order. + +**default:** `{ "includeConditionalImports" : false }` + +--- + > TODO: Add support for enabling/disabling specific syntax transformations in > the pipeline. diff --git a/Documentation/RuleDocumentation.md b/Documentation/RuleDocumentation.md index 510cc070..3f0977ed 100644 --- a/Documentation/RuleDocumentation.md +++ b/Documentation/RuleDocumentation.md @@ -411,6 +411,9 @@ The order of the import groups is 1) regular imports, 2) declaration imports, 3) imports, and 4) @testable imports. These groups are separated by a single blank line. Blank lines in between the import declarations are removed. +By default, imports within conditional compilation blocks (`#if`, `#elseif`, `#else`) are not ordered. +This behavior can be controlled via the `orderedImports.includeConditionalImports` configuration option. + Lint: If an import appears anywhere other than the beginning of the file it resides in, not lexicographically ordered, or not in the appropriate import group, a lint error is raised. diff --git a/Sources/SwiftFormat/API/Configuration+Default.swift b/Sources/SwiftFormat/API/Configuration+Default.swift index ccb4f229..2bf7924e 100644 --- a/Sources/SwiftFormat/API/Configuration+Default.swift +++ b/Sources/SwiftFormat/API/Configuration+Default.swift @@ -43,5 +43,6 @@ extension Configuration { self.multiElementCollectionTrailingCommas = true self.reflowMultilineStringLiterals = .never self.indentBlankLines = false + self.orderedImports = OrderedImportsConfiguration() } } diff --git a/Sources/SwiftFormat/API/Configuration.swift b/Sources/SwiftFormat/API/Configuration.swift index 9051de17..8fef820c 100644 --- a/Sources/SwiftFormat/API/Configuration.swift +++ b/Sources/SwiftFormat/API/Configuration.swift @@ -48,6 +48,7 @@ public struct Configuration: Codable, Equatable { case multiElementCollectionTrailingCommas case reflowMultilineStringLiterals case indentBlankLines + case orderedImports } /// A dictionary containing the default enabled/disabled states of rules, keyed by the rules' @@ -301,6 +302,9 @@ public struct Configuration: Codable, Equatable { /// If false (the default), the whitespace in blank lines will be removed entirely. public var indentBlankLines: Bool + /// Configuration for the `OrderedImports` rule. + public var orderedImports: OrderedImportsConfiguration + /// Creates a new `Configuration` by loading it from a configuration file. public init(contentsOf url: URL) throws { let data = try Data(contentsOf: url) @@ -443,6 +447,13 @@ public struct Configuration: Codable, Equatable { ) ?? defaults.indentBlankLines + self.orderedImports = + try container.decodeIfPresent( + OrderedImportsConfiguration.self, + forKey: .orderedImports + ) + ?? defaults.orderedImports + // If the `rules` key is not present at all, default it to the built-in set // so that the behavior is the same as if the configuration had been // default-initialized. To get an empty rules dictionary, one can explicitly @@ -481,6 +492,8 @@ public struct Configuration: Codable, Equatable { try container.encode(noAssignmentInExpressions, forKey: .noAssignmentInExpressions) try container.encode(multiElementCollectionTrailingCommas, forKey: .multiElementCollectionTrailingCommas) try container.encode(reflowMultilineStringLiterals, forKey: .reflowMultilineStringLiterals) + try container.encode(indentBlankLines, forKey: .indentBlankLines) + try container.encode(orderedImports, forKey: .orderedImports) try container.encode(rules, forKey: .rules) } @@ -546,3 +559,11 @@ public struct NoAssignmentInExpressionsConfiguration: Codable, Equatable { public init() {} } + +/// Configuration for the `OrderedImports` rule. +public struct OrderedImportsConfiguration: Codable, Equatable { + /// Determines whether imports within conditional compilation blocks should be ordered. + public var includeConditionalImports: Bool = false + + public init() {} +} diff --git a/Sources/SwiftFormat/Rules/OrderedImports.swift b/Sources/SwiftFormat/Rules/OrderedImports.swift index 93abebd2..bc82bfa8 100644 --- a/Sources/SwiftFormat/Rules/OrderedImports.swift +++ b/Sources/SwiftFormat/Rules/OrderedImports.swift @@ -17,6 +17,9 @@ import SwiftSyntax /// imports, and 4) @testable imports. These groups are separated by a single blank line. Blank lines in /// between the import declarations are removed. /// +/// By default, imports within conditional compilation blocks (`#if`, `#elseif`, `#else`) are not ordered. +/// This behavior can be controlled via the `orderedImports.includeConditionalImports` configuration option. +/// /// Lint: If an import appears anywhere other than the beginning of the file it resides in, /// not lexicographically ordered, or not in the appropriate import group, a lint error is /// raised. @@ -26,7 +29,13 @@ import SwiftSyntax public final class OrderedImports: SyntaxFormatRule { public override func visit(_ node: SourceFileSyntax) -> SourceFileSyntax { - let lines = generateLines(codeBlockItemList: node.statements, context: context) + var newNode = node + newNode.statements = orderImports(in: node.statements) + return newNode + } + + private func orderImports(in codeBlockItemList: CodeBlockItemListSyntax) -> CodeBlockItemListSyntax { + let lines = generateLines(codeBlockItemList: codeBlockItemList, context: context) // Stores the formatted and sorted lines that will be used to reconstruct the list of code block // items later. @@ -47,6 +56,10 @@ public final class OrderedImports: SyntaxFormatRule { // Perform linting on the grouping of the imports. checkGrouping(linesSection) + if let firstLine = fileHeader.first, firstLine.type == .blankLine { + fileHeader.removeFirst() + } + if let lastLine = fileHeader.last, lastLine.type == .blankLine { fileHeader.removeLast() } @@ -118,6 +131,27 @@ public final class OrderedImports: SyntaxFormatRule { } } + if context.configuration.orderedImports.includeConditionalImports, + let syntaxNode = line.syntaxNode, + case .ifConfigCodeBlock(let ifConfigCodeBlock) = syntaxNode + { + var ifConfigDecl = ifConfigCodeBlock.item.cast(IfConfigDeclSyntax.self) + + let newClauses = ifConfigDecl.clauses.map { clause in + guard case .statements(let codeBlockItemList) = clause.elements else { + return clause + } + var newClause = clause + var newCodeBlockItemList = orderImports(in: codeBlockItemList) + newCodeBlockItemList.leadingTrivia = .newline + newCodeBlockItemList.leadingTrivia + newClause.elements = .statements(newCodeBlockItemList) + return newClause + } + + ifConfigDecl.clauses = IfConfigClauseListSyntax(newClauses) + line.syntaxNode = .ifConfigCodeBlock(CodeBlockItemSyntax(item: .decl(DeclSyntax(ifConfigDecl)))) + } + // Separate lines into different categories along with any associated comments. switch line.type { case .regularImport: @@ -154,9 +188,7 @@ public final class OrderedImports: SyntaxFormatRule { formatAndAppend(linesSection: lines[lastSliceStartIndex.. [CodeBlockItemSyntax] { switch syntaxNode { case .importCodeBlock(let codeBlock, _): append(codeBlockItem: codeBlock) + case .ifConfigCodeBlock(let ifConfigCodeBlock): + append(codeBlockItem: ifConfigCodeBlock) case .nonImportCodeBlocks(let codeBlocks): codeBlocks.forEach(append(codeBlockItem:)) } @@ -458,6 +497,9 @@ private class Line { case nonImportCodeBlocks([CodeBlockItemSyntax]) /// A single code block item whose content must be an import decl. case importCodeBlock(CodeBlockItemSyntax, sortable: Bool) + /// A single code block item whose content must be an if config decl. + /// This is used to sort conditional imports. + case ifConfigCodeBlock(CodeBlockItemSyntax) } /// Stores line comments. `syntaxNode` need not be defined, since a comment can exist by itself on @@ -478,7 +520,7 @@ private class Line { var type: LineType { if let syntaxNode = syntaxNode { switch syntaxNode { - case .nonImportCodeBlocks: + case .nonImportCodeBlocks, .ifConfigCodeBlock: return .codeBlock case .importCodeBlock(let importCodeBlock, _): guard let importDecl = importCodeBlock.item.as(ImportDeclSyntax.self) else { @@ -542,6 +584,8 @@ private class Line { return codeBlock.firstToken(viewMode: .sourceAccurate) case .nonImportCodeBlocks(let codeBlocks): return codeBlocks.first?.firstToken(viewMode: .sourceAccurate) + case .ifConfigCodeBlock(let codeBlock): + return codeBlock.firstToken(viewMode: .sourceAccurate) } } @@ -592,6 +636,8 @@ extension Line: CustomStringConvertible { description += "\(codeBlocks.count) code blocks " case .importCodeBlock(_, let sortable): description += "\(sortable ? "sorted" : "unsorted") import \(importName) " + case .ifConfigCodeBlock: + description += "if config code block " } } diff --git a/Tests/SwiftFormatTests/Rules/OrderedImportsTests.swift b/Tests/SwiftFormatTests/Rules/OrderedImportsTests.swift index 9a3c51d6..b7a65b33 100644 --- a/Tests/SwiftFormatTests/Rules/OrderedImportsTests.swift +++ b/Tests/SwiftFormatTests/Rules/OrderedImportsTests.swift @@ -609,16 +609,70 @@ final class OrderedImportsTests: LintOrFormatRuleTestCase { ) } - func testConditionalImports() { + func testConditionalImportsWhenEnabled() { + var configuration = Configuration.forTesting + configuration.orderedImports.includeConditionalImports = true + + assertFormatting( + OrderedImports.self, + input: """ + import Zebras + 1️⃣import Apples + #if canImport(Darwin) + import Foundation + 2️⃣import Darwin + #elseif canImport(Glibc) + import Glibc + 3️⃣import Foundation + #endif + 4️⃣import Aardvarks + + foo() + bar() + baz() + """, + expected: """ + import Aardvarks + import Apples + import Zebras + + #if canImport(Darwin) + import Darwin + import Foundation + #elseif canImport(Glibc) + import Foundation + import Glibc + #endif + + foo() + bar() + baz() + """, + findings: [ + FindingSpec("1️⃣", message: "sort import statements lexicographically"), + FindingSpec("2️⃣", message: "sort import statements lexicographically"), + FindingSpec("3️⃣", message: "sort import statements lexicographically"), + FindingSpec("4️⃣", message: "place imports at the top of the file"), + ], + configuration: configuration + ) + } + + func testConditionalImportsWhenDisabled() { + var configuration = Configuration.forTesting + configuration.orderedImports.includeConditionalImports = false + assertFormatting( OrderedImports.self, input: """ import Zebras 1️⃣import Apples #if canImport(Darwin) + import Foundation import Darwin #elseif canImport(Glibc) import Glibc + import Foundation #endif 2️⃣import Aardvarks @@ -632,9 +686,11 @@ final class OrderedImportsTests: LintOrFormatRuleTestCase { import Zebras #if canImport(Darwin) + import Foundation import Darwin #elseif canImport(Glibc) import Glibc + import Foundation #endif foo() @@ -644,7 +700,57 @@ final class OrderedImportsTests: LintOrFormatRuleTestCase { findings: [ FindingSpec("1️⃣", message: "sort import statements lexicographically"), FindingSpec("2️⃣", message: "place imports at the top of the file"), - ] + ], + configuration: configuration + ) + } + + func testNestedConditionalImports() { + var configuration = Configuration.forTesting + configuration.orderedImports.includeConditionalImports = true + + assertFormatting( + OrderedImports.self, + input: """ + import A + #if FOO + import D + #if BAR + import F + 1️⃣import E + #else + import H + 2️⃣import G + #endif + 3️⃣5️⃣import C + #endif + 4️⃣import B + """, + expected: """ + import A + import B + + #if FOO + import C + import D + + #if BAR + import E + import F + #else + import G + import H + #endif + #endif + """, + findings: [ + FindingSpec("1️⃣", message: "sort import statements lexicographically"), + FindingSpec("2️⃣", message: "sort import statements lexicographically"), + FindingSpec("3️⃣", message: "place imports at the top of the file"), + FindingSpec("4️⃣", message: "place imports at the top of the file"), + FindingSpec("5️⃣", message: "sort import statements lexicographically"), + ], + configuration: configuration ) } @@ -843,4 +949,73 @@ final class OrderedImportsTests: LintOrFormatRuleTestCase { ] ) } + + func testPreservesEmptyConditionalCompilationBlock() { + var configuration = Configuration.forTesting + configuration.orderedImports.includeConditionalImports = true + + let code = """ + import Apples + import Zebras + + #if FOO + #endif + + foo() + """ + + assertFormatting( + OrderedImports.self, + input: code, + expected: code, + configuration: configuration + ) + } + + func testPreservesHeaderCommentInConditionalCompilationBlock() { + var configuration = Configuration.forTesting + configuration.orderedImports.includeConditionalImports = true + + let code = """ + import Apples + + #if FOO + // Performing FOO-specific logic + + import Foundation + #endif + + foo() + """ + + assertFormatting( + OrderedImports.self, + input: code, + expected: code, + configuration: configuration + ) + } + + func testPreservesCommentsOnlyInConditionalCompilationBlock() { + var configuration = Configuration.forTesting + configuration.orderedImports.includeConditionalImports = true + + let code = """ + import Apples + + #if FOO + // Just a comment + // Another comment + #endif + + foo() + """ + + assertFormatting( + OrderedImports.self, + input: code, + expected: code, + configuration: configuration + ) + } }