Skip to content

Commit ff61ae1

Browse files
committed
Add 'as'/'is' enum case accessor code actions
Replaces the single accessor refactoring with two: "Generate 'as' accessors for enum cases" adds an `as<Case>` property for each case with associated values, and "Generate 'is' accessors for enum cases" adds an `is<Case>` Boolean for every case. Resolves #2522.
1 parent d65afbf commit ff61ae1

4 files changed

Lines changed: 555 additions & 0 deletions

File tree

Sources/SwiftSyntaxCodeActions/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ add_library(SwiftSyntaxCodeActions STATIC
66
ConvertIntegerLiteral.swift
77
ConvertJSONToCodableStruct.swift
88
ConvertStringConcatenationToStringInterpolation.swift
9+
GenerateEnumCaseAccessors.swift
910
IndentationRemover.swift
1011
PackageManifestEdits.swift
1112
SyntaxCodeActionProvider.swift
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
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+
import SwiftBasicFormat
14+
import SwiftRefactor
15+
import SwiftSyntax
16+
17+
/// Syntactic code action that adds an `as<Case>` computed property for every
18+
/// enum case with associated values, returning the value(s) as an optional.
19+
struct GenerateEnumCaseAsAccessors: EditRefactoringProvider {
20+
static func textRefactor(syntax: EnumDeclSyntax, in context: Void) throws -> [SourceEdit] {
21+
var usedNames = existingMemberNames(in: syntax)
22+
let indentation = accessorIndentation(for: syntax)
23+
24+
var accessors: [String] = []
25+
for element in enumCaseElements(of: syntax) {
26+
guard let parameters = element.parameterClause?.parameters, !parameters.isEmpty else {
27+
continue
28+
}
29+
let name = "as\(capitalizedCaseName(of: element))"
30+
guard !usedNames.contains(name) else { continue }
31+
usedNames.insert(name)
32+
accessors.append(
33+
makeAsAccessor(
34+
name: name,
35+
casePattern: element.name.text,
36+
parameters: Array(parameters),
37+
indentation: indentation
38+
)
39+
)
40+
}
41+
42+
return try sourceEdits(insertingAccessors: accessors, into: syntax)
43+
}
44+
45+
private static func makeAsAccessor(
46+
name: String,
47+
casePattern: String,
48+
parameters: [EnumCaseParameterSyntax],
49+
indentation: (member: String, body: String)
50+
) -> String {
51+
let bindings = parameters.enumerated().map { index, parameter -> String in
52+
if let label = parameter.firstName, label.text != "_" {
53+
return label.text
54+
}
55+
return parameters.count == 1 ? "value" : "value\(index + 1)"
56+
}
57+
let types = parameters.map { $0.type.trimmedDescription }
58+
let returnType: String
59+
if parameters.count == 1 {
60+
returnType = needsParenthesesBeforeOptional(parameters[0].type) ? "(\(types[0]))?" : "\(types[0])?"
61+
} else {
62+
returnType = "(\(types.joined(separator: ", ")))?"
63+
}
64+
let boundValues = bindings.joined(separator: ", ")
65+
let returnValue = bindings.count == 1 ? bindings[0] : "(\(boundValues))"
66+
return """
67+
\(indentation.member)var \(name): \(returnType) {
68+
\(indentation.body)if case let .\(casePattern)(\(boundValues)) = self { return \(returnValue) }
69+
\(indentation.body)return nil
70+
\(indentation.member)}
71+
"""
72+
}
73+
}
74+
75+
extension GenerateEnumCaseAsAccessors: SyntaxRefactoringCodeActionProvider {
76+
static let title: String = "Generate 'as' accessors for enum cases"
77+
78+
static func nodeToRefactor(in scope: SyntaxCodeActionScope) -> EnumDeclSyntax? {
79+
return scope.innermostNodeContainingRange?.findParentOfSelf(
80+
ofType: EnumDeclSyntax.self,
81+
stoppingIf: { $0.is(CodeBlockSyntax.self) }
82+
)
83+
}
84+
}
85+
86+
/// Syntactic code action that adds an `is<Case>` Boolean computed property for
87+
/// every enum case.
88+
struct GenerateEnumCaseIsAccessors: EditRefactoringProvider {
89+
static func textRefactor(syntax: EnumDeclSyntax, in context: Void) throws -> [SourceEdit] {
90+
var usedNames = existingMemberNames(in: syntax)
91+
let indentation = accessorIndentation(for: syntax)
92+
93+
var accessors: [String] = []
94+
for element in enumCaseElements(of: syntax) {
95+
let name = "is\(capitalizedCaseName(of: element))"
96+
guard !usedNames.contains(name) else { continue }
97+
usedNames.insert(name)
98+
accessors.append(
99+
makeIsAccessor(name: name, casePattern: element.name.text, indentation: indentation)
100+
)
101+
}
102+
103+
return try sourceEdits(insertingAccessors: accessors, into: syntax)
104+
}
105+
106+
private static func makeIsAccessor(
107+
name: String,
108+
casePattern: String,
109+
indentation: (member: String, body: String)
110+
) -> String {
111+
return """
112+
\(indentation.member)var \(name): Bool {
113+
\(indentation.body)if case .\(casePattern) = self { return true }
114+
\(indentation.body)return false
115+
\(indentation.member)}
116+
"""
117+
}
118+
}
119+
120+
extension GenerateEnumCaseIsAccessors: SyntaxRefactoringCodeActionProvider {
121+
static let title: String = "Generate 'is' accessors for enum cases"
122+
123+
static func nodeToRefactor(in scope: SyntaxCodeActionScope) -> EnumDeclSyntax? {
124+
return scope.innermostNodeContainingRange?.findParentOfSelf(
125+
ofType: EnumDeclSyntax.self,
126+
stoppingIf: { $0.is(CodeBlockSyntax.self) }
127+
)
128+
}
129+
}
130+
131+
// MARK: - Shared helpers
132+
133+
private func enumCaseElements(of enumDecl: EnumDeclSyntax) -> [EnumCaseElementSyntax] {
134+
enumDecl.memberBlock.members.flatMap { member -> [EnumCaseElementSyntax] in
135+
guard let caseDecl = member.decl.as(EnumCaseDeclSyntax.self) else { return [] }
136+
return Array(caseDecl.elements)
137+
}
138+
}
139+
140+
/// The `as`/`is` accessor name for a case, with the leading character upper-cased.
141+
private func capitalizedCaseName(of element: EnumCaseElementSyntax) -> String {
142+
let caseName = element.name.identifier?.name ?? element.name.text
143+
return caseName.prefix(1).uppercased() + caseName.dropFirst()
144+
}
145+
146+
/// Whether `type` must be parenthesized to be made optional. Types that the postfix `?` already
147+
/// binds to are returned as-is; anything else (function, composition, `any`, attributed, … types)
148+
/// is parenthesized so e.g. `(Int) -> Void` becomes `((Int) -> Void)?` rather than `(Int) -> Void?`.
149+
private func needsParenthesesBeforeOptional(_ type: TypeSyntax) -> Bool {
150+
switch type.as(TypeSyntaxEnum.self) {
151+
case .identifierType, .memberType, .arrayType, .dictionaryType, .optionalType,
152+
.implicitlyUnwrappedOptionalType, .tupleType, .metatypeType:
153+
return false
154+
default:
155+
return true
156+
}
157+
}
158+
159+
/// The indentation for generated members and their bodies, derived from the
160+
/// enclosing enum's indentation and the file's inferred indentation width.
161+
private func accessorIndentation(for enumDecl: EnumDeclSyntax) -> (member: String, body: String) {
162+
let base = enumDecl.firstToken(viewMode: .sourceAccurate)?.indentationOfLine ?? []
163+
let step = BasicFormat.inferIndentation(of: enumDecl.root) ?? .spaces(4)
164+
let member = base + step
165+
return (member: member.description, body: (member + step).description)
166+
}
167+
168+
/// Names of all identifier-declaring members, so generated accessors never collide with an existing one.
169+
private func existingMemberNames(in enumDecl: EnumDeclSyntax) -> Set<String> {
170+
func canonicalName(_ token: TokenSyntax) -> String {
171+
token.identifier?.name ?? token.text
172+
}
173+
174+
var names: Set<String> = []
175+
for member in enumDecl.memberBlock.members {
176+
switch member.decl.as(DeclSyntaxEnum.self) {
177+
case .enumCaseDecl(let caseDecl):
178+
for element in caseDecl.elements {
179+
names.insert(canonicalName(element.name))
180+
}
181+
case .variableDecl(let variable):
182+
for binding in variable.bindings {
183+
if let pattern = binding.pattern.as(IdentifierPatternSyntax.self) {
184+
names.insert(canonicalName(pattern.identifier))
185+
}
186+
}
187+
case .functionDecl(let function):
188+
names.insert(canonicalName(function.name))
189+
case .typeAliasDecl(let typeAlias):
190+
names.insert(canonicalName(typeAlias.name))
191+
case .structDecl(let structDecl):
192+
names.insert(canonicalName(structDecl.name))
193+
case .classDecl(let classDecl):
194+
names.insert(canonicalName(classDecl.name))
195+
case .enumDecl(let nestedEnum):
196+
names.insert(canonicalName(nestedEnum.name))
197+
case .actorDecl(let actorDecl):
198+
names.insert(canonicalName(actorDecl.name))
199+
default:
200+
break
201+
}
202+
}
203+
return names
204+
}
205+
206+
private func sourceEdits(insertingAccessors accessors: [String], into enumDecl: EnumDeclSyntax) throws -> [SourceEdit] {
207+
guard !accessors.isEmpty else {
208+
throw RefactoringNotApplicableError("No accessors to generate")
209+
}
210+
// Insert before the closing brace's leading trivia so the brace keeps its own
211+
// newline and indentation (correct for both top-level and nested enums).
212+
let insertion = "\n\n" + accessors.joined(separator: "\n\n")
213+
let position = enumDecl.memberBlock.rightBrace.position
214+
return [SourceEdit(range: position..<position, replacement: insertion)]
215+
}

Sources/SwiftSyntaxCodeActions/SyntaxCodeActions.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ package let allSyntaxCodeActions: [any SyntaxCodeActionProvider.Type] = {
2727
ConvertStringConcatenationToStringInterpolation.self,
2828
ConvertZeroParameterFunctionToComputedProperty.self,
2929
FormatRawStringLiteral.self,
30+
GenerateEnumCaseAsAccessors.self,
31+
GenerateEnumCaseIsAccessors.self,
3032
MigrateToNewIfLetSyntax.self,
3133
OpaqueParameterToGeneric.self,
3234
RemoveRedundantParentheses.self,

0 commit comments

Comments
 (0)