Skip to content

Commit a15b8f2

Browse files
authored
Add redundantThrows rule (#2206)
1 parent eb53639 commit a15b8f2

File tree

6 files changed

+440
-0
lines changed

6 files changed

+440
-0
lines changed

Rules.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
* [redundantReturn](#redundantReturn)
6161
* [redundantSelf](#redundantSelf)
6262
* [redundantStaticSelf](#redundantStaticSelf)
63+
* [redundantThrows](#redundantThrows)
6364
* [redundantType](#redundantType)
6465
* [redundantTypedThrows](#redundantTypedThrows)
6566
* [redundantVoidReturnType](#redundantVoidReturnType)
@@ -2835,6 +2836,54 @@ Remove explicit `Self` where applicable.
28352836
</details>
28362837
<br/>
28372838

2839+
## redundantThrows
2840+
2841+
Remove redundant `throws` keyword from function declarations that don't throw any errors.
2842+
2843+
Option | Description
2844+
--- | ---
2845+
`--redundant-throws` | Remove redundant throws from functions: "tests-only" (default) or "always"
2846+
2847+
<details>
2848+
<summary>Examples</summary>
2849+
2850+
```diff
2851+
// With --redundant-throws tests-only (default)
2852+
import Testing
2853+
2854+
- @Test func myFeature() throws {
2855+
+ @Test func myFeature() throws {
2856+
#expect(foo == 1)
2857+
}
2858+
2859+
import XCTest
2860+
2861+
class TestCase: XCTestCase {
2862+
- func testMyFeature() throws {
2863+
+ func testMyFeature() {
2864+
XCTAssertEqual(foo, 1)
2865+
}
2866+
}
2867+
```
2868+
2869+
Also supports `--redundant-throws always`.
2870+
This will cause warnings anywhere the updated method is called with `try`, since `try` is now redundant at the callsite.
2871+
2872+
```diff
2873+
// With --redundant-throws always
2874+
- func myNonThrowingMethod() throws -> Int {
2875+
+ func myNonThrowingMethod() -> Int {
2876+
return 0
2877+
}
2878+
2879+
// Possibly elsewhere in codebase:
2880+
let value = try myNonThrowingMethod()
2881+
+ `- warning: no calls to throwing functions occur within 'try' expression
2882+
```
2883+
2884+
</details>
2885+
<br/>
2886+
28382887
## redundantType
28392888

28402889
Remove redundant type from variable declarations.

Sources/OptionDescriptor.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1425,6 +1425,12 @@ struct _Descriptors {
14251425
help: "Insert line After switch cases:",
14261426
keyPath: \.blankLineAfterSwitchCase
14271427
)
1428+
let redundantThrows = OptionDescriptor(
1429+
argumentName: "redundant-throws",
1430+
displayName: "Redundant Throws",
1431+
help: "Remove redundant throws from functions:",
1432+
keyPath: \.redundantThrows
1433+
)
14281434
let allowPartialWrapping = OptionDescriptor(
14291435
argumentName: "allow-partial-wrapping",
14301436
displayName: "Allow Partial Wrapping",

Sources/Options.swift

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

834835
/// Deprecated
@@ -971,6 +972,7 @@ public struct FormatOptions: CustomStringConvertible {
971972
preferFileMacro: Bool = true,
972973
lineBetweenConsecutiveGuards: Bool = false,
973974
blankLineAfterSwitchCase: BlankLineAfterSwitchCase = .multilineOnly,
975+
redundantThrows: RedundantThrowsMode = .testsOnly,
974976
allowPartialWrapping: Bool = true,
975977
// Doesn't really belong here, but hard to put elsewhere
976978
fragment: Bool = false,
@@ -1102,6 +1104,7 @@ public struct FormatOptions: CustomStringConvertible {
11021104
self.preferFileMacro = preferFileMacro
11031105
self.lineBetweenConsecutiveGuards = lineBetweenConsecutiveGuards
11041106
self.blankLineAfterSwitchCase = blankLineAfterSwitchCase
1107+
self.redundantThrows = redundantThrows
11051108
self.allowPartialWrapping = allowPartialWrapping
11061109
self.indentComments = indentComments
11071110
self.fragment = fragment
@@ -1148,6 +1151,14 @@ public struct FormatOptions: CustomStringConvertible {
11481151
}
11491152
}
11501153

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+
case testsOnly = "tests-only"
1158+
/// Remove redundant throws from all functions (can cause build failures)
1159+
case always
1160+
}
1161+
11511162
public enum MarkdownFormattingMode: String, CaseIterable {
11521163
/// Swift code in markdown files is ignored (default)
11531164
case ignore

Sources/RuleRegistry.generated.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ let ruleRegistry: [String: FormatRule] = [
9191
"redundantReturn": .redundantReturn,
9292
"redundantSelf": .redundantSelf,
9393
"redundantStaticSelf": .redundantStaticSelf,
94+
"redundantThrows": .redundantThrows,
9495
"redundantType": .redundantType,
9596
"redundantTypedThrows": .redundantTypedThrows,
9697
"redundantVoidReturnType": .redundantVoidReturnType,
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
//
2+
// RedundantThrows.swift
3+
// SwiftFormat
4+
//
5+
// Created by Cal Stephens on 2025-09-16.
6+
// Copyright © 2024 Nick Lockwood. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
public extension FormatRule {
12+
static let redundantThrows = FormatRule(
13+
help: "Remove redundant `throws` keyword from function declarations that don't throw any errors.",
14+
orderAfter: [.noForceUnwrapInTests, .noForceTryInTests, .noGuardInTests, .throwingTests],
15+
options: ["redundant-throws"]
16+
) { formatter in
17+
let testFramework = formatter.detectTestingFramework()
18+
if formatter.options.redundantThrows == .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("throws") }),
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.redundantThrows == .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 try keywords (excluding try! and try?) or throw statements
47+
var bodyContainsThrowingCode = false
48+
for index in bodyRange {
49+
if formatter.tokens[index] == .keyword("try") {
50+
// Check if this try is followed by ! or ? (which means it doesn't need throws)
51+
if let nextTokenIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: index),
52+
formatter.tokens[nextTokenIndex].isUnwrapOperator
53+
{
54+
continue // Skip try! and try?
55+
}
56+
57+
// Only count try keywords that are directly in this function's body
58+
// (not in nested closures or functions)
59+
if formatter.isInFunctionBody(of: functionDecl, at: index) {
60+
bodyContainsThrowingCode = true
61+
break
62+
}
63+
} else if formatter.tokens[index] == .keyword("throw") {
64+
// Only count throw statements that are directly in this function's body
65+
// (not in nested closures or functions)
66+
if formatter.isInFunctionBody(of: functionDecl, at: index) {
67+
bodyContainsThrowingCode = true
68+
break
69+
}
70+
}
71+
}
72+
73+
// If the body doesn't contain any throwing code, remove the throws
74+
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+
}
101+
}
102+
}
103+
} examples: {
104+
"""
105+
```diff
106+
// With --redundant-throws tests-only (default)
107+
import Testing
108+
109+
- @Test func myFeature() throws {
110+
+ @Test func myFeature() throws {
111+
#expect(foo == 1)
112+
}
113+
114+
import XCTest
115+
116+
class TestCase: XCTestCase {
117+
- func testMyFeature() throws {
118+
+ func testMyFeature() {
119+
XCTAssertEqual(foo, 1)
120+
}
121+
}
122+
```
123+
124+
Also supports `--redundant-throws always`.
125+
This will cause warnings anywhere the updated method is called with `try`, since `try` is now redundant at the callsite.
126+
127+
```diff
128+
// With --redundant-throws always
129+
- func myNonThrowingMethod() throws -> Int {
130+
+ func myNonThrowingMethod() -> Int {
131+
return 0
132+
}
133+
134+
// Possibly elsewhere in codebase:
135+
let value = try myNonThrowingMethod()
136+
+ `- warning: no calls to throwing functions occur within 'try' expression
137+
```
138+
"""
139+
}
140+
}

0 commit comments

Comments
 (0)