Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
98 changes: 98 additions & 0 deletions Sources/SwiftRefactor/MoveMembersToExtension.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
//===----------------------------------------------------------------------===//
//
// 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
//
//===----------------------------------------------------------------------===//

#if compiler(>=6)
public import SwiftSyntax
#else
import SwiftSyntax
#endif

public struct MoveMembersToExtension: SyntaxRefactoringProvider {

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Superfluous newline, same in the functions below.

public struct Context {
public let range: Range<AbsolutePosition>

public init(range: Range<AbsolutePosition>) {
self.range = range
}
}

public static func refactor(syntax: SourceFileSyntax, in context: Context) throws -> SourceFileSyntax {

guard
let statement = syntax.statements.first(where: { $0.item.range.contains(context.range) }),
let decl = statement.item.asProtocol(NamedDeclSyntax.self),
let declGroup = statement.item.asProtocol(DeclGroupSyntax.self),
let index = syntax.statements.index(of: statement)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let’s be a little more specific which index this is.

Suggested change
let index = syntax.statements.index(of: statement)
let statementIndex = syntax.statements.index(of: statement)

else {
throw RefactoringNotApplicableError("Type declaration not found")
}

let selectedMembers = declGroup.memberBlock.members.filter { context.range.contains($0.range) }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we check for intersects here so that you also move those declarations that you partially selected, eg. where you missed the function body?
Also, should we use $0.trimmedRange so that you don’t also move declarations whose leading whitespace or comments you may have selected?


guard !selectedMembers.isEmpty else {
throw RefactoringNotApplicableError("No members to move")
}

for member in selectedMembers {
try validateMember(member)
}

let remainingMembers = declGroup.memberBlock.members.filter { !context.range.contains($0.range) }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of doing a range-based check here, should we check if selectedMembers contains that member? Something like the following.

Suggested change
let remainingMembers = declGroup.memberBlock.members.filter { !context.range.contains($0.range) }
let remainingMembers = declGroup.memberBlock.members.filter { !selectedMembers..contains($0) }


let updatedMemberBlock = declGroup.memberBlock.with(\.members, remainingMembers)
let updatedDeclGroup = declGroup.with(\.memberBlock, updatedMemberBlock)
Comment on lines +52 to +53
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can be simplified slightly

Suggested change
let updatedMemberBlock = declGroup.memberBlock.with(\.members, remainingMembers)
let updatedDeclGroup = declGroup.with(\.memberBlock, updatedMemberBlock)
var updatedDeclGroup = declGroup
updatedDeclGroup.memberBlock.members = declGroup.memberBlock.members.filter { !selectedMembers.contains($0) }

let updatedItem = statement.with(\.item, .decl(DeclSyntax(updatedDeclGroup)))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let updatedItem = statement.with(\.item, .decl(DeclSyntax(updatedDeclGroup)))
let updatedStatement = statement.with(\.item, .decl(DeclSyntax(updatedDeclGroup)))


let extensionMemberBlockSyntax = declGroup.memberBlock.with(\.members, selectedMembers)

var declName = decl.name

// e.g, after `Outer<T>` trailing trivia is empty
if declName.trailingTrivia.isEmpty {
declName = declName.with(\.trailingTrivia, .space)
}
Comment on lines +58 to +63
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can ensure that we have a space here by running

Suggested change
var declName = decl.name
// e.g, after `Outer<T>` trailing trivia is empty
if declName.trailingTrivia.isEmpty {
declName = declName.with(\.trailingTrivia, .space)
}
var declName = decl.name
declName.trailingTrivia = declName.trailingTrivia.merging(.space)


let extensionDecl = ExtensionDeclSyntax(
leadingTrivia: .newlines(2),
extendedType: IdentifierTypeSyntax(
leadingTrivia: .space,
name: declName
),
memberBlock: extensionMemberBlockSyntax
)

var updatedStatements = syntax.statements
updatedStatements.remove(at: index)
updatedStatements.insert(updatedItem, at: index)
Comment on lines +75 to +76
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn’t this the same as

Suggested change
updatedStatements.remove(at: index)
updatedStatements.insert(updatedItem, at: index)
updatedStatements[index] = updatedItem

updatedStatements.insert(
CodeBlockItemSyntax(item: .decl(DeclSyntax(extensionDecl))),
at: syntax.statements.index(after: index)
)
return syntax.with(\.statements, updatedStatements)
Comment on lines +74 to +81
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can also modify syntax directly and don’t have to go through updatedStatements.

Suggested change
var updatedStatements = syntax.statements
updatedStatements.remove(at: index)
updatedStatements.insert(updatedItem, at: index)
updatedStatements.insert(
CodeBlockItemSyntax(item: .decl(DeclSyntax(extensionDecl))),
at: syntax.statements.index(after: index)
)
return syntax.with(\.statements, updatedStatements)
var syntax = syntax
syntax.statements[index] = updatedItem
syntax.statements.insert(
CodeBlockItemSyntax(item: .decl(DeclSyntax(extensionDecl))),
at: syntax.statements.index(after: index)
)
return syntax

}

private static func validateMember(_ member: MemberBlockItemSyntax) throws {

if member.decl.is(AccessorDeclSyntax.self) || member.decl.is(DeinitializerDeclSyntax.self)
|| member.decl.is(EnumCaseDeclSyntax.self)
{
throw RefactoringNotApplicableError("Cannot move this type of declaration")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we be a little more specific in the error message about what we can’t move. I would imagine that this error message is annoying to get if you selected 200 lines of code and you don’t know what this is.

Actually, just thinking about it, when you select 5 functions and one deinitializer, maybe we should just move the 5 functions and leave the deinitializer where it is. And only emit an error like deinitializers cannot be moved to an extension if the deinitializer is the only declaration that was selected.

}

if let varDecl = member.decl.as(VariableDeclSyntax.self),
varDecl.bindings.contains(where: { $0.accessorBlock == nil || $0.initializer != nil })
{
throw RefactoringNotApplicableError("Cannot move stored properties to extension")
}
}
}
207 changes: 207 additions & 0 deletions Tests/SwiftRefactorTest/MoveMembersToExtensionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
//===----------------------------------------------------------------------===//
//
// 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
//
//===----------------------------------------------------------------------===//

