mirror of
https://github.com/realm/SwiftLint.git
synced 2026-06-06 20:18:40 +00:00
449190d324
A rule must conform to ManuallyTestedExamplesRule to skip generation of a test for its examples.
139 lines
5.5 KiB
Swift
139 lines
5.5 KiB
Swift
import Foundation
|
|
import SourceKittenFramework
|
|
|
|
public struct MultipleClosuresWithTrailingClosureRule: ASTRule, ConfigurationProviderRule {
|
|
public var configuration = SeverityConfiguration(.warning)
|
|
|
|
public init() {}
|
|
|
|
public static let description = RuleDescription(
|
|
identifier: "multiple_closures_with_trailing_closure",
|
|
name: "Multiple Closures with Trailing Closure",
|
|
description: "Trailing closure syntax should not be used when passing more than one closure argument.",
|
|
kind: .style,
|
|
nonTriggeringExamples: [
|
|
Example("foo.map { $0 + 1 }\n"),
|
|
Example("foo.reduce(0) { $0 + $1 }\n"),
|
|
Example("if let foo = bar.map({ $0 + 1 }) {\n\n}\n"),
|
|
Example("foo.something(param1: { $0 }, param2: { $0 + 1 })\n"),
|
|
Example("""
|
|
UIView.animate(withDuration: 1.0) {
|
|
someView.alpha = 0.0
|
|
}
|
|
"""),
|
|
Example("foo.method { print(0) } arg2: { print(1) }"),
|
|
Example("foo.methodWithParenArgs((0, 1), arg2: (0, 1, 2)) { $0 } arg4: { $0 }")
|
|
],
|
|
triggeringExamples: [
|
|
Example("foo.something(param1: { $0 }) ↓{ $0 + 1 }"),
|
|
Example("""
|
|
UIView.animate(withDuration: 1.0, animations: {
|
|
someView.alpha = 0.0
|
|
}) ↓{ _ in
|
|
someView.removeFromSuperview()
|
|
}
|
|
"""),
|
|
Example("foo.multipleTrailing(arg1: { $0 }) { $0 } arg3: { $0 }"),
|
|
Example("foo.methodWithParenArgs(param1: { $0 }, param2: (0, 1), (0, 1)) { $0 }")
|
|
]
|
|
)
|
|
|
|
public func validate(file: SwiftLintFile, kind: SwiftExpressionKind,
|
|
dictionary: SourceKittenDictionary) -> [StyleViolation] {
|
|
guard kind == .call,
|
|
case let arguments = dictionary.enclosedArguments,
|
|
case let closureArguments = arguments.filterClosures(file: file),
|
|
// Any violations must have at least one closure argument.
|
|
closureArguments.isNotEmpty,
|
|
// If there is no closing paren (e.g. `foo { ... }`), there is no violation.
|
|
let closingParenOffset = dictionary.closingParenLocation(file: file),
|
|
// Find all trailing closures.
|
|
case let trailingClosureArguments = closureArguments.filter({
|
|
isTrailingClosure(argument: $0, closingParenOffset: closingParenOffset)
|
|
}),
|
|
// If there are no trailing closures, there is no violation.
|
|
trailingClosureArguments.isNotEmpty,
|
|
// If all closure arguments are trailing closures, there is no violation
|
|
trailingClosureArguments.count != closureArguments.count,
|
|
let firstTrailingClosureOffset = trailingClosureArguments.first?.offset else {
|
|
return []
|
|
}
|
|
|
|
return [
|
|
StyleViolation(ruleDescription: Self.description,
|
|
severity: configuration.severity,
|
|
location: Location(file: file, byteOffset: firstTrailingClosureOffset))
|
|
]
|
|
}
|
|
|
|
// A closure is 'trailing' if it appears outside the closing paren.
|
|
private func isTrailingClosure(argument: SourceKittenDictionary,
|
|
closingParenOffset: ByteCount) -> Bool {
|
|
guard let argOffset = argument.offset else {
|
|
return false
|
|
}
|
|
|
|
return argOffset > closingParenOffset
|
|
}
|
|
}
|
|
|
|
private extension SourceKittenDictionary {
|
|
func closingParenLocation(file: SwiftLintFile) -> ByteCount? {
|
|
guard self.expressionKind == .call,
|
|
case let arguments = self.enclosedArguments,
|
|
arguments.isNotEmpty else {
|
|
return nil
|
|
}
|
|
|
|
func rangeBetween(_ expr1: SourceKittenDictionary, and expr2: SourceKittenDictionary) -> ByteRange? {
|
|
guard let offset1 = expr1.offset,
|
|
let length1 = expr1.length,
|
|
let offset2 = expr2.offset,
|
|
case let end1 = offset1 + length1,
|
|
end1 <= offset2 else {
|
|
return nil
|
|
}
|
|
|
|
return ByteRange(location: end1, length: offset2 - end1)
|
|
}
|
|
|
|
var searchRanges: [ByteRange] = []
|
|
for index in arguments.indices.dropLast() {
|
|
let currentArg = arguments[index]
|
|
let nextArg = arguments[index + 1]
|
|
if let range = rangeBetween(currentArg, and: nextArg) {
|
|
searchRanges.append(range)
|
|
}
|
|
}
|
|
|
|
if let lastOffset = arguments.last?.offset,
|
|
let lastLength = arguments.last?.length,
|
|
let callOffset = self.offset,
|
|
let callLength = self.length,
|
|
case let lastEnd = lastOffset + lastLength,
|
|
case let callEnd = callOffset + callLength,
|
|
lastEnd <= callEnd {
|
|
searchRanges.append(ByteRange(location: lastEnd, length: callEnd - lastEnd))
|
|
}
|
|
|
|
for byteRange in searchRanges {
|
|
if let range = file.stringView.byteRangeToNSRange(byteRange),
|
|
let match = regex("^\\s*\\)").firstMatch(in: file.contents, options: [], range: range)?.range {
|
|
return file.stringView.byteOffset(fromLocation: match.location)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private extension Array where Element == SourceKittenDictionary {
|
|
func filterClosures(file: SwiftLintFile) -> [SourceKittenDictionary] {
|
|
return filter { argument in
|
|
return argument.substructure.contains(where: { dictionary in
|
|
dictionary.expressionKind == .closure
|
|
})
|
|
}
|
|
}
|
|
}
|