Skip to content

Commit 35e6d73

Browse files
committed
Support file-level ignore directive for specific rules
1 parent c108ff4 commit 35e6d73

File tree

5 files changed

+377
-42
lines changed

5 files changed

+377
-42
lines changed

Documentation/IgnoringSource.md

+16
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,22 @@ var a = foo+bar+baz
7777
These ignore comments also apply to all children of the node, identical to the
7878
behavior of the formatting ignore directive described above.
7979

80+
You can also disable specific source transforming rules for an entire file
81+
by using the file-level ignore directive with a list of rule names. For example:
82+
83+
```swift
84+
// swift-format-ignore-file: DoNotUseSemicolons, FullyIndirectEnum
85+
import Zoo
86+
import Arrays
87+
88+
struct Foo {
89+
func foo() { bar();baz(); }
90+
}
91+
```
92+
In this case, only the DoNotUseSemicolons and FullyIndirectEnum rules are disabled
93+
throughout the file, while all other formatting rules (such as line breaking and
94+
indentation) remain active.
95+
8096
## Understanding Nodes
8197

8298
`swift-format` parses Swift into an abstract syntax tree, where each element of

Sources/SwiftFormat/Core/RuleMask.swift

+57-39
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ import SwiftSyntax
3535
/// 2. | let a = 123
3636
/// Ignores `RuleName` and `OtherRuleName` for line 2.
3737
///
38+
/// 1. | // swift-format-ignore-file: RuleName
39+
/// 2. | let a = 123
40+
/// 3. | class Foo { }
41+
/// Ignores `RuleName` for the entire file (lines 2-3).
42+
///
43+
/// 1. | // swift-format-ignore-file: RuleName, OtherRuleName
44+
/// 2. | let a = 123
45+
/// 3. | class Foo { }
46+
/// Ignores `RuleName` and `OtherRuleName` for the entire file (lines 2-3).
47+
///
3848
/// The rules themselves reference RuleMask to see if it is disabled for the line it is currently
3949
/// examining.
4050
@_spi(Testing)
@@ -85,6 +95,29 @@ extension SourceRange {
8595
}
8696
}
8797

