Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ disabled_rules:
- prefer_nimble
- prefixed_toplevel_constant
- required_deinit
- sorted_collection_members
- sorted_enum_cases
- strict_fileprivate
- switch_case_on_newline
Expand Down
1 change: 1 addition & 0 deletions Source/SwiftLintBuiltInRules/Models/BuiltInRules.swift
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ public let builtInRules: [any Rule.Type] = [
ShorthandOperatorRule.self,
ShorthandOptionalBindingRule.self,
SingleTestClassRule.self,
SortedCollectionMembersRule.self,
SortedEnumCasesRule.self,
SortedFirstLastRule.self,
SortedImportsRule.self,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import SwiftLintCore

@AutoConfigParser
struct SortedCollectionMembersConfiguration: SeverityBasedRuleConfiguration {
@ConfigurationElement(key: "severity")
private(set) var severityConfiguration = SeverityConfiguration<Parent>(.warning)

@ConfigurationElement(key: "reverse")
private(set) var reverse = false
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
import Foundation
import SwiftLintCore
import SwiftSyntax

@SwiftSyntaxRule(optIn: true)
struct SortedCollectionMembersRule: Rule {
var configuration = SortedCollectionMembersConfiguration()

static let description = RuleDescription(
identifier: "sorted_collection_members",
name: "Sorted Collection Members",
description: "Please keep the elements of this collection literal sorted",
kind: .style,
nonTriggeringExamples: #examples([
// Arrays

"[1, 2, 3]",
"[a, b, c]",
"[c, b, a]".asExample(configuration: reverseSort),
"[]",
"[1]",
"[a]",
"""
["a", "b", "c"]
""",
"""
["c", "b", "a"]
""".asExample(configuration: reverseSort),
"""
["a"]
""",
"""
[
.thingA,
.thingB,
// comments are ignored in sort checking
.thingC,
]
""",
"""
[
.thingC,
.thingB,
// comments are ignored in sort checking
.thingA,
]
""".asExample(configuration: reverseSort),

// Dictionaries

"[1: 1, 2: 2, 3: 3]",
"[a: 200, b: 10, c: 0]",
"[c: 0, b: 0, a: 0]".asExample(configuration: reverseSort),
"[:]",
"[1: 1]",
"[a: 1]",
"""
["a": "A", "b": "B", "c": "C"]
""",
"""
["c": "C", "b": "B", "a": "A"]
""".asExample(configuration: reverseSort),
"""
["a": "A"]
""",
"""
[
.thingA: "A",
.thingB: "B",
// comments are ignored in sort checking
.thingC: "C",
]
""",
"""
[
.thingC: "C",
.thingB: "B",
// comments are ignored in sort checking
.thingA: "A",
]
""".asExample(configuration: reverseSort),
]),
triggeringExamples: #examples([
// Arrays

"[1, ↓3, 2]",
"[↓1, 3, 2]".asExample(configuration: reverseSort),
"[↓1, 2, 3]".asExample(configuration: reverseSort),
"""
[↓"b", "c", "a"]
""",
"""
[↓"b", "c", "a"]
""".asExample(configuration: reverseSort),
"""
[
↓.thingBNoComment,
.thingCNoComment,
.thingANoComment,
]
""",
"""
[
↓.thingANoComment,
.thingBNoComment,
.thingCNoComment,
]
""".asExample(configuration: reverseSort),
"""
[
↓.thingBWithComment,
// Comments are not counted when evaluating sort order
.thingCWithComment,
.thingAWithComment,
]
""",
"""
[
↓.thingAWithComment,
.thingBWithComment,
// Comments are not counted when evaluating sort order
.thingCWithComment,
]
""".asExample(configuration: reverseSort),
// integration-style test
"""
let package = Package(
name: "Packages",
platforms: [
↓.macOS(.v10_15),
.iOS(.v26),
],
products: [
↓.library(name: "Library2", type: type, targets: ["Library2"]),
.library(name: "Library1", type: type, targets: ["Library1"]),
],
dependencies: [
↓.package(
url: "https://github.com/qux/quiz",
exact: "4.5.6"
),
.package(
url: "https://github.com/foo/bar",
exact: "1.2.3"
),
],
targets: [
↓.target(
name: "CoolFeatureB",
dependencies: [
"SomeDependency",
.product(name: "AnotherDependency", package: "another-dependency"),
]
),
.target(
name: "CoolFeatureA",
dependencies: [
↓.dependencyB,
.dependencyA,
]
),
]
)
""",

// Dictionaries are unordered, but you may still want to sort dictionary
// literals for code style reasons, or if you're using them to construct
// an instance of KeyValuePairs or some other ordered dictionary-like
// structure.

"[1: 1, ↓3: 3, 2: 2]",
"[↓1: 1, 3: 3, 2: 2]".asExample(configuration: reverseSort),
"[↓1: 1, 2: 2, 3: 3]".asExample(configuration: reverseSort),
"""
[↓"b": 0, "c": 0, "a": 0]
""",
"""
[↓"b": 0, "c": 0, "a": 0]
""".asExample(configuration: reverseSort),
"""
[
↓.thingBNoComment: 0,
.thingCNoComment: 0,
.thingANoComment: 0,
]
""",
"""
[
↓.thingANoComment: 0,
.thingBNoComment: 0,
.thingCNoComment: 0,
]
""".asExample(configuration: reverseSort),
"""
[
↓.thingBWithComment: 0,
// Comments are not counted when evaluating sort order
.thingCWithComment: 0,
.thingAWithComment: 0,
]
""",
"""
[
↓.thingAWithComment: 0,
.thingBWithComment: 0,
// Comments are not counted when evaluating sort order
.thingCWithComment: 0,
]
""".asExample(configuration: reverseSort),
])
)
}

