Skip to content

Comments

Refactor MoveMembersToExtension#3265

Open
myaumura wants to merge 4 commits intoswiftlang:mainfrom
myaumura:add-move-to-extension
Open

Refactor MoveMembersToExtension#3265
myaumura wants to merge 4 commits intoswiftlang:mainfrom
myaumura:add-move-to-extension

Conversation

@myaumura
Copy link
Contributor

@myaumura myaumura commented Feb 5, 2026

This PR implements the MoveMembersToExtension refactoring action as requested in issue.

@myaumura
Copy link
Contributor Author

myaumura commented Feb 5, 2026

@ahoppen
Could you please take a look at the PR and let me know if I’m going in the right direction, or if there’s a better approach?
Also, what do you think is the best way to implement the tests — using markers or something else?
I’d really appreciate any feedback.

Copy link
Member

@ahoppen ahoppen left a comment

Choose a reason for hiding this comment

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

Thanks for adding this, @myaumura.

Also: There’s no need to ping me when opening the PR. I receive notifications for newly opened PRs and will review them when I’ve got time.


public struct Context {
public let declName: TokenSyntax
public let selectedIdentifiers: [SyntaxIdentifier]
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 specify a range of declarations to move, not their identifiers. The base identifier names are not sufficient to identify a declaration if it’s overloaded.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed

let tree = SourceFileSyntax.parse(from: &parser)
let context = makeContextFromClass(markers: markers, source: tree)
try assertRefactorConvert(tree, 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.

var updatedStatements = syntax.statements
updatedStatements.remove(at: index)
updatedStatements.insert(updatedItem, at: index)
updatedStatements.append(CodeBlockItemSyntax(item: .decl(DeclSyntax(extensionDecl))))
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 insert the declaration after the updated item instead of at the end of the file?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed

Copy link
Member

@ahoppen ahoppen left a comment

Choose a reason for hiding this comment

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

I think the refactoring action almost works as I’d expect now, just a few more comments to give the implementation its final polish and make it easier to read / maintain in the future.

#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.

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?

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.

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)
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)))

Comment on lines +58 to +63
var declName = decl.name

// e.g, after `Outer<T>` trailing trivia is empty
if declName.trailingTrivia.isEmpty {
declName = declName.with(\.trailingTrivia, .space)
}
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)

Comment on lines +75 to +76
updatedStatements.remove(at: index)
updatedStatements.insert(updatedItem, at: index)
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

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)

Comment on lines +74 to +81
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)
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

Comment on lines +49 to +51
let context = MoveMembersToExtension.Context(
range: AbsolutePosition(utf8Offset: 11)..<AbsolutePosition(utf8Offset: 56)
)
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants