Skip to content

Commit 65b02a9

Browse files
authored
Merge pull request #3229 from myaumura/update-computed-properties-codeaction
Add new features to ConvertStoredPropertyToComputed
2 parents 9b153a9 + 4b2774d commit 65b02a9

File tree

6 files changed

+251
-25
lines changed

6 files changed

+251
-25
lines changed

Sources/SwiftRefactor/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ add_swift_syntax_library(SwiftRefactor
1414
ConvertComputedPropertyToZeroParameterFunction.swift
1515
ConvertStoredPropertyToComputed.swift
1616
ConvertZeroParameterFunctionToComputedProperty.swift
17+
DeclModifierRemover.swift
1718
ExpandEditorPlaceholder.swift
1819
FormatRawStringLiteral.swift
1920
IntegerLiteralUtilities.swift

Sources/SwiftRefactor/ConvertStoredPropertyToComputed.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ public struct ConvertStoredPropertyToComputed: SyntaxRefactoringProvider {
2222
throw RefactoringNotApplicableError("unsupported variable declaration")
2323
}
2424

25+
var syntax = syntax
26+
27+
if let lazyKeyword = syntax.modifiers.first(where: { $0.name.tokenKind == .keyword(.lazy) }) {
28+
syntax = DeclModifierRemover { $0.id == lazyKeyword.id }
29+
.rewrite(syntax)
30+
.cast(VariableDeclSyntax.self)
31+
}
32+
2533
var codeBlockSyntax: CodeBlockItemListSyntax
2634

2735
if let functionExpression = initializer.value.as(FunctionCallExprSyntax.self),
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2026 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
@_spi(RawSyntax) import SwiftSyntax
14+
15+
final class DeclModifierRemover: SyntaxRewriter {
16+
private let predicate: (DeclModifierSyntax) -> Bool
17+
18+
private var triviaToAttachToNextToken: Trivia = Trivia()
19+
20+
/// Initializes a modifier remover with a given predicate to determine which modifiers to remove.
21+
///
22+
/// - Parameter predicate: A closure that determines whether a given `AttributeSyntax` should be removed.
23+
/// If this closure returns `true` for an attribute, that attribute will be removed.
24+
public init(removingWhere predicate: @escaping (DeclModifierSyntax) -> Bool) {
25+
self.predicate = predicate
26+
super.init()
27+
}
28+
29+
public override func visit(_ node: DeclModifierListSyntax) -> DeclModifierListSyntax {
30+
var filteredModifiers: [DeclModifierListSyntax.Element] = []
31+
32+
for modifier in node {
33+
guard self.predicate(modifier) else {
34+
filteredModifiers.append(prependAndClearAccumulatedTrivia(to: modifier))
35+
continue
36+
}
37+
38+
// Removing modifier before comment leaves space before comment intact — doesn’t merge with following trivia.
39+
let trailingTrivia = modifier.trailingTrivia.trimmingPrefix(while: \.isSpaceOrTab)
40+
triviaToAttachToNextToken += modifier.leadingTrivia.merging(trailingTrivia)
41+
}
42+
43+
if !triviaToAttachToNextToken.isEmpty, !filteredModifiers.isEmpty {
44+
filteredModifiers[filteredModifiers.count - 1].trailingTrivia = filteredModifiers[filteredModifiers.count - 1]
45+
.trailingTrivia
46+
.merging(triviaToAttachToNextToken)
47+
triviaToAttachToNextToken = Trivia()
48+
}
49+
50+
return DeclModifierListSyntax(filteredModifiers)
51+
}
52+
53+
public override func visit(_ token: TokenSyntax) -> TokenSyntax {
54+
return prependAndClearAccumulatedTrivia(to: token)
55+
}
56+
57+
/// Prepends the accumulated trivia to the given node's leading trivia.
58+
///
59+
/// To preserve correct formatting after attribute removal, this function reassigns
60+
/// significant trivia accumulated from removed attributes to the provided subsequent node.
61+
/// Once attached, the accumulated trivia is cleared.
62+
///
63+
/// - Parameter node: The syntax node receiving the accumulated trivia.
64+
/// - Returns: The modified syntax node with the prepended trivia.
65+
private func prependAndClearAccumulatedTrivia<T: SyntaxProtocol>(to syntaxNode: T) -> T {
66+
guard !triviaToAttachToNextToken.isEmpty else { return syntaxNode }
67+
defer { triviaToAttachToNextToken = Trivia() }
68+
return syntaxNode.with(\.leadingTrivia, triviaToAttachToNextToken.merging(syntaxNode.leadingTrivia))
69+
}
70+
}

Sources/SwiftSyntax/Trivia.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,3 +215,27 @@ extension RawTriviaPiece: CustomDebugStringConvertible {
215215
TriviaPiece(raw: self).debugDescription
216216
}
217217
}
218+
219+
@_spi(RawSyntax)
220+
public extension Trivia {
221+
func trimmingPrefix(
222+
while predicate: (TriviaPiece) -> Bool
223+
) -> Trivia {
224+
Trivia(pieces: self.drop(while: predicate))
225+
}
226+
227+
func trimmingSuffix(
228+
while predicate: (TriviaPiece) -> Bool
229+
) -> Trivia {
230+
Trivia(
231+
pieces: self[...]
232+
.reversed()
233+
.drop(while: predicate)
234+
.reversed()
235+
)
236+
}
237+
238+
var startsWithNewline: Bool {
239+
self.first?.isNewline ?? false
240+
}
241+
}

