Repository: swiftlang/swift-syntax (swift)

Issue:
Code action to convert between computed property and stored property
### Description

A stored property with an initializer, e.g.,

```swift
static let defaultColor: Color = .red
```

might be better off as a computed property:

```swift
static var defaultColor: Color { .red }
```

or vice versa. Let's create a pair of code actions to go from the first to second or vice-versa.

Pull request:
Resolves swiftlang/sourcekit-lsp#1245

Patch:
diff --git a/Sources/SwiftRefactor/CMakeLists.txt b/Sources/SwiftRefactor/CMakeLists.txt
index 6959d719cf4..18a17a75802 100644
--- a/Sources/SwiftRefactor/CMakeLists.txt
+++ b/Sources/SwiftRefactor/CMakeLists.txt
@@ -9,6 +9,8 @@
 add_swift_syntax_library(SwiftRefactor
   AddSeparatorsToIntegerLiteral.swift
   CallToTrailingClosures.swift
+  ConvertComputedPropertyToStored.swift
+  ConvertStoredPropertyToComputed.swift
   ExpandEditorPlaceholder.swift
   FormatRawStringLiteral.swift
   IntegerLiteralUtilities.swift
diff --git a/Sources/SwiftRefactor/ConvertComputedPropertyToStored.swift b/Sources/SwiftRefactor/ConvertComputedPropertyToStored.swift
new file mode 100644
index 00000000000..67965623296
--- /dev/null
+++ b/Sources/SwiftRefactor/ConvertComputedPropertyToStored.swift
@@ -0,0 +1,96 @@
+//===----------------------------------------------------------------------===//
+//
+// This source file is part of the Swift.org open source project
+//
+// Copyright (c) 2014 - 2024 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
+//
+//===----------------------------------------------------------------------===//
+
+#if swift(>=6)
+public import SwiftSyntax
+#else
+import SwiftSyntax
+#endif
+
+public struct ConvertComputedPropertyToStored: SyntaxRefactoringProvider {
+  public static func refactor(syntax: VariableDeclSyntax, in context: ()) -> VariableDeclSyntax? {
+    guard syntax.bindings.count == 1, let binding = syntax.bindings.first,
+      let accessorBlock = binding.accessorBlock, case let .getter(body) = accessorBlock.accessors, !body.isEmpty
+    else {
+      return nil
+    }
+
+    let refactored = { (initializer: InitializerClauseSyntax) -> VariableDeclSyntax in
+      let newBinding =
+        binding
+        .with(\.initializer, initializer)
+        .with(\.accessorBlock, nil)
+
+      let bindingSpecifier = syntax.bindingSpecifier
+        .with(\.tokenKind, .keyword(.let))
+
+      return
+        syntax
+        .with(\.bindingSpecifier, bindingSpecifier)
+        .with(\.bindings, PatternBindingListSyntax([newBinding]))
+    }
+
+    guard body.count == 1 else {
+      let closure = ClosureExprSyntax(
+        leftBrace: accessorBlock.leftBrace,
+        statements: body,
+        rightBrace: accessorBlock.rightBrace
+      )
+
+      return refactored(
+        InitializerClauseSyntax(
+          equal: .equalToken(trailingTrivia: .space),
+          value: FunctionCallExprSyntax(callee: closure)
+        )
+      )
+    }
+
+    guard body.count == 1, let item = body.first?.item else {
+      return nil
+    }
+
+    if let item = item.as(ReturnStmtSyntax.self), let expression = item.expression {
+      let trailingTrivia: Trivia = expression.leadingTrivia.isEmpty ? .space : []
+      return refactored(
+        InitializerClauseSyntax(
+          leadingTrivia: accessorBlock.leftBrace.trivia,
+          equal: .equalToken(trailingTrivia: trailingTrivia),
+          value: expression,
+          trailingTrivia: accessorBlock.rightBrace.trivia.droppingTrailingWhitespace
+        )
+      )
+    } else if var item = item.as(ExprSyntax.self) {
+      item.trailingTrivia = item.trailingTrivia.droppingTrailingWhitespace
+      return refactored(
+        InitializerClauseSyntax(
+          equal: .equalToken(trailingTrivia: .space),
+          value: item,
+          trailingTrivia: accessorBlock.trailingTrivia
+        )
+      )
+    }
+
+    return nil
+  }
+}
+
+fileprivate extension TokenSyntax {
+  var trivia: Trivia {
+    return leadingTrivia + trailingTrivia
+  }
+}
+
+fileprivate extension Trivia {
+  var droppingTrailingWhitespace: Trivia {
+    return Trivia(pieces: self.reversed().drop(while: \.isWhitespace).reversed())
+  }
+}
diff --git a/Sources/SwiftRefactor/ConvertStoredPropertyToComputed.swift b/Sources/SwiftRefactor/ConvertStoredPropertyToComputed.swift
new file mode 100644
index 00000000000..49e6e88dd6e
--- /dev/null
+++ b/Sources/SwiftRefactor/ConvertStoredPropertyToComputed.swift
@@ -0,0 +1,69 @@
+//===----------------------------------------------------------------------===//
+//
+// This source file is part of the Swift.org open source project
+//
+// Copyright (c) 2014 - 2024 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
+//
+//===----------------------------------------------------------------------===//
+
+#if swift(>=6)
+public import SwiftSyntax
+#else
+import SwiftSyntax
+#endif
+
+public struct ConvertStoredPropertyToComputed: SyntaxRefactoringProvider {
+  public static func refactor(syntax: VariableDeclSyntax, in context: ()) -> VariableDeclSyntax? {
+    guard syntax.bindings.count == 1, let binding = syntax.bindings.first, let initializer = binding.initializer else {
+      return nil
+    }
+
+    var codeBlockSyntax: CodeBlockItemListSyntax
+
+    if let functionExpression = initializer.value.as(FunctionCallExprSyntax.self),
+      let closureExpression = functionExpression.calledExpression.as(ClosureExprSyntax.self)
+    {
+      guard functionExpression.arguments.isEmpty else { return nil }
+
+      codeBlockSyntax = closureExpression.statements
+      codeBlockSyntax.leadingTrivia =
+        closureExpression.leftBrace.leadingTrivia + closureExpression.leftBrace.trailingTrivia
+        + codeBlockSyntax.leadingTrivia
+      codeBlockSyntax.trailingTrivia +=
+        closureExpression.trailingTrivia + closureExpression.rightBrace.leadingTrivia
+        + closureExpression.rightBrace.trailingTrivia + functionExpression.trailingTrivia
+    } else {
+      var body = CodeBlockItemListSyntax([
+        CodeBlockItemSyntax(
+          item: .expr(initializer.value)
+        )
+      ])
+      body.leadingTrivia = initializer.equal.trailingTrivia + body.leadingTrivia
+      body.trailingTrivia += .space
+      codeBlockSyntax = body
+    }
+
+    let newBinding =
+      binding
+      .with(\.initializer, nil)
+      .with(
+        \.accessorBlock,
+        AccessorBlockSyntax(
+          accessors: .getter(codeBlockSyntax)
+        )
+      )
+
+    let newBindingSpecifier =
+      syntax.bindingSpecifier
+      .with(\.tokenKind, .keyword(.var))
+
+    return
+      syntax
+      .with(\.bindingSpecifier, newBindingSpecifier)
+      .with(\.bindings, PatternBindingListSyntax([newBinding]))
+  }
+}


Test patch:
diff --git a/Tests/SwiftRefactorTest/ConvertComputedPropertyToStoredTest.swift b/Tests/SwiftRefactorTest/ConvertComputedPropertyToStoredTest.swift
new file mode 100644
index 00000000000..328675ec7be
--- /dev/null
+++ b/Tests/SwiftRefactorTest/ConvertComputedPropertyToStoredTest.swift
@@ -0,0 +1,163 @@
+//===----------------------------------------------------------------------===//
+//
+// This source file is part of the Swift.org open source project
+//
+// Copyright (c) 2014 - 2024 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
+//
+//===----------------------------------------------------------------------===//
+
+import SwiftRefactor
+import SwiftSyntax
+import SwiftSyntaxBuilder
+import XCTest
+import _SwiftSyntaxTestSupport
+
+final class ConvertComputedPropertyToStoredTest: XCTestCase {
+  func testToStored() throws {
+    let baseline: DeclSyntax = """
+      var defaultColor: Color { Color() /* some text */ }
+      """
+
+    let expected: DeclSyntax = """
+      let defaultColor: Color = Color() /* some text */
+      """
+
+    try assertRefactorConvert(baseline, expected: expected)
+  }
+
+  func testToStoredWithReturnStatement() throws {
+    let baseline: DeclSyntax = """
+      var defaultColor: Color {
+        return Color()
+      }
+      """
+
+    let expected: DeclSyntax = """
+      let defaultColor: Color = Color()
+      """
+
+    try assertRefactorConvert(baseline, expected: expected)
+  }
+
+  func testToStoredWithReturnStatementAndTrailingComment() throws {
+    let baseline: DeclSyntax = """
+      var defaultColor: Color {
+        return Color() /* some text */
+      }
+      """
+
+    let expected: DeclSyntax = """
+      let defaultColor: Color = Color() /* some text */
+      """
+
+    try assertRefactorConvert(baseline, expected: expected)
+  }
+
+  func testToStoredWithReturnStatementAndTrailingCommentOnNewLine() throws {
+    let baseline: DeclSyntax = """
+      var defaultColor: Color {
+        return Color()
+        /* some text */
+      }
+      """
+
+    let expected: DeclSyntax = """
+      let defaultColor: Color = Color()
+        /* some text */
+      """
+
+    try assertRefactorConvert(baseline, expected: expected)
+  }
+
+  func testToStoredWithMultipleStatementsInAccessor() throws {
+    let baseline: DeclSyntax = """
+      var defaultColor: Color {
+        let color = Color()
+        return color
+      }
+      """
+
+    let expected: DeclSyntax = """
+      let defaultColor: Color = {
+        let color = Color()
+        return color
+      }()
+      """
+
+    try assertRefactorConvert(baseline, expected: expected)
+  }
+
+  func testToStoredWithMultipleStatementsInAccessorAndTrailingCommentsOnNewLine() throws {
+    let baseline: DeclSyntax = """
+      var defaultColor: Color {
+        let color = Color()
+        return color
+        // returns color
+      }
+      """
+
+    let expected: DeclSyntax = """
+      let defaultColor: Color = {
+        let color = Color()
+        return color
+        // returns color
+      }()
+      """
+
+    try assertRefactorConvert(baseline, expected: expected)
+  }
+
+  func testToStoredWithMultipleStatementsInAccessorAndLeadingComments() throws {
+    let baseline: DeclSyntax = """
+      var defaultColor: Color { // returns color
+        let color = Color()
+        return color
+      }
+      """
+
+    let expected: DeclSyntax = """
+      let defaultColor: Color = { // returns color
+        let color = Color()
+        return color
+      }()
+      """
+
+    try assertRefactorConvert(baseline, expected: expected)
+  }
+
+  func testToStoreWithSeparatingComments() throws {
+    let baseline: DeclSyntax = """
+      var x: Int {
+        return
+        /* One */ 1
+      }
+      """
+
+    let expected: DeclSyntax = """
+      let x: Int =
+        /* One */ 1
+      """
+
+    try assertRefactorConvert(baseline, expected: expected)
+  }
+}
+
+fileprivate func assertRefactorConvert(
+  _ callDecl: DeclSyntax,
+  expected: DeclSyntax?,
+  file: StaticString = #filePath,
+  line: UInt = #line
+) throws {
+  try assertRefactor(
+    callDecl,
+    context: (),
+    provider: ConvertComputedPropertyToStored.self,
+    expected: expected,
+    file: file,
+    line: line
+  )
+}
diff --git a/Tests/SwiftRefactorTest/ConvertStoredPropertyToComputedTest.swift b/Tests/SwiftRefactorTest/ConvertStoredPropertyToComputedTest.swift
new file mode 100644
index 00000000000..1127b037b33
--- /dev/null
+++ b/Tests/SwiftRefactorTest/ConvertStoredPropertyToComputedTest.swift
@@ -0,0 +1,189 @@
+//===----------------------------------------------------------------------===//
+//
+// This source file is part of the Swift.org open source project
+//
+// Copyright (c) 2014 - 2024 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
+//
+//===----------------------------------------------------------------------===//
+
+import SwiftRefactor
+import SwiftSyntax
+import SwiftSyntaxBuilder
+import XCTest
+import _SwiftSyntaxTestSupport
+
+final class ConvertStoredPropertyToComputedTest: XCTestCase {
+  func testRefactoringStoredPropertyWithInitializer1() throws {
+    let baseline: DeclSyntax = """
+      static let defaultColor: Color = .red
+      """
+
+    let expected: DeclSyntax = """
+      static var defaultColor: Color { .red }
+      """
+
+    try assertRefactorConvert(baseline, expected: expected)
+  }
+
+  func testRefactoringStoredPropertyWithInitializer1AndLeadingComments() throws {
+    let baseline: DeclSyntax = """
+      static let defaultColor: Color = /* red */ .red
+      """
+
+    let expected: DeclSyntax = """
+      static var defaultColor: Color { /* red */ .red }
+      """
+
+    try assertRefactorConvert(baseline, expected: expected)
+  }
+
+  func testRefactoringStoredPropertyWithInitializer1AndLeadingComments2() throws {
+    let baseline: DeclSyntax = """
+      static let defaultColor: Color = 
+        /* red */ .red
+      """
+
+    let expected: DeclSyntax = """
+      static var defaultColor: Color { 
+        /* red */ .red }
+      """
+
+    try assertRefactorConvert(baseline, expected: expected)
+  }
+
+  func testRefactoringStoredPropertyWithInitializer1AndTrailingComments() throws {
+    let baseline: DeclSyntax = """
+      static let defaultColor: Color = .red /* red */
+      """
+
+    let expected: DeclSyntax = """
+      static var defaultColor: Color { .red /* red */ }
+      """
+
+    try assertRefactorConvert(baseline, expected: expected)
+  }
+
+  func testRefactoringStoredPropertyWithInitializer1AndTrailingComments2() throws {
+    let baseline: DeclSyntax = """
+      static let defaultColor: Color = .red
+        /* red */
+      """
+
+    let expected: DeclSyntax = """
+      static var defaultColor: Color { .red
+        /* red */ }
+      """
+
+    try assertRefactorConvert(baseline, expected: expected)
+  }
+
+  func testRefactoringStoredPropertyWithInitializerAndComments() throws {
+    let baseline: DeclSyntax = """
+      static /* one */ let defaultColor: Color = .red
+      """
+
+    let expected: DeclSyntax = """
+      static /* one */ var defaultColor: Color { .red }
+      """
+
+    try assertRefactorConvert(baseline, expected: expected)
+  }
+
+  func testRefactoringStoredPropertyWithInitializerAndCommentsInBinding() throws {
+    let baseline: DeclSyntax = """
+      static let /* binding */ defaultColor: Color = .red
+      """
+
+    let expected: DeclSyntax = """
+      static var /* binding */ defaultColor: Color { .red }
+      """
+
+    try assertRefactorConvert(baseline, expected: expected)
+  }
+
+  func testRefactoringStoredPropertyWithInitializer2() throws {
+    let baseline: DeclSyntax = """
+      static let defaultColor: Color = Color.red
+      """
+
+    let expected: DeclSyntax = """
+      static var defaultColor: Color { Color.red }
+      """
+
+    try assertRefactorConvert(baseline, expected: expected)
+  }
+
+  func testRefactoringStoredPropertyWithInitializer3() throws {
+    let baseline: DeclSyntax = """
+      var defaultColor: Color = Color.red
+      """
+
+    let expected: DeclSyntax = """
+      var defaultColor: Color { Color.red }
+      """
+
+    try assertRefactorConvert(baseline, expected: expected)
+  }
+
+  func testRefactoringStoredPropertyWithInitializer4() throws {
+    let baseline: DeclSyntax = """
+      var defaultColor: Color = Color()
+      """
+
+    let expected: DeclSyntax = """
+      var defaultColor: Color { Color() }
+      """
+
+    try assertRefactorConvert(baseline, expected: expected)
+  }
+
+  func testRefactoringStoredPropertyWithMultipleStatements() throws {
+    let baseline: DeclSyntax = """
+      var three: Int = {
+        let one = 1
+        let two = 2
+        return 1 + 2
+      }()
+      """
+
+    let expected: DeclSyntax = """
+      var three: Int {
+        let one = 1
+        let two = 2
+        return 1 + 2
+      }
+      """
+
+    try assertRefactorConvert(baseline, expected: expected)
+  }
+
+  func testRefactoringStoredPropertyWithFunctionCallAndArguments() throws {
+    let baseline: DeclSyntax = """
+      let myVar = { value in
+        return value
+      }(1)
+      """
+
+    try assertRefactorConvert(baseline, expected: nil)
+  }
+}
+
+fileprivate func assertRefactorConvert(
+  _ callDecl: DeclSyntax,
+  expected: DeclSyntax?,
+  file: StaticString = #filePath,
+  line: UInt = #line
+) throws {
+  try assertRefactor(
+    callDecl,
+    context: (),
+    provider: ConvertStoredPropertyToComputed.self,
+    expected: expected,
+    file: file,
+    line: line
+  )
+}