private let reverseSort: [String: any Sendable] = ["reverse": true]

// TODO: seems like /*>*/ syntax has an off-by-one compared with ↓ syntax
// TODO: find a way to opt out of visiting nodes that do not apply to us.
// TODO: test that inline override works, so a file where this is enabled can opt-out for a single array

private extension SortedCollectionMembersRule {
final class Visitor: ViolationsSyntaxVisitor<ConfigurationType> {
override func visit(_ node: ArrayExprSyntax) -> SyntaxVisitorContinueKind {
let sortedNames = node.elements
.map(\.expression.trimmedDescription)
.sorted(by: configuration.reverse ? (>) : (<))

let originalAndSorted = zip(zip(node.elements.indices, node.elements), sortedNames)
for ((originalIndex, originalElement), sortedName) in originalAndSorted
where originalElement.expression.trimmedDescription != sortedName {
violations.append(node.elements[originalIndex].positionAfterSkippingLeadingTrivia)
// break on the first sorting violation because everything after it is necessarily not sorted
break
}

return .visitChildren
}

override func visit(_ node: DictionaryExprSyntax) -> SyntaxVisitorContinueKind {
let content = node.content

let elements: DictionaryElementListSyntax
switch content {
case .colon:
// empty dictionary, so there's nothing to sort
return .visitChildren
case .elements(let dictionaryElementListSyntax):
elements = dictionaryElementListSyntax
}

let sortedNames = elements
.map(\.key.trimmedDescription)
.sorted(by: configuration.reverse ? (>) : (<))

let originalAndSorted = zip(zip(elements.indices, elements), sortedNames)
for ((originalIndex, originalElement), sortedName) in originalAndSorted
where originalElement.key.trimmedDescription != sortedName {
violations.append(elements[originalIndex].positionAfterSkippingLeadingTrivia)
// break on the first sorting violation because everything after it is necessarily not sorted
break
}

return .visitChildren
}
}
}
12 changes: 12 additions & 0 deletions Tests/BuiltInRulesTests/SortedCollectionMembersRuleTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import TestHelpers
import Testing

@testable import SwiftLintBuiltInRules

@Suite(.rulesRegistered)
struct SortedCollectionMembersRuleTests {
@Test
func verify() {
verifyRule(SortedCollectionMembersRule.description, ruleConfiguration: [])
}
}
12 changes: 6 additions & 6 deletions Tests/GeneratedTests/GeneratedTests_08.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,25 +186,25 @@ struct SingleTestClassRuleGeneratedTests {
}

@Suite(.rulesRegistered)
struct SortedEnumCasesRuleGeneratedTests {
struct SortedCollectionMembersRuleGeneratedTests {
@Test
func withDefaultConfiguration() {
verifyRule(SortedEnumCasesRule.description)
verifyRule(SortedCollectionMembersRule.description)
}
}

@Suite(.rulesRegistered)
struct SortedFirstLastRuleGeneratedTests {
struct SortedEnumCasesRuleGeneratedTests {
@Test
func withDefaultConfiguration() {
verifyRule(SortedFirstLastRule.description)
verifyRule(SortedEnumCasesRule.description)
}
}

@Suite(.rulesRegistered)
struct SortedImportsRuleGeneratedTests {
struct SortedFirstLastRuleGeneratedTests {
@Test
func withDefaultConfiguration() {
verifyRule(SortedImportsRule.description)
verifyRule(SortedFirstLastRule.description)
}
}
16 changes: 8 additions & 8 deletions Tests/GeneratedTests/GeneratedTests_09.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ import Testing
@testable import SwiftLintBuiltInRules
@testable import SwiftLintCore

@Suite(.rulesRegistered)
struct SortedImportsRuleGeneratedTests {
@Test
func withDefaultConfiguration() {
verifyRule(SortedImportsRule.description)
}
}

@Suite(.rulesRegistered)
struct StatementPositionRuleGeneratedTests {
@Test
Expand Down Expand Up @@ -200,11 +208,3 @@ struct UnhandledThrowingTaskRuleGeneratedTests {
verifyRule(UnhandledThrowingTaskRule.description)
}
}

@Suite(.rulesRegistered)
struct UnneededBreakInSwitchRuleGeneratedTests {
@Test
func withDefaultConfiguration() {
verifyRule(UnneededBreakInSwitchRule.description)
}
}
16 changes: 8 additions & 8 deletions Tests/GeneratedTests/GeneratedTests_10.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ import Testing
@testable import SwiftLintBuiltInRules
@testable import SwiftLintCore

@Suite(.rulesRegistered)
struct UnneededBreakInSwitchRuleGeneratedTests {
@Test
func withDefaultConfiguration() {
verifyRule(UnneededBreakInSwitchRule.description)
}
}

@Suite(.rulesRegistered)
struct UnneededEscapingRuleGeneratedTests {
@Test
Expand Down Expand Up @@ -200,11 +208,3 @@ struct VoidFunctionInTernaryConditionRuleGeneratedTests {
verifyRule(VoidFunctionInTernaryConditionRule.description)
}
}

@Suite(.rulesRegistered)
struct VoidReturnRuleGeneratedTests {
@Test
func withDefaultConfiguration() {
verifyRule(VoidReturnRule.description)
}
}
Loading
Loading