From 4e6aeb2256fbb759e9a83e11c112279c6b960c37 Mon Sep 17 00:00:00 2001 From: ayush-that Date: Mon, 1 Jun 2026 13:33:37 +0530 Subject: [PATCH 1/3] Add 'as'/'is' enum case accessor code actions --- Sources/SwiftSyntaxCodeActions/CMakeLists.txt | 1 + .../GenerateEnumCaseAccessors.swift | 199 ++++++++ .../SyntaxCodeActions.swift | 2 + Tests/SourceKitLSPTests/CodeActionTests.swift | 444 ++++++++++++++++++ 4 files changed, 646 insertions(+) create mode 100644 Sources/SwiftSyntaxCodeActions/GenerateEnumCaseAccessors.swift diff --git a/Sources/SwiftSyntaxCodeActions/CMakeLists.txt b/Sources/SwiftSyntaxCodeActions/CMakeLists.txt index 627e1265d..ad65e1785 100644 --- a/Sources/SwiftSyntaxCodeActions/CMakeLists.txt +++ b/Sources/SwiftSyntaxCodeActions/CMakeLists.txt @@ -6,6 +6,7 @@ add_library(SwiftSyntaxCodeActions STATIC ConvertIntegerLiteral.swift ConvertJSONToCodableStruct.swift ConvertStringConcatenationToStringInterpolation.swift + GenerateEnumCaseAccessors.swift IndentationRemover.swift PackageManifestEdits.swift SyntaxCodeActionProvider.swift diff --git a/Sources/SwiftSyntaxCodeActions/GenerateEnumCaseAccessors.swift b/Sources/SwiftSyntaxCodeActions/GenerateEnumCaseAccessors.swift new file mode 100644 index 000000000..de6d17638 --- /dev/null +++ b/Sources/SwiftSyntaxCodeActions/GenerateEnumCaseAccessors.swift @@ -0,0 +1,199 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftBasicFormat +import SwiftExtensions +import SwiftRefactor +import SwiftSyntax + +/// Syntactic code action that adds an `as` computed property for every +/// enum case with associated values, returning the value(s) as an optional. +struct GenerateEnumCaseAsAccessors: SyntaxRefactoringCodeActionProvider { + static let title: String = "Generate 'as' accessors for enum cases" + + static func nodeToRefactor(in scope: SyntaxCodeActionScope) -> EnumDeclSyntax? { + return scope.innermostNodeContainingRange?.findParentOfSelf( + ofType: EnumDeclSyntax.self, + stoppingIf: { $0.is(CodeBlockSyntax.self) } + ) + } + + static func textRefactor(syntax: EnumDeclSyntax, in context: Void) throws -> [SourceEdit] { + let indentation = accessorIndentation(for: syntax) + let existingNames = existingMemberNames(of: syntax) + + var accessors: [String] = [] + for element in enumCaseElements(of: syntax) { + guard let parameters = element.parameterClause?.parameters, !parameters.isEmpty else { + continue + } + let name = "as\(capitalizedCaseName(of: element))" + guard !existingNames.contains(name) else { continue } + accessors.append( + makeAsAccessor( + name: name, + casePattern: element.name.text, + parameters: parameters, + baseIndentation: indentation.base, + indentationStep: indentation.step + ) + ) + } + + return try sourceEdits(insertingAccessors: accessors, into: syntax) + } + + private static func makeAsAccessor( + name: String, + casePattern: String, + parameters: EnumCaseParameterListSyntax, + baseIndentation: Trivia, + indentationStep: Trivia + ) -> String { + let memberIndentation = (baseIndentation + indentationStep).description + let bodyIndentation = (baseIndentation + indentationStep + indentationStep).description + let bindings = parameters.enumerated().map { index, parameter -> String in + if let label = parameter.firstName, label.text != "_" { + return label.text + } + return parameters.count == 1 ? "value" : "value\(index + 1)" + } + let returnType: String + if let parameter = parameters.only { + let type = parameter.type.trimmedDescription + returnType = needsParenthesesBeforeOptional(parameter.type) ? "(\(type))?" : "\(type)?" + } else { + let elements = parameters.map { parameter -> String in + if let label = parameter.firstName, label.text != "_" { + return "\(label.text): \(parameter.type.trimmedDescription)" + } + return parameter.type.trimmedDescription + } + returnType = "(\(elements.joined(separator: ", ")))?" + } + let boundValues = bindings.joined(separator: ", ") + let returnValue = bindings.only ?? "(\(boundValues))" + return """ + \(memberIndentation)var \(name): \(returnType) { + \(bodyIndentation)if case let .\(casePattern)(\(boundValues)) = self { return \(returnValue) } + \(bodyIndentation)return nil + \(memberIndentation)} + """ + } +} + +/// Syntactic code action that adds an `is` Boolean computed property for +/// every enum case. +struct GenerateEnumCaseIsAccessors: SyntaxRefactoringCodeActionProvider { + static let title: String = "Generate 'is' accessors for enum cases" + + static func nodeToRefactor(in scope: SyntaxCodeActionScope) -> EnumDeclSyntax? { + return scope.innermostNodeContainingRange?.findParentOfSelf( + ofType: EnumDeclSyntax.self, + stoppingIf: { $0.is(CodeBlockSyntax.self) } + ) + } + + static func textRefactor(syntax: EnumDeclSyntax, in context: Void) throws -> [SourceEdit] { + let indentation = accessorIndentation(for: syntax) + let existingNames = existingMemberNames(of: syntax) + + let accessors = enumCaseElements(of: syntax).compactMap { element -> String? in + let name = "is\(capitalizedCaseName(of: element))" + guard !existingNames.contains(name) else { return nil } + return makeIsAccessor( + name: name, + casePattern: element.name.text, + baseIndentation: indentation.base, + indentationStep: indentation.step + ) + } + + return try sourceEdits(insertingAccessors: accessors, into: syntax) + } + + private static func makeIsAccessor( + name: String, + casePattern: String, + baseIndentation: Trivia, + indentationStep: Trivia + ) -> String { + let memberIndentation = (baseIndentation + indentationStep).description + let bodyIndentation = (baseIndentation + indentationStep + indentationStep).description + return """ + \(memberIndentation)var \(name): Bool { + \(bodyIndentation)if case .\(casePattern) = self { return true } + \(bodyIndentation)return false + \(memberIndentation)} + """ + } +} + +// MARK: - Shared helpers + +private func enumCaseElements(of enumDecl: EnumDeclSyntax) -> [EnumCaseElementSyntax] { + enumDecl.memberBlock.members.flatMap { member -> [EnumCaseElementSyntax] in + guard let caseDecl = member.decl.as(EnumCaseDeclSyntax.self) else { return [] } + return Array(caseDecl.elements) + } +} + +/// The names of the enum's existing properties, so an accessor whose name is already taken isn't generated again. +private func existingMemberNames(of enumDecl: EnumDeclSyntax) -> Set { + var names: Set = [] + for member in enumDecl.memberBlock.members { + guard let variable = member.decl.as(VariableDeclSyntax.self) else { continue } + for binding in variable.bindings { + if let pattern = binding.pattern.as(IdentifierPatternSyntax.self) { + names.insert(pattern.identifier.identifier?.name ?? pattern.identifier.text) + } + } + } + return names +} + +/// The `as`/`is` accessor name for a case, with the leading character upper-cased. +private func capitalizedCaseName(of element: EnumCaseElementSyntax) -> String { + let caseName = element.name.identifier?.name ?? element.name.text + return caseName.prefix(1).uppercased() + caseName.dropFirst() +} + +/// Whether `type` must be parenthesized to be made optional. Types that the postfix `?` already +/// binds to are returned as-is; anything else (function, composition, `any`, attributed, … types) +/// is parenthesized so e.g. `(Int) -> Void` becomes `((Int) -> Void)?` rather than `(Int) -> Void?`. +private func needsParenthesesBeforeOptional(_ type: TypeSyntax) -> Bool { + switch type.as(TypeSyntaxEnum.self) { + case .identifierType, .memberType, .arrayType, .dictionaryType, .optionalType, + .implicitlyUnwrappedOptionalType, .tupleType, .metatypeType: + return false + default: + return true + } +} + +/// The enclosing enum's indentation and the file's inferred indentation step. +private func accessorIndentation(for enumDecl: EnumDeclSyntax) -> (base: Trivia, step: Trivia) { + let base = enumDecl.firstToken(viewMode: .sourceAccurate)?.indentationOfLine ?? [] + let step = BasicFormat.inferIndentation(of: enumDecl.root) ?? .spaces(4) + return (base: base, step: step) +} + +private func sourceEdits(insertingAccessors accessors: [String], into enumDecl: EnumDeclSyntax) throws -> [SourceEdit] { + guard !accessors.isEmpty else { + throw RefactoringNotApplicableError("No accessors to generate") + } + // Insert before the closing brace's leading trivia so the brace keeps its own + // newline and indentation (correct for both top-level and nested enums). + let insertion = "\n\n" + accessors.joined(separator: "\n\n") + let position = enumDecl.memberBlock.rightBrace.position + return [SourceEdit(range: position.. Void)2️⃣ + } + """, + markers: ["1️⃣"], + exhaustive: false + ) { uri, positions in + [ + CodeAction( + title: "Generate 'as' accessors for enum cases", + kind: .refactorInline, + diagnostics: nil, + edit: WorkspaceEdit( + changes: [ + uri: [ + TextEdit( + range: positions["2️⃣"].. Void)? { + if case let .handler(value) = self { return value } + return nil + } + """ + ) + ] + ] + ) + ) + ] + } + } + + func testGenerateEnumCaseAsAccessorsInfersIndentation() async throws { + try await assertCodeActions( + """ + 1️⃣enum Value { + case text(String) + case number(Int) + case other(Bool)2️⃣ + } + """, + markers: ["1️⃣"], + exhaustive: false + ) { uri, positions in + [ + CodeAction( + title: "Generate 'as' accessors for enum cases", + kind: .refactorInline, + diagnostics: nil, + edit: WorkspaceEdit( + changes: [ + uri: [ + TextEdit( + range: positions["2️⃣"].. Date: Mon, 29 Jun 2026 22:08:26 +0530 Subject: [PATCH 2/3] Document enum case accessor actions and rename their existing-names helper --- Documentation/Refactoring Actions.md | 2 ++ .../SwiftSyntaxCodeActions/GenerateEnumCaseAccessors.swift | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Documentation/Refactoring Actions.md b/Documentation/Refactoring Actions.md index d1c661656..97c318b86 100644 --- a/Documentation/Refactoring Actions.md +++ b/Documentation/Refactoring Actions.md @@ -81,6 +81,8 @@ The specific refactorings available depend on what code is selected or where the | **Move To Extension** | Select one or more member declarations inside a type which aren't stored properties | | **Convert To Computed Property** | Select a variable declaration with an initializer | | **Expand 'some' parameters to generic parameters** | Cursor on a function declaration using `some` opaque parameter types | +| **Generate 'as' accessors for enum cases** | Cursor inside an enum that has a case with associated values | +| **Generate 'is' accessors for enum cases** | Cursor inside an enum that has a case | ### Source Organization diff --git a/Sources/SwiftSyntaxCodeActions/GenerateEnumCaseAccessors.swift b/Sources/SwiftSyntaxCodeActions/GenerateEnumCaseAccessors.swift index de6d17638..86f137a9a 100644 --- a/Sources/SwiftSyntaxCodeActions/GenerateEnumCaseAccessors.swift +++ b/Sources/SwiftSyntaxCodeActions/GenerateEnumCaseAccessors.swift @@ -29,7 +29,7 @@ struct GenerateEnumCaseAsAccessors: SyntaxRefactoringCodeActionProvider { static func textRefactor(syntax: EnumDeclSyntax, in context: Void) throws -> [SourceEdit] { let indentation = accessorIndentation(for: syntax) - let existingNames = existingMemberNames(of: syntax) + let existingNames = existingVariableNames(of: syntax) var accessors: [String] = [] for element in enumCaseElements(of: syntax) { @@ -105,7 +105,7 @@ struct GenerateEnumCaseIsAccessors: SyntaxRefactoringCodeActionProvider { static func textRefactor(syntax: EnumDeclSyntax, in context: Void) throws -> [SourceEdit] { let indentation = accessorIndentation(for: syntax) - let existingNames = existingMemberNames(of: syntax) + let existingNames = existingVariableNames(of: syntax) let accessors = enumCaseElements(of: syntax).compactMap { element -> String? in let name = "is\(capitalizedCaseName(of: element))" @@ -148,7 +148,7 @@ private func enumCaseElements(of enumDecl: EnumDeclSyntax) -> [EnumCaseElementSy } /// The names of the enum's existing properties, so an accessor whose name is already taken isn't generated again. -private func existingMemberNames(of enumDecl: EnumDeclSyntax) -> Set { +private func existingVariableNames(of enumDecl: EnumDeclSyntax) -> Set { var names: Set = [] for member in enumDecl.memberBlock.members { guard let variable = member.decl.as(VariableDeclSyntax.self) else { continue } From c58835e1305f41ba27024b7fcae69384ce512406 Mon Sep 17 00:00:00 2001 From: ayush-that Date: Thu, 2 Jul 2026 21:22:41 +0530 Subject: [PATCH 3/3] Import ToolsProtocolsSwiftExtensions for its only helper --- Sources/SwiftSyntaxCodeActions/GenerateEnumCaseAccessors.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/SwiftSyntaxCodeActions/GenerateEnumCaseAccessors.swift b/Sources/SwiftSyntaxCodeActions/GenerateEnumCaseAccessors.swift index 86f137a9a..92e2c1fec 100644 --- a/Sources/SwiftSyntaxCodeActions/GenerateEnumCaseAccessors.swift +++ b/Sources/SwiftSyntaxCodeActions/GenerateEnumCaseAccessors.swift @@ -14,6 +14,7 @@ import SwiftBasicFormat import SwiftExtensions import SwiftRefactor import SwiftSyntax +@_spi(SourceKitLSP) import ToolsProtocolsSwiftExtensions /// Syntactic code action that adds an `as` computed property for every /// enum case with associated values, returning the value(s) as an optional.