diff --git a/CHANGELOG.md b/CHANGELOG.md index e32680d436..99df986a6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ [Jordan Rose](https://github.com/jrose-signal) [SimplyDanny](https://github.com/SimplyDanny) +* Add `Cognitive Complexity Rule` + [lorwe](https://github.com/lorwe) + [#3335](https://github.com/realm/SwiftLint/issues/3335) + #### Bug Fixes * Run command plugin in whole package if no targets are defined in the diff --git a/Source/SwiftLintBuiltInRules/Models/BuiltInRules.swift b/Source/SwiftLintBuiltInRules/Models/BuiltInRules.swift index eeb3abce7c..7330ddd297 100644 --- a/Source/SwiftLintBuiltInRules/Models/BuiltInRules.swift +++ b/Source/SwiftLintBuiltInRules/Models/BuiltInRules.swift @@ -19,6 +19,7 @@ public let builtInRules: [any Rule.Type] = [ ClosureEndIndentationRule.self, ClosureParameterPositionRule.self, ClosureSpacingRule.self, + CognitiveComplexityRule.self, CollectionAlignmentRule.self, ColonRule.self, CommaInheritanceRule.self, diff --git a/Source/SwiftLintBuiltInRules/Rules/Metrics/CognitiveComplexityRule.swift b/Source/SwiftLintBuiltInRules/Rules/Metrics/CognitiveComplexityRule.swift new file mode 100644 index 0000000000..9356b99be5 --- /dev/null +++ b/Source/SwiftLintBuiltInRules/Rules/Metrics/CognitiveComplexityRule.swift @@ -0,0 +1,266 @@ +import Foundation +import SwiftSyntax + +@SwiftSyntaxRule +struct CognitiveComplexityRule: Rule { + var configuration = CognitiveComplexityConfiguration() + + static let description = RuleDescription( + identifier: "cognitive_complexity", + name: "Cognitive Complexity", + description: "Cognitive complexity of function bodies should be limited.", + kind: .metrics, + nonTriggeringExamples: [ + Example(""" + func f1(count: Int, buffer: [Int]) -> Int { + if count == 0 + || buffer.count = 0 { + return 0 + } + var sum = 0 + for index in 0.. 0 + && buffer[index] <= 10 { + if buffer.count > 10 { + if buffer[index] % 2 == 0 { + sum += buffer[index] + } else if sum > 0 { + sum -= buffer[index] + } + } + } + } + if sum < 0 { + return -sum + } + return sum + } + """), + Example(""" + func f2(count: Int, buffer: [Int]) -> Int { + var sum = 0 + for index in 0.. 0 && buffer[index] <= 10 { + if buffer.count > 10 { + switch buffer[index] % 2 { + case 0: + if sum > 0 { + sum += buffer[index] + } + default: + if sum > 0 { + sum -= buffer[index] + } + } + } + } + } + return sum + } + """), + ], + triggeringExamples: [ + Example(""" + func f3(count: Int, buffer: [Int]) -> Int { + guard count > 0, + buffer.count > 0 { + return 0 + } + var sum = 0 + for index in 0.. 0 + && buffer[index] <= 10 { + if buffer.count > 10 { + if buffer[index] % 2 == 0 { + sum += buffer[index] + } else if sum > 0 { + sum -= buffer[index] + } else if sum < 0 { + sum += buffer[index] + } + } + } + } + if sum < 0 { + return -sum + } + return sum + } + """), + ] + ) +} + +private extension CognitiveComplexityRule { + final class Visitor: ViolationsSyntaxVisitor { + override func visitPost(_ node: FunctionDeclSyntax) { + guard let body = node.body else { + return + } + + // for legacy reasons, we try to put the violation in the static or class keyword + let violationToken = node.modifiers.staticOrClassModifier ?? node.funcKeyword + validate(body: body, violationToken: violationToken) + } + + override func visitPost(_ node: InitializerDeclSyntax) { + guard let body = node.body else { + return + } + + validate(body: body, violationToken: node.initKeyword) + } + + private func validate(body: CodeBlockSyntax, violationToken: TokenSyntax) { + let complexity = ComplexityVisitor( + ignoresLogicalOperatorSequences: configuration.ignoresLogicalOperatorSequences + ).walk(tree: body, handler: \.complexity) + + for parameter in configuration.params where complexity > parameter.value { + let reason = "Function should have cognitive complexity \(configuration.length.warning) or less; " + + "currently complexity is \(complexity)" + + let violation = ReasonedRuleViolation( + position: violationToken.positionAfterSkippingLeadingTrivia, + reason: reason, + severity: parameter.severity + ) + violations.append(violation) + return + } + } + } + + private class ComplexityVisitor: SyntaxVisitor { + private let ignoresLogicalOperatorSequences: Bool + private(set) var complexity = 0 + private var nesting = 0 + + init(ignoresLogicalOperatorSequences: Bool) { + self.ignoresLogicalOperatorSequences = ignoresLogicalOperatorSequences + super.init(viewMode: .sourceAccurate) + } + + override func visit(_: ForStmtSyntax) -> SyntaxVisitorContinueKind { + nesting += 1 + return .visitChildren + } + + override func visitPost(_: ForStmtSyntax) { + nesting -= 1 + complexity += nesting + 1 + } + + override func visit(_: IfExprSyntax) -> SyntaxVisitorContinueKind { + nesting += 1 + return .visitChildren + } + + override func visitPost(_ node: IfExprSyntax) { + nesting -= 1 + let nesting = node.parent?.as(IfExprSyntax.self)?.elseBody?.is(IfExprSyntax.self) == true ? 0 : nesting + if ignoresLogicalOperatorSequences { + complexity += nesting + 1 + } else { + complexity += nesting + node.conditions.sequenceCount + } + } + + override func visit(_: GuardStmtSyntax) -> SyntaxVisitorContinueKind { + nesting += 1 + return .visitChildren + } + + override func visitPost(_ node: GuardStmtSyntax) { + nesting -= 1 + if ignoresLogicalOperatorSequences { + complexity += nesting + 1 + } else { + complexity += nesting + node.conditions.sequenceCount + } + } + + override func visit(_: RepeatStmtSyntax) -> SyntaxVisitorContinueKind { + nesting += 1 + return .visitChildren + } + + override func visitPost(_: RepeatStmtSyntax) { + nesting -= 1 + complexity += nesting + 1 + } + + override func visit(_: WhileStmtSyntax) -> SyntaxVisitorContinueKind { + nesting += 1 + return .visitChildren + } + + override func visitPost(_: WhileStmtSyntax) { + nesting -= 1 + complexity += nesting + 1 + } + + override func visit(_: CatchClauseSyntax) -> SyntaxVisitorContinueKind { + nesting += 1 + return .visitChildren + } + + override func visitPost(_: CatchClauseSyntax) { + nesting -= 1 + complexity += nesting + 1 + } + + override func visitPost(_: SwitchExprSyntax) { + complexity += 1 + } + + override func visitPost(_: TernaryExprSyntax) { + complexity += 1 + } + + override func visitPost(_ node: BreakStmtSyntax) { + if node.label != nil { + complexity += 1 + } + } + + override func visitPost(_ node: ContinueStmtSyntax) { + if node.label != nil { + complexity += 1 + } + } + + override func visit(_: FunctionDeclSyntax) -> SyntaxVisitorContinueKind { + nesting += 1 + return .visitChildren + } + + override func visitPost(_: FunctionDeclSyntax) { + nesting -= 1 + } + + override func visit(_: ClosureExprSyntax) -> SyntaxVisitorContinueKind { + nesting += 1 + return .visitChildren + } + + override func visitPost(_: ClosureExprSyntax) { + nesting -= 1 + } + } +} + +private extension DeclModifierListSyntax { + var staticOrClassModifier: TokenSyntax? { + first { element in + let kind = element.name.tokenKind + return kind == .keyword(.static) || kind == .keyword(.class) + }?.name + } +} + +private extension ConditionElementListSyntax { + var sequenceCount: Int { + description.components(separatedBy: .newlines).count + } +} diff --git a/Source/SwiftLintBuiltInRules/Rules/RuleConfigurations/CognitiveComplexityConfiguration.swift b/Source/SwiftLintBuiltInRules/Rules/RuleConfigurations/CognitiveComplexityConfiguration.swift new file mode 100644 index 0000000000..13deadb187 --- /dev/null +++ b/Source/SwiftLintBuiltInRules/Rules/RuleConfigurations/CognitiveComplexityConfiguration.swift @@ -0,0 +1,16 @@ +import SourceKittenFramework +import SwiftLintCore + +@AutoConfigParser +struct CognitiveComplexityConfiguration: RuleConfiguration { + typealias Parent = CognitiveComplexityRule + + @ConfigurationElement(inline: true) + private(set) var length = SeverityLevelsConfiguration(warning: 15, error: 20) + @ConfigurationElement(key: "ignores_logical_operator_sequences") + private(set) var ignoresLogicalOperatorSequences = false + + var params: [RuleParameter] { + length.params + } +} diff --git a/Tests/GeneratedTests/GeneratedTests.swift b/Tests/GeneratedTests/GeneratedTests.swift index d00101a90b..8fb942f889 100644 --- a/Tests/GeneratedTests/GeneratedTests.swift +++ b/Tests/GeneratedTests/GeneratedTests.swift @@ -103,6 +103,12 @@ final class ClosureSpacingRuleGeneratedTests: SwiftLintTestCase { } } +final class CognitiveComplexityRuleGeneratedTests: SwiftLintTestCase { + func testWithDefaultConfiguration() { + verifyRule(CognitiveComplexityRule.description) + } +} + final class CollectionAlignmentRuleGeneratedTests: SwiftLintTestCase { func testWithDefaultConfiguration() { verifyRule(CollectionAlignmentRule.description)