Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Documentation/Refactoring Actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ The specific refactorings available depend on what code is selected or where the
|--------|---------|
| **Remove Unused Imports** | Cursor on an import declaration (only available when file has no errors) |
| **Remove Redundant Parentheses** | Cursor on a parenthesized expression that contains a redundant outer set of parentheses |
| **Swap operands** | Cursor or selection on a supported binary operator in an infix expression |

### Package.swift Manifest Editing

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
@_spi(SourceKitLSP) import LanguageServerProtocol
import SourceKitLSP
import SwiftSyntax
import SwiftSyntaxCodeActions

private class StartOfIdentifierFinder: SyntaxAnyVisitor {
let requestedPosition: AbsolutePosition
Expand All @@ -34,7 +35,7 @@ private class StartOfIdentifierFinder: SyntaxAnyVisitor {
if token.tokenKind.isPunctuation || token.tokenKind == .endOfFile {
return .skipChildren
}
if (token.positionAfterSkippingLeadingTrivia...token.endPositionBeforeTrailingTrivia).contains(requestedPosition) {
if token.containsContent(at: requestedPosition) {
self.resolvedPosition = token.positionAfterSkippingLeadingTrivia
}
return .skipChildren
Expand Down
1 change: 1 addition & 0 deletions Sources/SwiftSyntaxCodeActions/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ add_library(SwiftSyntaxCodeActions STATIC
ConvertStringConcatenationToStringInterpolation.swift
IndentationRemover.swift
PackageManifestEdits.swift
SwapBinaryOperands.swift
SyntaxCodeActionProvider.swift
SyntaxCodeActions.swift
SyntaxRefactoringCodeActionProvider.swift
Expand Down
172 changes: 172 additions & 0 deletions Sources/SwiftSyntaxCodeActions/SwapBinaryOperands.swift
Comment thread
somiljain2006 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2026 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//

internal import LanguageServerProtocol
internal import SourceKitLSP
import SwiftBasicFormat
import SwiftExtensions
import SwiftOperators
import SwiftRefactor
import SwiftSyntax

struct SwapBinaryOperands: SyntaxRefactoringCodeActionProvider {
package static let title: String = "Swap operands"

package static func nodeToRefactor(in scope: SyntaxCodeActionScope) -> ExprSyntax? {
guard let node = scope.innermostNodeContainingRange else {
return nil
}

guard
let opExpr = node.findParentOfSelf(
ofType: ExprSyntax.self,
stoppingIf: { $0.is(CodeBlockItemSyntax.self) },
matching: { $0.is(BinaryOperatorExprSyntax.self) }
)?.as(BinaryOperatorExprSyntax.self)
else {
return nil
}

// Only offer the refactoring when the operator participates in a
// binary expression that can be folded into an InfixOperatorExprSyntax.
guard
opExpr.parent?.is(InfixOperatorExprSyntax.self) == true
|| opExpr.parent?.parent?.is(SequenceExprSyntax.self) == true
Comment thread
somiljain2006 marked this conversation as resolved.
Outdated
else {
return nil
}

let startPos = scope.snapshot.absolutePosition(of: scope.request.range.lowerBound)
let endPos = scope.snapshot.absolutePosition(of: scope.request.range.upperBound)
let selectionRange = startPos..<endPos

// Only offer the refactoring when the cursor or selection targets the
// operator token. This prevents offering the action when the cursor is placed on
// either operand or in surrounding trivia.
let tokenRange = opExpr.operator.trimmedRange
guard selectionRange.isEmpty ? opExpr.operator.containsContent(at: startPos) : selectionRange.overlaps(tokenRange)
Comment thread
somiljain2006 marked this conversation as resolved.
Outdated
else {
return nil
}

return ExprSyntax(opExpr)
}

package static func textRefactor(syntax opExpr: ExprSyntax, in context: Void) throws -> [SourceEdit] {
guard let binOp = opExpr.as(BinaryOperatorExprSyntax.self) else {
throw RefactoringNotApplicableError("Selected expression is not a binary operator")
}

// Locate the smallest expression that may contain the operator under the cursor.
// SequenceExprSyntax needs to be folded before precedence-aware rewriting can occur.
let exprToFold: ExprSyntax
if let infixExpr = binOp.parent?.as(InfixOperatorExprSyntax.self) {
exprToFold = ExprSyntax(infixExpr)
} else if let seqExpr = binOp.parent?.parent?.as(SequenceExprSyntax.self) {
exprToFold = ExprSyntax(seqExpr)
} else {
throw RefactoringNotApplicableError("Could not find an infix or sequence expression to fold")
}

let foldedExpr: ExprSyntax

// Fold operator precedence so nested expressions such as
// `1 + -2 * 5` become a structured tree where the selected
// infix operator can be identified reliably.
if exprToFold.is(SequenceExprSyntax.self) {
guard let folded = OperatorTable.standardOperators.foldAll(exprToFold, errorHandler: { _ in }).as(ExprSyntax.self)
else {
throw RefactoringNotApplicableError("Failed to fold operator sequence")
}
foldedExpr = folded
} else {
foldedExpr = exprToFold.detached
}

let currentOperatorText = binOp.operator.text

// Calculate the relative UTF-8 offset.
let targetRelativeOffset =
binOp.operator.positionAfterSkippingLeadingTrivia.utf8Offset - exprToFold.position.utf8Offset
let targetPosition = AbsolutePosition(utf8Offset: foldedExpr.position.utf8Offset + targetRelativeOffset)

guard
let token = foldedExpr.token(at: targetPosition),
let infixExpr = token.parent?.as(BinaryOperatorExprSyntax.self)?.parent?.as(InfixOperatorExprSyntax.self)
else {
throw RefactoringNotApplicableError("Could not locate the target operator in the folded tree")
}

// Avoid offering the refactoring for malformed expressions such as
// `a +` or `+ b` while the user is still typing.
guard !infixExpr.hasError else {
throw RefactoringNotApplicableError("Expression is malformed or incomplete")
}

let newOperatorText: String

// Comparison operators must be inverted when operands are swapped.
// Symmetric operators can be reused unchanged.
switch currentOperatorText {
case "<": newOperatorText = ">"
case ">": newOperatorText = "<"
case "<=": newOperatorText = ">="
case ">=": newOperatorText = "<="
case "+", "*", "==", "!=", "===", "!==", "&&", "||", "&", "|", "^":
newOperatorText = currentOperatorText
default:
throw RefactoringNotApplicableError("Operator '\(currentOperatorText)' cannot be swapped")
}

let leftOperand = infixExpr.leftOperand
let rightOperand = infixExpr.rightOperand

// Preserve operand trivia so whitespace and comments remain attached
// to the same side of the expression after swapping.
var newLeft = rightOperand
newLeft.leadingTrivia = leftOperand.leadingTrivia
newLeft.trailingTrivia = leftOperand.trailingTrivia

var newRight = leftOperand
newRight.leadingTrivia = rightOperand.leadingTrivia
newRight.trailingTrivia = rightOperand.trailingTrivia

guard let targetBinOp = infixExpr.operator.as(BinaryOperatorExprSyntax.self) else {
throw RefactoringNotApplicableError("Failed to cast operator to BinaryOperatorExprSyntax")
}
let newToken = targetBinOp.operator.with(\.tokenKind, .binaryOperator(newOperatorText))
let newOperatorExpr = ExprSyntax(targetBinOp.with(\.operator, newToken))

let newInfix =
infixExpr
.with(\.leftOperand, newLeft)
.with(\.operator, newOperatorExpr)
.with(\.rightOperand, newRight)

// Calculate the absolute range of the targeted expression in the original source
// by applying its relative offset from the detached folded tree.
let rootOffset = exprToFold.position.utf8Offset
let foldedRootOffset = foldedExpr.position.utf8Offset

let startOffset = rootOffset + (infixExpr.position.utf8Offset - foldedRootOffset)
let endOffset = rootOffset + (infixExpr.endPosition.utf8Offset - foldedRootOffset)

let editRange = AbsolutePosition(utf8Offset: startOffset)..<AbsolutePosition(utf8Offset: endOffset)

return [
SourceEdit(
range: editRange,
replacement: newInfix.description
)
]
}
}
8 changes: 8 additions & 0 deletions Sources/SwiftSyntaxCodeActions/SyntaxCodeActionProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,11 @@ private func tokenForRefactoring(
}
return token
}

extension TokenSyntax {
/// Returns whether the given position is within the token's text content,
/// inclusive of the start and end boundaries, ignoring leading and trailing trivia.
package func containsContent(at position: AbsolutePosition) -> Bool {
return (positionAfterSkippingLeadingTrivia...endPositionBeforeTrailingTrivia).contains(position)
}
Comment thread
somiljain2006 marked this conversation as resolved.
Outdated
}
1 change: 1 addition & 0 deletions Sources/SwiftSyntaxCodeActions/SyntaxCodeActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ package let allSyntaxCodeActions: [any SyntaxCodeActionProvider.Type] = {
RemoveRedundantParentheses.self,
RemoveSeparatorsFromIntegerLiteral.self,
ConvertCommentToDocComment.self,
SwapBinaryOperands.self,
]
#if !NO_SWIFTPM_DEPENDENCY
result.append(PackageManifestEdits.self)
Expand Down
Loading