diff --git a/CHANGELOG.md b/CHANGELOG.md index 375603d4e8..3e334d558c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ * `line_length` * `trailing_whitespace` * `vertical_whitespace` + * `vertical_whitespace_closing_braces` [JP Simard](https://github.com/jpsim) [Matt Pennig](https://github.com/pennig) diff --git a/Source/SwiftLintBuiltInRules/Rules/Style/VerticalWhitespaceClosingBracesRule.swift b/Source/SwiftLintBuiltInRules/Rules/Style/VerticalWhitespaceClosingBracesRule.swift index d23484f20f..29fa71eecc 100644 --- a/Source/SwiftLintBuiltInRules/Rules/Style/VerticalWhitespaceClosingBracesRule.swift +++ b/Source/SwiftLintBuiltInRules/Rules/Style/VerticalWhitespaceClosingBracesRule.swift @@ -1,7 +1,8 @@ -import Foundation -import SourceKittenFramework +import SwiftLintCore +import SwiftSyntax -struct VerticalWhitespaceClosingBracesRule: CorrectableRule, OptInRule { +@SwiftSyntaxRule(correctable: true, optIn: true) +struct VerticalWhitespaceClosingBracesRule: Rule { var configuration = VerticalWhitespaceClosingBracesConfiguration() static let description = RuleDescription( @@ -14,52 +15,304 @@ struct VerticalWhitespaceClosingBracesRule: CorrectableRule, OptInRule { triggeringExamples: Array(VerticalWhitespaceClosingBracesRuleExamples.violatingToValidExamples.keys.sorted()), corrections: VerticalWhitespaceClosingBracesRuleExamples.violatingToValidExamples.removingViolationMarkers() ) +} + +private struct TriviaAnalysis { + var consecutiveNewlines = 0 + var violationStartPosition: AbsolutePosition? + var violationEndPosition: AbsolutePosition? +} - private let pattern = "((?:\\n[ \\t]*)+)(\\n[ \\t]*[)}\\]])" - private let trivialLinePattern = "((?:\\n[ \\t]*)+)(\\n[ \\t)}\\]]*$)" +private struct CorrectionState { + var result = [TriviaPiece]() + var consecutiveNewlines = 0 + var pendingWhitespace = [TriviaPiece]() + var correctionCount = 0 + var hasViolation = false +} - func validate(file: SwiftLintFile) -> [StyleViolation] { - let pattern = configuration.onlyEnforceBeforeTrivialLines ? self.trivialLinePattern : self.pattern +private struct NewlineProcessingContext { + let currentPosition: AbsolutePosition + let consecutiveNewlines: Int + var violationStartPosition: AbsolutePosition? + var violationEndPosition: AbsolutePosition? +} - let patternRegex: NSRegularExpression = regex(pattern) +private func isTokenLineTrivialHelper( + for token: TokenSyntax, + file: SwiftLintFile, + locationConverter: SourceLocationConverter +) -> Bool { + let lineColumn = locationConverter.location(for: token.positionAfterSkippingLeadingTrivia) + let line = lineColumn.line - return file.violatingRanges(for: pattern).map { violationRange in - let substring = file.contents.substring(from: violationRange.location, length: violationRange.length) - let matchResult = patternRegex.firstMatch(in: substring, options: [], range: substring.fullNSRange)! - let violatingSubrange = matchResult.range(at: 1) - let characterOffset = violationRange.location + violatingSubrange.location + 1 + guard let lineContent = file.lines.first(where: { $0.index == line })?.content else { + return false + } - return StyleViolation( - ruleDescription: Self.description, - severity: configuration.severityConfiguration.severity, - location: Location(file: file, characterOffset: characterOffset) + let trimmedLine = lineContent.trimmingCharacters(in: .whitespaces) + let closingBraces: Set = ["]", "}", ")"] + return !trimmedLine.isEmpty && trimmedLine.allSatisfy { closingBraces.contains($0) } +} + +private extension VerticalWhitespaceClosingBracesRule { + final class Visitor: ViolationsSyntaxVisitor { + override func visitPost(_ node: TokenSyntax) { + guard node.isClosingBrace else { + return + } + + let triviaAnalysis = analyzeTriviaForViolations( + trivia: node.leadingTrivia, + token: node, + position: node.position ) + + if let violation = triviaAnalysis { + violations.append( + ReasonedRuleViolation( + position: violation.position, + correction: .init( + start: violation.position, + end: violation.endPosition, + replacement: "" + ) + ) + ) + } + } + + private func analyzeTriviaForViolations( + trivia: Trivia, + token: TokenSyntax, + position: AbsolutePosition + ) -> (position: AbsolutePosition, endPosition: AbsolutePosition)? { + let analysis = analyzeTrivia(trivia: trivia, startPosition: position) + + guard let startPos = analysis.violationStartPosition, + let endPos = analysis.violationEndPosition, + analysis.consecutiveNewlines >= 2 else { + return nil + } + + if configuration.onlyEnforceBeforeTrivialLines && + !isTokenLineTrivialHelper(for: token, file: file, locationConverter: locationConverter) { + return nil + } + + return (position: startPos, endPosition: endPos) + } + + private func analyzeTrivia( + trivia: Trivia, + startPosition: AbsolutePosition + ) -> TriviaAnalysis { + var result = TriviaAnalysis() + var currentPosition = startPosition + + for piece in trivia { + let (newlines, positionAdvance) = processTriviaPiece( + piece: piece, + currentPosition: currentPosition, + consecutiveNewlines: result.consecutiveNewlines, + violationStartPosition: &result.violationStartPosition, + violationEndPosition: &result.violationEndPosition + ) + result.consecutiveNewlines = newlines + currentPosition = currentPosition.advanced(by: positionAdvance) + } + + return result + } + + private func processTriviaPiece( + piece: TriviaPiece, + currentPosition: AbsolutePosition, + consecutiveNewlines: Int, + violationStartPosition: inout AbsolutePosition?, + violationEndPosition: inout AbsolutePosition? + ) -> (newlines: Int, positionAdvance: Int) { + switch piece { + case .newlines(let count), .carriageReturns(let count): + var context = NewlineProcessingContext( + currentPosition: currentPosition, + consecutiveNewlines: consecutiveNewlines, + violationStartPosition: violationStartPosition, + violationEndPosition: violationEndPosition + ) + let result = processNewlines( + count: count, + bytesPerNewline: 1, + context: &context + ) + violationStartPosition = context.violationStartPosition + violationEndPosition = context.violationEndPosition + return result + case .carriageReturnLineFeeds(let count): + var context = NewlineProcessingContext( + currentPosition: currentPosition, + consecutiveNewlines: consecutiveNewlines, + violationStartPosition: violationStartPosition, + violationEndPosition: violationEndPosition + ) + let result = processNewlines( + count: count, + bytesPerNewline: 2, + context: &context + ) + violationStartPosition = context.violationStartPosition + violationEndPosition = context.violationEndPosition + return result + case .spaces, .tabs: + return (consecutiveNewlines, piece.sourceLength.utf8Length) + default: + // Any other trivia breaks the sequence + violationStartPosition = nil + violationEndPosition = nil + return (0, piece.sourceLength.utf8Length) + } } - } - func correct(file: SwiftLintFile) -> Int { - let pattern = configuration.onlyEnforceBeforeTrivialLines ? self.trivialLinePattern : self.pattern - let violatingRanges = file.ruleEnabled(violatingRanges: file.violatingRanges(for: pattern), for: self) - guard violatingRanges.isNotEmpty else { - return 0 + private func processNewlines( + count: Int, + bytesPerNewline: Int, + context: inout NewlineProcessingContext + ) -> (newlines: Int, positionAdvance: Int) { + var newConsecutiveNewlines = context.consecutiveNewlines + var totalAdvance = 0 + + for _ in 0..= 2 newlines. + if newConsecutiveNewlines >= 2 { + context.violationEndPosition = context.currentPosition.advanced(by: totalAdvance + bytesPerNewline) + } + totalAdvance += bytesPerNewline + } + + return (newConsecutiveNewlines, totalAdvance) } - let patternRegex = regex(pattern) - var fileContents = file.contents - for violationRange in violatingRanges.reversed() { - fileContents = patternRegex.stringByReplacingMatches( - in: fileContents, - options: [], - range: violationRange, - withTemplate: "$2" + } + + final class Rewriter: ViolationsSyntaxRewriter { + override func visit(_ token: TokenSyntax) -> TokenSyntax { + guard token.isClosingBrace else { + return super.visit(token) + } + + let correctedTrivia = correctTrivia( + trivia: token.leadingTrivia, + token: token ) + + if correctedTrivia.hasCorrections { + numberOfCorrections += correctedTrivia.correctionCount + return super.visit(token.with(\.leadingTrivia, correctedTrivia.trivia)) + } + + return super.visit(token) + } + + private func correctTrivia( + trivia: Trivia, + token: TokenSyntax + ) -> (trivia: Trivia, hasCorrections: Bool, correctionCount: Int) { + // First check if we should apply corrections + if configuration.onlyEnforceBeforeTrivialLines && + !isTokenLineTrivialHelper(for: token, file: file, locationConverter: locationConverter) { + return (trivia: trivia, hasCorrections: false, correctionCount: 0) + } + + var state = CorrectionState() + + for piece in trivia { + processPieceForCorrection(piece: piece, state: &state) + } + + // Add any remaining whitespace + state.result.append(contentsOf: state.pendingWhitespace) + + return (trivia: Trivia(pieces: state.result), + hasCorrections: state.correctionCount > 0, + correctionCount: state.correctionCount) + } + + private func processPieceForCorrection(piece: TriviaPiece, state: inout CorrectionState) { + switch piece { + case .newlines(let count), .carriageReturns(let count): + let newlineCreator = piece.isNewline ? TriviaPiece.newlines : TriviaPiece.carriageReturns + processNewlinesForCorrection( + count: count, + newlineCreator: { newlineCreator($0) }, + state: &state + ) + case .carriageReturnLineFeeds(let count): + processNewlinesForCorrection( + count: count, + newlineCreator: { TriviaPiece.carriageReturnLineFeeds($0) }, + state: &state + ) + case .spaces, .tabs: + // Only keep whitespace if we haven't seen a violation yet + if !state.hasViolation { + state.pendingWhitespace.append(piece) + } + default: + // Other trivia breaks the sequence + state.consecutiveNewlines = 0 + state.hasViolation = false + state.result.append(contentsOf: state.pendingWhitespace) + state.result.append(piece) + state.pendingWhitespace.removeAll() + } + } + + private func processNewlinesForCorrection( + count: Int, + newlineCreator: (Int) -> TriviaPiece, + state: inout CorrectionState + ) { + for _ in 0.. [NSRange] { - match(pattern: pattern, excludingSyntaxKinds: SyntaxKind.commentAndStringKinds) +private extension TokenSyntax { + var isClosingBrace: Bool { + switch tokenKind { + case .rightBrace, .rightParen, .rightSquare: + return true + default: + return false + } + } +} + +private extension TriviaPiece { + var isNewline: Bool { + switch self { + case .newlines, .carriageReturns, .carriageReturnLineFeeds: + return true + default: + return false + } } }