diff --git a/CHANGELOG.md b/CHANGELOG.md index e95227c9e..5d0044388 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,11 @@ [SimplyDanny](https://github.com/SimplyDanny) [#4990](https://github.com/realm/SwiftLint/issues/4990) +* Add `grouping` option to the `sorted_imports` rule allowing + to sort groups of imports defined by their preceding attributes + (e.g. `@testable`, `@_exported`, ...). + [hiltonc](https://github.com/hiltonc) + #### Bug Fixes * Do not trigger `prefer_self_in_static_references` rule on `typealias` diff --git a/Source/SwiftLintBuiltInRules/Rules/RuleConfigurations/SortedImportsConfiguration.swift b/Source/SwiftLintBuiltInRules/Rules/RuleConfigurations/SortedImportsConfiguration.swift new file mode 100644 index 000000000..6336a5f25 --- /dev/null +++ b/Source/SwiftLintBuiltInRules/Rules/RuleConfigurations/SortedImportsConfiguration.swift @@ -0,0 +1,37 @@ +struct SortedImportsConfiguration: RuleConfiguration, Equatable { + typealias Parent = SortedImportsRule + + enum SortedImportsGroupingConfiguration: String { + /// Sorts import lines based on any import attributes (e.g. `@testable`, `@_exported`, etc.), followed by a case + /// insensitive comparison of the imported module name. + case attributes + /// Sorts import lines based on a case insensitive comparison of the imported module name. + case names + } + + private(set) var severity = SeverityConfiguration(.warning) + private(set) var grouping = SortedImportsGroupingConfiguration.names + + var consoleDescription: String { + return "severity: \(severity.consoleDescription)" + + ", grouping: \(grouping)" + } + + mutating func apply(configuration: Any) throws { + guard let configuration = configuration as? [String: Any] else { + throw Issue.unknownConfiguration(ruleID: Parent.identifier) + } + + if let rawGrouping = configuration["grouping"] { + guard let rawGrouping = rawGrouping as? String, + let grouping = SortedImportsGroupingConfiguration(rawValue: rawGrouping) else { + throw Issue.unknownConfiguration(ruleID: Parent.identifier) + } + self.grouping = grouping + } + + if let severityString = configuration["severity"] as? String { + try severity.apply(configuration: severityString) + } + } +} diff --git a/Source/SwiftLintBuiltInRules/Rules/Style/SortedImportsRule.swift b/Source/SwiftLintBuiltInRules/Rules/Style/SortedImportsRule.swift index 5a1eaff23..77d9d707b 100644 --- a/Source/SwiftLintBuiltInRules/Rules/Style/SortedImportsRule.swift +++ b/Source/SwiftLintBuiltInRules/Rules/Style/SortedImportsRule.swift @@ -1,30 +1,36 @@ import Foundation import SourceKittenFramework -extension Line { - fileprivate var contentRange: NSRange { +fileprivate extension Line { + var contentRange: NSRange { return NSRange(location: range.location, length: content.bridge().length) } + // `Line` in this rule always contains word import + // This method returns contents of line that are before import + func importAttributes() -> String { + return content[importAttributesRange()].trimmingCharacters(in: .whitespaces) + } + // `Line` in this rule always contains word import // This method returns contents of line that are after import - private func importModule() -> Substring { + func importModule() -> Substring { return content[importModuleRange()] } - fileprivate func importModuleRange() -> Range { + func importAttributesRange() -> Range { + let rangeOfImport = content.range(of: "import") + precondition(rangeOfImport != nil) + return content.startIndex.. Range { let rangeOfImport = content.range(of: "import") precondition(rangeOfImport != nil) let moduleStart = content.rangeOfCharacter(from: CharacterSet.whitespaces.inverted, options: [], range: rangeOfImport!.upperBound.. Bool { - return lhs.importModule().lowercased() <= rhs.importModule().lowercased() - } } private extension Sequence where Element == Line { @@ -47,88 +53,16 @@ private extension Sequence where Element == Line { } struct SortedImportsRule: CorrectableRule, ConfigurationProviderRule, OptInRule { - var configuration = SeverityConfiguration(.warning) + var configuration = SortedImportsConfiguration() static let description = RuleDescription( identifier: "sorted_imports", name: "Sorted Imports", description: "Imports should be sorted", kind: .style, - nonTriggeringExamples: [ - Example("import AAA\nimport BBB\nimport CCC\nimport DDD"), - Example("import Alamofire\nimport API"), - Example("import labc\nimport Ldef"), - Example("import BBB\n// comment\nimport AAA\nimport CCC"), - Example("@testable import AAA\nimport CCC"), - Example("import AAA\n@testable import CCC"), - Example(""" - import EEE.A - import FFF.B - #if os(Linux) - import DDD.A - import EEE.B - #else - import CCC - import DDD.B - #endif - import AAA - import BBB - """) - ], - triggeringExamples: [ - Example("import AAA\nimport ZZZ\nimport ↓BBB\nimport CCC"), - Example("import DDD\n// comment\nimport CCC\nimport ↓AAA"), - Example("@testable import CCC\nimport ↓AAA"), - Example("import CCC\n@testable import ↓AAA"), - Example(""" - import FFF.B - import ↓EEE.A - #if os(Linux) - import DDD.A - import EEE.B - #else - import DDD.B - import ↓CCC - #endif - import AAA - import BBB - """) - ], - corrections: [ - Example("import AAA\nimport ZZZ\nimport ↓BBB\nimport CCC"): - Example("import AAA\nimport BBB\nimport CCC\nimport ZZZ"), - Example("import BBB // comment\nimport ↓AAA"): Example("import AAA\nimport BBB // comment"), - Example("import BBB\n// comment\nimport CCC\nimport ↓AAA"): - Example("import BBB\n// comment\nimport AAA\nimport CCC"), - Example("@testable import CCC\nimport ↓AAA"): Example("import AAA\n@testable import CCC"), - Example("import CCC\n@testable import ↓AAA"): Example("@testable import AAA\nimport CCC"), - Example(""" - import FFF.B - import ↓EEE.A - #if os(Linux) - import DDD.A - import EEE.B - #else - import DDD.B - import ↓CCC - #endif - import AAA - import BBB - """): - Example(""" - import EEE.A - import FFF.B - #if os(Linux) - import DDD.A - import EEE.B - #else - import CCC - import DDD.B - #endif - import AAA - import BBB - """) - ] + nonTriggeringExamples: SortedImportsRuleExamples.nonTriggeringExamples, + triggeringExamples: SortedImportsRuleExamples.triggeringExamples, + corrections: SortedImportsRuleExamples.corrections ) func validate(file: SwiftLintFile) -> [StyleViolation] { @@ -136,7 +70,7 @@ struct SortedImportsRule: CorrectableRule, ConfigurationProviderRule, OptInRule return violatingOffsets(inGroups: groups).map { index -> StyleViolation in let location = Location(file: file, characterOffset: index) return StyleViolation(ruleDescription: Self.description, - severity: configuration.severity, + severity: configuration.severity.severity, location: location) } } @@ -162,7 +96,7 @@ struct SortedImportsRule: CorrectableRule, ConfigurationProviderRule, OptInRule return groups.flatMap { group in return zip(group, group.dropFirst()).reduce(into: []) { violatingOffsets, groupPair in let (previous, current) = groupPair - let isOrderedCorrectly = previous <= current + let isOrderedCorrectly = should(previous, comeBefore: current) if isOrderedCorrectly { return } @@ -173,6 +107,24 @@ struct SortedImportsRule: CorrectableRule, ConfigurationProviderRule, OptInRule } } + /// - returns: whether `lhs` should come before `rhs` based on a comparison of the contents of the import lines + private func should(_ lhs: Line, comeBefore rhs: Line) -> Bool { + switch configuration.grouping { + case .attributes: + let lhsAttributes = lhs.importAttributes() + let rhsAttributes = rhs.importAttributes() + if lhsAttributes != rhsAttributes { + if lhsAttributes.isEmpty != rhsAttributes.isEmpty { + return rhsAttributes.isEmpty + } + return lhsAttributes < rhsAttributes + } + case .names: + break + } + return lhs.importModule().lowercased() <= rhs.importModule().lowercased() + } + func correct(file: SwiftLintFile) -> [Correction] { let groups = importGroups(in: file, filterEnabled: true) @@ -186,7 +138,7 @@ struct SortedImportsRule: CorrectableRule, ConfigurationProviderRule, OptInRule } let correctedContents = NSMutableString(string: file.contents) - for group in groups.map({ $0.sorted(by: <=) }) { + for group in groups.map({ $0.sorted(by: should(_:comeBefore:)) }) { guard let first = group.first?.contentRange else { continue } diff --git a/Source/SwiftLintBuiltInRules/Rules/Style/SortedImportsRuleExamples.swift b/Source/SwiftLintBuiltInRules/Rules/Style/SortedImportsRuleExamples.swift new file mode 100644 index 000000000..d7940bc44 --- /dev/null +++ b/Source/SwiftLintBuiltInRules/Rules/Style/SortedImportsRuleExamples.swift @@ -0,0 +1,233 @@ +internal struct SortedImportsRuleExamples { + private static let groupByAttributesConfiguration = ["grouping": "attributes"] + + static let nonTriggeringExamples = [ + Example(""" + import AAA + import BBB + import CCC + import DDD + """), + Example(""" + import Alamofire + import API + """), + Example(""" + import labc + import Ldef + """), + Example(""" + import BBB + // comment + import AAA + import CCC + """), + Example(""" + @testable import AAA + import CCC + """), + Example(""" + import AAA + @testable import CCC + """), + Example(""" + import EEE.A + import FFF.B + #if os(Linux) + import DDD.A + import EEE.B + #else + import CCC + import DDD.B + #endif + import AAA + import BBB + """), + Example(""" + @testable import AAA + @testable import BBB + """, configuration: groupByAttributesConfiguration, excludeFromDocumentation: true), + Example(""" + @testable import BBB + import AAA + """, configuration: groupByAttributesConfiguration, excludeFromDocumentation: true), + Example(""" + @_exported import BBB + @testable import AAA + """, configuration: groupByAttributesConfiguration, excludeFromDocumentation: true), + Example(""" + @_exported @testable import BBB + import AAA + """, configuration: groupByAttributesConfiguration, excludeFromDocumentation: true) + ] + + static let triggeringExamples = [ + Example(""" + import AAA + import ZZZ + import ↓BBB + import CCC + """), + Example(""" + import DDD + // comment + import CCC + import ↓AAA + """), + Example(""" + @testable import CCC + import ↓AAA + """), + Example(""" + import CCC + @testable import ↓AAA + """), + Example(""" + import FFF.B + import ↓EEE.A + #if os(Linux) + import DDD.A + import EEE.B + #else + import DDD.B + import ↓CCC + #endif + import AAA + import BBB + """), + Example(""" + @testable import BBB + @testable import ↓AAA + """, configuration: groupByAttributesConfiguration, excludeFromDocumentation: true), + Example(""" + import AAA + @testable import ↓BBB + """, configuration: groupByAttributesConfiguration, excludeFromDocumentation: true), + Example(""" + import BBB + @testable import ↓AAA + """, configuration: groupByAttributesConfiguration, excludeFromDocumentation: true), + Example(""" + @testable import AAA + @_exported import ↓BBB + """, configuration: groupByAttributesConfiguration, excludeFromDocumentation: true), + Example(""" + import AAA + @_exported @testable import ↓BBB + """, configuration: groupByAttributesConfiguration, excludeFromDocumentation: true) + ] + + static let corrections = [ + Example(""" + import AAA + import ZZZ + import ↓BBB + import CCC + """): + Example(""" + import AAA + import BBB + import CCC + import ZZZ + """), + Example(""" + import BBB // comment + import ↓AAA + """): Example(""" + import AAA + import BBB // comment + """), + Example(""" + import BBB + // comment + import CCC + import ↓AAA + """): + Example(""" + import BBB + // comment + import AAA + import CCC + """), + Example(""" + @testable import CCC + import ↓AAA + """): Example(""" + import AAA + @testable import CCC + """), + Example(""" + import CCC + @testable import ↓AAA + """): Example(""" + @testable import AAA + import CCC + """), + Example(""" + import FFF.B + import ↓EEE.A + #if os(Linux) + import DDD.A + import EEE.B + #else + import DDD.B + import ↓CCC + #endif + import AAA + import BBB + """): + Example(""" + import EEE.A + import FFF.B + #if os(Linux) + import DDD.A + import EEE.B + #else + import CCC + import DDD.B + #endif + import AAA + import BBB + """), + Example(""" + @testable import BBB + @testable import ↓AAA + """, configuration: groupByAttributesConfiguration): + Example(""" + @testable import AAA + @testable import BBB + """), + Example(""" + import AAA + @testable import ↓BBB + """, configuration: groupByAttributesConfiguration): + Example(""" + @testable import BBB + import AAA + """), + Example(""" + import BBB + @testable import ↓AAA + """, configuration: groupByAttributesConfiguration): + Example(""" + @testable import AAA + import BBB + """), + Example(""" + @testable import AAA + @_exported import ↓BBB + """, configuration: groupByAttributesConfiguration): + Example(""" + @_exported import BBB + @testable import AAA + """), + Example(""" + import AAA + @_exported @testable import ↓BBB + """, configuration: groupByAttributesConfiguration): + Example(""" + @_exported @testable import BBB + import AAA + """) + ] +}