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 = [.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: type(of: 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)) } }