Sources/SwiftSyntaxMacroExpansion/MacroSystem.swift

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@
1414
internal import SwiftDiagnostics
1515
internal import SwiftOperators
1616
@_spi(MacroExpansion) internal import SwiftParser
17-
public import SwiftSyntax
17+
@_spi(RawSyntax) public import SwiftSyntax
1818
internal import SwiftSyntaxBuilder
1919
@_spi(MacroExpansion) @_spi(ExperimentalLanguageFeature) public import SwiftSyntaxMacros
2020
#else
2121
import SwiftDiagnostics
2222
import SwiftOperators
2323
@_spi(MacroExpansion) import SwiftParser
24-
import SwiftSyntax
24+
@_spi(RawSyntax) import SwiftSyntax
2525
import SwiftSyntaxBuilder
2626
@_spi(MacroExpansion) @_spi(ExperimentalLanguageFeature) import SwiftSyntaxMacros
2727
#endif
@@ -533,6 +533,7 @@ public class AttributeRemover: SyntaxRewriter {
533533
/// If this closure returns `true` for an attribute, that attribute will be removed.
534534
public init(removingWhere predicate: @escaping (AttributeSyntax) -> Bool) {
535535
self.predicate = predicate
536+
super.init()
536537
}
537538

538539
public override func visit(_ node: AttributeListSyntax) -> AttributeListSyntax {
@@ -611,34 +612,12 @@ public class AttributeRemover: SyntaxRewriter {
611612
/// - Parameter node: The syntax node receiving the accumulated trivia.
612613
/// - Returns: The modified syntax node with the prepended trivia.
613614
private func prependAndClearAccumulatedTrivia<T: SyntaxProtocol>(to syntaxNode: T) -> T {
615+
guard !triviaToAttachToNextToken.isEmpty else { return syntaxNode }
614616
defer { triviaToAttachToNextToken = Trivia() }
615617
return syntaxNode.with(\.leadingTrivia, triviaToAttachToNextToken + syntaxNode.leadingTrivia)
616618
}
617619
}
618620

619-
private extension Trivia {
620-
func trimmingPrefix(
621-
while predicate: (TriviaPiece) -> Bool
622-
) -> Trivia {
623-
Trivia(pieces: self.drop(while: predicate))
624-
}
625-
626-
func trimmingSuffix(
627-
while predicate: (TriviaPiece) -> Bool
628-
) -> Trivia {
629-
Trivia(
630-
pieces: self[...]
631-
.reversed()
632-
.drop(while: predicate)
633-
.reversed()
634-
)
635-
}
636-
637-
var startsWithNewline: Bool {
638-
self.first?.isNewline ?? false
639-
}
640-
}
641-
642621
let diagnosticDomain: String = "SwiftSyntaxMacroExpansion"
643622

644623
private enum MacroApplicationError: DiagnosticMessage, Error {

Tests/SwiftRefactorTest/ConvertStoredPropertyToComputedTest.swift

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,130 @@ final class ConvertStoredPropertyToComputedTest: XCTestCase {
170170

171171
try assertRefactorConvert(baseline, expected: nil)
172172
}
173+
174+
func testRefactoringStoredPropertyWithLazyKeyword() throws {
175+
let baseline: DeclSyntax = """
176+
lazy var defaultColor: Color = .red
177+
"""
178+
179+
let expected: DeclSyntax = """
180+
var defaultColor: Color { .red }
181+
"""
182+
183+
try assertRefactorConvert(baseline, expected: expected)
184+
}
185+
186+
func testRefactoringStoredPropertyWithModifiers() throws {
187+
let baseline: DeclSyntax = """
188+
private lazy var defaultColor: Color = .red
189+
"""
190+
191+
let expected: DeclSyntax = """
192+
private var defaultColor: Color { .red }
193+
"""
194+
195+
try assertRefactorConvert(baseline, expected: expected)
196+
}
197+
198+
func testRefactoringStoredPropertyWithModifiers2() throws {
199+
let baseline: DeclSyntax = """
200+
lazy private var defaultColor: Color = .red
201+
"""
202+
203+
let expected: DeclSyntax = """
204+
private var defaultColor: Color { .red }
205+
"""
206+
207+
try assertRefactorConvert(baseline, expected: expected)
208+
}
209+
210+
func testRefactoringStoredPropertyWithModifiersAndComment() throws {
211+
let baseline: DeclSyntax = """
212+
lazy /* some comment */ private var defaultColor: Color = .red
213+
"""
214+
215+
let expected: DeclSyntax = """
216+
/* some comment */ private var defaultColor: Color { .red }
217+
"""
218+
219+
try assertRefactorConvert(baseline, expected: expected)
220+
}
221+
222+
func testRefactoringStoredPropertyWithModifiersAndComment2() throws {
223+
let baseline: DeclSyntax = """
224+
private /* comment */ lazy var defaultColor: Color = .red
225+
"""
226+
227+
let expected: DeclSyntax = """
228+
private /* comment */ var defaultColor: Color { .red }
229+
"""
230+
231+
try assertRefactorConvert(baseline, expected: expected)
232+
}
233+
234+
func testRefactoringStoredPropertyWithModifierAndComment() throws {
235+
let baseline: DeclSyntax = """
236+
lazy /* comment */ var defaultColor: Color = .red
237+
"""
238+
239+
let expected: DeclSyntax = """
240+
/* comment */ var defaultColor: Color { .red }
241+
"""
242+
243+
try assertRefactorConvert(baseline, expected: expected)
244+
}
245+
246+
func testRefactoringStructStoredPropertiyWithModifiers() throws {
247+
let baseline: DeclSyntax = """
248+
struct Foo {
249+
lazy private var defaultColor: Color = .red
250+
}
251+
"""
252+
253+
let expected: DeclSyntax = """
254+
struct Foo {
255+
private var defaultColor: Color { .red }
256+
}
257+
"""
258+
259+
try assertRefactorStructConvert(baseline, expected: expected)
260+
}
261+
262+
func testRefactoringStructStoredPropertiyWithModifiers2() throws {
263+
let baseline: DeclSyntax = """
264+
struct Foo {
265+
private
266+
/* comment */ lazy var defaultColor: Color = .red
267+
}
268+
"""
269+
270+
let expected: DeclSyntax = """
271+
struct Foo {
272+
private
273+
/* comment */ var defaultColor: Color { .red }
274+
}
275+
"""
276+
277+
try assertRefactorStructConvert(baseline, expected: expected)
278+
}
279+
280+
func testRefactoringStructStoredPropertiyWithModifiers3() throws {
281+
let baseline: DeclSyntax = """
282+
struct Foo {
283+
private /* comment */
284+
/* another comment */ lazy var defaultColor: Color = .red
285+
}
286+
"""
287+
288+
let expected: DeclSyntax = """
289+
struct Foo {
290+
private /* comment */
291+
/* another comment */ var defaultColor: Color { .red }
292+
}
293+
"""
294+
295+
try assertRefactorStructConvert(baseline, expected: expected)
296+
}
173297
}
174298

175299
private func assertRefactorConvert(
@@ -187,3 +311,23 @@ private func assertRefactorConvert(
187311
line: line
188312
)
189313
}
314+
315+
private func assertRefactorStructConvert(
316+
_ callDecl: DeclSyntax,
317+
expected: DeclSyntax,
318+
file: StaticString = #filePath,
319+
line: UInt = #line
320+
) throws {
321+
322+
let structCallDecl = try XCTUnwrap(callDecl.as(StructDeclSyntax.self))
323+
let variable = try XCTUnwrap(structCallDecl.memberBlock.members.first?.decl.as(VariableDeclSyntax.self))
324+
let refactored = try ConvertStoredPropertyToComputed.refactor(syntax: variable, in: ())
325+
326+
let members = MemberBlockItemListSyntax {
327+
MemberBlockItemSyntax(decl: DeclSyntax(refactored))
328+
}
329+
330+
let refactoredMemberBlock = structCallDecl.memberBlock.with(\.members, members)
331+
let refactoredStruct = structCallDecl.with(\.memberBlock, refactoredMemberBlock)
332+
assertStringsEqualWithDiff(refactoredStruct.description, expected.description)
333+
}

0 commit comments

Comments
 (0)