Files
Alex HoppenandPadmashree S S a3f9b807c7 Add refactoring action to convert stored to computed properties
This is the first syntactic refactoring action that needs to perform a cursor info request on `codeAction/resolve`, so the majority of this PR is to add infrastructure for that.

Based on https://github.com/swiftlang/sourcekit-lsp/pull/2496.

Co-Authored-By: Padmashree S S <padmashreess2006@gmail.com>
2026-07-10 14:12:32 +02:00

168 lines
6.4 KiB
Swift

//===----------------------------------------------------------------------===//
//
// 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?.as(ExprListSyntax.self)?.parent?.is(SequenceExprSyntax.self) == true
else {
return nil
}
// 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 scope.range.overlapsOrTouches(tokenRange) 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?.as(ExprListSyntax.self)?.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
)
]
}
}