Add ignores_literals option to trailing_whitespace rule (#6309)

This commit is contained in:
Nandhini Subramani
2025-10-18 14:01:52 +05:30
committed by GitHub
parent 67e98911fc
commit 2c2ca671ed
8 changed files with 119 additions and 36 deletions
+5
View File
@@ -95,6 +95,11 @@
[nandhinisubbu](https://github.com/nandhinisubbu)
[#6304](https://github.com/realm/SwiftLint/issues/6304)
* Add `ignores_literals` configuration for `trailing_whitespace` rule.
It allows to ignore trailing whitespace in multiline strings.
[nandhinisubbu](https://github.com/nandhinisubbu)
[#6194](https://github.com/realm/SwiftLint/issues/6194)
### Bug Fixes
* Ignore function, initializer and subscript declarations alike when the
@@ -198,29 +198,6 @@ private final class InterpolatedStringLineVisitor: SyntaxVisitor {
}
}
// Visitor to find line ranges covered by multiline string literals
private final class MultilineStringLiteralVisitor: SyntaxVisitor {
let locationConverter: SourceLocationConverter
var linesSpanned = Set<Int>()
init(locationConverter: SourceLocationConverter) {
self.locationConverter = locationConverter
super.init(viewMode: .sourceAccurate)
}
override func visitPost(_ node: StringLiteralExprSyntax) {
guard node.openingQuote.tokenKind == .multilineStringQuote else {
return
}
let startLocation = locationConverter.location(for: node.positionAfterSkippingLeadingTrivia)
let endLocation = locationConverter.location(for: node.endPositionBeforeTrailingTrivia)
guard startLocation.line < endLocation.line else {
return
}
linesSpanned.formUnion(startLocation.line...endLocation.line)
}
}
// Visitor to find lines with regex literals
private final class RegexLiteralVisitor: SyntaxVisitor {
let locationConverter: SourceLocationConverter
@@ -10,4 +10,6 @@ struct TrailingWhitespaceConfiguration: SeverityBasedRuleConfiguration {
private(set) var ignoresEmptyLines = false
@ConfigurationElement(key: "ignores_comments")
private(set) var ignoresComments = true
@ConfigurationElement(key: "ignores_literals")
private(set) var ignoresLiterals = false
}
@@ -15,9 +15,14 @@ struct TrailingWhitespaceRule: Rule {
nonTriggeringExamples: [
Example("let name: String\n"), Example("//\n"), Example("// \n"),
Example("let name: String //\n"), Example("let name: String // \n"),
Example("let stringWithSpace = \"hello \"\n"),
Example("let multiline = \"\"\"\n line with spaces \n \"\"\" \n",
configuration: ["ignores_literals": true]),
],
triggeringExamples: [
Example("let name: String↓ \n"), Example("/* */ let name: String↓ \n")
Example("let name: String↓ \n"), Example("/* */ let name: String↓ \n"),
Example("let codeWithSpace = 123↓ \n", configuration: ["ignores_literals": true],
testWrappingInComment: false),
],
corrections: [
Example("let name: String↓ \n"): Example("let name: String\n"),
@@ -32,12 +37,20 @@ private extension TrailingWhitespaceRule {
private var linesFullyCoveredByBlockComments = Set<Int>()
private var linesEndingWithComment = Set<Int>()
// Pre-computed string literal information for performance
private var stringLiteralLines = Set<Int>()
override func visit(_ node: SourceFileSyntax) -> SyntaxVisitorContinueKind {
// Pre-compute all comment information in a single pass if needed
if configuration.ignoresComments {
precomputeCommentInformation(node)
}
// Pre-compute string literal information if needed
if configuration.ignoresLiterals {
precomputeStringLiteralInformation(node)
}
// Process each line for trailing whitespace violations
for lineContents in file.lines {
let line = lineContents.content
@@ -66,6 +79,14 @@ private extension TrailingWhitespaceRule {
}
}
// Apply `ignoresLiterals` configuration
if configuration.ignoresLiterals {
// Check if line contains string literals
if stringLiteralLines.contains(lineNumber) {
continue
}
}
// Calculate violation position
let lineStartPos = locationConverter.position(ofLine: lineNumber, column: 1)
let violationStartOffset = line.utf8.count - trailingWhitespaceInfo.byteLength
@@ -91,6 +112,13 @@ private extension TrailingWhitespaceRule {
determineLineEndingComments(using: lineCommentRanges)
}
/// Pre-computes string literal information in a single pass for better performance
private func precomputeStringLiteralInformation(_ node: SourceFileSyntax) {
// Collects line numbers that contain multiline string literals
let stringLiteralVisitor = MultilineStringLiteralVisitor(locationConverter: locationConverter)
stringLiteralLines = stringLiteralVisitor.walk(tree: node, handler: \.linesSpanned)
}
/// Collects ranges of line comments organized by line number
private func collectLineCommentRanges(from node: SourceFileSyntax) -> [Int: [Range<AbsolutePosition>]] {
var lineCommentRanges: [Int: [Range<AbsolutePosition>]] = [:]
@@ -0,0 +1,40 @@
import SwiftSyntax
/// Visitor to collect line numbers that are covered by multiline string literals.
///
/// This visitor traverses the syntax tree to identify multiline string literals (those using triple quotes `"""`)
/// and collects all line numbers that fall within their boundaries. This is useful for rules that need to
/// apply different behavior to content inside multiline string literals.
public final class MultilineStringLiteralVisitor: SyntaxVisitor {
/// The location converter to use for mapping positions to line numbers.
private let locationConverter: SourceLocationConverter
/// Line numbers that are covered by multiline string literals.
public private(set) var linesSpanned = Set<Int>()
/// Initializer.
///
/// - Parameter locationConverter: The location converter to use for mapping positions to line numbers.
public init(locationConverter: SourceLocationConverter) {
self.locationConverter = locationConverter
super.init(viewMode: .sourceAccurate)
}
/// Visits string literal expressions and collects line numbers for multiline string literals.
///
/// Only processes string literals that use triple quotes (`"""`) and span multiple lines.
/// Single-line string literals are ignored.
///
/// - Parameter node: The string literal expression to examine.
override public func visitPost(_ node: StringLiteralExprSyntax) {
guard node.openingQuote.tokenKind == .multilineStringQuote else {
return
}
let startLocation = locationConverter.location(for: node.positionAfterSkippingLeadingTrivia)
let endLocation = locationConverter.location(for: node.endPositionBeforeTrailingTrivia)
guard startLocation.line < endLocation.line else {
return
}
linesSpanned.formUnion(startLocation.line...endLocation.line)
}
}
@@ -29,4 +29,22 @@ final class TrailingWhitespaceRuleTests: SwiftLintTestCase {
ruleConfiguration: ["ignores_empty_lines": false, "ignores_comments": false],
commentDoesntViolate: false)
}
func testWithIgnoresLiteralsEnabled() {
// Perform additional tests with the ignores_literals setting enabled.
// This setting only ignores trailing whitespace inside multiline string literals.
let baseDescription = TrailingWhitespaceRule.description
let nonTriggeringExamples = baseDescription.nonTriggeringExamples + [
Example("let multiline = \"\"\"\n content \n \"\"\"\n"),
]
let triggeringExamples = baseDescription.triggeringExamples + [
Example("let codeWithSpace = 123 \n"),
Example("var number = 42 \n"),
]
let description = baseDescription.with(nonTriggeringExamples: nonTriggeringExamples)
.with(triggeringExamples: triggeringExamples)
verifyRule(description,
ruleConfiguration: ["ignores_literals": true])
}
}
@@ -137,7 +137,8 @@ final class RuleConfigurationTests: SwiftLintTestCase {
func testTrailingWhitespaceConfigurationThrowsOnBadConfig() {
let config = "unknown"
var configuration = TrailingWhitespaceConfiguration(ignoresEmptyLines: false,
ignoresComments: true)
ignoresComments: true,
ignoresLiterals: false)
checkError(Issue.invalidConfiguration(ruleID: TrailingWhitespaceRule.identifier)) {
try configuration.apply(configuration: config)
}
@@ -145,27 +146,32 @@ final class RuleConfigurationTests: SwiftLintTestCase {
func testTrailingWhitespaceConfigurationInitializerSetsIgnoresEmptyLines() {
let configuration1 = TrailingWhitespaceConfiguration(ignoresEmptyLines: false,
ignoresComments: true)
ignoresComments: true,
ignoresLiterals: false)
XCTAssertFalse(configuration1.ignoresEmptyLines)
let configuration2 = TrailingWhitespaceConfiguration(ignoresEmptyLines: true,
ignoresComments: true)
ignoresComments: true,
ignoresLiterals: false)
XCTAssertTrue(configuration2.ignoresEmptyLines)
}
func testTrailingWhitespaceConfigurationInitializerSetsIgnoresComments() {
let configuration1 = TrailingWhitespaceConfiguration(ignoresEmptyLines: false,
ignoresComments: true)
ignoresComments: true,
ignoresLiterals: false)
XCTAssertTrue(configuration1.ignoresComments)
let configuration2 = TrailingWhitespaceConfiguration(ignoresEmptyLines: false,
ignoresComments: false)
ignoresComments: false,
ignoresLiterals: false)
XCTAssertFalse(configuration2.ignoresComments)
}
func testTrailingWhitespaceConfigurationApplyConfigurationSetsIgnoresEmptyLines() {
var configuration = TrailingWhitespaceConfiguration(ignoresEmptyLines: false,
ignoresComments: true)
ignoresComments: true,
ignoresLiterals: false)
do {
let config1 = ["ignores_empty_lines": true]
try configuration.apply(configuration: config1)
@@ -181,7 +187,8 @@ final class RuleConfigurationTests: SwiftLintTestCase {
func testTrailingWhitespaceConfigurationApplyConfigurationSetsIgnoresComments() {
var configuration = TrailingWhitespaceConfiguration(ignoresEmptyLines: false,
ignoresComments: true)
ignoresComments: true,
ignoresLiterals: false)
do {
let config1 = ["ignores_comments": true]
try configuration.apply(configuration: config1)
@@ -197,22 +204,27 @@ final class RuleConfigurationTests: SwiftLintTestCase {
func testTrailingWhitespaceConfigurationCompares() {
let configuration1 = TrailingWhitespaceConfiguration(ignoresEmptyLines: false,
ignoresComments: true)
ignoresComments: true,
ignoresLiterals: false)
let configuration2 = TrailingWhitespaceConfiguration(ignoresEmptyLines: true,
ignoresComments: true)
ignoresComments: true,
ignoresLiterals: false)
XCTAssertNotEqual(configuration1, configuration2)
let configuration3 = TrailingWhitespaceConfiguration(ignoresEmptyLines: true,
ignoresComments: true)
ignoresComments: true,
ignoresLiterals: false)
XCTAssertEqual(configuration2, configuration3)
let configuration4 = TrailingWhitespaceConfiguration(ignoresEmptyLines: false,
ignoresComments: false)
ignoresComments: false,
ignoresLiterals: false)
XCTAssertNotEqual(configuration1, configuration4)
let configuration5 = TrailingWhitespaceConfiguration(ignoresEmptyLines: true,
ignoresComments: false)
ignoresComments: false,
ignoresLiterals: false)
XCTAssertNotEqual(configuration1, configuration5)
}
@@ -1207,6 +1207,7 @@ trailing_whitespace:
severity: warning
ignores_empty_lines: false
ignores_comments: true
ignores_literals: false
meta:
opt-in: false
correctable: true