├── .gitignore ├── Sources ├── Macros │ ├── Extensions │ │ └── TokenKind+keyword.swift │ ├── AssociatedValues │ │ ├── AssociatedValuesMacroError.swift │ │ └── AssociatedValuesMacro.swift │ ├── Plugins.swift │ ├── Singleton │ │ ├── SingletonMacroError.swift │ │ └── SingletonMacro.swift │ ├── Symbol │ │ ├── SymbolMacroError.swift │ │ └── SymbolMacro.swift │ └── URL │ │ ├── URLMacroError.swift │ │ └── URLMacro.swift └── SwiftMacros │ └── SwiftMacros.swift ├── Tests └── MacroTests │ ├── DefineMacros.swift │ ├── SymbolMacroTests.swift │ ├── URLMacroTests.swift │ ├── SingletonMacroTests.swift │ └── AssociatedValuesMacroTests.swift ├── Package.resolved ├── .github └── workflows │ └── swift.yml ├── LICENSE ├── Package.swift └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | xcuserdata/ 4 | DerivedData/ 5 | .swiftpm -------------------------------------------------------------------------------- /Sources/Macros/Extensions/TokenKind+keyword.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | 3 | extension TokenKind { 4 | var keyword: Keyword? { 5 | switch self { 6 | case let .keyword(keyword): 7 | return keyword 8 | default: 9 | return nil 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/Macros/AssociatedValues/AssociatedValuesMacroError.swift: -------------------------------------------------------------------------------- 1 | enum AssociatedValuesMacroError: Error, CustomStringConvertible { 2 | case notAEnum 3 | 4 | var description: String { 5 | switch self { 6 | case .notAEnum: 7 | "Can only be applied to enum" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/Macros/Plugins.swift: -------------------------------------------------------------------------------- 1 | import SwiftCompilerPlugin 2 | import SwiftSyntaxMacros 3 | 4 | @main 5 | struct Plugins: CompilerPlugin { 6 | let providingMacros: [Macro.Type] = [ 7 | URLMacro.self, 8 | SymbolMacro.self, 9 | AssociatedValuesMacro.self, 10 | SingletonMacro.self 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /Sources/Macros/Singleton/SingletonMacroError.swift: -------------------------------------------------------------------------------- 1 | enum SingletonMacroError: Error, CustomStringConvertible { 2 | case notAStructOrClass 3 | 4 | var description: String { 5 | switch self { 6 | case .notAStructOrClass: 7 | "Can only be applied to a struct or class" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Tests/MacroTests/DefineMacros.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftSyntaxMacros 3 | 4 | #if canImport(Macros) 5 | @testable import Macros 6 | 7 | let testMacros: [String: Macro.Type] = [ 8 | "symbol": SymbolMacro.self, 9 | "URL": URLMacro.self, 10 | "AssociatedValues": AssociatedValuesMacro.self, 11 | "Singleton": SingletonMacro.self 12 | ] 13 | #endif 14 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-syntax", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/swiftlang/swift-syntax", 7 | "state" : { 8 | "revision" : "4799286537280063c85a32f09884cfbca301b1a1", 9 | "version" : "602.0.0" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Macros/Symbol/SymbolMacroError.swift: -------------------------------------------------------------------------------- 1 | enum SymbolMacroError: Error, CustomStringConvertible { 2 | case invalidSymbol(name: String) 3 | case parseName 4 | 5 | var description: String { 6 | switch self { 7 | case .parseName: 8 | "Cannot parse SF Symbol name" 9 | case .invalidSymbol(let name): 10 | "\"\(name)\" is not a valid symbol" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | on: 3 | push: 4 | branches: ["main"] 5 | pull_request: 6 | branches: ["main"] 7 | jobs: 8 | build: 9 | name: Build and test 10 | runs-on: macos-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Build 14 | run: swift build --build-tests -v 15 | - name: Run tests 16 | run: swift test -v 17 | 18 | -------------------------------------------------------------------------------- /Sources/Macros/URL/URLMacroError.swift: -------------------------------------------------------------------------------- 1 | enum URLMacroError: Error, CustomStringConvertible { 2 | case requiresStaticStringLiteral 3 | case malformedURL(urlString: String) 4 | 5 | var description: String { 6 | switch self { 7 | case .requiresStaticStringLiteral: 8 | "#URL requires a static string literal" 9 | case .malformedURL(let urlString): 10 | "The input URL is malformed: \(urlString)" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/SwiftMacros/SwiftMacros.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @freestanding(expression) 4 | public macro URL(_ string: String) -> URL = #externalMacro(module: "Macros", type: "URLMacro") 5 | 6 | @freestanding(expression) 7 | public macro symbol(_ name: String) -> String = #externalMacro(module: "Macros", type: "SymbolMacro") 8 | 9 | @attached(member, names: arbitrary) 10 | public macro AssociatedValues() = #externalMacro(module: "Macros", type: "AssociatedValuesMacro") 11 | 12 | @attached(member, names: named(init), named(shared)) 13 | public macro Singleton() = #externalMacro(module: "Macros", type: "SingletonMacro") 14 | -------------------------------------------------------------------------------- /Sources/Macros/URL/URLMacro.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntaxMacros 2 | import SwiftSyntax 3 | import Foundation 4 | 5 | struct URLMacro: ExpressionMacro { 6 | static func expansion( 7 | of node: some FreestandingMacroExpansionSyntax, 8 | in context: some MacroExpansionContext 9 | ) throws -> ExprSyntax { 10 | guard 11 | let argument = node.arguments.first?.expression, 12 | let segments = argument.as(StringLiteralExprSyntax.self)?.segments, 13 | segments.count == 1, 14 | case .stringSegment(let literalSegment)? = segments.first 15 | else { 16 | throw URLMacroError.requiresStaticStringLiteral 17 | } 18 | 19 | guard let _ = URL(string: literalSegment.content.text) else { 20 | throw URLMacroError.malformedURL(urlString: "\(argument)") 21 | } 22 | 23 | return "URL(string: \(argument))!" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Macros/Symbol/SymbolMacro.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntaxMacros 2 | import SwiftSyntax 3 | import SwiftUI 4 | 5 | struct SymbolMacro: ExpressionMacro { 6 | static func expansion( 7 | of node: some FreestandingMacroExpansionSyntax, 8 | in context: some MacroExpansionContext 9 | ) throws -> ExprSyntax { 10 | guard 11 | let argument = node.arguments.first?.expression, 12 | let segments = argument.as(StringLiteralExprSyntax.self)?.segments, 13 | segments.count == 1, 14 | case .stringSegment(let literalSegment)? = segments.first 15 | else { 16 | throw SymbolMacroError.parseName 17 | } 18 | 19 | try verifySymbol(name: literalSegment.content.text) 20 | return "\"\(raw: literalSegment.content.text)\"" 21 | } 22 | 23 | private static func verifySymbol(name: String) throws { 24 | #if canImport(UIKit) 25 | if let _ = UIImage(systemName: name) { return } 26 | #else 27 | if let _ = NSImage(systemSymbolName: name, accessibilityDescription: nil) { return } 28 | #endif 29 | throw SymbolMacroError.invalidSymbol(name: name) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Andrea Bernardini 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | import CompilerPluginSupport 6 | 7 | let package = Package( 8 | name: "SwiftMacros", 9 | platforms: [ 10 | .macOS(.v12), 11 | .iOS(.v13), 12 | ], 13 | products: [ 14 | .library( 15 | name: "SwiftMacros", 16 | targets: [ 17 | "SwiftMacros" 18 | ] 19 | ) 20 | ], 21 | dependencies: [ 22 | .package(url: "https://github.com/swiftlang/swift-syntax", "509.0.0"..<"603.0.0") 23 | ], 24 | targets: [ 25 | .macro( 26 | name: "Macros", 27 | dependencies: [ 28 | .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), 29 | .product(name: "SwiftCompilerPlugin", package: "swift-syntax") 30 | ] 31 | ), 32 | .target( 33 | name: "SwiftMacros", 34 | dependencies: [ 35 | "Macros" 36 | ] 37 | ), 38 | .testTarget( 39 | name: "MacroTests", 40 | dependencies: [ 41 | "Macros", 42 | .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax") 43 | ] 44 | ) 45 | ] 46 | ) 47 | -------------------------------------------------------------------------------- /Tests/MacroTests/SymbolMacroTests.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Macros) 2 | import SwiftSyntaxMacros 3 | import SwiftSyntaxMacrosTestSupport 4 | import Testing 5 | 6 | @Suite("Symbol Macro") 7 | struct SymbolMacroTests { 8 | @Test 9 | func validSymbol() { 10 | assertMacroExpansion( 11 | """ 12 | #symbol("swift") 13 | """, 14 | expandedSource: 15 | """ 16 | "swift" 17 | """, 18 | macros: testMacros 19 | ) 20 | } 21 | 22 | @Test 23 | func invalidSymbol() { 24 | assertMacroExpansion( 25 | """ 26 | #symbol("test") 27 | """, 28 | expandedSource: 29 | """ 30 | #symbol("test") 31 | """, 32 | diagnostics: [ 33 | DiagnosticSpec(message: #""test" is not a valid symbol"#, line: 1, column: 1) 34 | ], 35 | macros: testMacros 36 | ) 37 | } 38 | 39 | @Test 40 | func parseSymbolNameWithError() { 41 | assertMacroExpansion( 42 | #""" 43 | #symbol("\("swift")") 44 | """#, 45 | expandedSource: 46 | #""" 47 | #symbol("\("swift")") 48 | """#, 49 | diagnostics: [ 50 | DiagnosticSpec(message: #"Cannot parse SF Symbol name"#, line: 1, column: 1) 51 | ], 52 | macros: testMacros 53 | ) 54 | } 55 | } 56 | #endif 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Swift Macros 2 | Contains a collection of useful macros for my personal projects. 3 | 4 | ## Usage 5 | ### Symbol 6 | ```swift 7 | let symbol = #symbol("swift") // Macro expands to "swift" 8 | ``` 9 | > In case the provided value is not a valid SF Symbol, Xcode will show a compile error. 10 | 11 | ### URL 12 | ```swift 13 | let url = #URL("https://www.swift.org") // Macro expands to URL(string: "https://www.swift.org")! 14 | ``` 15 | > In case the provided value is not a valid URL, Xcode will show a compile error. 16 | 17 | ### AssociatedValues 18 | Add variables to retrieve the associated values. 19 | 20 | ```swift 21 | @AssociatedValues 22 | enum Barcode { 23 | case upc(Int, Int, Int, Int) 24 | case qrCode(String) 25 | } 26 | 27 | // Expands to 28 | enum Barcode { 29 | ... 30 | 31 | var upcValue: (Int, Int, Int, Int)? { 32 | if case let .upc(v0, v1, v2, v3) = self { 33 | return (v0, v1, v2, v3) 34 | } 35 | return nil 36 | } 37 | 38 | var qrCodeValue: (String)? { 39 | if case let .qrCode(v0) = self { 40 | return v0 41 | } 42 | return nil 43 | } 44 | } 45 | ``` 46 | 47 | ### Singleton 48 | Generate singleton code for struct and class 49 | 50 | ```swift 51 | @Singleton 52 | struct UserStore { 53 | } 54 | 55 | // Expands to 56 | struct UserStore { 57 | static let shared = UserStore() 58 | 59 | private init() { 60 | } 61 | } 62 | ``` 63 | -------------------------------------------------------------------------------- /Sources/Macros/Singleton/SingletonMacro.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import SwiftSyntaxBuilder 3 | import SwiftSyntaxMacros 4 | 5 | struct SingletonMacro: MemberMacro { 6 | static func expansion( 7 | of node: AttributeSyntax, 8 | providingMembersOf declaration: some DeclGroupSyntax, 9 | conformingTo _: [TypeSyntax], 10 | in context: some MacroExpansionContext 11 | ) throws -> [DeclSyntax] { 12 | guard [SwiftSyntax.SyntaxKind.classDecl, .structDecl].contains(declaration.kind) else { 13 | throw SingletonMacroError.notAStructOrClass 14 | } 15 | 16 | let nameOfDecl = try name(providingMembersOf: declaration).text 17 | let isPublic = declaration.modifiers.map(\.name.tokenKind.keyword).contains(.public) 18 | let initializer = try InitializerDeclSyntax("private init()") {} 19 | 20 | let shared = "\(isPublic ? "public" : "") static let shared = \(nameOfDecl)()" 21 | 22 | return [ 23 | DeclSyntax(stringLiteral: shared), 24 | DeclSyntax(initializer), 25 | ] 26 | } 27 | } 28 | 29 | private extension SingletonMacro { 30 | static func name(providingMembersOf declaration: some DeclGroupSyntax) throws -> TokenSyntax { 31 | if let classDecl = declaration.as(ClassDeclSyntax.self) { 32 | classDecl.name 33 | } else if let structDecl = declaration.as(StructDeclSyntax.self) { 34 | structDecl.name 35 | } else { 36 | throw SingletonMacroError.notAStructOrClass 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Tests/MacroTests/URLMacroTests.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Macros) 2 | import SwiftSyntaxMacros 3 | import SwiftSyntaxMacrosTestSupport 4 | import Testing 5 | 6 | @Suite("URL Macro") 7 | struct URLMacroTests { 8 | @Test 9 | func validURL() { 10 | assertMacroExpansion( 11 | """ 12 | #URL("https://www.apple.com") 13 | """, 14 | expandedSource: 15 | """ 16 | URL(string: "https://www.apple.com")! 17 | """, 18 | macros: testMacros 19 | ) 20 | } 21 | 22 | @Test 23 | func UrlStringLiteralError() { 24 | assertMacroExpansion( 25 | #""" 26 | #URL("https://www.apple.com\(Int.random())") 27 | """#, 28 | expandedSource: 29 | #""" 30 | #URL("https://www.apple.com\(Int.random())") 31 | """#, 32 | diagnostics: [ 33 | DiagnosticSpec(message: "#URL requires a static string literal", line: 1, column: 1) 34 | ], 35 | macros: testMacros 36 | ) 37 | } 38 | 39 | @Test 40 | func malformedURLError() { 41 | assertMacroExpansion( 42 | """ 43 | #URL("https://www. apple.com") 44 | """, 45 | expandedSource: 46 | """ 47 | #URL("https://www. apple.com") 48 | """, 49 | diagnostics: [ 50 | DiagnosticSpec(message: #"The input URL is malformed: "https://www. apple.com""#, line: 1, column: 1) 51 | ], 52 | macros: testMacros 53 | ) 54 | } 55 | } 56 | #endif 57 | -------------------------------------------------------------------------------- /Sources/Macros/AssociatedValues/AssociatedValuesMacro.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import SwiftSyntaxBuilder 3 | import SwiftSyntaxMacros 4 | 5 | struct AssociatedValuesMacro: MemberMacro { 6 | static func expansion( 7 | of node: AttributeSyntax, 8 | providingMembersOf declaration: some DeclGroupSyntax, 9 | conformingTo _: [TypeSyntax], 10 | in context: some MacroExpansionContext 11 | ) throws -> [DeclSyntax] { 12 | guard let enumDecl = declaration.as(EnumDeclSyntax.self) else { 13 | throw AssociatedValuesMacroError.notAEnum 14 | } 15 | 16 | return try enumDecl.memberBlock.members.compactMap { 17 | $0.decl.as(EnumCaseDeclSyntax.self)? 18 | .elements 19 | .compactMap { $0 } 20 | } 21 | .reduce([], +) 22 | .compactMap { element -> DeclSyntax? in 23 | guard let associatedValue = element.parameterClause else { 24 | return nil 25 | } 26 | 27 | let variableNames = associatedValue 28 | .parameters 29 | .enumerated() 30 | .map { index, _ in 31 | "v\(index)" 32 | } 33 | .joined(separator: ", ") 34 | 35 | return DeclSyntax( 36 | try VariableDeclSyntax("\(declaration.modifiers)var \(element.name)Value: \(raw: associatedValue)?") { 37 | try IfExprSyntax("if case let .\(element.name)(\(raw: variableNames)) = self") { 38 | if associatedValue.parameters.count == 1 { 39 | "return \(raw: variableNames)" 40 | } else { 41 | "return (\(raw: variableNames))" 42 | } 43 | } 44 | "return nil" 45 | } 46 | ) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Tests/MacroTests/SingletonMacroTests.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Macros) 2 | import SwiftSyntaxMacros 3 | import SwiftSyntaxMacrosTestSupport 4 | import Testing 5 | 6 | @Suite("Singleton Macro") 7 | struct SingletonMacroTests { 8 | @Test 9 | func generateAPublicSignletonProperty() { 10 | assertMacroExpansion( 11 | """ 12 | @Singleton 13 | public struct UserStore { 14 | } 15 | """, 16 | expandedSource: 17 | """ 18 | public struct UserStore { 19 | 20 | public static let shared = UserStore() 21 | 22 | private init() { 23 | } 24 | } 25 | """, 26 | macros: testMacros, 27 | indentationWidth: .spaces(2) 28 | ) 29 | } 30 | 31 | @Test 32 | func generateASignletonProperty() { 33 | assertMacroExpansion( 34 | """ 35 | @Singleton 36 | class UserStore { 37 | } 38 | """, 39 | expandedSource: 40 | """ 41 | class UserStore { 42 | 43 | static let shared = UserStore() 44 | 45 | private init() { 46 | } 47 | } 48 | """, 49 | macros: testMacros, 50 | indentationWidth: .spaces(2) 51 | ) 52 | } 53 | 54 | @Test 55 | func macroIsOnlySupportClassOrStruct() { 56 | assertMacroExpansion( 57 | """ 58 | @Singleton 59 | enum UserStore { 60 | } 61 | """, 62 | expandedSource: 63 | """ 64 | enum UserStore { 65 | } 66 | """, 67 | diagnostics: [ 68 | DiagnosticSpec(message: "Can only be applied to a struct or class", line: 1, column: 1) 69 | ], 70 | macros: testMacros 71 | ) 72 | } 73 | } 74 | #endif 75 | -------------------------------------------------------------------------------- /Tests/MacroTests/AssociatedValuesMacroTests.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Macros) 2 | import SwiftSyntaxMacros 3 | import SwiftSyntaxMacrosTestSupport 4 | import Testing 5 | 6 | @Suite("Associated Values Macro") 7 | struct AssociatedValuesMacroTests { 8 | @Test 9 | func generateVarForAssociatedValues() { 10 | assertMacroExpansion( 11 | """ 12 | @AssociatedValues 13 | enum SomeEnum { 14 | case none 15 | case labeledValue(a: String, b: String) 16 | case optional(String?) 17 | case value(Int) 18 | case closure(() -> Void) 19 | indirect case someEnum(SomeEnum) 20 | } 21 | """, 22 | expandedSource: 23 | """ 24 | enum SomeEnum { 25 | case none 26 | case labeledValue(a: String, b: String) 27 | case optional(String?) 28 | case value(Int) 29 | case closure(() -> Void) 30 | indirect case someEnum(SomeEnum) 31 | 32 | var labeledValueValue: (a: String, b: String)? { 33 | if case let .labeledValue(v0, v1) = self { 34 | return (v0, v1) 35 | } 36 | return nil 37 | } 38 | 39 | var optionalValue: (String?)? { 40 | if case let .optional(v0) = self { 41 | return v0 42 | } 43 | return nil 44 | } 45 | 46 | var valueValue: (Int)? { 47 | if case let .value(v0) = self { 48 | return v0 49 | } 50 | return nil 51 | } 52 | 53 | var closureValue: (() -> Void)? { 54 | if case let .closure(v0) = self { 55 | return v0 56 | } 57 | return nil 58 | } 59 | 60 | var someEnumValue: (SomeEnum)? { 61 | if case let .someEnum(v0) = self { 62 | return v0 63 | } 64 | return nil 65 | } 66 | } 67 | """, 68 | macros: testMacros, 69 | indentationWidth: .spaces(2) 70 | ) 71 | } 72 | 73 | @Test 74 | func macroIsOnlySupportEnum() { 75 | assertMacroExpansion( 76 | #""" 77 | @AssociatedValues 78 | struct SomeStructure { 79 | 80 | } 81 | """#, 82 | expandedSource: 83 | #""" 84 | struct SomeStructure { 85 | 86 | } 87 | """#, 88 | diagnostics: [ 89 | DiagnosticSpec(message: #"Can only be applied to enum"#, line: 1, column: 1) 90 | ], 91 | macros: testMacros 92 | ) 93 | } 94 | } 95 | #endif 96 | --------------------------------------------------------------------------------