From 5a8591b146c83052242f09fe6c0e1910c7c0af99 Mon Sep 17 00:00:00 2001 From: TTOzzi Date: Sat, 1 Mar 2025 23:06:29 +0900 Subject: [PATCH 1/3] Support file-level ignore directive for specific rules --- Documentation/IgnoringSource.md | 16 ++ Sources/SwiftFormat/Core/RuleMask.swift | 57 +++--- .../SwiftFormatTests/Core/RuleMaskTests.swift | 171 ++++++++++++++++++ 3 files changed, 217 insertions(+), 27 deletions(-) diff --git a/Documentation/IgnoringSource.md b/Documentation/IgnoringSource.md index 40637426a..42bf4e0f3 100644 --- a/Documentation/IgnoringSource.md +++ b/Documentation/IgnoringSource.md @@ -77,6 +77,22 @@ var a = foo+bar+baz These ignore comments also apply to all children of the node, identical to the behavior of the formatting ignore directive described above. +You can also disable specific source transforming rules for an entire file +by using the file-level ignore directive with a list of rule names. For example: + +```swift +// swift-format-ignore-file: DoNotUseSemicolons, FullyIndirectEnum +import Zoo +import Arrays + +struct Foo { + func foo() { bar();baz(); } +} +``` +In this case, only the DoNotUseSemicolons and FullyIndirectEnum rules are disabled +throughout the file, while all other formatting rules (such as line breaking and +indentation) remain active. + ## Understanding Nodes `swift-format` parses Swift into an abstract syntax tree, where each element of diff --git a/Sources/SwiftFormat/Core/RuleMask.swift b/Sources/SwiftFormat/Core/RuleMask.swift index 9edf2449c..bb3c73eab 100644 --- a/Sources/SwiftFormat/Core/RuleMask.swift +++ b/Sources/SwiftFormat/Core/RuleMask.swift @@ -35,6 +35,16 @@ import SwiftSyntax /// 2. | let a = 123 /// Ignores `RuleName` and `OtherRuleName` for line 2. /// +/// 1. | // swift-format-ignore-file: RuleName +/// 2. | let a = 123 +/// 3. | class Foo { } +/// Ignores `RuleName` for the entire file (lines 2-3). +/// +/// 1. | // swift-format-ignore-file: RuleName, OtherRuleName +/// 2. | let a = 123 +/// 3. | class Foo { } +/// Ignores `RuleName` and `OtherRuleName` for the entire file (lines 2-3). +/// /// The rules themselves reference RuleMask to see if it is disabled for the line it is currently /// examining. @_spi(Testing) @@ -115,7 +125,7 @@ fileprivate class RuleStatusCollectionVisitor: SyntaxVisitor { private let ignoreRegex: NSRegularExpression /// Regex pattern to match an ignore comment that applies to an entire file. - private let ignoreFilePattern = #"^\s*\/\/\s*swift-format-ignore-file$"# + private let ignoreFilePattern = #"^\s*\/\/\s*swift-format-ignore-file((:\s+(([A-z0-9]+[,\s]*)+))?$|\s+$)"# /// Rule ignore regex object. private let ignoreFileRegex: NSRegularExpression @@ -140,40 +150,28 @@ fileprivate class RuleStatusCollectionVisitor: SyntaxVisitor { guard let firstToken = node.firstToken(viewMode: .sourceAccurate) else { return .visitChildren } - let comments = loneLineComments(in: firstToken.leadingTrivia, isFirstToken: true) - var foundIgnoreFileComment = false - for comment in comments { - let range = NSRange(comment.startIndex.. SyntaxVisitorContinueKind { guard let firstToken = node.firstToken(viewMode: .sourceAccurate) else { return .visitChildren } - return appendRuleStatusDirectives(from: firstToken, of: Syntax(node)) + let sourceRange = node.sourceRange(converter: sourceLocationConverter) + return appendRuleStatusDirectives(from: firstToken, of: sourceRange, using: ignoreRegex) } override func visit(_ node: MemberBlockItemSyntax) -> SyntaxVisitorContinueKind { guard let firstToken = node.firstToken(viewMode: .sourceAccurate) else { return .visitChildren } - return appendRuleStatusDirectives(from: firstToken, of: Syntax(node)) + let sourceRange = node.sourceRange(converter: sourceLocationConverter) + return appendRuleStatusDirectives(from: firstToken, of: sourceRange, using: ignoreRegex) } // MARK: - Helper Methods @@ -183,17 +181,19 @@ fileprivate class RuleStatusCollectionVisitor: SyntaxVisitor { /// /// - Parameters: /// - token: A token that may have comments that modify the status of rules. - /// - node: The node to which the token belongs. + /// - sourceRange: The range covering the node to which `token` belongs. If an ignore directive + /// is found among the comments, this entire range is used to ignore the specified rules. + /// - regex: The regular expression used to detect ignore directives. private func appendRuleStatusDirectives( from token: TokenSyntax, - of node: Syntax + of sourceRange: SourceRange, + using regex: NSRegularExpression ) -> SyntaxVisitorContinueKind { let isFirstInFile = token.previousToken(viewMode: .sourceAccurate) == nil - let matches = loneLineComments(in: token.leadingTrivia, isFirstToken: isFirstInFile) - .compactMap(ruleStatusDirectiveMatch) - let sourceRange = node.sourceRange(converter: sourceLocationConverter) - for match in matches { - switch match { + let comments = loneLineComments(in: token.leadingTrivia, isFirstToken: isFirstInFile) + for comment in comments { + guard let matchResult = ruleStatusDirectiveMatch(in: comment, using: regex) else { continue } + switch matchResult { case .all: allRulesIgnoredRanges.append(sourceRange) @@ -210,9 +210,12 @@ fileprivate class RuleStatusCollectionVisitor: SyntaxVisitor { /// Checks if a comment containing the given text matches a rule status directive. When it does /// match, its contents (e.g. list of rule names) are returned. - private func ruleStatusDirectiveMatch(in text: String) -> RuleStatusDirectiveMatch? { + private func ruleStatusDirectiveMatch( + in text: String, + using regex: NSRegularExpression + ) -> RuleStatusDirectiveMatch? { let textRange = NSRange(text.startIndex.. Date: Sun, 2 Mar 2025 00:22:23 +0900 Subject: [PATCH 2/3] Fix OrderedImports to treat file ignore directive comment as file header --- .../SwiftFormat/Rules/OrderedImports.swift | 10 ++++-- .../Rules/OrderedImportsTests.swift | 32 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/Sources/SwiftFormat/Rules/OrderedImports.swift b/Sources/SwiftFormat/Rules/OrderedImports.swift index 75fcb4572..8d3108895 100644 --- a/Sources/SwiftFormat/Rules/OrderedImports.swift +++ b/Sources/SwiftFormat/Rules/OrderedImports.swift @@ -86,7 +86,11 @@ public final class OrderedImports: SyntaxFormatRule { if atStartOfFile { switch line.type { case .comment: - commentBuffer.append(line) + if line.description.contains("swift-format-ignore-file") { + fileHeader.append(line) + } else { + commentBuffer.append(line) + } continue case .blankLine: @@ -520,8 +524,8 @@ fileprivate class Line { } } -extension Line: CustomDebugStringConvertible { - var debugDescription: String { +extension Line: CustomStringConvertible { + var description: String { var description = "" if !leadingTrivia.isEmpty { var newlinesCount = 0 diff --git a/Tests/SwiftFormatTests/Rules/OrderedImportsTests.swift b/Tests/SwiftFormatTests/Rules/OrderedImportsTests.swift index 73d33aa77..a28aa4112 100644 --- a/Tests/SwiftFormatTests/Rules/OrderedImportsTests.swift +++ b/Tests/SwiftFormatTests/Rules/OrderedImportsTests.swift @@ -651,4 +651,36 @@ final class OrderedImportsTests: LintOrFormatRuleTestCase { ] ) } + + func testImportsOrderWithFileIgnoreDirective() { + assertFormatting( + OrderedImports.self, + input: """ + // swift-format-ignore-file: DoNotUseSemicolons, FullyIndirectEnum + // Line comment for Zoo + import Zoo + // Line comment for Array + 1️⃣import Arrays + + struct Foo { + func foo() { bar();baz(); } + } + """, + expected: """ + // swift-format-ignore-file: DoNotUseSemicolons, FullyIndirectEnum + + // Line comment for Array + import Arrays + // Line comment for Zoo + import Zoo + + struct Foo { + func foo() { bar();baz(); } + } + """, + findings: [ + FindingSpec("1️⃣", message: "sort import statements lexicographically") + ] + ) + } } From ab12d604c7457dd122b0cb37babf1ceec1ad3aec Mon Sep 17 00:00:00 2001 From: TTOzzi Date: Sun, 2 Mar 2025 00:41:58 +0900 Subject: [PATCH 3/3] Define IgnoreDirective enum for directive constantization --- Sources/SwiftFormat/Core/RuleMask.swift | 59 +++++++++++-------- .../SwiftFormat/Rules/OrderedImports.swift | 2 +- 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/Sources/SwiftFormat/Core/RuleMask.swift b/Sources/SwiftFormat/Core/RuleMask.swift index bb3c73eab..de660faaf 100644 --- a/Sources/SwiftFormat/Core/RuleMask.swift +++ b/Sources/SwiftFormat/Core/RuleMask.swift @@ -95,6 +95,34 @@ extension SourceRange { } } +/// Represents the kind of ignore directive encountered in the source. +enum IgnoreDirective: CustomStringConvertible { + /// A node-level directive that disables rules for the following node and its children. + case node + /// A file-level directive that disables rules for the entire file. + case file + + var description: String { + switch self { + case .node: + return "swift-format-ignore" + case .file: + return "swift-format-ignore-file" + } + } + + /// Regex pattern to match an ignore comment. This pattern supports 0 or more comma delimited rule + /// names. The rule name(s), when present, are in capture group #3. + private var pattern: String { + return #"^\s*\/\/\s*"# + description + #"((:\s+(([A-z0-9]+[,\s]*)+))?$|\s+$)"# + } + + /// Rule ignore regex object. + fileprivate var regex: NSRegularExpression { + return try! NSRegularExpression(pattern: pattern, options: []) + } +} + /// A syntax visitor that finds `SourceRange`s of nodes that have rule status modifying comment /// directives. The changes requested in each comment is parsed and collected into a map to support /// status lookup per rule name. @@ -116,20 +144,6 @@ fileprivate class RuleStatusCollectionVisitor: SyntaxVisitor { /// Computes source locations and ranges for syntax nodes in a source file. private let sourceLocationConverter: SourceLocationConverter - /// Regex pattern to match an ignore comment. This pattern supports 0 or more comma delimited rule - /// names. The rule name(s), when present, are in capture group #3. - private let ignorePattern = - #"^\s*\/\/\s*swift-format-ignore((:\s+(([A-z0-9]+[,\s]*)+))?$|\s+$)"# - - /// Rule ignore regex object. - private let ignoreRegex: NSRegularExpression - - /// Regex pattern to match an ignore comment that applies to an entire file. - private let ignoreFilePattern = #"^\s*\/\/\s*swift-format-ignore-file((:\s+(([A-z0-9]+[,\s]*)+))?$|\s+$)"# - - /// Rule ignore regex object. - private let ignoreFileRegex: NSRegularExpression - /// Stores the source ranges in which all rules are ignored. var allRulesIgnoredRanges: [SourceRange] = [] @@ -137,9 +151,6 @@ fileprivate class RuleStatusCollectionVisitor: SyntaxVisitor { var ruleMap: [String: [SourceRange]] = [:] init(sourceLocationConverter: SourceLocationConverter) { - ignoreRegex = try! NSRegularExpression(pattern: ignorePattern, options: []) - ignoreFileRegex = try! NSRegularExpression(pattern: ignoreFilePattern, options: []) - self.sourceLocationConverter = sourceLocationConverter super.init(viewMode: .sourceAccurate) } @@ -155,7 +166,7 @@ fileprivate class RuleStatusCollectionVisitor: SyntaxVisitor { afterLeadingTrivia: false, afterTrailingTrivia: true ) - return appendRuleStatusDirectives(from: firstToken, of: sourceRange, using: ignoreFileRegex) + return appendRuleStatus(from: firstToken, of: sourceRange, for: .file) } override func visit(_ node: CodeBlockItemSyntax) -> SyntaxVisitorContinueKind { @@ -163,7 +174,7 @@ fileprivate class RuleStatusCollectionVisitor: SyntaxVisitor { return .visitChildren } let sourceRange = node.sourceRange(converter: sourceLocationConverter) - return appendRuleStatusDirectives(from: firstToken, of: sourceRange, using: ignoreRegex) + return appendRuleStatus(from: firstToken, of: sourceRange, for: .node) } override func visit(_ node: MemberBlockItemSyntax) -> SyntaxVisitorContinueKind { @@ -171,7 +182,7 @@ fileprivate class RuleStatusCollectionVisitor: SyntaxVisitor { return .visitChildren } let sourceRange = node.sourceRange(converter: sourceLocationConverter) - return appendRuleStatusDirectives(from: firstToken, of: sourceRange, using: ignoreRegex) + return appendRuleStatus(from: firstToken, of: sourceRange, for: .node) } // MARK: - Helper Methods @@ -183,16 +194,16 @@ fileprivate class RuleStatusCollectionVisitor: SyntaxVisitor { /// - token: A token that may have comments that modify the status of rules. /// - sourceRange: The range covering the node to which `token` belongs. If an ignore directive /// is found among the comments, this entire range is used to ignore the specified rules. - /// - regex: The regular expression used to detect ignore directives. - private func appendRuleStatusDirectives( + /// - directive: The type of ignore directive to look for. + private func appendRuleStatus( from token: TokenSyntax, of sourceRange: SourceRange, - using regex: NSRegularExpression + for directive: IgnoreDirective ) -> SyntaxVisitorContinueKind { let isFirstInFile = token.previousToken(viewMode: .sourceAccurate) == nil let comments = loneLineComments(in: token.leadingTrivia, isFirstToken: isFirstInFile) for comment in comments { - guard let matchResult = ruleStatusDirectiveMatch(in: comment, using: regex) else { continue } + guard let matchResult = ruleStatusDirectiveMatch(in: comment, using: directive.regex) else { continue } switch matchResult { case .all: allRulesIgnoredRanges.append(sourceRange) diff --git a/Sources/SwiftFormat/Rules/OrderedImports.swift b/Sources/SwiftFormat/Rules/OrderedImports.swift index 8d3108895..47dc6559a 100644 --- a/Sources/SwiftFormat/Rules/OrderedImports.swift +++ b/Sources/SwiftFormat/Rules/OrderedImports.swift @@ -86,7 +86,7 @@ public final class OrderedImports: SyntaxFormatRule { if atStartOfFile { switch line.type { case .comment: - if line.description.contains("swift-format-ignore-file") { + if line.description.contains(IgnoreDirective.file.description) { fileHeader.append(line) } else { commentBuffer.append(line)