219 lines
9.1 KiB
Swift
219 lines
9.1 KiB
Swift
import SwiftSyntax
|
|
|
|
struct TrailingCommaRule: SwiftSyntaxCorrectableRule, ConfigurationProviderRule {
|
|
var configuration = TrailingCommaConfiguration()
|
|
|
|
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
|
|
}()
|
|
|
|
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"),
|
|
Example("let foo = [Int]()\n")
|
|
],
|
|
triggeringExamples: Self.triggeringExamples,
|
|
corrections: Self.corrections
|
|
)
|
|
|
|
func makeVisitor(file: SwiftLintFile) -> ViolationsSyntaxVisitor {
|
|
Visitor(
|
|
mandatoryComma: configuration.mandatoryComma,
|
|
locationConverter: file.locationConverter
|
|
)
|
|
}
|
|
|
|
func makeRewriter(file: SwiftLintFile) -> ViolationsSyntaxRewriter? {
|
|
Rewriter(
|
|
mandatoryComma: configuration.mandatoryComma,
|
|
locationConverter: file.locationConverter,
|
|
disabledRegions: disabledRegions(file: file)
|
|
)
|
|
}
|
|
}
|
|
|
|
private extension TrailingCommaRule {
|
|
final class Visitor: ViolationsSyntaxVisitor {
|
|
private let mandatoryComma: Bool
|
|
private let locationConverter: SourceLocationConverter
|
|
|
|
init(mandatoryComma: Bool, locationConverter: SourceLocationConverter) {
|
|
self.mandatoryComma = mandatoryComma
|
|
self.locationConverter = locationConverter
|
|
super.init(viewMode: .sourceAccurate)
|
|
}
|
|
|
|
override func visitPost(_ node: DictionaryElementListSyntax) {
|
|
guard let lastElement = node.last else {
|
|
return
|
|
}
|
|
|
|
switch (lastElement.trailingComma, mandatoryComma) {
|
|
case (let commaToken?, false):
|
|
violations.append(violation(for: commaToken.positionAfterSkippingLeadingTrivia))
|
|
case (nil, true) where !locationConverter.isSingleLine(node: node):
|
|
violations.append(violation(for: lastElement.endPositionBeforeTrailingTrivia))
|
|
case (_, true), (nil, false):
|
|
break
|
|
}
|
|
}
|
|
|
|
override func visitPost(_ node: ArrayElementListSyntax) {
|
|
guard let lastElement = node.last else {
|
|
return
|
|
}
|
|
|
|
switch (lastElement.trailingComma, mandatoryComma) {
|
|
case (let commaToken?, false):
|
|
violations.append(violation(for: commaToken.positionAfterSkippingLeadingTrivia))
|
|
case (nil, true) where !locationConverter.isSingleLine(node: node):
|
|
violations.append(violation(for: lastElement.endPositionBeforeTrailingTrivia))
|
|
case (_, true), (nil, false):
|
|
break
|
|
}
|
|
}
|
|
|
|
private func violation(for position: AbsolutePosition) -> ReasonedRuleViolation {
|
|
let reason = mandatoryComma
|
|
? "Multi-line collection literals should have trailing commas"
|
|
: "Collection literals should not have trailing commas"
|
|
return ReasonedRuleViolation(position: position, reason: reason)
|
|
}
|
|
}
|
|
|
|
final class Rewriter: SyntaxRewriter, ViolationsSyntaxRewriter {
|
|
private(set) var correctionPositions: [AbsolutePosition] = []
|
|
private let mandatoryComma: Bool
|
|
private let locationConverter: SourceLocationConverter
|
|
private let disabledRegions: [SourceRange]
|
|
|
|
init(mandatoryComma: Bool, locationConverter: SourceLocationConverter, disabledRegions: [SourceRange]) {
|
|
self.mandatoryComma = mandatoryComma
|
|
self.locationConverter = locationConverter
|
|
self.disabledRegions = disabledRegions
|
|
}
|
|
|
|
override func visit(_ node: DictionaryElementListSyntax) -> DictionaryElementListSyntax {
|
|
guard let lastElement = node.last,
|
|
!lastElement.isContainedIn(regions: disabledRegions, locationConverter: locationConverter) else {
|
|
return super.visit(node)
|
|
}
|
|
|
|
switch (lastElement.trailingComma, mandatoryComma) {
|
|
case (let commaToken?, false):
|
|
correctionPositions.append(commaToken.positionAfterSkippingLeadingTrivia)
|
|
let newTrailingTrivia = (lastElement.valueExpression.trailingTrivia)
|
|
.appending(trivia: commaToken.leadingTrivia)
|
|
.appending(trivia: commaToken.trailingTrivia)
|
|
let newNode = node
|
|
.replacing(
|
|
childAt: lastElement.indexInParent,
|
|
with: lastElement
|
|
.with(\.trailingComma, nil)
|
|
.with(\.trailingTrivia, newTrailingTrivia)
|
|
)
|
|
return super.visit(newNode)
|
|
case (nil, true) where !locationConverter.isSingleLine(node: node):
|
|
correctionPositions.append(lastElement.endPositionBeforeTrailingTrivia)
|
|
let newNode = node
|
|
.replacing(
|
|
childAt: lastElement.indexInParent,
|
|
with: lastElement
|
|
.with(\.trailingTrivia, [])
|
|
.with(\.trailingComma, .commaToken())
|
|
.with(\.trailingTrivia, lastElement.trailingTrivia)
|
|
)
|
|
return super.visit(newNode)
|
|
case (_, true), (nil, false):
|
|
return super.visit(node)
|
|
}
|
|
}
|
|
|
|
override func visit(_ node: ArrayElementListSyntax) -> ArrayElementListSyntax {
|
|
guard let lastElement = node.last,
|
|
!lastElement.isContainedIn(regions: disabledRegions, locationConverter: locationConverter) else {
|
|
return super.visit(node)
|
|
}
|
|
|
|
switch (lastElement.trailingComma, mandatoryComma) {
|
|
case (let commaToken?, false):
|
|
correctionPositions.append(commaToken.positionAfterSkippingLeadingTrivia)
|
|
let newNode = node
|
|
.replacing(
|
|
childAt: lastElement.indexInParent,
|
|
with: lastElement
|
|
.with(\.trailingComma, nil)
|
|
.with(\.trailingTrivia,
|
|
(lastElement.expression.trailingTrivia)
|
|
.appending(trivia: commaToken.leadingTrivia)
|
|
.appending(trivia: commaToken.trailingTrivia)
|
|
)
|
|
)
|
|
return super.visit(newNode)
|
|
case (nil, true) where !locationConverter.isSingleLine(node: node):
|
|
correctionPositions.append(lastElement.endPositionBeforeTrailingTrivia)
|
|
let newNode = node.replacing(
|
|
childAt: lastElement.indexInParent,
|
|
with: lastElement
|
|
.with(\.expression, lastElement.expression.with(\.trailingTrivia, []))
|
|
.with(\.trailingComma, .commaToken())
|
|
.with(\.trailingTrivia, lastElement.expression.trailingTrivia)
|
|
)
|
|
return super.visit(newNode)
|
|
case (_, true), (nil, false):
|
|
return super.visit(node)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension SourceLocationConverter {
|
|
func isSingleLine(node: SyntaxProtocol) -> Bool {
|
|
location(for: node.positionAfterSkippingLeadingTrivia).line ==
|
|
location(for: node.endPositionBeforeTrailingTrivia).line
|
|
}
|
|
}
|
|
|
|
private extension Trivia {
|
|
func appending(trivia: Trivia) -> Trivia {
|
|
var result = self
|
|
for piece in trivia.pieces {
|
|
result = result.appending(piece)
|
|
}
|
|
return result
|
|
}
|
|
}
|