diff --git a/CHANGELOG.md b/CHANGELOG.md index f4db433e8..479415485 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ and fewer false positives. [JP Simard](https://github.com/jpsim) +* Migrate `vertical_whitespace` rule from SourceKit to SwiftSyntax for improved performance. + [Matt Pennig](https://github.com/pennig) + ### Bug Fixes * Improved error reporting when SwiftLint exits, because of an invalid configuration file diff --git a/Source/SwiftLintBuiltInRules/Rules/RuleConfigurations/VerticalWhitespaceConfiguration.swift b/Source/SwiftLintBuiltInRules/Rules/RuleConfigurations/VerticalWhitespaceConfiguration.swift index 2bc989d73..676a52451 100644 --- a/Source/SwiftLintBuiltInRules/Rules/RuleConfigurations/VerticalWhitespaceConfiguration.swift +++ b/Source/SwiftLintBuiltInRules/Rules/RuleConfigurations/VerticalWhitespaceConfiguration.swift @@ -4,8 +4,17 @@ import SwiftLintCore struct VerticalWhitespaceConfiguration: SeverityBasedRuleConfiguration { typealias Parent = VerticalWhitespaceRule + static let defaultDescriptionReason = "Limit vertical whitespace to a single empty line" + @ConfigurationElement(key: "severity") private(set) var severityConfiguration = SeverityConfiguration(.warning) @ConfigurationElement(key: "max_empty_lines") private(set) var maxEmptyLines = 1 + + var configuredDescriptionReason: String { + guard maxEmptyLines == 1 else { + return "Limit vertical whitespace to maximum \(maxEmptyLines) empty lines" + } + return Self.defaultDescriptionReason + } } diff --git a/Source/SwiftLintBuiltInRules/Rules/Style/VerticalWhitespaceRule.swift b/Source/SwiftLintBuiltInRules/Rules/Style/VerticalWhitespaceRule.swift index 2ff363264..4e1c827e0 100644 --- a/Source/SwiftLintBuiltInRules/Rules/Style/VerticalWhitespaceRule.swift +++ b/Source/SwiftLintBuiltInRules/Rules/Style/VerticalWhitespaceRule.swift @@ -1,146 +1,147 @@ import Foundation -import SourceKittenFramework +import SwiftSyntax -private let defaultDescriptionReason = "Limit vertical whitespace to a single empty line" - -struct VerticalWhitespaceRule: CorrectableRule { +@SwiftSyntaxRule(explicitRewriter: true, correctable: true) +struct VerticalWhitespaceRule: Rule { var configuration = VerticalWhitespaceConfiguration() static let description = RuleDescription( identifier: "vertical_whitespace", name: "Vertical Whitespace", - description: defaultDescriptionReason + ".", + description: VerticalWhitespaceConfiguration.defaultDescriptionReason, kind: .style, nonTriggeringExamples: [ Example("let abc = 0\n"), Example("let abc = 0\n\n"), Example("/* bcs \n\n\n\n*/"), Example("// bca \n\n"), + Example("class CCCC {\n \n}"), ], triggeringExamples: [ Example("let aaaa = 0\n\n\n"), Example("struct AAAA {}\n\n\n\n"), Example("class BBBB {}\n\n\n"), + Example("class CCCC {\n \n \n}"), ], corrections: [ Example("let b = 0\n\n\nclass AAA {}\n"): Example("let b = 0\n\nclass AAA {}\n"), Example("let c = 0\n\n\nlet num = 1\n"): Example("let c = 0\n\nlet num = 1\n"), Example("// bca \n\n\n"): Example("// bca \n\n"), + Example("class CCCC {\n \n \n \n}"): Example("class CCCC {\n \n}"), ] // End of line autocorrections are handled by Trailing Newline Rule. ) +} - private var configuredDescriptionReason: String { - guard configuration.maxEmptyLines == 1 else { - return "Limit vertical whitespace to maximum \(configuration.maxEmptyLines) empty lines" - } - return defaultDescriptionReason - } +private extension VerticalWhitespaceRule { + final class Visitor: ViolationsSyntaxVisitor { + override func visit(_ token: TokenSyntax) -> SyntaxVisitorContinueKind { + // The strategy here is to keep track of the position of the _first_ violating newline + // in each consecutive run, and report the violation when the run _ends_. - func validate(file: SwiftLintFile) -> [StyleViolation] { - let linesSections = violatingLineSections(in: file) - guard linesSections.isNotEmpty else { - return [] + if token.leadingTrivia.isEmpty { + return .visitChildren + } + + var consecutiveNewlines = 0 + var currentPosition = token.position + var violationPosition: AbsolutePosition? + + func process(_ count: Int, _ offset: Int) { + for _ in 0.. configuration.maxEmptyLines && violationPosition == nil { + violationPosition = currentPosition + } + consecutiveNewlines += 1 + currentPosition = currentPosition.advanced(by: offset) + } + } + + for piece in token.leadingTrivia { + switch piece { + case .newlines(let count), .carriageReturns(let count), .formfeeds(let count), .verticalTabs(let count): + process(count, 1) + case .carriageReturnLineFeeds(let count): + process(count, 2) // CRLF is 2 bytes + case .spaces, .tabs: + currentPosition += piece.sourceLength + default: + if let violationPosition { + report(violationPosition, consecutiveNewlines) + } + violationPosition = nil + consecutiveNewlines = 0 + currentPosition += piece.sourceLength + } + } + if let violationPosition { + report(violationPosition, consecutiveNewlines) + } + + return .visitChildren } - return linesSections.map { eachLastLine, eachSectionCount in - StyleViolation( - ruleDescription: Self.description, - severity: configuration.severityConfiguration.severity, - location: Location(file: file.path, line: eachLastLine.index), - reason: configuredDescriptionReason + "; currently \(eachSectionCount + 1)" - ) + private func report(_ position: AbsolutePosition, _ newlines: Int) { + violations.append(ReasonedRuleViolation( + position: position, + reason: configuration.configuredDescriptionReason + "; currently \(newlines - 1)" + )) } } - private typealias LineSection = (lastLine: Line, linesToRemove: Int) + final class Rewriter: ViolationsSyntaxRewriter { + override func visit(_ token: TokenSyntax) -> TokenSyntax { + var result = [TriviaPiece]() + var pendingWhitespace = [TriviaPiece]() + var consecutiveNewlines = 0 - private func violatingLineSections(in file: SwiftLintFile) -> [LineSection] { - let nonSpaceRegex = regex("\\S", options: []) - let filteredLines = file.lines.filter { - nonSpaceRegex.firstMatch(in: file.contents, options: [], range: $0.range) == nil - } + func process(_ count: Int, _ create: (Int) -> TriviaPiece) { + let linesToPreserve = min(count, max(0, configuration.maxEmptyLines + 1 - consecutiveNewlines)) + consecutiveNewlines += count - guard filteredLines.isNotEmpty else { - return [] - } + if count > linesToPreserve { + self.numberOfCorrections += count - linesToPreserve + } - let blankLinesSections = extractSections(from: filteredLines) - - // filtering out violations in comments and strings - let stringAndComments = SyntaxKind.commentAndStringKinds - let syntaxMap = file.syntaxMap - let result = blankLinesSections.compactMap { eachSection -> (lastLine: Line, linesToRemove: Int)? in - guard let lastLine = eachSection.last else { - return nil - } - let kindInSection = syntaxMap.kinds(inByteRange: lastLine.byteRange) - if stringAndComments.isDisjoint(with: kindInSection) { - return (lastLine, eachSection.count) + if linesToPreserve > 0 { + // We can still add this piece, even if we adjusted its count lower. + // Pull in any pending whitespace along with it. + result.append(contentsOf: pendingWhitespace) + result.append(create(linesToPreserve)) + pendingWhitespace.removeAll() + } else { + // We're now in violation. Dump pending whitespace so it's excluded from the result. + pendingWhitespace.removeAll() + } } - return nil - } - - return result.filter { $0.linesToRemove >= configuration.maxEmptyLines } - } - - private func extractSections(from lines: [Line]) -> [[Line]] { - var blankLinesSections = [[Line]]() - var lineSection = [Line]() - - var previousIndex = 0 - for (index, line) in lines.enumerated() { - let previousLine: Line = lines[previousIndex] - if previousLine.index + 1 == line.index { - lineSection.append(line) - } else if lineSection.isNotEmpty { - blankLinesSections.append(lineSection) - lineSection.removeAll() + for piece in token.leadingTrivia { + switch piece { + case .newlines(let count): + process(count, TriviaPiece.newlines) + case .carriageReturns(let count): + process(count, TriviaPiece.carriageReturns) + case .carriageReturnLineFeeds(let count): + process(count, TriviaPiece.carriageReturnLineFeeds) + case .formfeeds(let count): + process(count, TriviaPiece.formfeeds) + case .verticalTabs(let count): + process(count, TriviaPiece.verticalTabs) + case .spaces, .tabs: + pendingWhitespace.append(piece) + default: + // Reset and pull in pending whitespace + consecutiveNewlines = 0 + result.append(contentsOf: pendingWhitespace) + result.append(piece) + pendingWhitespace.removeAll() + } } - previousIndex = index - } - if lineSection.isNotEmpty { - blankLinesSections.append(lineSection) - } - - return blankLinesSections - } - - func correct(file: SwiftLintFile) -> Int { - let linesSections = violatingLineSections(in: file) - if linesSections.isEmpty { - return 0 - } - - var indexOfLinesToDelete = [Int]() - - for section in linesSections { - let linesToRemove = section.linesToRemove - configuration.maxEmptyLines + 1 - let start = section.lastLine.index - linesToRemove - indexOfLinesToDelete.append(contentsOf: start.. 0 { - file.write(correctedLines.joined(separator: "\n") + "\n") - } - return numberOfCorrections } } diff --git a/Tests/BuiltInRulesTests/VerticalWhitespaceRuleTests.swift b/Tests/BuiltInRulesTests/VerticalWhitespaceRuleTests.swift index 7f8589f40..f3fd7ad4e 100644 --- a/Tests/BuiltInRulesTests/VerticalWhitespaceRuleTests.swift +++ b/Tests/BuiltInRulesTests/VerticalWhitespaceRuleTests.swift @@ -9,7 +9,10 @@ final class VerticalWhitespaceRuleTests: SwiftLintTestCase { // Test with custom `max_empty_lines` let maxEmptyLinesDescription = VerticalWhitespaceRule.description .with(nonTriggeringExamples: [Example("let aaaa = 0\n\n\n")]) - .with(triggeringExamples: [Example("struct AAAA {}\n\n\n\n")]) + .with(triggeringExamples: [ + Example("struct AAAA {}\n\n\n\n"), + Example("class BBBB {\n \n \n \n}"), + ]) .with(corrections: [:]) verifyRule(maxEmptyLinesDescription, @@ -23,6 +26,7 @@ final class VerticalWhitespaceRuleTests: SwiftLintTestCase { .with(corrections: [ Example("let b = 0\n\n↓\n↓\n↓\n\nclass AAA {}\n"): Example("let b = 0\n\n\nclass AAA {}\n"), Example("let b = 0\n\n\nclass AAA {}\n"): Example("let b = 0\n\n\nclass AAA {}\n"), + Example("class BB {\n \n \n↓ \n let b = 0\n}\n"): Example("class BB {\n \n \n let b = 0\n}\n"), ]) verifyRule(maxEmptyLinesDescription,