mirror of
https://github.com/realm/SwiftLint.git
synced 2026-06-06 20:18:40 +00:00
179 lines
7.5 KiB
Swift
179 lines
7.5 KiB
Swift
import Foundation
|
|
import SourceKittenFramework
|
|
|
|
private enum TrailingCommaReason: String {
|
|
case missingTrailingCommaReason = "Multi-line collection literals should have trailing commas."
|
|
case extraTrailingCommaReason = "Collection literals should not have trailing commas."
|
|
}
|
|
|
|
private typealias CommaRuleViolation = (index: ByteCount, reason: TrailingCommaReason)
|
|
|
|
public struct TrailingCommaRule: SubstitutionCorrectableASTRule, ConfigurationProviderRule {
|
|
public var configuration = TrailingCommaConfiguration()
|
|
|
|
public init() {}
|
|
|
|
private static let triggeringExamples: [Example] = [
|
|
Example("let foo = [1, 2, 3↓,]\n"),
|
|
Example("let foo = [1, 2, 3↓, ]\n"),
|
|
Example("let foo = [1, 2, 3 ↓,]\n"),
|
|
Example("let foo = [1: 2, 2: 3↓, ]\n"),
|
|
Example("struct Bar {\n let foo = [1: 2, 2: 3↓, ]\n}\n"),
|
|
Example("let foo = [1, 2, 3↓,] + [4, 5, 6↓,]\n"),
|
|
Example("let example = [ 1,\n2↓,\n // 3,\n]"),
|
|
Example("let foo = [\"אבג\", \"αβγ\", \"🇺🇸\"↓,]\n"),
|
|
Example("class C {\n #if true\n func f() {\n let foo = [1, 2, 3↓,]\n }\n #endif\n}"),
|
|
Example("foo([1: \"\\(error)\"↓,])\n")
|
|
]
|
|
|
|
private static let corrections: [Example: Example] = {
|
|
let fixed = triggeringExamples.map { example -> Example in
|
|
let fixedString = example.code.replacingOccurrences(of: "↓,", with: "")
|
|
return example.with(code: fixedString)
|
|
}
|
|
var result: [Example: Example] = [:]
|
|
for (triggering, correction) in zip(triggeringExamples, fixed) {
|
|
result[triggering] = correction
|
|
}
|
|
return result
|
|
}()
|
|
|
|
public static let description = RuleDescription(
|
|
identifier: "trailing_comma",
|
|
name: "Trailing Comma",
|
|
description: "Trailing commas in arrays and dictionaries should be avoided/enforced.",
|
|
kind: .style,
|
|
nonTriggeringExamples: [
|
|
Example("let foo = [1, 2, 3]\n"),
|
|
Example("let foo = []\n"),
|
|
Example("let foo = [:]\n"),
|
|
Example("let foo = [1: 2, 2: 3]\n"),
|
|
Example("let foo = [Void]()\n"),
|
|
Example("let example = [ 1,\n 2\n // 3,\n]"),
|
|
Example("foo([1: \"\\(error)\"])\n")
|
|
],
|
|
triggeringExamples: TrailingCommaRule.triggeringExamples,
|
|
corrections: TrailingCommaRule.corrections
|
|
)
|
|
|
|
private static let commaRegex = regex(",", options: [.ignoreMetacharacters])
|
|
|
|
public func validate(file: SwiftLintFile, kind: SwiftExpressionKind,
|
|
dictionary: SourceKittenDictionary) -> [StyleViolation] {
|
|
if let (index, reason) = violationIndexAndReason(in: file, kind: kind, dictionary: dictionary) {
|
|
return violations(file: file, byteOffset: index, reason: reason.rawValue)
|
|
} else {
|
|
return []
|
|
}
|
|
}
|
|
|
|
public func violationRanges(in file: SwiftLintFile, kind: SwiftExpressionKind,
|
|
dictionary: SourceKittenDictionary) -> [NSRange] {
|
|
guard let (offset, reason) = violationIndexAndReason(in: file, kind: kind, dictionary: dictionary),
|
|
case let length: ByteCount = reason == .extraTrailingCommaReason ? 1 : 0,
|
|
case let byteRange = ByteRange(location: offset, length: length),
|
|
let range = file.stringView.byteRangeToNSRange(byteRange)
|
|
else {
|
|
return []
|
|
}
|
|
|
|
return [range]
|
|
}
|
|
|
|
public func substitution(for violationRange: NSRange, in file: SwiftLintFile) -> (NSRange, String)? {
|
|
return (violationRange, configuration.mandatoryComma ? "," : "")
|
|
}
|
|
|
|
private func violationIndexAndReason(in file: SwiftLintFile, kind: SwiftExpressionKind,
|
|
dictionary: SourceKittenDictionary) -> CommaRuleViolation? {
|
|
let allowedKinds: Set<SwiftExpressionKind> = [.array, .dictionary]
|
|
|
|
guard let bodyOffset = dictionary.bodyOffset,
|
|
let bodyLength = dictionary.bodyLength,
|
|
allowedKinds.contains(kind) else {
|
|
return nil
|
|
}
|
|
|
|
let endPositions = dictionary.elements.compactMap { $0.byteRange?.upperBound }
|
|
|
|
guard let lastPosition = endPositions.max(), bodyLength + bodyOffset >= lastPosition else {
|
|
return nil
|
|
}
|
|
|
|
let contents = file.stringView
|
|
if let (startLine, _) = contents.lineAndCharacter(forByteOffset: bodyOffset),
|
|
let (endLine, _) = contents.lineAndCharacter(forByteOffset: lastPosition),
|
|
configuration.mandatoryComma && startLine == endLine {
|
|
// shouldn't trigger if mandatory comma style and is a single-line declaration
|
|
return nil
|
|
}
|
|
|
|
let length = bodyLength + bodyOffset - lastPosition
|
|
let byteRangeAfterLastElement = ByteRange(location: lastPosition, length: length)
|
|
let contentsAfterLastElement = contents.substringWithByteRange(byteRangeAfterLastElement) ?? ""
|
|
|
|
// if a trailing comma is not present
|
|
guard let commaIndex = trailingCommaIndex(contents: contentsAfterLastElement, file: file, offset: lastPosition)
|
|
else {
|
|
guard configuration.mandatoryComma else {
|
|
return nil
|
|
}
|
|
|
|
return (lastPosition, .missingTrailingCommaReason)
|
|
}
|
|
|
|
// trailing comma is present, which is a violation if mandatoryComma is false
|
|
guard !configuration.mandatoryComma else {
|
|
return nil
|
|
}
|
|
|
|
let violationOffset = lastPosition + commaIndex
|
|
return (violationOffset, .extraTrailingCommaReason)
|
|
}
|
|
|
|
private func violations(file: SwiftLintFile, byteOffset: ByteCount, reason: String) -> [StyleViolation] {
|
|
return [
|
|
StyleViolation(ruleDescription: Self.description,
|
|
severity: configuration.severityConfiguration.severity,
|
|
location: Location(file: file, byteOffset: byteOffset),
|
|
reason: reason)
|
|
]
|
|
}
|
|
|
|
private func trailingCommaIndex(contents: String, file: SwiftLintFile, offset: ByteCount) -> ByteCount? {
|
|
// skip commas in comments
|
|
return TrailingCommaRule.commaRegex
|
|
.matches(in: contents, options: [], range: contents.fullNSRange)
|
|
.map { $0.range }
|
|
.last { nsRange in
|
|
let offsetCharacter = file.stringView.location(fromByteOffset: offset)
|
|
let offsetNSRange = NSRange(location: nsRange.location + offsetCharacter, length: nsRange.length)
|
|
let byteRange = file.stringView.NSRangeToByteRange(offsetNSRange)!
|
|
let kinds = file.syntaxMap.kinds(inByteRange: byteRange)
|
|
return SyntaxKind.commentKinds.isDisjoint(with: kinds)
|
|
}
|
|
.flatMap(contents.NSRangeToByteRange)?
|
|
.location
|
|
}
|
|
}
|
|
|
|
private extension String {
|
|
func NSRangeToByteRange(_ nsRange: NSRange) -> ByteRange? {
|
|
let utf16View = utf16
|
|
let utf8View = utf8
|
|
|
|
let startUTF16Index = utf16View.index(utf16View.startIndex, offsetBy: nsRange.location)
|
|
let endUTF16Index = utf16View.index(startUTF16Index, offsetBy: nsRange.length)
|
|
|
|
guard let startUTF8Index = startUTF16Index.samePosition(in: utf8View),
|
|
let endUTF8Index = endUTF16Index.samePosition(in: utf8View)
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
let byteOffset = utf8View.distance(from: utf8View.startIndex, to: startUTF8Index)
|
|
let length = utf8View.distance(from: startUTF8Index, to: endUTF8Index)
|
|
return ByteRange(location: ByteCount(byteOffset), length: ByteCount(length))
|
|
}
|
|
}
|