Skip to content

Commit 9fbd4f0

Browse files
committed
Add redundantAsync rule (#2207)
1 parent cd87505 commit 9fbd4f0

13 files changed

+497
-36
lines changed

Rules.md

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@
6060
* [redundantReturn](#redundantReturn)
6161
* [redundantSelf](#redundantSelf)
6262
* [redundantStaticSelf](#redundantStaticSelf)
63-
* [redundantThrows](#redundantThrows)
6463
* [redundantType](#redundantType)
6564
* [redundantTypedThrows](#redundantTypedThrows)
6665
* [redundantVoidReturnType](#redundantVoidReturnType)
@@ -118,9 +117,11 @@
118117
* [preferSwiftTesting](#preferSwiftTesting)
119118
* [privateStateVariables](#privateStateVariables)
120119
* [propertyTypes](#propertyTypes)
120+
* [redundantAsync](#redundantAsync)
121121
* [redundantEquatable](#redundantEquatable)
122122
* [redundantMemberwiseInit](#redundantMemberwiseInit)
123123
* [redundantProperty](#redundantProperty)
124+
* [redundantThrows](#redundantThrows)
124125
* [singlePropertyPerLine](#singlePropertyPerLine)
125126
* [sortSwitchCases](#sortSwitchCases)
126127
* [unusedPrivateDeclarations](#unusedPrivateDeclarations)
@@ -2242,6 +2243,54 @@ Option | Description
22422243
</details>
22432244
<br/>
22442245

2246+
## redundantAsync
2247+
2248+
Remove redundant `async` keyword from function declarations that don't contain any await expressions.
2249+
2250+
Option | Description
2251+
--- | ---
2252+
`--redundant-async` | Remove redundant async from functions: "tests-only" (default) or "always"
2253+
2254+
<details>
2255+
<summary>Examples</summary>
2256+
2257+
```diff
2258+
// With --redundant-async tests-only (default)
2259+
import Testing
2260+
2261+
- @Test func myFeature() async {
2262+
+ @Test func myFeature() {
2263+
#expect(foo == 1)
2264+
}
2265+
2266+
import XCTest
2267+
2268+
class TestCase: XCTestCase {
2269+
- func testMyFeature() async {
2270+
+ func testMyFeature() {
2271+
XCTAssertEqual(foo, 1)
2272+
}
2273+
}
2274+
```
2275+
2276+
Also supports `--redundant-async always`.
2277+
This will cause warnings anywhere the updated method is called with `await`, since `await` is now redundant at the callsite.
2278+
2279+
```diff
2280+
// With --redundant-async always
2281+
- func myNonAsyncMethod() async -> Int {
2282+
+ func myNonAsyncMethod() -> Int {
2283+
return 0
2284+
}
2285+
2286+
// Possibly elsewhere in codebase:
2287+
let value = await myNonAsyncMethod()
2288+
+ `- warning: no 'async' operations occur within 'await' expression
2289+
```
2290+
2291+
</details>
2292+
<br/>
2293+
22452294
## redundantBackticks
22462295

22472296
Remove redundant backticks around identifiers.

Sources/FormattingHelpers.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1277,6 +1277,33 @@ extension Formatter {
12771277
}
12781278
}
12791279

1280+
/// Removes the given `throws` or `async` effect from the given function declaration if present
1281+
func removeEffect(_ effect: String, from functionDecl: FunctionDeclaration) {
1282+
guard let effectsRange = functionDecl.effectsRange,
1283+
let effectIndex = index(of: .keyword(effect), in: effectsRange) ?? index(of: .identifier(effect), in: effectsRange)
1284+
else { return }
1285+
1286+
var endIndex = effectIndex
1287+
1288+
// Check if there's typed throws (throws(...))
1289+
if effect == "throws",
1290+
let nextTokenIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: effectIndex),
1291+
tokens[nextTokenIndex] == .startOfScope("("),
1292+
let endOfScope = endOfScope(at: nextTokenIndex)
1293+
{
1294+
endIndex = endOfScope
1295+
}
1296+
1297+
// Include trailing whitespace if present
1298+
if endIndex + 1 < tokens.count,
1299+
tokens[endIndex + 1].isSpace
1300+
{
1301+
endIndex += 1
1302+
}
1303+
1304+
removeTokens(in: effectIndex ... endIndex)
1305+
}
1306+
12801307
/// Whether or not the code block starting at the given `.startOfScope` token
12811308
/// has a single statement. This makes it eligible to be used with implicit return.
12821309
func blockBodyHasSingleStatement(

Sources/OptionDescriptor.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1431,6 +1431,12 @@ struct _Descriptors {
14311431
help: "Remove redundant throws from functions:",
14321432
keyPath: \.redundantThrows
14331433
)
1434+
let redundantAsync = OptionDescriptor(
1435+
argumentName: "redundant-async",
1436+
displayName: "Redundant Async",
1437+
help: "Remove redundant async from functions:",
1438+
keyPath: \.redundantAsync
1439+
)
14341440
let allowPartialWrapping = OptionDescriptor(
14351441
argumentName: "allow-partial-wrapping",
14361442
displayName: "Allow Partial Wrapping",

Sources/Options.swift

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -829,7 +829,8 @@ public struct FormatOptions: CustomStringConvertible {
829829
public var preferFileMacro: Bool
830830
public var lineBetweenConsecutiveGuards: Bool
831831
public var blankLineAfterSwitchCase: BlankLineAfterSwitchCase
832-
public var redundantThrows: RedundantThrowsMode
832+
public var redundantThrows: RedundantEffectMode
833+
public var redundantAsync: RedundantEffectMode
833834
public var allowPartialWrapping: Bool
834835

835836
/// Deprecated
@@ -972,7 +973,8 @@ public struct FormatOptions: CustomStringConvertible {
972973
preferFileMacro: Bool = true,
973974
lineBetweenConsecutiveGuards: Bool = false,
974975
blankLineAfterSwitchCase: BlankLineAfterSwitchCase = .multilineOnly,
975-
redundantThrows: RedundantThrowsMode = .testsOnly,
976+
redundantThrows: RedundantEffectMode = .testsOnly,
977+
redundantAsync: RedundantEffectMode = .testsOnly,
976978
allowPartialWrapping: Bool = true,
977979
// Doesn't really belong here, but hard to put elsewhere
978980
fragment: Bool = false,
@@ -1105,6 +1107,7 @@ public struct FormatOptions: CustomStringConvertible {
11051107
self.lineBetweenConsecutiveGuards = lineBetweenConsecutiveGuards
11061108
self.blankLineAfterSwitchCase = blankLineAfterSwitchCase
11071109
self.redundantThrows = redundantThrows
1110+
self.redundantAsync = redundantAsync
11081111
self.allowPartialWrapping = allowPartialWrapping
11091112
self.indentComments = indentComments
11101113
self.fragment = fragment
@@ -1151,11 +1154,11 @@ public struct FormatOptions: CustomStringConvertible {
11511154
}
11521155
}
11531156

1154-
/// Whether to remove redundant throws from test functions only or from all functions
1155-
public enum RedundantThrowsMode: String, CaseIterable {
1156-
/// Only remove redundant throws from test functions (default)
1157+
/// When to remove redundant `throws` / `async` effects
1158+
public enum RedundantEffectMode: String, CaseIterable {
1159+
/// Only remove redundant effects from test functions (default)
11571160
case testsOnly = "tests-only"
1158-
/// Remove redundant throws from all functions (can cause build failures)
1161+
/// Remove redundant effects from all functions (can cause additional warnings / errors)
11591162
case always
11601163
}
11611164

Sources/RuleRegistry.generated.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ let ruleRegistry: [String: FormatRule] = [
6868
"preferSwiftTesting": .preferSwiftTesting,
6969
"privateStateVariables": .privateStateVariables,
7070
"propertyTypes": .propertyTypes,
71+
"redundantAsync": .redundantAsync,
7172
"redundantBackticks": .redundantBackticks,
7273
"redundantBreak": .redundantBreak,
7374
"redundantClosure": .redundantClosure,

Sources/Rules/EnumNamespaces.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,8 @@ extension Formatter {
9090
return false
9191
} else if [.keyword("let"),
9292
.keyword("var"),
93-
.keyword("func")].contains(token),
93+
.keyword("func"),
94+
.keyword("subscript")].contains(token),
9495
!modifiersForDeclaration(at: j, contains: "static")
9596
{
9697
return false

Sources/Rules/RedundantAsync.swift

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
//
2+
// RedundantAsync.swift
3+
// SwiftFormat
4+
//
5+
// Created by Cal Stephens on 2025-09-18.
6+
// Copyright © 2024 Nick Lockwood. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
public extension FormatRule {
12+
static let redundantAsync = FormatRule(
13+
help: "Remove redundant `async` keyword from function declarations that don't contain any await expressions.",
14+
disabledByDefault: true,
15+
options: ["redundant-async"]
16+
) { formatter in
17+
let testFramework = formatter.detectTestingFramework()
18+
if formatter.options.redundantAsync == .testsOnly, testFramework == nil {
19+
return
20+
}
21+
22+
formatter.forEach(.keyword) { keywordIndex, keyword in
23+
guard ["func", "init", "subscript"].contains(keyword.string),
24+
let functionDecl = formatter.parseFunctionDeclaration(keywordIndex: keywordIndex),
25+
functionDecl.effects.contains(where: { $0.hasPrefix("async") }),
26+
let bodyRange = functionDecl.bodyRange
27+
else { return }
28+
29+
// Don't modify override functions - they need to match their parent's signature
30+
if formatter.modifiersForDeclaration(at: keywordIndex, contains: "override") {
31+
return
32+
}
33+
34+
if formatter.options.redundantAsync == .testsOnly {
35+
// Only process test functions
36+
guard let testFramework, functionDecl.returnType == nil else { return }
37+
38+
switch testFramework {
39+
case .xcTest:
40+
guard functionDecl.name?.starts(with: "test") == true else { return }
41+
case .swiftTesting:
42+
guard formatter.modifiersForDeclaration(at: keywordIndex, contains: "@Test") else { return }
43+
}
44+
}
45+
46+
// Check if the function body contains any await keywords
47+
var bodyContainsAwait = false
48+
for index in bodyRange {
49+
if formatter.tokens[index] == .keyword("await") {
50+
// Only count await keywords that are directly in this function's body
51+
// (not in nested closures or functions)
52+
if formatter.isInFunctionBody(of: functionDecl, at: index) {
53+
bodyContainsAwait = true
54+
break
55+
}
56+
}
57+
}
58+
59+
// If the body doesn't contain any await, remove the async
60+
if !bodyContainsAwait {
61+
formatter.removeEffect("async", from: functionDecl)
62+
}
63+
}
64+
} examples: {
65+
"""
66+
```diff
67+
// With --redundant-async tests-only (default)
68+
import Testing
69+
70+
- @Test func myFeature() async {
71+
+ @Test func myFeature() {
72+
#expect(foo == 1)
73+
}
74+
75+
import XCTest
76+
77+
class TestCase: XCTestCase {
78+
- func testMyFeature() async {
79+
+ func testMyFeature() {
80+
XCTAssertEqual(foo, 1)
81+
}
82+
}
83+
```
84+
85+
Also supports `--redundant-async always`.
86+
This will cause warnings anywhere the updated method is called with `await`, since `await` is now redundant at the callsite.
87+
88+
```diff
89+
// With --redundant-async always
90+
- func myNonAsyncMethod() async -> Int {
91+
+ func myNonAsyncMethod() -> Int {
92+
return 0
93+
}
94+
95+
// Possibly elsewhere in codebase:
96+
let value = await myNonAsyncMethod()
97+
+ `- warning: no 'async' operations occur within 'await' expression
98+
```
99+
"""
100+
}
101+
}

Sources/Rules/RedundantThrows.swift

Lines changed: 2 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import Foundation
1111
public extension FormatRule {
1212
static let redundantThrows = FormatRule(
1313
help: "Remove redundant `throws` keyword from function declarations that don't throw any errors.",
14+
disabledByDefault: true,
1415
orderAfter: [.noForceUnwrapInTests, .noForceTryInTests, .noGuardInTests, .throwingTests],
1516
options: ["redundant-throws"]
1617
) { formatter in
@@ -72,32 +73,7 @@ public extension FormatRule {
7273

7374
// If the body doesn't contain any throwing code, remove the throws
7475
if !bodyContainsThrowingCode {
75-
guard let effectsRange = functionDecl.effectsRange else { return }
76-
77-
// Find the throws keyword in the effects range
78-
for index in effectsRange {
79-
if formatter.tokens[index] == .keyword("throws") {
80-
var endIndex = index
81-
82-
// Check if there's typed throws (throws(...))
83-
if let nextTokenIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: index),
84-
formatter.tokens[nextTokenIndex] == .startOfScope("("),
85-
let endOfScope = formatter.endOfScope(at: nextTokenIndex)
86-
{
87-
endIndex = endOfScope
88-
}
89-
90-
// Include trailing whitespace if present
91-
if endIndex + 1 < formatter.tokens.count,
92-
formatter.tokens[endIndex + 1].isSpace
93-
{
94-
endIndex += 1
95-
}
96-
97-
formatter.removeTokens(in: index ... endIndex)
98-
break // Only remove the first throws found
99-
}
100-
}
76+
formatter.removeEffect("throws", from: functionDecl)
10177
}
10278
}
10379
} examples: {

Tests/Rules/EnumNamespacesTests.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,4 +454,15 @@ class EnumNamespacesTests: XCTestCase {
454454
"""
455455
testFormatting(for: input, rule: .enumNamespaces)
456456
}
457+
458+
func testEnumNamespacesNotAppliedToStructWithInstanceSubscript() {
459+
let input = """
460+
struct MyStruct {
461+
subscript(key: String) -> String {
462+
return key
463+
}
464+
}
465+
"""
466+
testFormatting(for: input, rule: .enumNamespaces, exclude: [.unusedArguments])
467+
}
457468
}

Tests/Rules/NoForceTryInTestsTests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,14 @@ final class NoForceTryInTestsTests: XCTestCase {
8383
import Testing
8484
8585
@Test func something() async {
86-
try! somethingThatThrows()
86+
try! await somethingThatThrows()
8787
}
8888
"""
8989
let output = """
9090
import Testing
9191
9292
@Test func something() async throws {
93-
try somethingThatThrows()
93+
try await somethingThatThrows()
9494
}
9595
"""
9696
testFormatting(for: input, output, rule: .noForceTryInTests)

0 commit comments

Comments
 (0)