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])) + } +} 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 + ) +}