Skip to content

Commit d585e82

Browse files
Refactor RemoveRedundantParentheses for improved correctness
Signed-off-by: Karan <[email protected]>
1 parent 0851433 commit d585e82

File tree

2 files changed

+88
-33
lines changed

2 files changed

+88
-33
lines changed

Sources/SwiftRefactor/RemoveRedundantParentheses.swift

Lines changed: 47 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,8 @@ public struct RemoveRedundantParentheses: SyntaxRefactoringProvider {
2727
syntax: TupleExprSyntax,
2828
in context: Void
2929
) -> ExprSyntax {
30-
// If the tuple has any unexpected nodes, it's not a simple parenthetical expression
31-
// we should be refactoring (e.g., parsing recovery might have put some parts in unexpected nodes).
32-
guard syntax.unexpectedBeforeLeftParen == nil,
33-
syntax.unexpectedBetweenLeftParenAndElements == nil,
34-
syntax.unexpectedBetweenElementsAndRightParen == nil,
35-
syntax.unexpectedAfterRightParen == nil
36-
else {
30+
// If the syntax tree has errors, we should not attempt to refactor it.
31+
guard !syntax.hasError else {
3732
return ExprSyntax(syntax)
3833
}
3934

@@ -49,7 +44,7 @@ public struct RemoveRedundantParentheses: SyntaxRefactoringProvider {
4944
}
5045

5146
// Case 2: Parentheses around simple expressions
52-
if canRemoveParentheses(around: innerExpr, in: syntax.parent) {
47+
if canRemoveParentheses(tuple: syntax, around: innerExpr, in: syntax.parent) {
5348
return preserveTrivia(from: syntax, to: innerExpr)
5449
}
5550

@@ -70,7 +65,8 @@ public struct RemoveRedundantParentheses: SyntaxRefactoringProvider {
7065
.with(\.trailingTrivia, trailingTrivia)
7166
}
7267

73-
private static func canRemoveParentheses(around expr: ExprSyntax, in parent: Syntax?) -> Bool {
68+
private static func canRemoveParentheses(tuple: TupleExprSyntax, around expr: ExprSyntax, in parent: Syntax?) -> Bool
69+
{
7470
// Safety Check: Ambiguous Closures
7571
// Closures and trailing closures inside conditions need parentheses to avoid ambiguity.
7672
// e.g. `if ({ true }) == ({ true }) {}` or `if (call { true }) == false {}`
@@ -96,28 +92,33 @@ public struct RemoveRedundantParentheses: SyntaxRefactoringProvider {
9692
return false
9793
}
9894

99-
// Allowlist: Parents where parentheses are always redundant (unless blocked above)
100-
if let parent = parent,
101-
parent.is(InitializerClauseSyntax.self)
102-
|| parent.is(ConditionElementSyntax.self)
103-
|| parent.is(ReturnStmtSyntax.self)
104-
|| parent.is(ThrowStmtSyntax.self)
105-
|| parent.is(SwitchExprSyntax.self)
106-
|| parent.is(RepeatStmtSyntax.self)
107-
{
108-
return true
95+
// Allowlist: Check keyPathInParent to explicitly know that this expression
96+
// occurs in a place where the parentheses are redundant.
97+
if let keyPath = tuple.keyPathInParent {
98+
switch keyPath {
99+
case \InitializerClauseSyntax.value,
100+
\ConditionElementSyntax.condition,
101+
\ReturnStmtSyntax.expression,
102+
\ThrowStmtSyntax.expression,
103+
\SwitchExprSyntax.subject,
104+
\RepeatStmtSyntax.condition:
105+
return true
106+
default:
107+
break
108+
}
109109
}
110110

111111
// Fallback: Allow if the expression itself is "simple"
112112
guard isSimpleExpression(expr) else {
113113
return false
114114
}
115115

116-
// Safety Check: Postfix Precedence
117-
// Expressions like `try`, `await`, `consume`, and `copy` bind looser than postfix expressions.
116+
// Safety Check: Postfix and Binary Precedence
117+
// Expressions like `try`, `await`, `consume`, and `copy` bind looser than postfix and infix expressions.
118118
// e.g., `(try? f()).description` is different from `try? f().description`.
119119
// The former accesses `.description` on the Optional result, the latter on the unwrapped value.
120-
if isPostfixParent(parent) {
120+
// Similarly, `(try? f()) + 1` is different from `try? f() + 1` (Int? + Int vs Int + Int).
121+
if parentHasTighterBindingThanEffect(parent) {
121122
switch expr.as(ExprSyntaxEnum.self) {
122123
case .tryExpr, .awaitExpr, .unsafeExpr, .consumeExpr, .copyExpr:
123124
return false
@@ -129,17 +130,32 @@ public struct RemoveRedundantParentheses: SyntaxRefactoringProvider {
129130
return true
130131
}
131132

132-
/// Returns true if parent is a postfix expression where the tuple is the base.
133-
private static func isPostfixParent(_ parent: Syntax?) -> Bool {
133+
/// Returns true if parent is an expression with higher precedence than effects (try/await/etc).
134+
/// This includes postfix expressions (member access, subscript, call, force unwrap, optional chaining),
135+
/// infix operators, type casting (as/is), and ternary expressions.
136+
private static func parentHasTighterBindingThanEffect(_ parent: Syntax?) -> Bool {
134137
switch parent?.as(SyntaxEnum.self) {
135-
case .memberAccessExpr(let memberAccess):
136-
return memberAccess.base != nil
137-
case .subscriptCallExpr(let subscriptCall):
138-
return subscriptCall.calledExpression.is(TupleExprSyntax.self)
139-
case .functionCallExpr(let functionCall):
140-
return functionCall.calledExpression.is(TupleExprSyntax.self)
141-
case .postfixOperatorExpr:
138+
// Postfix expressions: member access, subscript, function call, force unwrap, and postfix operators
139+
// These all bind tighter than effect expressions (try/await/etc).
140+
// For member access, since we're a TupleExprSyntax, we are always the base.
141+
case .memberAccessExpr, .subscriptCallExpr, .functionCallExpr, .forceUnwrapExpr, .postfixOperatorExpr:
142142
return true
143+
144+
case .optionalChainingExpr(let optionalChaining):
145+
// Optional chaining (?.) binds tighter than effects
146+
return optionalChaining.expression != nil
147+
148+
// Infix operators and sequence expressions bind tighter than effects.
149+
// For sequence expressions (before SwiftOperators folding), the parent chain
150+
// is: TupleExpr -> ExprList -> SequenceExpr, e.g., `(try? f()) + 1`.
151+
case .infixOperatorExpr, .sequenceExpr, .exprList:
152+
return true
153+
154+
// Type casting operators (as, is) bind tighter than effects.
155+
// Ternary operator also binds tighter than effects.
156+
case .asExpr, .isExpr, .ternaryExpr:
157+
return true
158+
143159
default:
144160
return false
145161
}

Tests/SwiftRefactorTest/RemoveRedundantParenthesesTests.swift

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,6 @@ final class RemoveRedundantParenthesesTest: XCTestCase {
101101
try assertParenRemoval("if (call { true }) {}", expected: "if (call { true }) {}")
102102
try assertParenRemoval("while ({ true }) {}", expected: "while ({ true }) {}")
103103
try assertParenRemoval("guard (call { true }) else {}", expected: "guard (call { true }) else {}")
104-
try assertParenRemoval("repeat {} while (call { true })", expected: "repeat {} while (call { true })")
105104
// Nested in sequence expressions
106105
try assertParenRemoval("if ({ true }) == ({ true }) {}", expected: "if ({ true }) == ({ true }) {}")
107106
// Macro expansions with trailing closures
@@ -129,7 +128,6 @@ final class RemoveRedundantParenthesesTest: XCTestCase {
129128
try assertParenRemoval("(any Equatable).Protocol", expected: "(any Equatable).Protocol")
130129
try assertParenRemoval("(A & B).Protocol", expected: "(A & B).Protocol")
131130
try assertParenRemoval("(@escaping () -> Int).self", expected: "(@escaping () -> Int).self")
132-
try assertParenRemoval("(some P).self", expected: "(some P).self")
133131
try assertParenRemoval("(T...).self", expected: "(T...).self")
134132

135133
// Simple types allow removing parentheses
@@ -144,12 +142,41 @@ final class RemoveRedundantParenthesesTest: XCTestCase {
144142
try assertParenRemoval("(try? f()).description", expected: "(try? f()).description")
145143
try assertParenRemoval("(try! f()).description", expected: "(try! f()).description")
146144

145+
// `try?` also binds looser than optional chaining.
146+
// `(try? f())?.bar` is different from `try? f()?.bar`.
147+
try assertParenRemoval("(try? f())?.bar", expected: "(try? f())?.bar")
148+
try assertParenRemoval("(try! f())?.bar", expected: "(try! f())?.bar")
149+
147150
// `await` also binds looser than member access.
148151
try assertParenRemoval("(await f()).description", expected: "(await f()).description")
149152

150153
// `consume` and `copy` also bind looser than member access.
151154
try assertParenRemoval("(consume x).property", expected: "(consume x).property")
152155
try assertParenRemoval("(copy x).property", expected: "(copy x).property")
156+
157+
// Infix operators bind tighter than effects
158+
// `(try? f()) + 1` is `Optional<Int> + Int` while `try? f() + 1` is `Int + Int`.
159+
try assertParenRemoval("(try? f()) + 1", expected: "(try? f()) + 1")
160+
try assertParenRemoval("(try! f()) + 1", expected: "(try! f()) + 1")
161+
try assertParenRemoval("(await f()) + 1", expected: "(await f()) + 1")
162+
163+
// Type casting binds tighter than effects
164+
// `(try? f()) as Int` is different from `try? f() as Int`.
165+
try assertParenRemoval("(try? f()) as Int", expected: "(try? f()) as Int")
166+
try assertParenRemoval("(try! f()) as Int", expected: "(try! f()) as Int")
167+
try assertParenRemoval("(await f()) as Int", expected: "(await f()) as Int")
168+
// `is` check
169+
try assertParenRemoval("(try? f()) is Int", expected: "(try? f()) is Int")
170+
171+
// Ternary operator binds tighter than effects
172+
// `(try? f()) ? x : y` is different from `try? f() ? x : y`.
173+
try assertParenRemoval("(try? f()) ? x : y", expected: "(try? f()) ? x : y")
174+
try assertParenRemoval("(await f()) ? x : y", expected: "(await f()) ? x : y")
175+
176+
// Force unwrap binds tighter than effects
177+
// `(try? f())!` is different from `try? f()!`.
178+
try assertParenRemoval("(try? f())!", expected: "(try? f())!")
179+
try assertParenRemoval("(await f())!", expected: "(await f())!")
153180
}
154181

155182
func testRedundantParenthesesInControlFlow() throws {
@@ -172,6 +199,18 @@ final class RemoveRedundantParenthesesTest: XCTestCase {
172199
// Multiple conditions
173200
try assertParenRemoval("if (x), (y) {}", expected: "if x, y {}")
174201
}
202+
203+
func testSequenceExpressions() throws {
204+
// Sequence expressions (before SwiftOperators folding) bind tighter than effects.
205+
// In `(try? f()) + 1`, the parentheses must be preserved because the sequence
206+
// expression structure makes `try? f()` the left operand of `+`.
207+
try assertParenRemoval("(try? f()) - 1", expected: "(try? f()) - 1")
208+
try assertParenRemoval("(try? f()) * 1", expected: "(try? f()) * 1")
209+
210+
// Complex sequence expressions
211+
try assertParenRemoval("(try? f()) + g() + h()", expected: "(try? f()) + g() + h()")
212+
try assertParenRemoval("(await f()) + g()", expected: "(await f()) + g()")
213+
}
175214
}
176215

177216
// MARK: - Test Helper

0 commit comments

Comments
 (0)