Skip to content

Commit 5a8591b

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

File tree

3 files changed

+217
-27
lines changed

3 files changed

+217
-27
lines changed

Documentation/IgnoringSource.md

Lines changed: 16 additions & 0 deletions
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

Lines changed: 30 additions & 27 deletions
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)
@@ -115,7 +125,7 @@ fileprivate class RuleStatusCollectionVisitor: SyntaxVisitor {
115125
private let ignoreRegex: NSRegularExpression
116126

117127
/// Regex pattern to match an ignore comment that applies to an entire file.
118-
private let ignoreFilePattern = #"^\s*\/\/\s*swift-format-ignore-file$"#
128+
private let ignoreFilePattern = #"^\s*\/\/\s*swift-format-ignore-file((:\s+(([A-z0-9]+[,\s]*)+))?$|\s+$)"#
119129

120130
/// Rule ignore regex object.
121131
private let ignoreFileRegex: NSRegularExpression
@@ -140,40 +150,28 @@ fileprivate class RuleStatusCollectionVisitor: SyntaxVisitor {
140150
guard let firstToken = node.firstToken(viewMode: .sourceAccurate) else {
141151
return .visitChildren
142152
}
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-
156153
let sourceRange = node.sourceRange(
157154
converter: sourceLocationConverter,
158155
afterLeadingTrivia: false,
159156
afterTrailingTrivia: true
160157
)
161-
allRulesIgnoredRanges.append(sourceRange)
162-
return .skipChildren
158+
return appendRuleStatusDirectives(from: firstToken, of: sourceRange, using: ignoreFileRegex)
163159
}
164160

165161
override func visit(_ node: CodeBlockItemSyntax) -> SyntaxVisitorContinueKind {
166162
guard let firstToken = node.firstToken(viewMode: .sourceAccurate) else {
167163
return .visitChildren
168164
}
169-
return appendRuleStatusDirectives(from: firstToken, of: Syntax(node))
165+
let sourceRange = node.sourceRange(converter: sourceLocationConverter)
166+
return appendRuleStatusDirectives(from: firstToken, of: sourceRange, using: ignoreRegex)
170167
}
171168

172169
override func visit(_ node: MemberBlockItemSyntax) -> SyntaxVisitorContinueKind {
173170
guard let firstToken = node.firstToken(viewMode: .sourceAccurate) else {
174171
return .visitChildren
175172
}
176-
return appendRuleStatusDirectives(from: firstToken, of: Syntax(node))
173+
let sourceRange = node.sourceRange(converter: sourceLocationConverter)
174+
return appendRuleStatusDirectives(from: firstToken, of: sourceRange, using: ignoreRegex)
177175
}
178176

179177
// MARK: - Helper Methods
@@ -183,17 +181,19 @@ fileprivate class RuleStatusCollectionVisitor: SyntaxVisitor {
183181
///
184182
/// - Parameters:
185183
/// - token: A token that may have comments that modify the status of rules.
186-
/// - node: The node to which the token belongs.
184+
/// - sourceRange: The range covering the node to which `token` belongs. If an ignore directive
185+
/// is found among the comments, this entire range is used to ignore the specified rules.
186+
/// - regex: The regular expression used to detect ignore directives.
187187
private func appendRuleStatusDirectives(
188188
from token: TokenSyntax,
189-
of node: Syntax
189+
of sourceRange: SourceRange,
190+
using regex: NSRegularExpression
190191
) -> SyntaxVisitorContinueKind {
191192
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 {
193+
let comments = loneLineComments(in: token.leadingTrivia, isFirstToken: isFirstInFile)
194+
for comment in comments {
195+
guard let matchResult = ruleStatusDirectiveMatch(in: comment, using: regex) else { continue }
196+
switch matchResult {
197197
case .all:
198198
allRulesIgnoredRanges.append(sourceRange)
199199

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

211211
/// Checks if a comment containing the given text matches a rule status directive. When it does
212212
/// match, its contents (e.g. list of rule names) are returned.
213-
private func ruleStatusDirectiveMatch(in text: String) -> RuleStatusDirectiveMatch? {
213+
private func ruleStatusDirectiveMatch(
214+
in text: String,
215+
using regex: NSRegularExpression
216+
) -> RuleStatusDirectiveMatch? {
214217
let textRange = NSRange(text.startIndex..<text.endIndex, in: text)
215-
guard let match = ignoreRegex.firstMatch(in: text, options: [], range: textRange) else {
218+
guard let match = regex.firstMatch(in: text, options: [], range: textRange) else {
216219
return nil
217220
}
218221
guard match.numberOfRanges == 5 else { return .all }

Tests/SwiftFormatTests/Core/RuleMaskTests.swift

Lines changed: 171 additions & 0 deletions
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)