98+
/// Represents the kind of ignore directive encountered in the source.
99+
enum IgnoreDirective: CustomStringConvertible {
100+
/// A node-level directive that disables rules for the following node and its children.
101+
case node
102+
/// A file-level directive that disables rules for the entire file.
103+
case file
104+
105+
var description: String {
106+
switch self {
107+
case .node:
108+
return "swift-format-ignore"
109+
case .file:
110+
return "swift-format-ignore-file"
111+
}
112+
}
113+
114+
/// Regex pattern to match an ignore comment. This pattern supports 0 or more comma delimited rule
115+
/// names. The rule name(s), when present, are in capture group #3.
116+
fileprivate var pattern: String {
117+
return #"^\s*\/\/\s*"# + description + #"((:\s+(([A-z0-9]+[,\s]*)+))?$|\s+$)"#
118+
}
119+
}
120+
88121
/// A syntax visitor that finds `SourceRange`s of nodes that have rule status modifying comment
89122
/// directives. The changes requested in each comment is parsed and collected into a map to support
90123
/// status lookup per rule name.
@@ -106,18 +139,10 @@ fileprivate class RuleStatusCollectionVisitor: SyntaxVisitor {
106139
/// Computes source locations and ranges for syntax nodes in a source file.
107140
private let sourceLocationConverter: SourceLocationConverter
108141

109-
/// Regex pattern to match an ignore comment. This pattern supports 0 or more comma delimited rule
110-
/// names. The rule name(s), when present, are in capture group #3.
111-
private let ignorePattern =
112-
#"^\s*\/\/\s*swift-format-ignore((:\s+(([A-z0-9]+[,\s]*)+))?$|\s+$)"#
113-
114-
/// Rule ignore regex object.
142+
/// Cached regex object for ignoring rules at the node.
115143
private let ignoreRegex: NSRegularExpression
116144

117-
/// Regex pattern to match an ignore comment that applies to an entire file.
118-
private let ignoreFilePattern = #"^\s*\/\/\s*swift-format-ignore-file$"#
119-
120-
/// Rule ignore regex object.
145+
/// Cached regex object for ignoring rules at the file.
121146
private let ignoreFileRegex: NSRegularExpression
122147

123148
/// Stores the source ranges in which all rules are ignored.
@@ -127,8 +152,8 @@ fileprivate class RuleStatusCollectionVisitor: SyntaxVisitor {
127152
var ruleMap: [String: [SourceRange]] = [:]
128153

129154
init(sourceLocationConverter: SourceLocationConverter) {
130-
ignoreRegex = try! NSRegularExpression(pattern: ignorePattern, options: [])
131-
ignoreFileRegex = try! NSRegularExpression(pattern: ignoreFilePattern, options: [])
155+
ignoreRegex = try! NSRegularExpression(pattern: IgnoreDirective.node.pattern, options: [])
156+
ignoreFileRegex = try! NSRegularExpression(pattern: IgnoreDirective.file.pattern, options: [])
132157

133158
self.sourceLocationConverter = sourceLocationConverter
134159
super.init(viewMode: .sourceAccurate)
@@ -140,40 +165,28 @@ fileprivate class RuleStatusCollectionVisitor: SyntaxVisitor {
140165
guard let firstToken = node.firstToken(viewMode: .sourceAccurate) else {
141166
return .visitChildren
142167
}
143-
let comments = loneLineComments(in: firstToken.leadingTrivia, isFirstToken: true)
144-
var foundIgnoreFileComment = false
145-
for comment in comments {
146-
let range = NSRange(comment.startIndex..<comment.endIndex, in: comment)
147-
if ignoreFileRegex.firstMatch(in: comment, options: [], range: range) != nil {
148-
foundIgnoreFileComment = true
149-
break
150-
}
151-
}
152-
guard foundIgnoreFileComment else {
153-
return .visitChildren
154-
}
155-
156168
let sourceRange = node.sourceRange(
157169
converter: sourceLocationConverter,
158170
afterLeadingTrivia: false,
159171
afterTrailingTrivia: true
160172
)
161-
allRulesIgnoredRanges.append(sourceRange)
162-
return .skipChildren
173+
return appendRuleStatus(from: firstToken, of: sourceRange, using: ignoreFileRegex)
163174
}
164175

165176
override func visit(_ node: CodeBlockItemSyntax) -> SyntaxVisitorContinueKind {
166177
guard let firstToken = node.firstToken(viewMode: .sourceAccurate) else {
167178
return .visitChildren
168179
}
169-
return appendRuleStatusDirectives(from: firstToken, of: Syntax(node))
180+
let sourceRange = node.sourceRange(converter: sourceLocationConverter)
181+
return appendRuleStatus(from: firstToken, of: sourceRange, using: ignoreRegex)
170182
}
171183

172184
override func visit(_ node: MemberBlockItemSyntax) -> SyntaxVisitorContinueKind {
173185
guard let firstToken = node.firstToken(viewMode: .sourceAccurate) else {
174186
return .visitChildren
175187
}
176-
return appendRuleStatusDirectives(from: firstToken, of: Syntax(node))
188+
let sourceRange = node.sourceRange(converter: sourceLocationConverter)
189+
return appendRuleStatus(from: firstToken, of: sourceRange, using: ignoreRegex)
177190
}
178191

179192
// MARK: - Helper Methods
@@ -183,17 +196,19 @@ fileprivate class RuleStatusCollectionVisitor: SyntaxVisitor {
183196
///
184197
/// - Parameters:
185198
/// - token: A token that may have comments that modify the status of rules.
186-
/// - node: The node to which the token belongs.
187-
private func appendRuleStatusDirectives(
199+
/// - sourceRange: The range covering the node to which `token` belongs. If an ignore directive
200+
/// is found among the comments, this entire range is used to ignore the specified rules.
201+
/// - regex: The regular expression used to detect ignore directives.
202+
private func appendRuleStatus(
188203
from token: TokenSyntax,
189-
of node: Syntax
204+
of sourceRange: SourceRange,
205+
using regex: NSRegularExpression
190206
) -> SyntaxVisitorContinueKind {
191207
let isFirstInFile = token.previousToken(viewMode: .sourceAccurate) == nil
192-
let matches = loneLineComments(in: token.leadingTrivia, isFirstToken: isFirstInFile)
193-
.compactMap(ruleStatusDirectiveMatch)
194-
let sourceRange = node.sourceRange(converter: sourceLocationConverter)
195-
for match in matches {
196-
switch match {
208+
let comments = loneLineComments(in: token.leadingTrivia, isFirstToken: isFirstInFile)
209+
for comment in comments {
210+
guard let matchResult = ruleStatusDirectiveMatch(in: comment, using: regex) else { continue }
211+
switch matchResult {
197212
case .all:
198213
allRulesIgnoredRanges.append(sourceRange)
199214

@@ -210,9 +225,12 @@ fileprivate class RuleStatusCollectionVisitor: SyntaxVisitor {
210225

211226
/// Checks if a comment containing the given text matches a rule status directive. When it does
212227
/// match, its contents (e.g. list of rule names) are returned.
213-
private func ruleStatusDirectiveMatch(in text: String) -> RuleStatusDirectiveMatch? {
228+
private func ruleStatusDirectiveMatch(
229+
in text: String,
230+
using regex: NSRegularExpression
231+
) -> RuleStatusDirectiveMatch? {
214232
let textRange = NSRange(text.startIndex..<text.endIndex, in: text)
215-
guard let match = ignoreRegex.firstMatch(in: text, options: [], range: textRange) else {
233+
guard let match = regex.firstMatch(in: text, options: [], range: textRange) else {
216234
return nil
217235
}
218236
guard match.numberOfRanges == 5 else { return .all }

Sources/SwiftFormat/Rules/OrderedImports.swift

+11-3
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,15 @@ public final class OrderedImports: SyntaxFormatRule {
8686
if atStartOfFile {
8787
switch line.type {
8888
case .comment:
89-
commentBuffer.append(line)
89+
if line.description.contains(IgnoreDirective.file.description) {
90+
// If the file-level ignore directive is included in the comments of the import statements,
91+
// consider the comments before the file-level ignore directive as part of the fileHeader.
92+
fileHeader.append(contentsOf: commentBuffer)
93+
fileHeader.append(line)
94+
commentBuffer = []
95+
} else {
96+
commentBuffer.append(line)
97+
}
9098
continue
9199

92100
case .blankLine:
@@ -520,8 +528,8 @@ fileprivate class Line {
520528
}
521529
}
522530

523-
extension Line: CustomDebugStringConvertible {
524-
var debugDescription: String {
531+
extension Line: CustomStringConvertible {
532+
var description: String {
525533
var description = ""
526534
if !leadingTrivia.isEmpty {
527535
var newlinesCount = 0

Tests/SwiftFormatTests/Core/RuleMaskTests.swift

+171
Original file line numberDiff line numberDiff line change
@@ -230,4 +230,175 @@ final class RuleMaskTests: XCTestCase {
230230
XCTAssertEqual(mask.ruleState("rule1", at: location(ofLine: i)), .default)
231231
}
232232
}
233+
234+
func testSingleRuleWholeFileIgnore() {
235+
let text =
236+
"""
237+
// This file has important contents.
238+
// swift-format-ignore-file: rule1
239+
// Everything in this file is ignored.
240+
241+
let a = 5
242+
let b = 4
243+
244+
class Foo {
245+
let member1 = 0
246+
func foo() {
247+
baz()
248+
}
249+
}
250+
251+
struct Baz {
252+
}
253+
"""
254+
255+
let mask = createMask(sourceText: text)
256+
257+
let lineCount = text.split(separator: "\n").count
258+
for i in 0..<lineCount {
259+
XCTAssertEqual(mask.ruleState("rule1", at: location(ofLine: i)), .disabled)
260+
XCTAssertEqual(mask.ruleState("rule2", at: location(ofLine: i)), .default)
261+
}
262+
}
263+
264+
func testMultipleRulesWholeFileIgnore() {
265+
let text =
266+
"""
267+
// This file has important contents.
268+
// swift-format-ignore-file: rule1, rule2, rule3
269+
// Everything in this file is ignored.
270+
271+
let a = 5
272+
let b = 4
273+
274+
class Foo {
275+
let member1 = 0
276+
func foo() {
277+
baz()
278+
}
279+
}
280+
281+
struct Baz {
282+
}
283+
"""
284+
285+
let mask = createMask(sourceText: text)
286+
287+
let lineCount = text.split(separator: "\n").count
288+
for i in 0..<lineCount {
289+
XCTAssertEqual(mask.ruleState("rule1", at: location(ofLine: i)), .disabled)
290+
XCTAssertEqual(mask.ruleState("rule2", at: location(ofLine: i)), .disabled)
291+
XCTAssertEqual(mask.ruleState("rule3", at: location(ofLine: i)), .disabled)
292+
XCTAssertEqual(mask.ruleState("rule4", at: location(ofLine: i)), .default)
293+
}
294+
}
295+
296+
func testFileAndLineIgnoresMixed() {
297+
let text =
298+
"""
299+
// This file has important contents.
300+
// swift-format-ignore-file: rule1, rule2
301+
// Everything in this file is ignored.
302+
303+
let a = 5
304+
// swift-format-ignore: rule3
305+
let b = 4
306+
307+
class Foo {
308+
// swift-format-ignore: rule3, rule4
309+
let member1 = 0
310+
311+
func foo() {
312+
baz()
313+
}
314+
}
315+
316+
struct Baz {
317+
}
318+
"""
319+
320+
let mask = createMask(sourceText: text)
321+
322+
let lineCount = text.split(separator: "\n").count
323+
for i in 0..<lineCount {
324+
XCTAssertEqual(mask.ruleState("rule1", at: location(ofLine: i)), .disabled)
325+
XCTAssertEqual(mask.ruleState("rule2", at: location(ofLine: i)), .disabled)
326+
if i == 7 {
327+
XCTAssertEqual(mask.ruleState("rule3", at: location(ofLine: i)), .disabled)
328+
XCTAssertEqual(mask.ruleState("rule4", at: location(ofLine: i)), .default)
329+
} else if i == 11 {
330+
XCTAssertEqual(mask.ruleState("rule3", at: location(ofLine: i, column: 3)), .disabled)
331+
XCTAssertEqual(mask.ruleState("rule4", at: location(ofLine: i, column: 3)), .disabled)
332+
} else {
333+
XCTAssertEqual(mask.ruleState("rule3", at: location(ofLine: i)), .default)
334+
XCTAssertEqual(mask.ruleState("rule4", at: location(ofLine: i)), .default)
335+
}
336+
}
337+
}
338+
339+
func testMultipleSubsetFileIgnoreDirectives() {
340+
let text =
341+
"""
342+
// This file has important contents.
343+
// swift-format-ignore-file: rule1
344+
// swift-format-ignore-file: rule2
345+
// Everything in this file is ignored.
346+
347+
let a = 5
348+
let b = 4
349+
350+
class Foo {
351+
let member1 = 0
352+
353+
func foo() {
354+
baz()
355+
}
356+
}
357+
358+
struct Baz {
359+
}
360+
"""
361+
362+
let mask = createMask(sourceText: text)
363+
364+
let lineCount = text.split(separator: "\n").count
365+
for i in 0..<lineCount {
366+
XCTAssertEqual(mask.ruleState("rule1", at: location(ofLine: i)), .disabled)
367+
XCTAssertEqual(mask.ruleState("rule2", at: location(ofLine: i)), .disabled)
368+
XCTAssertEqual(mask.ruleState("rule3", at: location(ofLine: i)), .default)
369+
}
370+
}
371+
372+
func testSubsetAndAllFileIgnoreDirectives() {
373+
let text =
374+
"""
375+
// This file has important contents.
376+
// swift-format-ignore-file: rule1
377+
// swift-format-ignore-file
378+
// Everything in this file is ignored.
379+
380+
let a = 5
381+
let b = 4
382+
383+
class Foo {
384+
let member1 = 0
385+
386+
func foo() {
387+
baz()
388+
}
389+
}
390+
391+
struct Baz {
392+
}
393+
"""
394+
395+
let mask = createMask(sourceText: text)
396+
397+
let lineCount = text.split(separator: "\n").count
398+
for i in 0..<lineCount {
399+
XCTAssertEqual(mask.ruleState("rule1", at: location(ofLine: i)), .disabled)
400+
XCTAssertEqual(mask.ruleState("rule2", at: location(ofLine: i)), .disabled)
401+
XCTAssertEqual(mask.ruleState("rule3", at: location(ofLine: i)), .disabled)
402+
}
403+
}
233404
}

0 commit comments

Comments
 (0)