Files
SwiftLint/Source/SwiftLintFramework/Rules/ClosureSpacingRule.swift
T
JP Simard b83e0991b9 Remove all file headers
The MIT license doesn't require that all files be prepended with this
licensing or copyright information. Realm confirmed that they're ok with this
change. This will enable some companies to contribute to SwiftLint and the
date & authorship information will remain accessible via git source control.
2018-05-04 13:42:02 -07:00

205 lines
8.7 KiB
Swift

import Foundation
import SourceKittenFramework
extension NSRange {
private func equals(_ other: NSRange) -> Bool {
return NSEqualRanges(self, other)
}
private func isStrictSubset(of other: NSRange) -> Bool {
if equals(other) { return false }
return NSUnionRange(self, other).equals(other)
}
fileprivate func isStrictSubset(in others: [NSRange]) -> Bool {
return others.contains(where: isStrictSubset)
}
}
public struct ClosureSpacingRule: CorrectableRule, ConfigurationProviderRule, OptInRule {
public var configuration = SeverityConfiguration(.warning)
public init() {}
public static let description = RuleDescription(
identifier: "closure_spacing",
name: "Closure Spacing",
description: "Closure expressions should have a single space inside each brace.",
kind: .style,
nonTriggeringExamples: [
"[].map ({ $0.description })",
"[].filter { $0.contains(location) }",
"extension UITableViewCell: ReusableView { }",
"extension UITableViewCell: ReusableView {}"
],
triggeringExamples: [
"[].filter(↓{$0.contains(location)})",
"[].map(↓{$0})",
"(↓{each in return result.contains(where: ↓{e in return e}) }).count",
"filter ↓{ sorted ↓{ $0 < $1}}"
],
corrections: [
"[].filter(↓{$0.contains(location)})":
"[].filter({ $0.contains(location) })",
"[].map(↓{$0})":
"[].map({ $0 })",
// Nested braces `{ {} }` do not get corrected on the first pass.
"filter ↓{sorted { $0 < $1}}":
"filter { sorted { $0 < $1} }",
// The user has to run tool again to fix remaining nested violations.
"filter { sorted ↓{ $0 < $1} }":
"filter { sorted { $0 < $1 } }",
"(↓{each in return result.contains(where: {e in return 0})}).count":
"({ each in return result.contains(where: {e in return 0}) }).count",
// second pass example
"({ each in return result.contains(where: ↓{e in return 0}) }).count":
"({ each in return result.contains(where: { e in return 0 }) }).count"
]
)
// this helps cut down the time to search through a file by
// skipping lines that do not have at least one `{` and one `}` brace
private func lineContainsBraces(in range: NSRange, content: NSString) -> NSRange? {
let start = content.range(of: "{", options: [.literal], range: range)
guard start.length != 0 else { return nil }
let end = content.range(of: "}", options: [.literal, .backwards], range: range)
guard end.length != 0 else { return nil }
guard start.location < end.location else { return nil }
return NSRange(location: start.location, length: end.location - start.location + 1)
}
// returns ranges of braces `{` or `}` in the same line
private func validBraces(in file: File) -> [NSRange] {
let nsstring = file.contents.bridge()
let bracePattern = regex("\\{|\\}")
let linesTokens = file.syntaxTokensByLines
let kindsToExclude = SyntaxKind.commentAndStringKinds.map { $0.rawValue }
// find all lines and occurences of open { and closed } braces
var linesWithBraces = [[NSRange]]()
for eachLine in file.lines {
guard let nsrange = lineContainsBraces(in: eachLine.range, content: nsstring) else {
continue
}
let braces = bracePattern.matches(in: file.contents, options: [],
range: nsrange).map { $0.range }
// filter out braces in comments and strings
let tokens = linesTokens[eachLine.index].filter { kindsToExclude.contains($0.type) }
let tokenRanges = tokens.compactMap {
file.contents.bridge().byteRangeToNSRange(start: $0.offset, length: $0.length)
}
linesWithBraces.append(braces.filter({ !$0.intersects(tokenRanges) }))
}
return linesWithBraces.flatMap { $0 }
}
// find ranges where violation exist. Returns ranges sorted by location.
private func findViolations(file: File) -> [NSRange] {
// match open braces to corresponding closing braces
func matchBraces(validBraceLocations: [NSRange]) -> [NSRange] {
if validBraceLocations.isEmpty { return [] }
var validBraces = validBraceLocations
var ranges = [NSRange]()
var bracesAsString = validBraces.map({
file.contents.substring(from: $0.location, length: $0.length)
}).joined()
while let foundRange = bracesAsString.range(of: "{}") {
let startIndex = bracesAsString.distance(from: bracesAsString.startIndex,
to: foundRange.lowerBound)
let location = validBraces[startIndex].location
let length = validBraces[startIndex + 1 ].location + 1 - location
ranges.append(NSRange(location: location, length: length))
bracesAsString.replaceSubrange(foundRange, with: "")
validBraces.removeSubrange(startIndex...startIndex + 1)
}
return ranges
}
// matching ranges of `{...}`
return matchBraces(validBraceLocations: validBraces(in: file))
.filter {
// removes enclosing brances to just content
let content = file.contents.substring(from: $0.location + 1, length: $0.length - 2)
if content.isEmpty || content == " " {
// case when {} is not a closure
return false
}
let cleaned = content.trimmingCharacters(in: .whitespaces)
return content != " " + cleaned + " "
}
.sorted {
$0.location < $1.location
}
}
public func validate(file: File) -> [StyleViolation] {
return findViolations(file: file).compactMap {
StyleViolation(ruleDescription: type(of: self).description,
severity: configuration.severity,
location: Location(file: file, characterOffset: $0.location))
}
}
// this will try to avoid nested ranges `{{}{}}` in single line
private func removeNested(_ ranges: [NSRange]) -> [NSRange] {
return ranges.filter { current in
return !current.isStrictSubset(in: ranges)
}
}
public func correct(file: File) -> [Correction] {
var matches = removeNested(findViolations(file: file)).filter {
!file.ruleEnabled(violatingRanges: [$0], for: self).isEmpty
}
guard !matches.isEmpty else { return [] }
// `matches` should be sorted by location from `findViolations`.
let start = NSRange(location: 0, length: 0)
let end = NSRange(location: file.contents.utf16.count, length: 0)
matches.insert(start, at: 0)
matches.append(end)
var fixedSections = [String]()
var matchIndex = 0
while matchIndex < matches.count - 1 {
defer { matchIndex += 1 }
// inverses the ranges to select non rule violation content
let current = matches[matchIndex].location + matches[matchIndex].length
let nextMatch = matches[matchIndex + 1]
let next = nextMatch.location
let length = next - current
let nonViolationContent = file.contents.substring(from: current, length: length)
if !nonViolationContent.isEmpty {
fixedSections.append(nonViolationContent)
}
// selects violation ranges and fixes them before adding back in
if nextMatch.length > 1 {
let violation = file.contents.substring(from: nextMatch.location + 1,
length: nextMatch.length - 2)
let cleaned = "{ " + violation.trimmingCharacters(in: .whitespaces) + " }"
fixedSections.append(cleaned)
}
// Catch all. Break at the end of loop.
if next == end.location { break }
}
// removes the start and end inserted above
if matches.count > 2 {
matches.remove(at: matches.count - 1)
matches.remove(at: 0)
}
// write changes to actual file
file.write(fixedSections.joined())
return matches.map {
Correction(ruleDescription: type(of: self).description,
location: Location(file: file, characterOffset: $0.location))
}
}
}