import SwiftParser
import SwiftRefactor
import SwiftSyntax
import SwiftSyntaxBuilder
import XCTest
import _SwiftSyntaxTestSupport

final class MoveMembersToExtensionTests: XCTestCase {
func testMoveFunctionFromClass() throws {
let baseline: SourceFileSyntax = """
class Foo {
func foo() {
print("Hello world!")
}

func bar() {
print("Hello world!")
}
}
"""

let expected: SourceFileSyntax = """
class Foo {

func bar() {
print("Hello world!")
}
}

extension Foo {
func foo() {
print("Hello world!")
}
}
"""

let context = MoveMembersToExtension.Context(
range: AbsolutePosition(utf8Offset: 11)..<AbsolutePosition(utf8Offset: 56)
)
Comment on lines +49 to +51
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of hard-coding UTF-8 offsets, which are hard to correlate back to the input text, could you use position markers (see extractMarkers). I find that usually that makes the tests significantly easier to read. Also, I think it would be good to add an assertion handler function so that the test reads as follows

assertMoveMembersToExtension(
  """
  class Foo 1️⃣{
    func foo() {
      print("Hello world!")
    }2️⃣

    func bar() {
      print("Hello world!")
    }
  }
  """,
  expected: """
  class Foo {

    func bar() {
      print("Hello world!")
    }
  }

  extension Foo {
    func foo() {
      print("Hello world!")
    }
  }
  """
)

And I probably mis-placed the position markers here, please double check.

try assertRefactorConvert(baseline, expected: expected, context: context)
}

func testMoveFunctionFromClass2() throws {
let baseline: SourceFileSyntax = """
class Foo {
func foo() {
print("Hello world!")
}

func bar() {
print("Hello world!")
}
}

struct Bar {
func foo() {}
}
"""

let expected: SourceFileSyntax = """
class Foo {

func bar() {
print("Hello world!")
}
}

extension Foo {
func foo() {
print("Hello world!")
}
}

struct Bar {
func foo() {}
}
"""

let context = MoveMembersToExtension.Context(
range: AbsolutePosition(utf8Offset: 11)..<AbsolutePosition(utf8Offset: 56)
)
try assertRefactorConvert(baseline, expected: expected, context: context)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should make sure that we either disallow or correctly handle extracting members from nested types.

Suggested test cases:

struct Outer {
  struct Inner {
    func moveThis() {}
  }
}
struct Outer<T> {
  struct Inner {
    func moveThis() {}
  }
}
func outer() {
  struct Inner {
    func moveThis() {}
  }
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a couple of tests, the rest are in progress.


func testMoveNestedFromStruct() throws {
let baseline: SourceFileSyntax = """
struct Outer {
struct Inner {
func moveThis() {}
}
}
"""

let expected: SourceFileSyntax = """
struct Outer {
}

extension Outer {
struct Inner {
func moveThis() {}
}
}
"""

let context = MoveMembersToExtension.Context(
range: AbsolutePosition(utf8Offset: 14)..<AbsolutePosition(utf8Offset: 58)
)
try assertRefactorConvert(baseline, expected: expected, context: context)
}

func testMoveNestedFromStruct2() throws {
let baseline: SourceFileSyntax = """
struct Outer<T> {
struct Inner {
func moveThis() {}
}
}
"""

let expected: SourceFileSyntax = """
struct Outer<T> {
}

extension Outer {
struct Inner {
func moveThis() {}
}
}
"""

let context = MoveMembersToExtension.Context(
range: AbsolutePosition(utf8Offset: 17)..<AbsolutePosition(utf8Offset: 61)
)
try assertRefactorConvert(baseline, expected: expected, context: context)
}

func testMoveMembersFromEnum() throws {
let baseline: SourceFileSyntax = """
enum Foo {
func foo() {
print("Hello world!")
}

func bar() {
print("Hello world!")
}
}

struct Bar {
func foo() {}
}
"""

let expected: SourceFileSyntax = """
enum Foo {

func bar() {
print("Hello world!")
}
}

extension Foo {
func foo() {
print("Hello world!")
}
}

struct Bar {
func foo() {}
}
"""

let context = MoveMembersToExtension.Context(
range: AbsolutePosition(utf8Offset: 10)..<AbsolutePosition(utf8Offset: 55)
)
try assertRefactorConvert(baseline, expected: expected, context: context)
}
}

private func assertRefactorConvert(
_ callDecl: SourceFileSyntax,
expected: SourceFileSyntax?,
context: MoveMembersToExtension.Context,
file: StaticString = #filePath,
line: UInt = #line
) throws {
try assertRefactor(
callDecl,
context: context,
provider: MoveMembersToExtension.self,
expected: expected,
file: file,
line: line
)
}