Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rewrite cyclomatic_complexity with SwiftSyntax #5308

Merged
merged 6 commits into from
Oct 29, 2023
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
* Speed up `closure_parameter_position` rule when there are no violations.
[Marcelo Fabri](https://github.com/marcelofabri)

* Rewrite `cyclomatic_complexity` rule using SwiftSyntax.
[Marcelo Fabri](https://github.com/marcelofabri)

#### Bug Fixes

* Fix correction of `explicit_init` rule by keeping significant trivia.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import Foundation
import SourceKittenFramework
import SwiftSyntax

struct CyclomaticComplexityRule: ASTRule {
@SwiftSyntaxRule
struct CyclomaticComplexityRule: Rule {
var configuration = CyclomaticComplexityConfiguration()

static let description = RuleDescription(
Expand All @@ -22,14 +23,14 @@ struct CyclomaticComplexityRule: ASTRule {
func f(code: Int) -> Int {
switch code {
case 0: fallthrough
case 0: return 1
case 0: return 1
case 0: return 1
case 0: return 1
case 0: return 1
case 0: return 1
case 0: return 1
case 0: return 1
case 1: return 1
case 2: return 1
case 3: return 1
case 4: return 1
case 5: return 1
case 6: return 1
case 7: return 1
case 8: return 1
default: return 1
}
}
Expand Down Expand Up @@ -69,70 +70,109 @@ struct CyclomaticComplexityRule: ASTRule {
""")
]
)
}

private extension CyclomaticComplexityRule {
final class Visitor: ViolationsSyntaxVisitor<ConfigurationType> {
override func visitPost(_ node: FunctionDeclSyntax) {
guard let body = node.body else {
return
}

func validate(file: SwiftLintFile, kind: SwiftDeclarationKind,
dictionary: SourceKittenDictionary) -> [StyleViolation] {
guard SwiftDeclarationKind.functionKinds.contains(kind) 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)
}

let complexity = measureComplexity(in: file, dictionary: dictionary)
override func visitPost(_ node: InitializerDeclSyntax) {
guard let body = node.body else {
return
}

for parameter in configuration.params where complexity > parameter.value {
let offset = dictionary.offset ?? 0
let reason = "Function should have complexity \(configuration.length.warning) or less; " +
"currently complexity is \(complexity)"
return [StyleViolation(ruleDescription: Self.description,
severity: parameter.severity,
location: Location(file: file, byteOffset: offset),
reason: reason)]
validate(body: body, violationToken: node.initKeyword)
}

return []
private func validate(body: CodeBlockSyntax, violationToken: TokenSyntax) {
let complexity = ComplexityVisitor(
ignoresCaseStatements: configuration.ignoresCaseStatements
).walk(tree: body, handler: \.complexity)

for parameter in configuration.params where complexity > parameter.value {
let reason = "Function should have 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 func measureComplexity(in file: SwiftLintFile, dictionary: SourceKittenDictionary) -> Int {
var hasSwitchStatements = false
private class ComplexityVisitor: SyntaxVisitor {
private(set) var complexity = 0
let ignoresCaseStatements: Bool

let complexity = dictionary.substructure.reduce(0) { complexity, subDict in
guard subDict.kind != nil else {
return complexity
}
init(ignoresCaseStatements: Bool) {
self.ignoresCaseStatements = ignoresCaseStatements
super.init(viewMode: .sourceAccurate)
}

if let declarationKind = subDict.declarationKind,
SwiftDeclarationKind.functionKinds.contains(declarationKind) {
return complexity
}
override func visitPost(_ node: ForStmtSyntax) {
complexity += 1
}

guard let statementKind = subDict.statementKind else {
return complexity + measureComplexity(in: file, dictionary: subDict)
}
override func visitPost(_ node: IfExprSyntax) {
complexity += 1
}

if statementKind == .switch {
hasSwitchStatements = true
}
let score = configuration.complexityStatements.contains(statementKind) ? 1 : 0
return complexity +
score +
measureComplexity(in: file, dictionary: subDict)
override func visitPost(_ node: GuardStmtSyntax) {
complexity += 1
}

if hasSwitchStatements && !configuration.ignoresCaseStatements {
return reduceSwitchComplexity(initialComplexity: complexity, file: file, dictionary: dictionary)
override func visitPost(_ node: RepeatStmtSyntax) {
complexity += 1
}

return complexity
}
override func visitPost(_ node: WhileStmtSyntax) {
complexity += 1
}

// Switch complexity is reduced by `fallthrough` cases
override func visitPost(_ node: CatchClauseSyntax) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a bit weird, and maybe not intentional but this snippet:

do {} catch {}

contains a case according to SourceKitten:

{
  "key.diagnostic_stage" : "source.diagnostic.stage.swift.parse",
  "key.length" : 14,
  "key.offset" : 0,
  "key.substructure" : [
    {
      "key.bodylength" : 0,
      "key.bodyoffset" : 4,
      "key.kind" : "source.lang.swift.stmt.brace",
      "key.length" : 2,
      "key.offset" : 3
    },
    {
      "key.elements" : [
        {
          "key.kind" : "source.lang.swift.structure.elem.pattern",
          "key.length" : 1,
          "key.offset" : 12
        }
      ],
      "key.kind" : "source.lang.swift.stmt.case",
      "key.length" : 8,
      "key.offset" : 6
    }
  ]
}

since it's kind of a different branch I decided to keep counting it.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fact that SourceKit thinks that's a case statement is weird, but it's true that there are two branches in do {} catch {}: one where only do is invoked and another where do and catch are invoked.

complexity += 1
}

private func reduceSwitchComplexity(initialComplexity complexity: Int, file: SwiftLintFile,
dictionary: SourceKittenDictionary) -> Int {
let bodyRange = dictionary.bodyByteRange ?? ByteRange(location: 0, length: 0)
override func visitPost(_ node: SwitchCaseSyntax) {
if !ignoresCaseStatements {
complexity += 1
}
}

let contents = file.stringView.substringWithByteRange(bodyRange) ?? ""
override func visitPost(_ node: FallThroughStmtSyntax) {
// Switch complexity is reduced by `fallthrough` cases
if !ignoresCaseStatements {
complexity -= 1
}
}

override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind {
.skipChildren
}

override func visit(_ node: InitializerDeclSyntax) -> SyntaxVisitorContinueKind {
.skipChildren
}
}
}

let fallthroughCount = contents.components(separatedBy: "fallthrough").count - 1
return complexity - fallthroughCount
private extension DeclModifierListSyntax {
var staticOrClassModifier: TokenSyntax? {
first { element in
let kind = element.name.tokenKind
return kind == .keyword(.static) || kind == .keyword(.class)
}?.name
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,6 @@ import SwiftLintCore
struct CyclomaticComplexityConfiguration: RuleConfiguration {
typealias Parent = CyclomaticComplexityRule

private static let defaultComplexityStatements: Set<StatementKind> = [
.forEach,
.if,
.guard,
.for,
.repeatWhile,
.while
]

@ConfigurationElement
private(set) var length = SeverityLevelsConfiguration<Parent>(warning: 10, error: 20)
@ConfigurationElement(key: "ignores_case_statements")
Expand All @@ -22,8 +13,4 @@ struct CyclomaticComplexityConfiguration: RuleConfiguration {
var params: [RuleParameter<Int>] {
return length.params
}

var complexityStatements: Set<StatementKind> {
Self.defaultComplexityStatements.union(ignoresCaseStatements ? [] : [.case])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ class CyclomaticComplexityRuleTests: SwiftLintTestCase {
return Example(example)
}()

private lazy var complexSwitchInitExample: Example = {
var example = "init() {\n"
example += " switch foo {\n"
for index in (0...30) {
example += " case \(index): print(\"\(index)\")\n"
}
example += " }\n"
example += "}\n"
return Example(example)
}()

private lazy var complexIfExample: Example = {
let nest = 22
var example = "func nestThoseIfs() {\n"
Expand Down Expand Up @@ -47,7 +58,7 @@ class CyclomaticComplexityRuleTests: SwiftLintTestCase {

func testIgnoresCaseStatementsConfigurationDisabled() {
let baseDescription = CyclomaticComplexityRule.description
let triggeringExamples = baseDescription.triggeringExamples + [complexSwitchExample]
let triggeringExamples = baseDescription.triggeringExamples + [complexSwitchExample, complexSwitchInitExample]
let nonTriggeringExamples = baseDescription.nonTriggeringExamples

let description = baseDescription.with(nonTriggeringExamples: nonTriggeringExamples)
Expand Down