import SwiftSyntax @SwiftSyntaxRule(explicitRewriter: true, optIn: true) struct ClosureSpacingRule: Rule { var configuration = SeverityConfiguration(.warning) static let description = RuleDescription( identifier: "closure_spacing", name: "Closure Spacing", description: "Closure expressions should have a single space inside each brace", kind: .style, nonTriggeringExamples: [ Example("[].map ({ $0.description })"), Example("[].filter { $0.contains(location) }"), Example("extension UITableViewCell: ReusableView { }"), Example("extension UITableViewCell: ReusableView {}"), Example(#"let r = /\{\}/"#, excludeFromDocumentation: true), Example(""" var tapped: (UITapGestureRecognizer) -> Void = { _ in /* no-op */ } """, excludeFromDocumentation: true), Example(""" let test1 = func1(arg: { /* do nothing */ }) let test2 = func1 { /* do nothing */ } """, excludeFromDocumentation: true), ], triggeringExamples: [ Example("[].filter↓{ $0.contains(location) }"), Example("[].filter(↓{$0.contains(location)})"), Example("[].map(↓{$0})"), Example("(↓{each in return result.contains(where: ↓{e in return e}) }).count"), Example("filter ↓{ sorted ↓{ $0 < $1}}"), Example(""" var tapped: (UITapGestureRecognizer) -> Void = ↓{ _ in /* no-op */ } """, excludeFromDocumentation: true), ], corrections: [ Example("[].filter(↓{$0.contains(location) })"): Example("[].filter({ $0.contains(location) })"), Example("[].map(↓{$0})"): Example("[].map({ $0 })"), Example("filter ↓{sorted ↓{ $0 < $1}}"): Example("filter { sorted { $0 < $1 } }"), Example("(↓{each in return result.contains(where: ↓{e in return 0})}).count"): Example("({ each in return result.contains(where: { e in return 0 }) }).count"), ] ) } private extension ClosureSpacingRule { final class Visitor: ViolationsSyntaxVisitor { override func visitPost(_ node: ClosureExprSyntax) { if node.shouldCheckForClosureSpacingRule(locationConverter: locationConverter), node.violations.hasViolations { violations.append(node.positionAfterSkippingLeadingTrivia) } } } final class Rewriter: ViolationsSyntaxRewriter { override func visit(_ node: ClosureExprSyntax) -> ExprSyntax { var node = node node.statements = visit(node.statements) guard node.shouldCheckForClosureSpacingRule(locationConverter: locationConverter) else { return super.visit(node) } let violations = node.violations if violations.leftBraceLeftSpace { node.leftBrace = node.leftBrace.with(\.leadingTrivia, .spaces(1)) } if violations.leftBraceRightSpace { node.leftBrace = node.leftBrace.with(\.trailingTrivia, .spaces(1)) } if violations.rightBraceLeftSpace { node.rightBrace = node.rightBrace.with(\.leadingTrivia, .spaces(1)) } if violations.rightBraceRightSpace { node.rightBrace = node.rightBrace.with(\.trailingTrivia, .spaces(1)) } if violations.hasViolations { numberOfCorrections += 1 } return super.visit(node) } } } // MARK: - Private Helpers private struct ClosureSpacingRuleClosureViolations { let leftBraceLeftSpace: Bool let leftBraceRightSpace: Bool let rightBraceLeftSpace: Bool let rightBraceRightSpace: Bool var hasViolations: Bool { leftBraceLeftSpace || leftBraceRightSpace || rightBraceLeftSpace || rightBraceRightSpace } } private extension ClosureExprSyntax { var violations: ClosureSpacingRuleClosureViolations { ClosureSpacingRuleClosureViolations( leftBraceLeftSpace: !leftBrace.hasSingleSpaceToItsLeft && !leftBrace.hasAllowedNoSpaceLeftToken && !leftBrace.hasLeadingNewline, leftBraceRightSpace: !leftBrace.hasSingleSpaceToItsRight, rightBraceLeftSpace: !rightBrace.hasSingleSpaceToItsLeft, rightBraceRightSpace: !rightBrace.hasSingleSpaceToItsRight && !rightBrace.hasAllowedNoSpaceRightToken && !rightBrace.hasTrailingLineComment ) } func shouldCheckForClosureSpacingRule(locationConverter: SourceLocationConverter) -> Bool { guard parent?.is(PostfixOperatorExprSyntax.self) == false, // Workaround for Regex literals (rightBrace.position.utf8Offset - leftBrace.position.utf8Offset) > 1, // Allow '{}' case let startLine = startLocation(converter: locationConverter).line, case let endLine = endLocation(converter: locationConverter).line, startLine == endLine // Only check single-line closures else { return false } return true } } private extension TokenSyntax { var hasSingleSpaceToItsLeft: Bool { if case .spaces(1) = Array(leadingTrivia).last { return true } if let previousToken = previousToken(viewMode: .sourceAccurate), case .spaces(1) = Array(previousToken.trailingTrivia).last { return true } return false } var hasSingleSpaceToItsRight: Bool { if case .spaces(1) = trailingTrivia.first { return true } if let nextToken = nextToken(viewMode: .sourceAccurate), case .spaces(1) = nextToken.leadingTrivia.first { return true } return false } var hasLeadingNewline: Bool { leadingTrivia.contains { piece in if case .newlines = piece { return true } return false } } var hasTrailingLineComment: Bool { trailingTrivia.contains { piece in if case .lineComment = piece { return true } return false } } var hasAllowedNoSpaceLeftToken: Bool { let previousTokenKind = parent?.previousToken(viewMode: .sourceAccurate)?.tokenKind return previousTokenKind == .leftParen || previousTokenKind == .leftSquare } var hasAllowedNoSpaceRightToken: Bool { let allowedKinds = [ TokenKind.colon, .comma, .endOfFile, .exclamationMark, .leftParen, .leftSquare, .period, .postfixQuestionMark, .rightParen, .rightSquare, .semicolon, ] if case .newlines = trailingTrivia.first { return true } if case .newlines = nextToken(viewMode: .sourceAccurate)?.leadingTrivia.first { return true } if let nextToken = nextToken(viewMode: .sourceAccurate), allowedKinds.contains(nextToken.tokenKind) { return true } return false } }