mirror of
https://github.com/realm/SwiftLint.git
synced 2026-06-06 20:18:40 +00:00
1b3e9945af
This Fixes case when the right part of the expression is an array or a string Previously, strings and comments tokens were ignored. In the current implementation, matching done first and then those are filtered if the operator is within the string token
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))
|
|
}
|
|
}
|