├── Makefile ├── .editorconfig ├── .gitignore ├── Tests └── MacroTestingTests │ ├── BaseTestCase.swift │ ├── FuncUniqueMacroTests.swift │ ├── EquatableExtensionMacroTests.swift │ ├── StringifyMacroTests.swift │ ├── FontLiteralMacroTests.swift │ ├── MacroExamples │ ├── EntryMacro.swift │ ├── MemberDeprecatedMacro.swift │ ├── FuncUniqueMacro.swift │ ├── Diagnostics.swift │ ├── PeerValueWithSuffixMacro.swift │ ├── EquatableExtensionMacro.swift │ ├── StringifyMacro.swift │ ├── URLMacro.swift │ ├── CaseDetectionMacro.swift │ ├── FontLiteralMacro.swift │ ├── WarningMacro.swift │ ├── DiagnosticsAndFixitsEmitterMacro.swift │ ├── CustomCodable.swift │ ├── NewTypeMacro.swift │ ├── DefaultFatalErrorImplementationMacro.swift │ ├── DictionaryIndirectionMacro.swift │ ├── AddBlocker.swift │ ├── WrapStoredPropertiesMacro.swift │ ├── MetaEnumMacro.swift │ ├── ObservableMacro.swift │ ├── AddCompletionHandlerMacro.swift │ ├── AddAsyncMacro.swift │ └── OptionSetMacro.swift │ ├── CaseDetectionMacroTests.swift │ ├── AddBlockerTests.swift │ ├── AssertMacroTests.swift │ ├── MemberDeprecatedMacroTests.swift │ ├── DiagnosticsAndFixitsEmitterMacroTests.swift │ ├── EntryMacroTests.swift │ ├── URLMacroTests.swift │ ├── PeerValueWithSuffixMacroTests.swift │ ├── MacroNameTests.swift │ ├── WarningMacroTests.swift │ ├── CustomCodableMacroTests.swift │ ├── SwiftTestingTests.swift │ ├── DefaultFatalErrorImplementationMacroTests.swift │ ├── NewTypeMacroTests.swift │ ├── WrapStoredPropertiesMacroTests.swift │ ├── DictionaryStorageMacroTests.swift │ ├── IndentationWidthTests.swift │ ├── FixItTests.swift │ ├── AddCompletionHandlerTests.swift │ ├── MetaEnumMacroTests.swift │ ├── AddAsyncTests.swift │ ├── ObservableMacroTests.swift │ └── OptionSetMacroTests.swift ├── .swiftpm ├── xcode │ └── package.xcworkspace │ │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── configuration │ └── Package.resolved ├── .spi.yml ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ └── bug_report.yml ├── workflows │ ├── format.yml │ ├── ci.yml │ └── release.yml └── CODE_OF_CONDUCT.md ├── Sources └── MacroTesting │ ├── Documentation.docc │ ├── AssertMacro.md │ ├── WithMacroTesting.md │ └── MacroTesting.md │ ├── Internal │ ├── RecordIssue.swift │ ├── Diagnostic+UnderlineHighlights.swift │ └── Deprecations.swift │ ├── SwiftSyntax │ └── SourceEdit.swift │ ├── _SwiftSyntaxTestSupport │ └── FixItApplier.swift │ ├── MacrosTestTrait.swift │ └── SwiftDiagnostics │ └── DiagnosticsFormatter.swift ├── LICENSE ├── Package@swift-5.9.swift ├── Package.swift ├── Package.resolved └── README.md /Makefile: -------------------------------------------------------------------------------- 1 | format: 2 | swift format \ 3 | --ignore-unparsable-files \ 4 | --in-place \ 5 | --recursive \ 6 | ./Package.swift ./Sources ./Tests 7 | 8 | .PHONY: format 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/BaseTestCase.swift: -------------------------------------------------------------------------------- 1 | import MacroTesting 2 | import XCTest 3 | 4 | class BaseTestCase: XCTestCase { 5 | override func invokeTest() { 6 | withMacroTesting( 7 | record: .missing 8 | ) { 9 | super.invokeTest() 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | # This is manifest file for the Swift Package Index for it to auto-generate and 2 | # host DocC documentation. 3 | # 4 | # For reference see https://swiftpackageindex.com/swiftpackageindex/spimanifest/documentation/spimanifest/commonusecases#Host-DocC-documentation-in-the-Swift-Package-Index 5 | 6 | version: 1 7 | builder: 8 | configs: 9 | - documentation_targets: 10 | # First item in the list is the "landing" (default) target 11 | - MacroTesting 12 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/FuncUniqueMacroTests.swift: -------------------------------------------------------------------------------- 1 | import MacroTesting 2 | import XCTest 3 | 4 | final class FuncUniqueMacroTests: BaseTestCase { 5 | override func invokeTest() { 6 | withMacroTesting( 7 | macros: [FuncUniqueMacro.self] 8 | ) { 9 | super.invokeTest() 10 | } 11 | } 12 | 13 | func testExpansionCreatesDeclarationWithUniqueFunction() { 14 | assertMacro { 15 | """ 16 | #FuncUnique() 17 | """ 18 | } expansion: { 19 | """ 20 | class MyClass { 21 | func __macro_local_6uniquefMu_() { 22 | } 23 | } 24 | """ 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | 3 | contact_links: 4 | - name: Project Discussion 5 | url: https://github.com/pointfreeco/swift-macro-testing/discussions 6 | about: Library Q&A, ideas, and more 7 | - name: Documentation 8 | url: https://swiftpackageindex.com/pointfreeco/swift-macro-testing/main/documentation 9 | about: Read the documentation 10 | - name: Videos 11 | url: https://www.pointfree.co/episodes/ep250-testing-debugging-macros-part-1 12 | about: Watch videos to get a behind-the-scenes look at how this library was motivated and built 13 | - name: Slack 14 | url: https://www.pointfree.co/slack-invite 15 | about: Community chat 16 | -------------------------------------------------------------------------------- /.swiftpm/configuration/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-snapshot-testing", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/pointfreeco/swift-snapshot-testing", 7 | "state" : { 8 | "revision" : "6d932a79e7173b275b96c600c86c603cf84f153c", 9 | "version" : "1.17.4" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-syntax", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/swiftlang/swift-syntax", 16 | "state" : { 17 | "revision" : "303e5c5c36d6a558407d364878df131c3546fad8", 18 | "version" : "510.0.2" 19 | } 20 | } 21 | ], 22 | "version" : 2 23 | } 24 | -------------------------------------------------------------------------------- /Sources/MacroTesting/Documentation.docc/AssertMacro.md: -------------------------------------------------------------------------------- 1 | # ``MacroTesting/assertMacro(_:indentationWidth:record:of:diagnostics:fixes:expansion:fileID:file:function:line:column:)-8zqk4`` 2 | 3 | ## Topics 4 | 5 | ### Overloads 6 | 7 | - ``assertMacro(_:record:of:matches:fileID:file:function:line:column:)-2wi38`` 8 | - ``assertMacro(_:indentationWidth:record:of:diagnostics:fixes:expansion:fileID:file:function:line:column:)-90l38`` 9 | 10 | ### Deprecations 11 | 12 | - ``assertMacro(_:record:of:matches:fileID:file:function:line:column:)-6vxvm`` 13 | - ``assertMacro(_:applyFixIts:record:of:matches:fileID:file:function:line:column:)-4381w`` 14 | - ``assertMacro(_:applyFixIts:record:of:matches:fileID:file:function:line:column:)-9mzoj`` 15 | -------------------------------------------------------------------------------- /Sources/MacroTesting/Documentation.docc/WithMacroTesting.md: -------------------------------------------------------------------------------- 1 | # ``MacroTesting/withMacroTesting(indentationWidth:record:macros:operation:)-6ayf5`` 2 | 3 | ## Topics 4 | 5 | ### Overloads 6 | 7 | - ``withMacroTesting(indentationWidth:record:macros:operation:)-7cm1s`` 8 | - ``withMacroTesting(indentationWidth:record:macros:operation:)-5a7qi`` 9 | - ``withMacroTesting(indentationWidth:record:macros:operation:)-9ghea`` 10 | 11 | ### Deprecations 12 | 13 | - ``withMacroTesting(indentationWidth:isRecording:macros:operation:)-1yql2`` 14 | - ``withMacroTesting(indentationWidth:isRecording:macros:operation:)-9du8s`` 15 | - ``withMacroTesting(indentationWidth:isRecording:macros:operation:)-91prk`` 16 | - ``withMacroTesting(indentationWidth:isRecording:macros:operation:)-5id9j`` 17 | -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: Format 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: 9 | group: format-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | swift_format: 14 | name: swift-format 15 | runs-on: macos-13 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Xcode Select 19 | run: sudo xcode-select -s /Applications/Xcode_14.3.1.app 20 | - name: Tap 21 | run: brew install swift-format 22 | - name: Format 23 | run: make format 24 | - uses: stefanzweifel/git-auto-commit-action@v4 25 | with: 26 | commit_message: Run swift-format 27 | branch: 'main' 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/EquatableExtensionMacroTests.swift: -------------------------------------------------------------------------------- 1 | import MacroTesting 2 | import XCTest 3 | 4 | final class EquatableExtensionMacroTests: XCTestCase { 5 | override func invokeTest() { 6 | withMacroTesting(macros: ["equatable": EquatableExtensionMacro.self]) { 7 | super.invokeTest() 8 | } 9 | } 10 | 11 | func testExpansionAddsExtensionWithEquatableConformance() { 12 | assertMacro { 13 | """ 14 | @equatable 15 | final public class Message { 16 | let text: String 17 | let sender: String 18 | } 19 | """ 20 | } expansion: { 21 | """ 22 | final public class Message { 23 | let text: String 24 | let sender: String 25 | } 26 | 27 | extension Message: Equatable { 28 | } 29 | """ 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/StringifyMacroTests.swift: -------------------------------------------------------------------------------- 1 | import MacroTesting 2 | import XCTest 3 | 4 | final class StringifyMacroTests: BaseTestCase { 5 | override func invokeTest() { 6 | withMacroTesting(macros: [StringifyMacro.self]) { 7 | super.invokeTest() 8 | } 9 | } 10 | 11 | func testExpansionWithBasicArithmeticExpression() { 12 | assertMacro { 13 | """ 14 | let a = #stringify(x + y) 15 | """ 16 | } expansion: { 17 | """ 18 | let a = (x + y, "x + y") 19 | """ 20 | } 21 | } 22 | 23 | func testExpansionWithStringInterpolation() { 24 | assertMacro { 25 | #""" 26 | let b = #stringify("Hello, \(name)") 27 | """# 28 | } expansion: { 29 | #""" 30 | let b = ("Hello, \(name)", #""Hello, \(name)""#) 31 | """# 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/MacroTesting/Internal/RecordIssue.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if canImport(Testing) 4 | import Testing 5 | #endif 6 | 7 | @_spi(Internals) 8 | public func recordIssue( 9 | _ message: @autoclosure () -> String, 10 | fileID: StaticString, 11 | filePath: StaticString, 12 | line: UInt, 13 | column: UInt 14 | ) { 15 | #if canImport(Testing) 16 | if Test.current != nil { 17 | Issue.record( 18 | Comment(rawValue: message()), 19 | sourceLocation: SourceLocation( 20 | fileID: fileID.description, 21 | filePath: filePath.description, 22 | line: Int(line), 23 | column: Int(column) 24 | ) 25 | ) 26 | } else { 27 | XCTFail(message(), file: filePath, line: line) 28 | } 29 | #else 30 | XCTFail(message(), file: filePath, line: line) 31 | #endif 32 | } 33 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/FontLiteralMacroTests.swift: -------------------------------------------------------------------------------- 1 | import MacroTesting 2 | import XCTest 3 | 4 | final class FontLiteralMacroTests: BaseTestCase { 5 | override func invokeTest() { 6 | withMacroTesting(macros: [FontLiteralMacro.self]) { 7 | super.invokeTest() 8 | } 9 | } 10 | 11 | func testExpansionWithNamedArguments() { 12 | assertMacro { 13 | """ 14 | #fontLiteral(name: "Comic Sans", size: 14, weight: .thin) 15 | """ 16 | } expansion: { 17 | """ 18 | .init(fontLiteralName: "Comic Sans", size: 14, weight: .thin) 19 | """ 20 | } 21 | } 22 | 23 | func testExpansionWithUnlabeledFirstArgument() { 24 | assertMacro { 25 | """ 26 | #fontLiteral("Copperplate Gothic", size: 69, weight: .bold) 27 | """ 28 | } expansion: { 29 | """ 30 | .init(fontLiteralName: "Copperplate Gothic", size: 69, weight: .bold) 31 | """ 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/MacroExamples/EntryMacro.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import SwiftSyntaxMacros 3 | 4 | // Not complete, just enough to unit test lexical context. 5 | public struct EntryMacro: AccessorMacro { 6 | public static func expansion( 7 | of node: AttributeSyntax, 8 | providingAccessorsOf declaration: some DeclSyntaxProtocol, 9 | in context: some MacroExpansionContext 10 | ) throws -> [AccessorDeclSyntax] { 11 | let isInEnvironmentValues = context.lexicalContext.contains { lexicalContext in 12 | lexicalContext.as(ExtensionDeclSyntax.self)?.extendedType.trimmedDescription 13 | == "EnvironmentValues" 14 | } 15 | 16 | guard isInEnvironmentValues else { 17 | throw MacroExpansionErrorMessage( 18 | "'@Entry' macro can only attach to var declarations inside extensions of EnvironmentValues") 19 | } 20 | 21 | return [ 22 | AccessorDeclSyntax(accessorSpecifier: .keyword(.get)) { 23 | "fatalError()" 24 | } 25 | ] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/CaseDetectionMacroTests.swift: -------------------------------------------------------------------------------- 1 | import MacroTesting 2 | import XCTest 3 | 4 | final class CaseDetectionMacroTests: BaseTestCase { 5 | override func invokeTest() { 6 | withMacroTesting(macros: [CaseDetectionMacro.self]) { 7 | super.invokeTest() 8 | } 9 | } 10 | 11 | func testExpansionAddsComputedProperties() { 12 | assertMacro { 13 | """ 14 | @CaseDetection 15 | enum Animal { 16 | case dog 17 | case cat(curious: Bool) 18 | } 19 | """ 20 | } expansion: { 21 | """ 22 | enum Animal { 23 | case dog 24 | case cat(curious: Bool) 25 | 26 | var isDog: Bool { 27 | if case .dog = self { 28 | return true 29 | } 30 | 31 | return false 32 | } 33 | 34 | var isCat: Bool { 35 | if case .cat = self { 36 | return true 37 | } 38 | 39 | return false 40 | } 41 | } 42 | """ 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/AddBlockerTests.swift: -------------------------------------------------------------------------------- 1 | import MacroTesting 2 | import XCTest 3 | 4 | final class AddBlockerTests: BaseTestCase { 5 | override func invokeTest() { 6 | withMacroTesting(macros: [AddBlocker.self]) { 7 | super.invokeTest() 8 | } 9 | } 10 | 11 | func testExpansionTransformsAdditionToSubtractionAndEmitsWarning() { 12 | assertMacro { 13 | """ 14 | #addBlocker(x * y + z) 15 | """ 16 | } diagnostics: { 17 | """ 18 | #addBlocker(x * y + z) 19 | ───── ┬ ─ 20 | ╰─ ⚠️ blocked an add; did you mean to subtract? 21 | ✏️ use '-' 22 | """ 23 | } fixes: { 24 | """ 25 | #addBlocker(x * y - z) 26 | """ 27 | } expansion: { 28 | """ 29 | x * y - z 30 | """ 31 | } 32 | } 33 | 34 | func testExpansionPreservesSubtraction() { 35 | assertMacro { 36 | """ 37 | #addBlocker(x * y - z) 38 | """ 39 | } expansion: { 40 | """ 41 | x * y - z 42 | """ 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/AssertMacroTests.swift: -------------------------------------------------------------------------------- 1 | import MacroTesting 2 | import XCTest 3 | 4 | final class AssertMacroTests: BaseTestCase { 5 | #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || os(watchOS) 6 | func testMacrosRequired() { 7 | XCTExpectFailure { 8 | assertMacro { 9 | """ 10 | #forgotToConfigure() 11 | """ 12 | } 13 | } issueMatcher: { 14 | $0.compactDescription == """ 15 | failed - No macros configured for this assertion. Pass a mapping to this function, e.g.: 16 | 17 | assertMacro(["stringify": StringifyMacro.self]) { … } 18 | 19 | Or wrap your assertion using 'withMacroTesting', e.g. in 'invokeTest': 20 | 21 | class StringifyMacroTests: XCTestCase { 22 | override func invokeTest() { 23 | withMacroTesting(macros: ["stringify": StringifyMacro.self]) { 24 | super.invokeTest() 25 | } 26 | } 27 | … 28 | } 29 | """ 30 | } 31 | } 32 | #endif 33 | } 34 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/MacroExamples/MemberDeprecatedMacro.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2014 - 2023 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 SwiftSyntax 14 | import SwiftSyntaxMacros 15 | 16 | /// Add '@available(*, deprecated)' to members. 17 | public enum MemberDeprecatedMacro: MemberAttributeMacro { 18 | public static func expansion( 19 | of node: AttributeSyntax, 20 | attachedTo declaration: some DeclGroupSyntax, 21 | providingAttributesFor member: some DeclSyntaxProtocol, 22 | in context: some MacroExpansionContext 23 | ) throws -> [AttributeSyntax] { 24 | return ["@available(*, deprecated)"] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/MemberDeprecatedMacroTests.swift: -------------------------------------------------------------------------------- 1 | import MacroTesting 2 | import XCTest 3 | 4 | final class MemberDepreacatedMacroTests: XCTestCase { 5 | override func invokeTest() { 6 | withMacroTesting(macros: ["memberDeprecated": MemberDeprecatedMacro.self]) { 7 | super.invokeTest() 8 | } 9 | } 10 | 11 | func testExpansionMarksMembersAsDeprecated() { 12 | assertMacro { 13 | """ 14 | @memberDeprecated 15 | public struct SomeStruct { 16 | typealias MacroName = String 17 | 18 | public var oldProperty: Int = 420 19 | 20 | func oldMethod() { 21 | print("This is an old method.") 22 | } 23 | } 24 | """ 25 | } expansion: { 26 | """ 27 | public struct SomeStruct { 28 | @available(*, deprecated) 29 | typealias MacroName = String 30 | @available(*, deprecated) 31 | 32 | public var oldProperty: Int = 420 33 | @available(*, deprecated) 34 | 35 | func oldMethod() { 36 | print("This is an old method.") 37 | } 38 | } 39 | """ 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Point-Free 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 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/MacroExamples/FuncUniqueMacro.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2014 - 2023 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 SwiftSyntax 14 | import SwiftSyntaxBuilder 15 | import SwiftSyntaxMacros 16 | 17 | /// Func With unique name. 18 | public enum FuncUniqueMacro: DeclarationMacro { 19 | public static func expansion( 20 | of node: some FreestandingMacroExpansionSyntax, 21 | in context: some MacroExpansionContext 22 | ) throws -> [DeclSyntax] { 23 | let name = context.makeUniqueName("unique") 24 | return [ 25 | """ 26 | class MyClass { 27 | func \(name)() {} 28 | } 29 | """ 30 | ] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/DiagnosticsAndFixitsEmitterMacroTests.swift: -------------------------------------------------------------------------------- 1 | import MacroTesting 2 | import XCTest 3 | 4 | final class DiagnosticsAndFixitsEmitterMacroTests: BaseTestCase { 5 | override func invokeTest() { 6 | withMacroTesting(macros: [DiagnosticsAndFixitsEmitterMacro.self]) { 7 | super.invokeTest() 8 | } 9 | } 10 | 11 | func testExpansionEmitsDiagnosticsAndFixits() { 12 | assertMacro { 13 | """ 14 | @DiagnosticsAndFixitsEmitter 15 | struct FooBar { 16 | let foo: Foo 17 | let bar: Bar 18 | } 19 | """ 20 | } diagnostics: { 21 | """ 22 | @DiagnosticsAndFixitsEmitter 23 | ┬────────────────────────── 24 | ├─ ⚠️ This is the first diagnostic. 25 | │ ✏️ This is the first fix-it. 26 | │ ✏️ This is the second fix-it. 27 | ╰─ ℹ️ This is the second diagnostic, it's a note. 28 | struct FooBar { 29 | let foo: Foo 30 | let bar: Bar 31 | } 32 | """ 33 | } expansion: { 34 | """ 35 | struct FooBar { 36 | let foo: Foo 37 | let bar: Bar 38 | } 39 | """ 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/MacroExamples/Diagnostics.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2014 - 2023 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 SwiftDiagnostics 14 | import SwiftSyntax 15 | 16 | struct SimpleDiagnosticMessage: DiagnosticMessage, Error { 17 | let message: String 18 | let diagnosticID: MessageID 19 | let severity: DiagnosticSeverity 20 | } 21 | 22 | extension SimpleDiagnosticMessage: FixItMessage { 23 | var fixItID: MessageID { diagnosticID } 24 | } 25 | 26 | enum CustomError: Error, CustomStringConvertible { 27 | case message(String) 28 | 29 | var description: String { 30 | switch self { 31 | case .message(let text): 32 | return text 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/MacroExamples/PeerValueWithSuffixMacro.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2014 - 2023 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 SwiftSyntax 14 | import SwiftSyntaxMacros 15 | 16 | /// Peer 'var' with the name suffixed with '_peer'. 17 | public enum PeerValueWithSuffixNameMacro: PeerMacro { 18 | public static func expansion( 19 | of node: AttributeSyntax, 20 | providingPeersOf declaration: some DeclSyntaxProtocol, 21 | in context: some MacroExpansionContext 22 | ) throws -> [DeclSyntax] { 23 | guard let identified = declaration.asProtocol(NamedDeclSyntax.self) else { 24 | return [] 25 | } 26 | return ["var \(raw: identified.name.text)_peer: Int { 1 }"] 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/EntryMacroTests.swift: -------------------------------------------------------------------------------- 1 | import MacroTesting 2 | import XCTest 3 | 4 | final class EntryMacroTests: BaseTestCase { 5 | override func invokeTest() { 6 | withMacroTesting( 7 | macros: [ 8 | EntryMacro.self 9 | ] 10 | ) { 11 | super.invokeTest() 12 | } 13 | } 14 | 15 | func testWithinEnvironmentValues() { 16 | assertMacro { 17 | """ 18 | extension EnvironmentValues { 19 | @Entry var x: String = "" 20 | } 21 | """ 22 | } expansion: { 23 | """ 24 | extension EnvironmentValues { 25 | var x: String { 26 | get { 27 | fatalError() 28 | } 29 | } 30 | } 31 | """ 32 | } 33 | } 34 | 35 | func testNotWithinEnvironmentValues() { 36 | assertMacro { 37 | """ 38 | extension String { 39 | @Entry var x: String = "" 40 | } 41 | """ 42 | } diagnostics: { 43 | """ 44 | extension String { 45 | @Entry var x: String = "" 46 | ┬───── 47 | ╰─ 🛑 '@Entry' macro can only attach to var declarations inside extensions of EnvironmentValues 48 | } 49 | """ 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/MacroExamples/EquatableExtensionMacro.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2014 - 2023 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 SwiftSyntax 14 | import SwiftSyntaxMacros 15 | 16 | public enum EquatableExtensionMacro: ExtensionMacro { 17 | public static func expansion( 18 | of node: AttributeSyntax, 19 | attachedTo declaration: some DeclGroupSyntax, 20 | providingExtensionsOf type: some TypeSyntaxProtocol, 21 | conformingTo protocols: [TypeSyntax], 22 | in context: some MacroExpansionContext 23 | ) throws -> [ExtensionDeclSyntax] { 24 | let equatableExtension = try ExtensionDeclSyntax("extension \(type.trimmed): Equatable {}") 25 | 26 | return [equatableExtension] 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Package@swift-5.9.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "swift-macro-testing", 7 | platforms: [ 8 | .iOS(.v13), 9 | .macOS(.v10_15), 10 | .tvOS(.v13), 11 | .watchOS(.v6), 12 | ], 13 | products: [ 14 | .library( 15 | name: "MacroTesting", 16 | targets: ["MacroTesting"] 17 | ) 18 | ], 19 | dependencies: [ 20 | .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.18.0"), 21 | .package(url: "https://github.com/swiftlang/swift-syntax", "509.0.0"..<"603.0.0"), 22 | ], 23 | targets: [ 24 | .target( 25 | name: "MacroTesting", 26 | dependencies: [ 27 | .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), 28 | .product(name: "SwiftDiagnostics", package: "swift-syntax"), 29 | .product(name: "SwiftOperators", package: "swift-syntax"), 30 | .product(name: "SwiftParserDiagnostics", package: "swift-syntax"), 31 | .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), 32 | .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), 33 | ] 34 | ), 35 | .testTarget( 36 | name: "MacroTestingTests", 37 | dependencies: [ 38 | "MacroTesting" 39 | ] 40 | ), 41 | ] 42 | ) 43 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/URLMacroTests.swift: -------------------------------------------------------------------------------- 1 | import MacroTesting 2 | import XCTest 3 | 4 | final class URLMacroTests: BaseTestCase { 5 | override func invokeTest() { 6 | withMacroTesting(macros: ["URL": URLMacro.self]) { 7 | super.invokeTest() 8 | } 9 | } 10 | 11 | func testExpansionWithMalformedURLEmitsError() { 12 | assertMacro { 13 | """ 14 | let invalid = #URL("https://not a url.com") 15 | """ 16 | } diagnostics: { 17 | """ 18 | let invalid = #URL("https://not a url.com") 19 | ┬──────────────────────────── 20 | ╰─ 🛑 malformed url: "https://not a url.com" 21 | """ 22 | } 23 | } 24 | 25 | func testExpansionWithStringInterpolationEmitsError() { 26 | assertMacro { 27 | #""" 28 | #URL("https://\(domain)/api/path") 29 | """# 30 | } diagnostics: { 31 | #""" 32 | #URL("https://\(domain)/api/path") 33 | ┬───────────────────────────────── 34 | ╰─ 🛑 #URL requires a static string literal 35 | """# 36 | } 37 | } 38 | 39 | func testExpansionWithValidURL() { 40 | assertMacro { 41 | """ 42 | let valid = #URL("https://swift.org/") 43 | """ 44 | } expansion: { 45 | """ 46 | let valid = URL(string: "https://swift.org/")! 47 | """ 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "swift-macro-testing", 7 | platforms: [ 8 | .iOS(.v13), 9 | .macOS(.v10_15), 10 | .tvOS(.v13), 11 | .watchOS(.v6), 12 | ], 13 | products: [ 14 | .library( 15 | name: "MacroTesting", 16 | targets: ["MacroTesting"] 17 | ) 18 | ], 19 | dependencies: [ 20 | .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.17.4"), 21 | .package(url: "https://github.com/swiftlang/swift-syntax", "509.0.0"..<"603.0.0"), 22 | ], 23 | targets: [ 24 | .target( 25 | name: "MacroTesting", 26 | dependencies: [ 27 | .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), 28 | .product(name: "SwiftDiagnostics", package: "swift-syntax"), 29 | .product(name: "SwiftOperators", package: "swift-syntax"), 30 | .product(name: "SwiftParserDiagnostics", package: "swift-syntax"), 31 | .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), 32 | .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), 33 | ] 34 | ), 35 | .testTarget( 36 | name: "MacroTestingTests", 37 | dependencies: [ 38 | "MacroTesting" 39 | ] 40 | ), 41 | ], 42 | swiftLanguageModes: [.v5] 43 | ) 44 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/PeerValueWithSuffixMacroTests.swift: -------------------------------------------------------------------------------- 1 | import MacroTesting 2 | import XCTest 3 | 4 | final class PeerValueWithSuffixNameMacroTests: XCTestCase { 5 | override func invokeTest() { 6 | withMacroTesting(macros: [PeerValueWithSuffixNameMacro.self]) { 7 | super.invokeTest() 8 | } 9 | } 10 | 11 | func testExpansionAddsPeerValueToPrivateActor() { 12 | assertMacro { 13 | """ 14 | @PeerValueWithSuffixName 15 | private actor Counter { 16 | var value = 0 17 | } 18 | """ 19 | } expansion: { 20 | """ 21 | private actor Counter { 22 | var value = 0 23 | } 24 | 25 | var Counter_peer: Int { 26 | 1 27 | } 28 | """ 29 | } 30 | } 31 | 32 | func testExpansionAddsPeerValueToFunction() { 33 | assertMacro { 34 | """ 35 | @PeerValueWithSuffixName 36 | func someFunction() {} 37 | """ 38 | } expansion: { 39 | """ 40 | func someFunction() {} 41 | 42 | var someFunction_peer: Int { 43 | 1 44 | } 45 | """ 46 | } 47 | } 48 | 49 | func testExpansionIgnoresVariables() { 50 | assertMacro { 51 | """ 52 | @PeerValueWithSuffixName 53 | var someVariable: Int 54 | """ 55 | } expansion: { 56 | """ 57 | var someVariable: Int 58 | """ 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/MacroNameTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import MacroTesting 4 | 5 | final class MacroNameTests: BaseTestCase { 6 | func testBasics() { 7 | XCTAssertEqual( 8 | macroName(className: "AddAsyncHandler", isExpression: false), 9 | "AddAsyncHandler" 10 | ) 11 | XCTAssertEqual( 12 | macroName(className: "AddAsyncHandlerMacro", isExpression: false), 13 | "AddAsyncHandler" 14 | ) 15 | XCTAssertEqual( 16 | macroName(className: "URL", isExpression: false), 17 | "URL" 18 | ) 19 | XCTAssertEqual( 20 | macroName(className: "URLMacro", isExpression: false), 21 | "URL" 22 | ) 23 | XCTAssertEqual( 24 | macroName(className: "URL", isExpression: true), 25 | "url" 26 | ) 27 | XCTAssertEqual( 28 | macroName(className: "URLMacro", isExpression: true), 29 | "url" 30 | ) 31 | XCTAssertEqual( 32 | macroName(className: "URLComponents", isExpression: true), 33 | "urlComponents" 34 | ) 35 | XCTAssertEqual( 36 | macroName(className: "URLComponentsMacro", isExpression: true), 37 | "urlComponents" 38 | ) 39 | XCTAssertEqual( 40 | macroName(className: "FontLiteral", isExpression: true), 41 | "fontLiteral" 42 | ) 43 | XCTAssertEqual( 44 | macroName(className: "FontLiteralMacro", isExpression: true), 45 | "fontLiteral" 46 | ) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "db288110c168673c49d51156064e9cf0a6c0719e024eb30dd405e28fe59a65ec", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-custom-dump", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/pointfreeco/swift-custom-dump", 8 | "state" : { 9 | "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", 10 | "version" : "1.3.3" 11 | } 12 | }, 13 | { 14 | "identity" : "swift-snapshot-testing", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/pointfreeco/swift-snapshot-testing", 17 | "state" : { 18 | "revision" : "a8b7c5e0ed33d8ab8887d1654d9b59f2cbad529b", 19 | "version" : "1.18.7" 20 | } 21 | }, 22 | { 23 | "identity" : "swift-syntax", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/swiftlang/swift-syntax", 26 | "state" : { 27 | "revision" : "4799286537280063c85a32f09884cfbca301b1a1", 28 | "version" : "602.0.0" 29 | } 30 | }, 31 | { 32 | "identity" : "xctest-dynamic-overlay", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 35 | "state" : { 36 | "revision" : "b2ed9eabefe56202ee4939dd9fc46b6241c88317", 37 | "version" : "1.6.1" 38 | } 39 | } 40 | ], 41 | "version" : 3 42 | } 43 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/WarningMacroTests.swift: -------------------------------------------------------------------------------- 1 | import MacroTesting 2 | import XCTest 3 | 4 | final class WarningMacroTests: BaseTestCase { 5 | override func invokeTest() { 6 | withMacroTesting(macros: ["myWarning": WarningMacro.self]) { 7 | super.invokeTest() 8 | } 9 | } 10 | 11 | func testExpansionWithValidStringLiteralEmitsWarning() { 12 | assertMacro { 13 | """ 14 | #myWarning("This is a warning") 15 | """ 16 | } diagnostics: { 17 | """ 18 | #myWarning("This is a warning") 19 | ┬────────────────────────────── 20 | ╰─ ⚠️ This is a warning 21 | """ 22 | } expansion: { 23 | """ 24 | () 25 | """ 26 | } 27 | } 28 | 29 | func testExpansionWithInvalidExpressionEmitsError() { 30 | assertMacro { 31 | """ 32 | #myWarning(42) 33 | """ 34 | } diagnostics: { 35 | """ 36 | #myWarning(42) 37 | ┬───────────── 38 | ╰─ 🛑 #myWarning macro requires a string literal 39 | """ 40 | } 41 | } 42 | 43 | func testExpansionWithStringInterpolationEmitsError() { 44 | assertMacro { 45 | #""" 46 | #myWarning("Say hello \(number) times!") 47 | """# 48 | } diagnostics: { 49 | #""" 50 | #myWarning("Say hello \(number) times!") 51 | ┬─────────────────────────────────────── 52 | ╰─ 🛑 #myWarning macro requires a string literal 53 | """# 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/CustomCodableMacroTests.swift: -------------------------------------------------------------------------------- 1 | import MacroTesting 2 | import XCTest 3 | 4 | final class CustomCodableMacroTests: BaseTestCase { 5 | override func invokeTest() { 6 | withMacroTesting(macros: [CodableKey.self, CustomCodable.self]) { 7 | super.invokeTest() 8 | } 9 | } 10 | 11 | func testExpansionAddsDefaultCodingKeys() { 12 | assertMacro { 13 | """ 14 | @CustomCodable 15 | struct Person { 16 | let name: String 17 | let age: Int 18 | } 19 | """ 20 | } expansion: { 21 | """ 22 | struct Person { 23 | let name: String 24 | let age: Int 25 | 26 | enum CodingKeys: String, CodingKey { 27 | case name 28 | case age 29 | } 30 | } 31 | """ 32 | } 33 | } 34 | 35 | func testExpansionWithCodableKeyAddsCustomCodingKeys() { 36 | assertMacro { 37 | """ 38 | @CustomCodable 39 | struct Person { 40 | let name: String 41 | @CodableKey("user_age") let age: Int 42 | 43 | func randomFunction() {} 44 | } 45 | """ 46 | } expansion: { 47 | """ 48 | struct Person { 49 | let name: String 50 | let age: Int 51 | 52 | func randomFunction() {} 53 | 54 | enum CodingKeys: String, CodingKey { 55 | case name 56 | case age = "user_age" 57 | } 58 | } 59 | """ 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/SwiftTestingTests.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Testing) 2 | import MacroTesting 3 | import Testing 4 | 5 | @Suite( 6 | .macros( 7 | ["URL": URLMacro.self], 8 | record: .failed 9 | ) 10 | ) 11 | struct URLMacroSwiftTestingTests { 12 | @Test 13 | func expansionWithMalformedURLEmitsError() { 14 | assertMacro { 15 | """ 16 | let invalid = #URL("https://not a url.com") 17 | """ 18 | } diagnostics: { 19 | """ 20 | let invalid = #URL("https://not a url.com") 21 | ┬──────────────────────────── 22 | ╰─ 🛑 malformed url: "https://not a url.com" 23 | """ 24 | } 25 | } 26 | 27 | @Test 28 | func expansionWithStringInterpolationEmitsError() { 29 | assertMacro { 30 | #""" 31 | #URL("https://\(domain)/api/path") 32 | """# 33 | } diagnostics: { 34 | #""" 35 | #URL("https://\(domain)/api/path") 36 | ┬───────────────────────────────── 37 | ╰─ 🛑 #URL requires a static string literal 38 | """# 39 | } 40 | } 41 | 42 | @Test 43 | func expansionWithValidURL() { 44 | assertMacro { 45 | """ 46 | let valid = #URL("https://swift.org/") 47 | """ 48 | } expansion: { 49 | """ 50 | let valid = URL(string: "https://swift.org/")! 51 | """ 52 | } 53 | } 54 | } 55 | #endif 56 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/MacroExamples/StringifyMacro.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2014 - 2023 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 SwiftSyntax 14 | import SwiftSyntaxBuilder 15 | import SwiftSyntaxMacros 16 | 17 | /// Implementation of the `stringify` macro, which takes an expression 18 | /// of any type and produces a tuple containing the value of that expression 19 | /// and the source code that produced the value. For example 20 | /// 21 | /// #stringify(x + y) 22 | /// 23 | /// will expand to 24 | /// 25 | /// (x + y, "x + y") 26 | public enum StringifyMacro: ExpressionMacro { 27 | public static func expansion( 28 | of node: some FreestandingMacroExpansionSyntax, 29 | in context: some MacroExpansionContext 30 | ) -> ExprSyntax { 31 | guard let argument = node.arguments.first?.expression else { 32 | fatalError("compiler bug: the macro does not have any arguments") 33 | } 34 | 35 | return "(\(argument), \(literal: argument.description))" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/MacroExamples/URLMacro.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2014 - 2023 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 Foundation 14 | import SwiftSyntax 15 | import SwiftSyntaxMacros 16 | 17 | /// Creates a non-optional URL from a static string. The string is checked to 18 | /// be valid during compile time. 19 | public enum URLMacro: ExpressionMacro { 20 | public static func expansion( 21 | of node: some FreestandingMacroExpansionSyntax, 22 | in context: some MacroExpansionContext 23 | ) throws -> ExprSyntax { 24 | guard let argument = node.arguments.first?.expression, 25 | let segments = argument.as(StringLiteralExprSyntax.self)?.segments, 26 | segments.count == 1, 27 | case .stringSegment(let literalSegment)? = segments.first 28 | else { 29 | throw CustomError.message("#URL requires a static string literal") 30 | } 31 | 32 | guard URL(string: literalSegment.content.text) != nil else { 33 | throw CustomError.message("malformed url: \(argument)") 34 | } 35 | 36 | return "URL(string: \(argument))!" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/MacroExamples/CaseDetectionMacro.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2014 - 2023 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 SwiftSyntax 14 | import SwiftSyntaxMacros 15 | 16 | public enum CaseDetectionMacro: MemberMacro { 17 | public static func expansion( 18 | of node: AttributeSyntax, 19 | providingMembersOf declaration: some DeclGroupSyntax, 20 | in context: some MacroExpansionContext 21 | ) throws -> [DeclSyntax] { 22 | declaration.memberBlock.members 23 | .compactMap { $0.decl.as(EnumCaseDeclSyntax.self) } 24 | .map { $0.elements.first!.name } 25 | .map { ($0, $0.initialUppercased) } 26 | .map { original, uppercased in 27 | """ 28 | var is\(raw: uppercased): Bool { 29 | if case .\(raw: original) = self { 30 | return true 31 | } 32 | 33 | return false 34 | } 35 | """ 36 | } 37 | } 38 | } 39 | 40 | extension TokenSyntax { 41 | fileprivate var initialUppercased: String { 42 | let name = self.text 43 | guard let initial = name.first else { 44 | return name 45 | } 46 | 47 | return "\(initial.uppercased())\(name.dropFirst())" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/MacroTesting/Internal/Diagnostic+UnderlineHighlights.swift: -------------------------------------------------------------------------------- 1 | import SwiftDiagnostics 2 | import SwiftSyntax 3 | import SwiftSyntaxMacroExpansion 4 | 5 | extension Array where Element == Diagnostic { 6 | func underlineHighlights( 7 | sourceString: String, 8 | lineNumber: Int, 9 | column: Int, 10 | context: BasicMacroExpansionContext 11 | ) -> String? { 12 | let (highlightColumns, highlightLineLength) = self.reduce( 13 | into: (highlightColumns: Set(), highlightLineLength: column + 1) 14 | ) { partialResult, diag in 15 | for highlight in diag.highlights { 16 | let startLocation = context.location( 17 | for: highlight.positionAfterSkippingLeadingTrivia, anchoredAt: diag.node, fileName: "" 18 | ) 19 | let endLocation = context.location( 20 | for: highlight.endPositionBeforeTrailingTrivia, anchoredAt: diag.node, fileName: "" 21 | ) 22 | guard 23 | startLocation.line == lineNumber, 24 | startLocation.line == endLocation.line, 25 | sourceString.contains(diag.node.trimmedDescription) 26 | else { continue } 27 | partialResult.highlightColumns.formUnion(startLocation.column.. ExprSyntax { 24 | let argList = replaceFirstLabel( 25 | of: node.arguments, 26 | with: "fontLiteralName" 27 | ) 28 | return ".init(\(argList))" 29 | } 30 | } 31 | 32 | /// Replace the label of the first element in the tuple with the given 33 | /// new label. 34 | private func replaceFirstLabel( 35 | of tuple: LabeledExprListSyntax, 36 | with newLabel: String 37 | ) -> LabeledExprListSyntax { 38 | if tuple.isEmpty { 39 | return tuple 40 | } 41 | 42 | var tuple = tuple 43 | tuple[tuple.startIndex].label = .identifier(newLabel) 44 | tuple[tuple.startIndex].colon = .colonToken() 45 | return tuple 46 | } 47 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/MacroExamples/WarningMacro.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2014 - 2023 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 SwiftDiagnostics 14 | import SwiftSyntax 15 | import SwiftSyntaxMacros 16 | 17 | /// Implementation of the `myWarning` macro, which mimics the behavior of the 18 | /// built-in `#warning`. 19 | public enum WarningMacro: ExpressionMacro { 20 | public static func expansion( 21 | of node: some FreestandingMacroExpansionSyntax, 22 | in context: some MacroExpansionContext 23 | ) throws -> ExprSyntax { 24 | guard let firstElement = node.arguments.first, 25 | let stringLiteral = firstElement.expression 26 | .as(StringLiteralExprSyntax.self), 27 | stringLiteral.segments.count == 1, 28 | case let .stringSegment(messageString)? = stringLiteral.segments.first 29 | else { 30 | throw CustomError.message("#myWarning macro requires a string literal") 31 | } 32 | 33 | context.diagnose( 34 | Diagnostic( 35 | node: Syntax(node), 36 | message: SimpleDiagnosticMessage( 37 | message: messageString.content.description, 38 | diagnosticID: MessageID(domain: "test123", id: "error"), 39 | severity: .warning 40 | ) 41 | ) 42 | ) 43 | 44 | return "()" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/DefaultFatalErrorImplementationMacroTests.swift: -------------------------------------------------------------------------------- 1 | import MacroTesting 2 | import XCTest 3 | 4 | final class DefaultFatalErrorImplementationMacroTests: BaseTestCase { 5 | override func invokeTest() { 6 | withMacroTesting( 7 | macros: ["defaultFatalErrorImplementation": DefaultFatalErrorImplementationMacro.self] 8 | ) { 9 | super.invokeTest() 10 | } 11 | } 12 | 13 | func testExpansionWhenAttachedToProtocolExpandsCorrectly() { 14 | assertMacro { 15 | """ 16 | @defaultFatalErrorImplementation 17 | protocol MyProtocol { 18 | func foo() 19 | func bar() -> Int 20 | } 21 | """ 22 | } expansion: { 23 | """ 24 | protocol MyProtocol { 25 | func foo() 26 | func bar() -> Int 27 | } 28 | 29 | extension MyProtocol { 30 | func foo() { 31 | fatalError("whoops 😅") 32 | } 33 | func bar() -> Int { 34 | fatalError("whoops 😅") 35 | } 36 | } 37 | """ 38 | } 39 | } 40 | 41 | func testExpansionWhenNotAttachedToProtocolProducesDiagnostic() { 42 | assertMacro { 43 | """ 44 | @defaultFatalErrorImplementation 45 | class MyClass {} 46 | """ 47 | } diagnostics: { 48 | """ 49 | @defaultFatalErrorImplementation 50 | ┬─────────────────────────────── 51 | ╰─ 🛑 Macro `defaultFatalErrorImplementation` can only be applied to a protocol 52 | class MyClass {} 53 | """ 54 | } 55 | } 56 | 57 | func testExpansionWhenAttachedToEmptyProtocolDoesNotAddExtension() { 58 | assertMacro { 59 | """ 60 | @defaultFatalErrorImplementation 61 | protocol EmptyProtocol {} 62 | """ 63 | } expansion: { 64 | """ 65 | protocol EmptyProtocol {} 66 | """ 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/NewTypeMacroTests.swift: -------------------------------------------------------------------------------- 1 | import MacroTesting 2 | import XCTest 3 | 4 | final class NewTypeMacroTests: BaseTestCase { 5 | override func invokeTest() { 6 | withMacroTesting(macros: [NewTypeMacro.self]) { 7 | super.invokeTest() 8 | } 9 | } 10 | 11 | func testExpansionAddsStringRawType() { 12 | assertMacro { 13 | """ 14 | @NewType(String.self) 15 | struct Username { 16 | } 17 | """ 18 | } expansion: { 19 | """ 20 | struct Username { 21 | 22 | typealias RawValue = String 23 | 24 | var rawValue: RawValue 25 | 26 | init(_ rawValue: RawValue) { 27 | self.rawValue = rawValue 28 | } 29 | } 30 | """ 31 | } 32 | } 33 | 34 | func testExpansionWithPublicAddsPublicStringRawType() { 35 | assertMacro { 36 | """ 37 | @NewType(String.self) 38 | public struct MyString { 39 | } 40 | """ 41 | } expansion: { 42 | """ 43 | public struct MyString { 44 | 45 | public typealias RawValue = String 46 | 47 | public var rawValue: RawValue 48 | 49 | public init(_ rawValue: RawValue) { 50 | self.rawValue = rawValue 51 | } 52 | } 53 | """ 54 | } 55 | } 56 | 57 | func testExpansionOnClassEmitsError() { 58 | assertMacro { 59 | """ 60 | @NewType(Int.self) 61 | class NotAUsername { 62 | } 63 | """ 64 | } diagnostics: { 65 | """ 66 | @NewType(Int.self) 67 | ┬───────────────── 68 | ╰─ 🛑 @NewType can only be applied to a struct declarations. 69 | class NotAUsername { 70 | } 71 | """ 72 | } 73 | } 74 | 75 | func testExpansionWithMissingRawTypeEmitsError() { 76 | assertMacro { 77 | """ 78 | @NewType 79 | struct NoRawType { 80 | } 81 | """ 82 | } diagnostics: { 83 | """ 84 | @NewType 85 | ┬─────── 86 | ╰─ 🛑 @NewType requires the raw type as an argument, in the form "RawType.self". 87 | struct NoRawType { 88 | } 89 | """ 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/WrapStoredPropertiesMacroTests.swift: -------------------------------------------------------------------------------- 1 | import MacroTesting 2 | import XCTest 3 | 4 | final class WrapStoredPropertiesMacroTests: BaseTestCase { 5 | override func invokeTest() { 6 | withMacroTesting(macros: ["wrapStoredProperties": WrapStoredPropertiesMacro.self]) { 7 | super.invokeTest() 8 | } 9 | } 10 | 11 | func testExpansionAddsPublished() { 12 | assertMacro { 13 | """ 14 | @wrapStoredProperties("Published") 15 | struct Test { 16 | var value: Int 17 | } 18 | """ 19 | } expansion: { 20 | """ 21 | struct Test { 22 | @Published 23 | var value: Int 24 | } 25 | """ 26 | } 27 | } 28 | 29 | func testExpansionAddsDeprecationAttribute() { 30 | assertMacro { 31 | """ 32 | @wrapStoredProperties(#"available(*, deprecated, message: "hands off my data")"#) 33 | struct Test { 34 | var value: Int 35 | } 36 | """ 37 | } expansion: { 38 | """ 39 | struct Test { 40 | @available(*, deprecated, message: "hands off my data") 41 | var value: Int 42 | } 43 | """ 44 | } 45 | } 46 | 47 | func testExpansionIgnoresComputedProperty() { 48 | assertMacro { 49 | """ 50 | @wrapStoredProperties("Published") 51 | struct Test { 52 | var value: Int { 53 | get { return 0 } 54 | set {} 55 | } 56 | } 57 | """ 58 | } expansion: { 59 | """ 60 | struct Test { 61 | var value: Int { 62 | get { return 0 } 63 | set {} 64 | } 65 | } 66 | """ 67 | } 68 | } 69 | 70 | func testExpansionWithInvalidAttributeEmitsError() { 71 | assertMacro { 72 | """ 73 | @wrapStoredProperties(12) 74 | struct Test { 75 | var value: Int 76 | } 77 | """ 78 | } diagnostics: { 79 | """ 80 | @wrapStoredProperties(12) 81 | ┬──────────────────────── 82 | ╰─ 🛑 macro requires a string literal containing the name of an attribute 83 | struct Test { 84 | var value: Int 85 | } 86 | """ 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/DictionaryStorageMacroTests.swift: -------------------------------------------------------------------------------- 1 | import MacroTesting 2 | import XCTest 3 | 4 | final class DictionaryStorageMacroTests: BaseTestCase { 5 | override func invokeTest() { 6 | withMacroTesting( 7 | macros: [ 8 | DictionaryStorageMacro.self, 9 | DictionaryStoragePropertyMacro.self, 10 | ] 11 | ) { 12 | super.invokeTest() 13 | } 14 | } 15 | 16 | func testExpansionConvertsStoredProperties() { 17 | assertMacro { 18 | """ 19 | @DictionaryStorage 20 | struct Point { 21 | var x: Int = 1 22 | var y: Int = 2 23 | } 24 | """ 25 | } expansion: { 26 | """ 27 | struct Point { 28 | var x: Int { 29 | get { 30 | _storage["x", default: 1] as! Int 31 | } 32 | set { 33 | _storage["x"] = newValue 34 | } 35 | } 36 | var y: Int { 37 | get { 38 | _storage["y", default: 2] as! Int 39 | } 40 | set { 41 | _storage["y"] = newValue 42 | } 43 | } 44 | 45 | var _storage: [String: Any] = [:] 46 | } 47 | """ 48 | } 49 | } 50 | 51 | func testExpansionWithoutInitializersEmitsError() { 52 | assertMacro { 53 | """ 54 | @DictionaryStorage 55 | class Point { 56 | let x: Int 57 | let y: Int 58 | } 59 | """ 60 | } diagnostics: { 61 | """ 62 | @DictionaryStorage 63 | class Point { 64 | let x: Int 65 | ╰─ 🛑 stored property must have an initializer 66 | let y: Int 67 | ╰─ 🛑 stored property must have an initializer 68 | } 69 | """ 70 | } 71 | } 72 | 73 | func testExpansionIgnoresComputedProperties() { 74 | assertMacro { 75 | """ 76 | @DictionaryStorage 77 | struct Test { 78 | var value: Int { 79 | get { return 0 } 80 | set {} 81 | } 82 | } 83 | """ 84 | } expansion: { 85 | """ 86 | struct Test { 87 | var value: Int { 88 | get { return 0 } 89 | set {} 90 | } 91 | 92 | var _storage: [String: Any] = [:] 93 | } 94 | """ 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/MacroExamples/DiagnosticsAndFixitsEmitterMacro.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2014 - 2023 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 SwiftDiagnostics 14 | import SwiftSyntax 15 | import SwiftSyntaxBuilder 16 | import SwiftSyntaxMacros 17 | 18 | /// Emits two diagnostics, the first of which is a warning and has two fix-its, and 19 | /// the second is a note and has no fix-its. 20 | public enum DiagnosticsAndFixitsEmitterMacro: MemberMacro { 21 | public static func expansion( 22 | of node: AttributeSyntax, 23 | providingMembersOf declaration: some DeclGroupSyntax, 24 | in context: some MacroExpansionContext 25 | ) throws -> [DeclSyntax] { 26 | let firstFixIt = FixIt( 27 | message: SimpleDiagnosticMessage( 28 | message: "This is the first fix-it.", 29 | diagnosticID: MessageID(domain: "domain", id: "fixit1"), 30 | severity: .error), 31 | changes: [ 32 | .replace(oldNode: Syntax(node), newNode: Syntax(node)) // no-op 33 | ]) 34 | let secondFixIt = FixIt( 35 | message: SimpleDiagnosticMessage( 36 | message: "This is the second fix-it.", 37 | diagnosticID: MessageID(domain: "domain", id: "fixit2"), 38 | severity: .error), 39 | changes: [ 40 | .replace(oldNode: Syntax(node), newNode: Syntax(node)) // no-op 41 | ]) 42 | 43 | context.diagnose( 44 | Diagnostic( 45 | node: node.attributeName, 46 | message: SimpleDiagnosticMessage( 47 | message: "This is the first diagnostic.", 48 | diagnosticID: MessageID(domain: "domain", id: "diagnostic2"), 49 | severity: .warning), 50 | fixIts: [firstFixIt, secondFixIt])) 51 | context.diagnose( 52 | Diagnostic( 53 | node: node.attributeName, 54 | message: SimpleDiagnosticMessage( 55 | message: "This is the second diagnostic, it's a note.", 56 | diagnosticID: MessageID(domain: "domain", id: "diagnostic2"), 57 | severity: .note))) 58 | 59 | return [] 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/MacroExamples/CustomCodable.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2014 - 2023 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 SwiftSyntax 14 | import SwiftSyntaxMacros 15 | 16 | public enum CustomCodable: MemberMacro { 17 | public static func expansion( 18 | of node: AttributeSyntax, 19 | providingMembersOf declaration: some DeclGroupSyntax, 20 | in context: some MacroExpansionContext 21 | ) throws -> [DeclSyntax] { 22 | let memberList = declaration.memberBlock.members 23 | 24 | let cases = memberList.compactMap({ member -> String? in 25 | // is a property 26 | guard 27 | let propertyName = member.decl.as(VariableDeclSyntax.self)?.bindings.first?.pattern.as( 28 | IdentifierPatternSyntax.self)?.identifier.text 29 | else { 30 | return nil 31 | } 32 | 33 | // if it has a CodableKey macro on it 34 | if let customKeyMacro = member.decl.as(VariableDeclSyntax.self)?.attributes.first(where: { 35 | element in 36 | element.as(AttributeSyntax.self)?.attributeName.as(IdentifierTypeSyntax.self)?.description 37 | == "CodableKey" 38 | }) { 39 | 40 | // Uses the value in the Macro 41 | let customKeyValue = customKeyMacro.as(AttributeSyntax.self)!.arguments!.as( 42 | LabeledExprListSyntax.self)!.first!.expression 43 | 44 | return "case \(propertyName) = \(customKeyValue)" 45 | } else { 46 | return "case \(propertyName)" 47 | } 48 | }) 49 | 50 | let codingKeys: DeclSyntax = """ 51 | enum CodingKeys: String, CodingKey { 52 | \(raw: cases.joined(separator: "\n")) 53 | } 54 | """ 55 | 56 | return [codingKeys] 57 | } 58 | } 59 | 60 | public struct CodableKey: PeerMacro { 61 | public static func expansion( 62 | of node: AttributeSyntax, 63 | providingPeersOf declaration: some DeclSyntaxProtocol, 64 | in context: some MacroExpansionContext 65 | ) throws -> [DeclSyntax] { 66 | // Does nothing, used only to decorate members with data 67 | return [] 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/IndentationWidthTests.swift: -------------------------------------------------------------------------------- 1 | import MacroTesting 2 | import SwiftSyntax 3 | import SwiftSyntaxMacros 4 | import XCTest 5 | 6 | private struct AddMemberMacro: MemberMacro { 7 | static func expansion( 8 | of node: AttributeSyntax, 9 | providingMembersOf declaration: some DeclGroupSyntax, 10 | in context: some MacroExpansionContext 11 | ) throws -> [DeclSyntax] { 12 | return ["let v: T"] 13 | } 14 | } 15 | 16 | final class IndentationWidthTests: XCTestCase { 17 | func testExpansionAddsMemberUsingDetectedIndentation() { 18 | assertMacro([AddMemberMacro.self]) { 19 | """ 20 | @AddMember 21 | struct S { 22 | let w: T 23 | } 24 | """ 25 | } expansion: { 26 | """ 27 | struct S { 28 | let w: T 29 | 30 | let v: T 31 | } 32 | """ 33 | } 34 | } 35 | 36 | func testExpansionAddsMemberToEmptyStructUsingDefaultIndentation() { 37 | assertMacro([AddMemberMacro.self]) { 38 | """ 39 | @AddMember 40 | struct S { 41 | } 42 | """ 43 | } expansion: { 44 | """ 45 | struct S { 46 | 47 | let v: T 48 | } 49 | """ 50 | } 51 | } 52 | 53 | func testExpansionAddsMemberToEmptyStructUsingTwoSpaceIndentation() { 54 | assertMacro( 55 | [AddMemberMacro.self], 56 | indentationWidth: .spaces(2) 57 | ) { 58 | """ 59 | @AddMember 60 | struct S { 61 | } 62 | """ 63 | } expansion: { 64 | """ 65 | struct S { 66 | 67 | let v: T 68 | } 69 | """ 70 | } 71 | } 72 | 73 | func testExpansionAddsMemberToEmptyStructUsingTwoSpaceIndentation_withMacroTesting() { 74 | withMacroTesting( 75 | indentationWidth: .spaces(2), 76 | macros: [AddMemberMacro.self] 77 | ) { 78 | assertMacro { 79 | """ 80 | @AddMember 81 | struct S { 82 | } 83 | """ 84 | } expansion: { 85 | """ 86 | struct S { 87 | 88 | let v: T 89 | } 90 | """ 91 | } 92 | } 93 | } 94 | 95 | func testExpansionAddsMemberUsingMistchedIndentation() { 96 | assertMacro( 97 | [AddMemberMacro.self], 98 | indentationWidth: .spaces(4) 99 | ) { 100 | """ 101 | @AddMember 102 | struct S { 103 | let w: T 104 | } 105 | """ 106 | } expansion: { 107 | """ 108 | struct S { 109 | let w: T 110 | 111 | let v: T 112 | } 113 | """ 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/MacroExamples/NewTypeMacro.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2014 - 2023 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 SwiftSyntax 14 | import SwiftSyntaxBuilder 15 | import SwiftSyntaxMacros 16 | 17 | public enum NewTypeMacro {} 18 | 19 | extension NewTypeMacro: MemberMacro { 20 | public static func expansion( 21 | of node: AttributeSyntax, 22 | providingMembersOf declaration: Declaration, 23 | in context: Context 24 | ) throws -> [DeclSyntax] where Declaration: DeclGroupSyntax, Context: MacroExpansionContext { 25 | do { 26 | guard 27 | case .argumentList(let arguments) = node.arguments, 28 | arguments.count == 1, 29 | let memberAccessExn = arguments.first? 30 | .expression.as(MemberAccessExprSyntax.self), 31 | let rawType = memberAccessExn.base?.as(DeclReferenceExprSyntax.self) 32 | else { 33 | throw CustomError.message( 34 | #"@NewType requires the raw type as an argument, in the form "RawType.self"."#) 35 | } 36 | 37 | guard let declaration = declaration.as(StructDeclSyntax.self) else { 38 | throw CustomError.message("@NewType can only be applied to a struct declarations.") 39 | } 40 | 41 | let access = declaration.modifiers.first(where: \.isNeededAccessLevelModifier) 42 | 43 | return [ 44 | "\(access)typealias RawValue = \(rawType)", 45 | "\(access)var rawValue: RawValue", 46 | "\(access)init(_ rawValue: RawValue) { self.rawValue = rawValue }", 47 | ] 48 | } catch { 49 | print("--------------- throwing \(error)") 50 | throw error 51 | } 52 | } 53 | } 54 | 55 | extension DeclModifierSyntax { 56 | var isNeededAccessLevelModifier: Bool { 57 | switch self.name.tokenKind { 58 | case .keyword(.public): return true 59 | default: return false 60 | } 61 | } 62 | } 63 | 64 | extension SyntaxStringInterpolation { 65 | // It would be nice for SwiftSyntaxBuilder to provide this out-of-the-box. 66 | mutating func appendInterpolation(_ node: Node?) { 67 | if let node { 68 | appendInterpolation(node) 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/FixItTests.swift: -------------------------------------------------------------------------------- 1 | import MacroTesting 2 | import SwiftDiagnostics 3 | import SwiftSyntax 4 | import SwiftSyntaxBuilder 5 | import SwiftSyntaxMacros 6 | import XCTest 7 | 8 | private enum ReplaceFirstMemberMacro: MemberMacro { 9 | public static func expansion( 10 | of node: AttributeSyntax, 11 | providingMembersOf declaration: some DeclGroupSyntax, 12 | in context: some MacroExpansionContext 13 | ) throws -> [DeclSyntax] { 14 | guard 15 | let nodeToReplace = declaration.memberBlock.members.first, 16 | let newNode = try? MemberBlockItemSyntax( 17 | decl: VariableDeclSyntax(SyntaxNodeString(stringLiteral: "\n let oye: Oye")) 18 | ) 19 | else { return [] } 20 | 21 | context.diagnose( 22 | Diagnostic( 23 | node: node.attributeName, 24 | message: SimpleDiagnosticMessage( 25 | message: "First member needs to be replaced", 26 | diagnosticID: MessageID(domain: "domain", id: "diagnostic2"), 27 | severity: .warning 28 | ), 29 | fixIts: [ 30 | FixIt( 31 | message: SimpleDiagnosticMessage( 32 | message: "Replace the first member", 33 | diagnosticID: MessageID(domain: "domain", id: "fixit1"), 34 | severity: .error 35 | ), 36 | changes: [ 37 | .replace(oldNode: Syntax(nodeToReplace), newNode: Syntax(newNode)) 38 | ] 39 | ) 40 | ] 41 | ) 42 | ) 43 | 44 | return [] 45 | } 46 | } 47 | 48 | final class FixItTests: BaseTestCase { 49 | override func invokeTest() { 50 | withMacroTesting(macros: [ReplaceFirstMemberMacro.self]) { 51 | super.invokeTest() 52 | } 53 | } 54 | 55 | func testReplaceFirstMember() { 56 | assertMacro { 57 | """ 58 | @ReplaceFirstMember 59 | struct FooBar { 60 | let foo: Foo 61 | let bar: Bar 62 | let baz: Baz 63 | } 64 | """ 65 | } diagnostics: { 66 | """ 67 | @ReplaceFirstMember 68 | ┬───────────────── 69 | ╰─ ⚠️ First member needs to be replaced 70 | ✏️ Replace the first member 71 | struct FooBar { 72 | let foo: Foo 73 | let bar: Bar 74 | let baz: Baz 75 | } 76 | """ 77 | } fixes: { 78 | """ 79 | @ReplaceFirstMember 80 | struct FooBar { 81 | let oye: Oye 82 | let bar: Bar 83 | let baz: Baz 84 | } 85 | """ 86 | } expansion: { 87 | """ 88 | struct FooBar { 89 | let oye: Oye 90 | let bar: Bar 91 | let baz: Baz 92 | } 93 | """ 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - '*' 10 | workflow_dispatch: 11 | 12 | concurrency: 13 | group: ci-${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | macos: 18 | name: macOS 19 | runs-on: macos-15 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Select Xcode 16.2 23 | run: sudo xcode-select -s /Applications/Xcode_16.2.app 24 | - name: Run tests 25 | run: swift test 26 | 27 | linux: 28 | name: Linux 29 | strategy: 30 | matrix: 31 | swift: 32 | - '6.0' 33 | runs-on: ubuntu-latest 34 | container: swift:${{ matrix.swift }} 35 | steps: 36 | - uses: actions/checkout@v4 37 | - name: Run tests 38 | run: swift test 39 | 40 | # NB: swift-snapshot-testing needs to be updated for Wasm support 41 | # wasm: 42 | # name: Wasm 43 | # runs-on: ubuntu-latest 44 | # steps: 45 | # - uses: actions/checkout@v4 46 | # - uses: bytecodealliance/actions/wasmtime/setup@v1 47 | # - name: Install Swift and Swift SDK for WebAssembly 48 | # run: | 49 | # PREFIX=/opt/swift 50 | # set -ex 51 | # curl -f -o /tmp/swift.tar.gz "https://download.swift.org/swift-6.0.2-release/ubuntu2204/swift-6.0.2-RELEASE/swift-6.0.2-RELEASE-ubuntu22.04.tar.gz" 52 | # sudo mkdir -p $PREFIX; sudo tar -xzf /tmp/swift.tar.gz -C $PREFIX --strip-component 1 53 | # $PREFIX/usr/bin/swift sdk install https://github.com/swiftwasm/swift/releases/download/swift-wasm-6.0.2-RELEASE/swift-wasm-6.0.2-RELEASE-wasm32-unknown-wasi.artifactbundle.zip --checksum 6ffedb055cb9956395d9f435d03d53ebe9f6a8d45106b979d1b7f53358e1dcb4 54 | # echo "$PREFIX/usr/bin" >> $GITHUB_PATH 55 | # 56 | # - name: Build 57 | # run: swift build --swift-sdk wasm32-unknown-wasi -Xlinker -z -Xlinker stack-size=$((1024 * 1024)) 58 | 59 | # NB: 5.9 snapshot outdated, wait for release 60 | # windows: 61 | # name: Windows 62 | # runs-on: windows-latest 63 | # steps: 64 | # - uses: compnerd/gha-setup-swift@main 65 | # with: 66 | # branch: swift-5.9-release 67 | # tag: 5.9-DEVELOPMENT-SNAPSHOT-2023-09-16-a 68 | # - uses: actions/checkout@v4 69 | # - name: Run tests 70 | # run: swift test 71 | 72 | android: 73 | strategy: 74 | matrix: 75 | swift: 76 | - "6.2" 77 | name: Android 78 | runs-on: ubuntu-latest 79 | steps: 80 | - uses: actions/checkout@v4 81 | - uses: skiptools/swift-android-action@v2 82 | with: 83 | swift-version: ${{ matrix.swift }} 84 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/AddCompletionHandlerTests.swift: -------------------------------------------------------------------------------- 1 | import MacroTesting 2 | import XCTest 3 | 4 | final class AddCompletionHandlerTests: BaseTestCase { 5 | override func invokeTest() { 6 | withMacroTesting(macros: [AddCompletionHandlerMacro.self]) { 7 | super.invokeTest() 8 | } 9 | } 10 | 11 | func testExpansionTransformsAsyncFunctionToCompletion() { 12 | assertMacro { 13 | """ 14 | @AddCompletionHandler 15 | func f(a: Int, for b: String, _ value: Double) async -> String { 16 | return b 17 | } 18 | """ 19 | } expansion: { 20 | """ 21 | func f(a: Int, for b: String, _ value: Double) async -> String { 22 | return b 23 | } 24 | 25 | func f(a: Int, for b: String, _ value: Double, completionHandler: @escaping (String) -> Void) { 26 | Task { 27 | completionHandler(await f(a: a, for: b, value)) 28 | } 29 | 30 | } 31 | """ 32 | } 33 | } 34 | 35 | func testExpansionOnStoredPropertyEmitsError() { 36 | assertMacro { 37 | """ 38 | struct Test { 39 | @AddCompletionHandler 40 | var value: Int 41 | } 42 | """ 43 | } diagnostics: { 44 | """ 45 | struct Test { 46 | @AddCompletionHandler 47 | ┬──────────────────── 48 | ╰─ 🛑 @addCompletionHandler only works on functions 49 | var value: Int 50 | } 51 | """ 52 | } 53 | } 54 | 55 | func testExpansionOnNonAsyncFunctionEmitsErrorWithFixItSuggestion() { 56 | assertMacro { 57 | """ 58 | struct Test { 59 | @AddCompletionHandler 60 | func fetchData() -> String { 61 | return "Hello, World!" 62 | } 63 | } 64 | """ 65 | } diagnostics: { 66 | """ 67 | struct Test { 68 | @AddCompletionHandler 69 | func fetchData() -> String { 70 | ┬─── 71 | ╰─ 🛑 can only add a completion-handler variant to an 'async' function 72 | ✏️ add 'async' 73 | return "Hello, World!" 74 | } 75 | } 76 | """ 77 | } fixes: { 78 | """ 79 | struct Test { 80 | @AddCompletionHandler 81 | func fetchData() async-> String { 82 | return "Hello, World!" 83 | } 84 | } 85 | """ 86 | } expansion: { 87 | """ 88 | struct Test { 89 | func fetchData() async-> String { 90 | return "Hello, World!" 91 | } 92 | 93 | func fetchData(completionHandler: @escaping (String) -> Void) { 94 | Task { 95 | completionHandler(await fetchData()) 96 | } 97 | 98 | } 99 | } 100 | """ 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | release: 4 | types: [published] 5 | workflow_dispatch: 6 | jobs: 7 | project-channel: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Dump Github context 11 | env: 12 | GITHUB_CONTEXT: ${{ toJSON(github) }} 13 | run: echo "$GITHUB_CONTEXT" 14 | - name: Slack Notification on SUCCESS 15 | if: success() 16 | uses: tokorom/action-slack-incoming-webhook@main 17 | env: 18 | INCOMING_WEBHOOK_URL: ${{ secrets.SLACK_PROJECT_CHANNEL_WEBHOOK_URL }} 19 | with: 20 | text: swift-macro-testing ${{ github.event.release.tag_name }} has been released. 21 | blocks: | 22 | [ 23 | { 24 | "type": "header", 25 | "text": { 26 | "type": "plain_text", 27 | "text": "swift-macro-testing ${{ github.event.release.tag_name}}" 28 | } 29 | }, 30 | { 31 | "type": "section", 32 | "text": { 33 | "type": "mrkdwn", 34 | "text": ${{ toJSON(github.event.release.body) }} 35 | } 36 | }, 37 | { 38 | "type": "section", 39 | "text": { 40 | "type": "mrkdwn", 41 | "text": "${{ github.event.release.html_url }}" 42 | } 43 | } 44 | ] 45 | 46 | releases-channel: 47 | runs-on: ubuntu-latest 48 | steps: 49 | - name: Dump Github context 50 | env: 51 | GITHUB_CONTEXT: ${{ toJSON(github) }} 52 | run: echo "$GITHUB_CONTEXT" 53 | - name: Slack Notification on SUCCESS 54 | if: success() 55 | uses: tokorom/action-slack-incoming-webhook@main 56 | env: 57 | INCOMING_WEBHOOK_URL: ${{ secrets.SLACK_RELEASES_WEBHOOK_URL }} 58 | with: 59 | text: swift-macro-testing ${{ github.event.release.tag_name }} has been released. 60 | blocks: | 61 | [ 62 | { 63 | "type": "header", 64 | "text": { 65 | "type": "plain_text", 66 | "text": "swift-macro-testing ${{ github.event.release.tag_name}}" 67 | } 68 | }, 69 | { 70 | "type": "section", 71 | "text": { 72 | "type": "mrkdwn", 73 | "text": ${{ toJSON(github.event.release.body) }} 74 | } 75 | }, 76 | { 77 | "type": "section", 78 | "text": { 79 | "type": "mrkdwn", 80 | "text": "${{ github.event.release.html_url }}" 81 | } 82 | } 83 | ] 84 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/MetaEnumMacroTests.swift: -------------------------------------------------------------------------------- 1 | import MacroTesting 2 | import XCTest 3 | 4 | final class MetaEnumMacroTests: BaseTestCase { 5 | override func invokeTest() { 6 | withMacroTesting(macros: [MetaEnumMacro.self]) { 7 | super.invokeTest() 8 | } 9 | } 10 | 11 | func testExpansionAddsNestedMetaEnum() { 12 | assertMacro { 13 | """ 14 | @MetaEnum enum Cell { 15 | case integer(Int) 16 | case text(String) 17 | case boolean(Bool) 18 | case null 19 | } 20 | """ 21 | } expansion: { 22 | """ 23 | enum Cell { 24 | case integer(Int) 25 | case text(String) 26 | case boolean(Bool) 27 | case null 28 | 29 | enum Meta { 30 | case integer 31 | case text 32 | case boolean 33 | case null 34 | init(_ __macro_local_6parentfMu_: Cell) { 35 | switch __macro_local_6parentfMu_ { 36 | case .integer: 37 | self = .integer 38 | case .text: 39 | self = .text 40 | case .boolean: 41 | self = .boolean 42 | case .null: 43 | self = .null 44 | } 45 | } 46 | } 47 | } 48 | """ 49 | } 50 | } 51 | 52 | func testExpansionAddsPublicNestedMetaEnum() { 53 | assertMacro { 54 | """ 55 | @MetaEnum public enum Cell { 56 | case integer(Int) 57 | case text(String) 58 | case boolean(Bool) 59 | } 60 | """ 61 | } expansion: { 62 | """ 63 | public enum Cell { 64 | case integer(Int) 65 | case text(String) 66 | case boolean(Bool) 67 | 68 | public enum Meta { 69 | case integer 70 | case text 71 | case boolean 72 | public init(_ __macro_local_6parentfMu_: Cell) { 73 | switch __macro_local_6parentfMu_ { 74 | case .integer: 75 | self = .integer 76 | case .text: 77 | self = .text 78 | case .boolean: 79 | self = .boolean 80 | } 81 | } 82 | } 83 | } 84 | """ 85 | } 86 | } 87 | 88 | func testExpansionOnStructEmitsError() { 89 | assertMacro { 90 | """ 91 | @MetaEnum struct Cell { 92 | let integer: Int 93 | let text: String 94 | let boolean: Bool 95 | } 96 | """ 97 | } diagnostics: { 98 | """ 99 | @MetaEnum struct Cell { 100 | ┬──────── 101 | ╰─ 🛑 '@MetaEnum' can only be attached to an enum, not a struct 102 | let integer: Int 103 | let text: String 104 | let boolean: Bool 105 | } 106 | """ 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Something isn't working as expected 3 | labels: [bug] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thank you for contributing to swift-macro-testing! 9 | 10 | Before you submit your issue, please complete each text area below with the relevant details for your bug, and complete the steps in the checklist 11 | - type: textarea 12 | attributes: 13 | label: Description 14 | description: | 15 | A short description of the incorrect behavior. 16 | 17 | If you think this issue has been recently introduced and did not occur in an earlier version, please note that. If possible, include the last version that the behavior was correct in addition to your current version. 18 | validations: 19 | required: true 20 | - type: checkboxes 21 | attributes: 22 | label: Checklist 23 | options: 24 | - label: If possible, I've reproduced the issue using the `main` branch of this package. 25 | required: false 26 | - label: This issue hasn't been addressed in an [existing GitHub issue](https://github.com/pointfreeco/swift-macro-testing/issues) or [discussion](https://github.com/pointfreeco/swift-macro-testing/discussions). 27 | required: true 28 | - type: textarea 29 | attributes: 30 | label: Expected behavior 31 | description: Describe what you expected to happen. 32 | validations: 33 | required: false 34 | - type: textarea 35 | attributes: 36 | label: Actual behavior 37 | description: Describe or copy/paste the behavior you observe. 38 | validations: 39 | required: false 40 | - type: textarea 41 | attributes: 42 | label: Steps to reproduce 43 | description: | 44 | Explanation of how to reproduce the incorrect behavior. 45 | 46 | This could include an attached project or link to code that is exhibiting the issue, and/or a screen recording. 47 | placeholder: | 48 | 1. ... 49 | validations: 50 | required: false 51 | - type: input 52 | attributes: 53 | label: swift-macro-testing version information 54 | description: The version of swift-macro-testing used to reproduce this issue. 55 | placeholder: "'0.1.0' for example, or a commit hash" 56 | - type: input 57 | attributes: 58 | label: Destination operating system 59 | description: The OS running swift-macro-testing. 60 | placeholder: "'macOS 14' for example" 61 | - type: input 62 | attributes: 63 | label: Xcode version information 64 | description: The version of Xcode used to reproduce this issue. 65 | placeholder: "The version displayed from 'Xcode 〉About Xcode'" 66 | - type: textarea 67 | attributes: 68 | label: Swift Compiler version information 69 | description: The version of Swift used to reproduce this issue. 70 | placeholder: Output from 'xcrun swiftc --version' 71 | render: shell 72 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/MacroExamples/DefaultFatalErrorImplementationMacro.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2014 - 2023 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 SwiftDiagnostics 14 | import SwiftSyntax 15 | import SwiftSyntaxBuilder 16 | import SwiftSyntaxMacros 17 | 18 | /// Provides default `fatalError` implementations for protocol methods. 19 | /// 20 | /// This macro generates extensions that add default `fatalError` implementations 21 | /// for each method in the protocol it is attached to. 22 | public enum DefaultFatalErrorImplementationMacro: ExtensionMacro { 23 | 24 | /// Unique identifier for messages related to this macro. 25 | private static let messageID = MessageID( 26 | domain: "MacroExamples", id: "ProtocolDefaultImplementation") 27 | 28 | /// Generates extension for the protocol to which this macro is attached. 29 | public static func expansion( 30 | of node: AttributeSyntax, 31 | attachedTo declaration: some DeclGroupSyntax, 32 | providingExtensionsOf type: some TypeSyntaxProtocol, 33 | conformingTo protocols: [TypeSyntax], 34 | in context: some MacroExpansionContext 35 | ) throws -> [ExtensionDeclSyntax] { 36 | 37 | // Validate that the macro is being applied to a protocol declaration 38 | guard let protocolDecl = declaration.as(ProtocolDeclSyntax.self) else { 39 | throw SimpleDiagnosticMessage( 40 | message: "Macro `defaultFatalErrorImplementation` can only be applied to a protocol", 41 | diagnosticID: messageID, 42 | severity: .error 43 | ) 44 | } 45 | 46 | // Extract all the methods from the protocol and assign default implementations 47 | let methods = protocolDecl.memberBlock.members 48 | .map(\.decl) 49 | .compactMap { declaration -> FunctionDeclSyntax? in 50 | guard var function = declaration.as(FunctionDeclSyntax.self) else { 51 | return nil 52 | } 53 | function.body = CodeBlockSyntax { 54 | ExprSyntax(#"fatalError("whoops 😅")"#) 55 | } 56 | return function 57 | } 58 | 59 | // Don't generate an extension if there are no methods 60 | if methods.isEmpty { 61 | return [] 62 | } 63 | 64 | // Generate the extension containing the default implementations 65 | let extensionDecl = ExtensionDeclSyntax(extendedType: type) { 66 | for method in methods { 67 | MemberBlockItemSyntax(decl: method) 68 | } 69 | } 70 | 71 | return [extensionDecl] 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/MacroExamples/DictionaryIndirectionMacro.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2014 - 2023 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 SwiftSyntax 14 | import SwiftSyntaxMacros 15 | 16 | public struct DictionaryStorageMacro {} 17 | 18 | extension DictionaryStorageMacro: MemberMacro { 19 | public static func expansion( 20 | of node: AttributeSyntax, 21 | providingMembersOf declaration: some DeclGroupSyntax, 22 | in context: some MacroExpansionContext 23 | ) throws -> [DeclSyntax] { 24 | return ["\n var _storage: [String: Any] = [:]"] 25 | } 26 | } 27 | 28 | extension DictionaryStorageMacro: MemberAttributeMacro { 29 | public static func expansion( 30 | of node: AttributeSyntax, 31 | attachedTo declaration: some DeclGroupSyntax, 32 | providingAttributesFor member: some DeclSyntaxProtocol, 33 | in context: some MacroExpansionContext 34 | ) throws -> [AttributeSyntax] { 35 | guard let property = member.as(VariableDeclSyntax.self), 36 | property.isStoredProperty 37 | else { 38 | return [] 39 | } 40 | 41 | return [ 42 | AttributeSyntax( 43 | leadingTrivia: [.newlines(1), .spaces(2)], 44 | attributeName: IdentifierTypeSyntax( 45 | name: .identifier("DictionaryStorageProperty") 46 | ) 47 | ) 48 | ] 49 | } 50 | } 51 | 52 | public struct DictionaryStoragePropertyMacro: AccessorMacro { 53 | public static func expansion< 54 | Context: MacroExpansionContext, 55 | Declaration: DeclSyntaxProtocol 56 | >( 57 | of node: AttributeSyntax, 58 | providingAccessorsOf declaration: Declaration, 59 | in context: Context 60 | ) throws -> [AccessorDeclSyntax] { 61 | guard let varDecl = declaration.as(VariableDeclSyntax.self), 62 | let binding = varDecl.bindings.first, 63 | let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier, 64 | binding.accessorBlock == nil, 65 | let type = binding.typeAnnotation?.type 66 | else { 67 | return [] 68 | } 69 | 70 | // Ignore the "_storage" variable. 71 | if identifier.text == "_storage" { 72 | return [] 73 | } 74 | 75 | guard let defaultValue = binding.initializer?.value else { 76 | throw CustomError.message("stored property must have an initializer") 77 | } 78 | 79 | return [ 80 | """ 81 | get { 82 | _storage[\(literal: identifier.text), default: \(defaultValue)] as! \(type) 83 | } 84 | """, 85 | """ 86 | set { 87 | _storage[\(literal: identifier.text)] = newValue 88 | } 89 | """, 90 | ] 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Sources/MacroTesting/SwiftSyntax/SourceEdit.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2014 - 2023 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 SwiftSyntax 14 | 15 | /// A textual edit to the original source represented by a range and a 16 | /// replacement. 17 | public struct SourceEdit: Equatable { 18 | /// The half-open range that this edit applies to. 19 | public let range: Range 20 | /// The text to replace the original range with. Empty for a deletion. 21 | public let replacement: String 22 | 23 | /// Length of the original source range that this edit applies to. Zero if 24 | /// this is an addition. 25 | public var length: SourceLength { 26 | return SourceLength(utf8Length: range.lowerBound.utf8Offset - range.upperBound.utf8Offset) 27 | } 28 | 29 | /// Create an edit to replace `range` in the original source with 30 | /// `replacement`. 31 | public init(range: Range, replacement: String) { 32 | self.range = range 33 | self.replacement = replacement 34 | } 35 | 36 | /// Convenience function to create a textual addition after the given node 37 | /// and its trivia. 38 | public static func insert(_ newText: String, after node: some SyntaxProtocol) -> SourceEdit { 39 | return SourceEdit(range: node.endPosition.. SourceEdit { 45 | return SourceEdit(range: node.position.. SourceEdit { 51 | return SourceEdit(range: node.position.. SourceEdit { 57 | return SourceEdit(range: node.position..) -> Void) -> Void { 16 | completionBlock(.success("a: \(a), b: \(b), value: \(value)")) 17 | } 18 | """# 19 | } expansion: { 20 | #""" 21 | func c(a: Int, for b: String, _ value: Double, completionBlock: @escaping (Result) -> Void) -> Void { 22 | completionBlock(.success("a: \(a), b: \(b), value: \(value)")) 23 | } 24 | 25 | func c(a: Int, for b: String, _ value: Double) async throws -> String { 26 | try await withCheckedThrowingContinuation { continuation in 27 | c(a: a, for: b, value) { returnValue in 28 | 29 | switch returnValue { 30 | case .success(let value): 31 | continuation.resume(returning: value) 32 | case .failure(let error): 33 | continuation.resume(throwing: error) 34 | } 35 | } 36 | } 37 | 38 | } 39 | """# 40 | } 41 | } 42 | 43 | func testExpansionTransformsFunctionWithBoolCompletionToAsync() { 44 | assertMacro { 45 | """ 46 | @AddAsync 47 | func d(a: Int, for b: String, _ value: Double, completionBlock: @escaping (Bool) -> Void) -> Void { 48 | completionBlock(true) 49 | } 50 | """ 51 | } expansion: { 52 | """ 53 | func d(a: Int, for b: String, _ value: Double, completionBlock: @escaping (Bool) -> Void) -> Void { 54 | completionBlock(true) 55 | } 56 | 57 | func d(a: Int, for b: String, _ value: Double) async -> Bool { 58 | await withCheckedContinuation { continuation in 59 | d(a: a, for: b, value) { returnValue in 60 | 61 | continuation.resume(returning: returnValue) 62 | } 63 | } 64 | 65 | } 66 | """ 67 | } 68 | } 69 | 70 | func testExpansionOnStoredPropertyEmitsError() { 71 | assertMacro { 72 | """ 73 | struct Test { 74 | @AddAsync 75 | var name: String 76 | } 77 | """ 78 | } diagnostics: { 79 | """ 80 | struct Test { 81 | @AddAsync 82 | ┬──────── 83 | ╰─ 🛑 @addAsync only works on functions 84 | var name: String 85 | } 86 | """ 87 | } 88 | } 89 | 90 | func testExpansionOnAsyncFunctionEmitsError() { 91 | assertMacro { 92 | """ 93 | struct Test { 94 | @AddAsync 95 | async func sayHello() { 96 | } 97 | } 98 | """ 99 | } diagnostics: { 100 | """ 101 | struct Test { 102 | @AddAsync 103 | ┬──────── 104 | ╰─ 🛑 @addAsync requires an function that returns void 105 | async func sayHello() { 106 | } 107 | } 108 | """ 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/MacroExamples/AddBlocker.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2014 - 2023 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 SwiftDiagnostics 14 | import SwiftOperators 15 | import SwiftSyntax 16 | import SwiftSyntaxMacros 17 | 18 | /// Implementation of the `addBlocker` macro, which demonstrates how to 19 | /// produce detailed diagnostics from a macro implementation for an utterly 20 | /// silly task: warning about every "add" (binary +) in the argument, with a 21 | /// Fix-It that changes it to a "-". 22 | public struct AddBlocker: ExpressionMacro { 23 | class AddVisitor: SyntaxRewriter { 24 | var diagnostics: [Diagnostic] = [] 25 | 26 | override func visit( 27 | _ node: InfixOperatorExprSyntax 28 | ) -> ExprSyntax { 29 | // Identify any infix operator + in the tree. 30 | if var binOp = node.operator.as(BinaryOperatorExprSyntax.self) { 31 | if binOp.operator.text == "+" { 32 | // Form the warning 33 | let messageID = MessageID(domain: "silly", id: "addblock") 34 | diagnostics.append( 35 | Diagnostic( 36 | // Where the warning should go (on the "+"). 37 | node: Syntax(node.operator), 38 | // The warning message and severity. 39 | message: SimpleDiagnosticMessage( 40 | message: "blocked an add; did you mean to subtract?", 41 | diagnosticID: messageID, 42 | severity: .warning 43 | ), 44 | // Highlight the left and right sides of the `+`. 45 | highlights: [ 46 | Syntax(node.leftOperand), 47 | Syntax(node.rightOperand), 48 | ], 49 | fixIts: [ 50 | // Fix-It to replace the '+' with a '-'. 51 | FixIt( 52 | message: SimpleDiagnosticMessage( 53 | message: "use '-'", 54 | diagnosticID: messageID, 55 | severity: .error 56 | ), 57 | changes: [ 58 | FixIt.Change.replace( 59 | oldNode: Syntax(binOp.operator), 60 | newNode: Syntax( 61 | TokenSyntax( 62 | .binaryOperator("-"), 63 | leadingTrivia: binOp.operator.leadingTrivia, 64 | trailingTrivia: binOp.operator.trailingTrivia, 65 | presence: .present 66 | ) 67 | ) 68 | ) 69 | ] 70 | ) 71 | ] 72 | ) 73 | ) 74 | 75 | binOp.operator.tokenKind = .binaryOperator("-") 76 | 77 | return ExprSyntax(node.with(\.operator, ExprSyntax(binOp))) 78 | } 79 | } 80 | 81 | return ExprSyntax(node) 82 | } 83 | } 84 | 85 | public static func expansion( 86 | of node: some FreestandingMacroExpansionSyntax, 87 | in context: some MacroExpansionContext 88 | ) throws -> ExprSyntax { 89 | let visitor = AddVisitor() 90 | let result = visitor.rewrite(Syntax(node)) 91 | 92 | for diag in visitor.diagnostics { 93 | context.diagnose(diag) 94 | } 95 | 96 | return result.asProtocol(FreestandingMacroExpansionSyntax.self)!.arguments.first!.expression 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/ObservableMacroTests.swift: -------------------------------------------------------------------------------- 1 | import MacroTesting 2 | import XCTest 3 | 4 | final class ObservableMacroTests: XCTestCase { 5 | override func invokeTest() { 6 | withMacroTesting( 7 | macros: [ 8 | "Observable": ObservableMacro.self, 9 | "ObservableProperty": ObservablePropertyMacro.self, 10 | ] 11 | ) { 12 | super.invokeTest() 13 | } 14 | } 15 | 16 | func testExpansion() { 17 | assertMacro { 18 | """ 19 | @Observable 20 | final class Dog { 21 | var name: String? 22 | var treat: Treat? 23 | 24 | var isHappy: Bool = true 25 | 26 | init() {} 27 | 28 | func bark() { 29 | print("bork bork") 30 | } 31 | } 32 | """ 33 | } expansion: { 34 | #""" 35 | final class Dog { 36 | var name: String? { 37 | get { 38 | _registrar.beginAccess(\.name) 39 | defer { 40 | _registrar.endAccess() 41 | } 42 | return _storage.name 43 | } 44 | set { 45 | _registrar.beginAccess(\.name) 46 | _registrar.register(observable: self, willSet: \.name, to: newValue) 47 | defer { 48 | _registrar.register(observable: self, didSet: \.name) 49 | _registrar.endAccess() 50 | } 51 | _storage.name = newValue 52 | } 53 | } 54 | var treat: Treat? { 55 | get { 56 | _registrar.beginAccess(\.treat) 57 | defer { 58 | _registrar.endAccess() 59 | } 60 | return _storage.treat 61 | } 62 | set { 63 | _registrar.beginAccess(\.treat) 64 | _registrar.register(observable: self, willSet: \.treat, to: newValue) 65 | defer { 66 | _registrar.register(observable: self, didSet: \.treat) 67 | _registrar.endAccess() 68 | } 69 | _storage.treat = newValue 70 | } 71 | } 72 | 73 | var isHappy: Bool { 74 | get { 75 | _registrar.beginAccess(\.isHappy) 76 | defer { 77 | _registrar.endAccess() 78 | } 79 | return _storage.isHappy 80 | } 81 | set { 82 | _registrar.beginAccess(\.isHappy) 83 | _registrar.register(observable: self, willSet: \.isHappy, to: newValue) 84 | defer { 85 | _registrar.register(observable: self, didSet: \.isHappy) 86 | _registrar.endAccess() 87 | } 88 | _storage.isHappy = newValue 89 | } 90 | } 91 | 92 | init() {} 93 | 94 | func bark() { 95 | print("bork bork") 96 | } 97 | 98 | let _registrar = ObservationRegistrar() 99 | 100 | public nonisolated func addObserver(_ observer: some Observer) { 101 | _registrar.addObserver(observer) 102 | } 103 | 104 | public nonisolated func removeObserver(_ observer: some Observer) { 105 | _registrar.removeObserver(observer) 106 | } 107 | 108 | private func withTransaction(_ apply: () throws -> T) rethrows -> T { 109 | _registrar.beginAccess() 110 | defer { 111 | _registrar.endAccess() 112 | } 113 | return try apply() 114 | } 115 | 116 | private struct Storage { 117 | 118 | var name: String? 119 | var treat: Treat? 120 | 121 | var isHappy: Bool = true 122 | } 123 | 124 | private var _storage = Storage() 125 | } 126 | 127 | extension Dog: Observable { 128 | } 129 | """# 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /Sources/MacroTesting/Documentation.docc/MacroTesting.md: -------------------------------------------------------------------------------- 1 | # ``MacroTesting`` 2 | 3 | Magical testing tools for Swift macros. 4 | 5 | ## Overview 6 | 7 | This library comes with a tool for testing macros that is more powerful and ergonomic than the 8 | default tool that comes with SwiftSyntax. To use the tool, simply specify the macros that you want 9 | to expand as well as a string of Swift source code that makes use of the macro. 10 | 11 | For example, to test the `#stringify` macro that comes with SPM's macro template all one needs to 12 | do is write the following: 13 | 14 | ```swift 15 | import MacroTesting 16 | import Testing 17 | 18 | @Suite(.macros([StringifyMacro.self])) 19 | struct StringifyTests { 20 | @Test func stringify() { 21 | assertMacro { 22 | """ 23 | #stringify(a + b) 24 | """ 25 | } 26 | } 27 | } 28 | ``` 29 | 30 | When you run this test the library will automatically expand the macros in the source code string 31 | and write the expansion into the test file: 32 | 33 | ```swift 34 | @Suite(.macros([StringifyMacro.self])) 35 | struct StringifyTests { 36 | @Test func stringify() { 37 | assertMacro { 38 | """ 39 | #stringify(a + b) 40 | """ 41 | } expansion: { 42 | """ 43 | (a + b, "a + b") 44 | """ 45 | } 46 | } 47 | ``` 48 | 49 | That is all it takes. 50 | 51 | If in the future the macro's output changes, such as adding labels to the tuple's arguments, then 52 | running the test again will produce a nicely formatted message: 53 | 54 | ```diff 55 | ❌ Actual output (+) differed from expected output (−). Difference: … 56 | 57 | - (a + b, "a + b") 58 | + (result: a + b, code: "a + b") 59 | ``` 60 | 61 | You can even have the library automatically re-record the macro expansion directly into your test 62 | file by providing the `record` argument to ``Testing/Trait/macros(_:indentationWidth:record:)``: 63 | 64 | ```swift 65 | @Suite(.macros([StringifyMacro.self], record: .all)) 66 | ``` 67 | 68 | Now when you run the test again the freshest expanded macro will be written to the `expansion` 69 | trailing closure. 70 | 71 | Macro Testing can also test diagnostics, such as warnings, errors, notes, and fix-its. When a macro 72 | expansion emits a diagnostic, it will render inline in the test. For example, a macro that adds 73 | completion handler functions to async functions may emit an error and fix-it when it is applied to a 74 | non-async function. The resulting macro test will fully capture this information, including where 75 | the diagnostics are emitted, how the fix-its are applied, and how the final macro expands: 76 | 77 | ```swift 78 | func testNonAsyncFunctionDiagnostic() { 79 | assertMacro { 80 | """ 81 | @AddCompletionHandler 82 | func f(a: Int, for b: String) -> String { 83 | return b 84 | } 85 | """ 86 | } diagnostics: { 87 | """ 88 | @AddCompletionHandler 89 | func f(a: Int, for b: String) -> String { 90 | ┬─── 91 | ╰─ 🛑 can only add a completion-handler variant to an 'async' function 92 | ✏️ add 'async' 93 | return b 94 | } 95 | """ 96 | } fixes: { 97 | """ 98 | @AddCompletionHandler 99 | func f(a: Int, for b: String) async -> String { 100 | return b 101 | } 102 | """ 103 | } expansion: { 104 | """ 105 | func f(a: Int, for b: String) async -> String { 106 | return b 107 | } 108 | 109 | func f(a: Int, for b: String, completionHandler: @escaping (String) -> Void) { 110 | Task { 111 | completionHandler(await f(a: a, for: b, value)) 112 | } 113 | } 114 | """ 115 | } 116 | } 117 | ``` 118 | 119 | ## Topics 120 | 121 | ### Essentials 122 | 123 | - ``assertMacro(_:indentationWidth:record:of:diagnostics:fixes:expansion:fileID:file:function:line:column:)-8zqk4`` 124 | - ``withMacroTesting(indentationWidth:record:macros:operation:)-7cm1s`` 125 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/MacroExamples/WrapStoredPropertiesMacro.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2014 - 2023 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 SwiftSyntax 14 | import SwiftSyntaxBuilder 15 | import SwiftSyntaxMacros 16 | 17 | /// Implementation of the `wrapStoredProperties` macro, which can be 18 | /// used to apply an attribute to all of the stored properties of a type. 19 | /// 20 | /// This macro demonstrates member-attribute macros, which allow an attribute 21 | /// written on a type or extension to apply attributes to the members 22 | /// declared within that type or extension. 23 | public struct WrapStoredPropertiesMacro: MemberAttributeMacro { 24 | public static func expansion< 25 | Declaration: DeclGroupSyntax, 26 | Context: MacroExpansionContext 27 | >( 28 | of node: AttributeSyntax, 29 | attachedTo decl: Declaration, 30 | providingAttributesFor member: some DeclSyntaxProtocol, 31 | in context: Context 32 | ) throws -> [AttributeSyntax] { 33 | guard let property = member.as(VariableDeclSyntax.self), 34 | property.isStoredProperty 35 | else { 36 | return [] 37 | } 38 | 39 | guard case let .argumentList(arguments) = node.arguments, 40 | let firstElement = arguments.first, 41 | let stringLiteral = firstElement.expression 42 | .as(StringLiteralExprSyntax.self), 43 | stringLiteral.segments.count == 1, 44 | case let .stringSegment(wrapperName)? = stringLiteral.segments.first 45 | else { 46 | throw CustomError.message( 47 | "macro requires a string literal containing the name of an attribute") 48 | } 49 | 50 | return [ 51 | AttributeSyntax( 52 | leadingTrivia: [.newlines(1), .spaces(2)], 53 | attributeName: IdentifierTypeSyntax( 54 | name: .identifier(wrapperName.content.text) 55 | ) 56 | ) 57 | ] 58 | } 59 | } 60 | 61 | extension VariableDeclSyntax { 62 | /// Determine whether this variable has the syntax of a stored property. 63 | /// 64 | /// This syntactic check cannot account for semantic adjustments due to, 65 | /// e.g., accessor macros or property wrappers. 66 | var isStoredProperty: Bool { 67 | if bindings.count != 1 { 68 | return false 69 | } 70 | 71 | let binding = bindings.first! 72 | switch binding.accessorBlock?.accessors { 73 | case .none: 74 | return true 75 | 76 | case .accessors(let accessors): 77 | for accessor in accessors { 78 | switch accessor.accessorSpecifier.tokenKind { 79 | case .keyword(.willSet), .keyword(.didSet): 80 | // Observers can occur on a stored property. 81 | break 82 | 83 | default: 84 | // Other accessors make it a computed property. 85 | return false 86 | } 87 | } 88 | 89 | return true 90 | 91 | case .getter: 92 | return false 93 | } 94 | } 95 | } 96 | 97 | extension DeclGroupSyntax { 98 | /// Enumerate the stored properties that syntactically occur in this 99 | /// declaration. 100 | func storedProperties() -> [VariableDeclSyntax] { 101 | return memberBlock.members.compactMap { member in 102 | guard let variable = member.decl.as(VariableDeclSyntax.self), 103 | variable.isStoredProperty 104 | else { 105 | return nil 106 | } 107 | 108 | return variable 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Sources/MacroTesting/_SwiftSyntaxTestSupport/FixItApplier.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2014 - 2023 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 SwiftDiagnostics 14 | import SwiftSyntax 15 | import SwiftSyntaxMacroExpansion 16 | 17 | public enum FixItApplier { 18 | /// Applies selected or all Fix-Its from the provided diagnostics to a given syntax tree. 19 | /// 20 | /// - Parameters: 21 | /// - diagnostics: An array of `Diagnostic` objects, each containing one or more Fix-Its. 22 | /// - filterByMessages: An optional array of message strings to filter which Fix-Its to apply. 23 | /// If `nil`, the first Fix-It from each diagnostic is applied. 24 | /// - tree: The syntax tree to which the Fix-Its will be applied. 25 | /// 26 | /// - Returns: A `String` representation of the modified syntax tree after applying the Fix-Its. 27 | // public static func applyFixes( 28 | // from diagnostics: [Diagnostic], 29 | // filterByMessages messages: [String]?, 30 | // to tree: any SyntaxProtocol 31 | // ) -> String { 32 | // let messages = messages ?? diagnostics.compactMap { $0.fixIts.first?.message.message } 33 | // 34 | // let edits = 35 | // diagnostics 36 | // .flatMap(\.fixIts) 37 | // .filter { messages.contains($0.message.message) } 38 | // .flatMap(\.edits) 39 | // 40 | // return self.apply(edits: edits, to: tree) 41 | // } 42 | 43 | /// Apply the given edits to the syntax tree. 44 | /// 45 | /// - Parameters: 46 | /// - edits: The edits to apply to the syntax tree 47 | /// - tree: he syntax tree to which the edits should be applied. 48 | /// - Returns: A `String` representation of the modified syntax tree after applying the edits. 49 | public static func apply( 50 | edits: [SourceEdit], 51 | to tree: any SyntaxProtocol 52 | ) -> String { 53 | var edits = edits 54 | var source = tree.description 55 | 56 | while let edit = edits.first { 57 | edits = Array(edits.dropFirst()) 58 | 59 | let startIndex = source.utf8.index(source.utf8.startIndex, offsetBy: edit.startUtf8Offset) 60 | let endIndex = source.utf8.index(source.utf8.startIndex, offsetBy: edit.endUtf8Offset) 61 | 62 | source.replaceSubrange(startIndex.. SourceEdit? in 65 | if remainingEdit.replacementRange.overlaps(edit.replacementRange) { 66 | // The edit overlaps with the previous edit. We can't apply both 67 | // without conflicts. Apply the one that's listed first and drop the 68 | // later edit. 69 | return nil 70 | } 71 | 72 | // If the remaining edit starts after or at the end of the edit that we just applied, 73 | // shift it by the current edit's difference in length. 74 | if edit.endUtf8Offset <= remainingEdit.startUtf8Offset { 75 | let startPosition = AbsolutePosition( 76 | utf8Offset: remainingEdit.startUtf8Offset - edit.replacementRange.count 77 | + edit.replacementLength) 78 | let endPosition = AbsolutePosition( 79 | utf8Offset: remainingEdit.endUtf8Offset - edit.replacementRange.count 80 | + edit.replacementLength) 81 | return SourceEdit( 82 | range: startPosition.. { 107 | return startUtf8Offset..=6) 2 | import SnapshotTesting 3 | import SwiftSyntax 4 | import SwiftSyntaxMacros 5 | import Testing 6 | 7 | /// A type representing the configuration of snapshot testing. 8 | @_documentation(visibility: private) 9 | public struct _MacrosTestTrait: SuiteTrait, TestTrait { 10 | public let isRecursive = true 11 | let configuration: MacroTestingConfiguration 12 | let record: SnapshotTestingConfiguration.Record? 13 | } 14 | 15 | extension Trait where Self == _MacrosTestTrait { 16 | /// Configure snapshot testing in a suite or test. 17 | /// 18 | /// - Parameters: 19 | /// - indentationWidth: The `Trivia` for setting indentation during macro expansion (e.g., `.spaces(2)`). 20 | /// Defaults to the original source's indentation if unspecified. 21 | /// - record: The recording strategy to use for macro expansions. This can be set to `.all`, `.missing`, 22 | /// `.never`, or `.failed`. If not provided, it uses the current configuration, which can also be set via 23 | /// the `SNAPSHOT_TESTING_RECORD` environment variable. 24 | /// - macros: A dictionary mapping macro names to their implementations. This specifies which macros 25 | /// should be expanded during testing. 26 | public static func macros( 27 | _ macros: [String: Macro.Type]? = nil, 28 | indentationWidth: Trivia? = nil, 29 | record: SnapshotTestingConfiguration.Record? = nil 30 | ) -> Self { 31 | _MacrosTestTrait( 32 | configuration: MacroTestingConfiguration( 33 | indentationWidth: indentationWidth, 34 | macros: macros 35 | ), 36 | record: record 37 | ) 38 | } 39 | 40 | /// Configure snapshot testing in a suite or test. 41 | /// 42 | /// - Parameters: 43 | /// - indentationWidth: The `Trivia` for setting indentation during macro expansion (e.g., `.spaces(2)`). 44 | /// Defaults to the original source's indentation if unspecified. 45 | /// - record: The recording strategy to use for macro expansions. This can be set to `.all`, `.missing`, 46 | /// `.never`, or `.failed`. If not provided, it uses the current configuration, which can also be set via 47 | /// the `SNAPSHOT_TESTING_RECORD` environment variable. 48 | /// - macros: An array of macros. This specifies which macros should be expanded during testing. 49 | public static func macros( 50 | _ macros: [Macro.Type]? = nil, 51 | indentationWidth: Trivia? = nil, 52 | record: SnapshotTestingConfiguration.Record? = nil 53 | ) -> Self { 54 | _MacrosTestTrait( 55 | configuration: MacroTestingConfiguration( 56 | indentationWidth: indentationWidth, 57 | macros: macros.map { Dictionary(macros: $0) } 58 | ), 59 | record: record 60 | ) 61 | } 62 | } 63 | 64 | #if swift(>=6.1) 65 | @_documentation(visibility: private) 66 | extension _MacrosTestTrait: TestScoping { 67 | public func provideScope( 68 | for test: Test, 69 | testCase: Test.Case?, 70 | performing function: () async throws -> Void 71 | ) async throws { 72 | try await MacroTestingConfiguration.$current.withValue(configuration) { 73 | try await SnapshotTestingConfiguration.$current.withValue( 74 | SnapshotTestingConfiguration(record: record, diffTool: nil) 75 | ) { 76 | try await function() 77 | } 78 | } 79 | } 80 | } 81 | #else 82 | extension Test { 83 | var indentationWidth: Trivia? { 84 | for trait in traits.reversed() { 85 | if let indentationWidth = (trait as? _MacrosTestTrait)?.configuration.indentationWidth { 86 | return indentationWidth 87 | } 88 | } 89 | return nil 90 | } 91 | 92 | var macros: [String: Macro.Type]? { 93 | for trait in traits.reversed() { 94 | if let macros = (trait as? _MacrosTestTrait)?.configuration.macros { 95 | return macros 96 | } 97 | } 98 | return nil 99 | } 100 | 101 | var record: SnapshotTestingConfiguration.Record? { 102 | for trait in traits.reversed() { 103 | if let macros = (trait as? _MacrosTestTrait)?.record { 104 | return macros 105 | } 106 | } 107 | return nil 108 | } 109 | } 110 | #endif 111 | #endif 112 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/MacroExamples/MetaEnumMacro.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2014 - 2023 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 SwiftDiagnostics 14 | import SwiftSyntax 15 | import SwiftSyntaxBuilder 16 | import SwiftSyntaxMacros 17 | 18 | public struct MetaEnumMacro { 19 | let parentTypeName: TokenSyntax 20 | let childCases: [EnumCaseElementSyntax] 21 | let access: DeclModifierListSyntax.Element? 22 | let parentParamName: TokenSyntax 23 | 24 | init( 25 | node: AttributeSyntax, declaration: some DeclGroupSyntax, context: some MacroExpansionContext 26 | ) throws { 27 | guard let enumDecl = declaration.as(EnumDeclSyntax.self) else { 28 | throw DiagnosticsError(diagnostics: [ 29 | CaseMacroDiagnostic.notAnEnum(declaration).diagnose(at: Syntax(node)) 30 | ]) 31 | } 32 | 33 | parentTypeName = enumDecl.name.with(\.trailingTrivia, []) 34 | 35 | access = enumDecl.modifiers.first(where: \.isNeededAccessLevelModifier) 36 | 37 | childCases = enumDecl.caseElements.map { parentCase in 38 | parentCase.with(\.parameterClause, nil) 39 | } 40 | 41 | parentParamName = context.makeUniqueName("parent") 42 | } 43 | 44 | func makeMetaEnum() -> DeclSyntax { 45 | // FIXME: Why does this need to be a string to make trailing trivia work properly? 46 | let caseDecls = 47 | childCases 48 | .map { childCase in 49 | " case \(childCase.name)" 50 | } 51 | .joined(separator: "\n") 52 | 53 | return """ 54 | \(access)enum Meta { 55 | \(raw: caseDecls) 56 | \(makeMetaInit()) 57 | } 58 | """ 59 | } 60 | 61 | func makeMetaInit() -> DeclSyntax { 62 | // FIXME: Why does this need to be a string to make trailing trivia work properly? 63 | let caseStatements = 64 | childCases 65 | .map { childCase in 66 | """ 67 | case .\(childCase.name): 68 | self = .\(childCase.name) 69 | """ 70 | } 71 | .joined(separator: "\n") 72 | 73 | return """ 74 | \(access)init(_ \(parentParamName): \(parentTypeName)) { 75 | switch \(parentParamName) { 76 | \(raw: caseStatements) 77 | } 78 | } 79 | """ 80 | } 81 | } 82 | 83 | extension MetaEnumMacro: MemberMacro { 84 | public static func expansion( 85 | of node: AttributeSyntax, 86 | providingMembersOf declaration: some DeclGroupSyntax, 87 | in context: some MacroExpansionContext 88 | ) throws -> [DeclSyntax] { 89 | let macro = try MetaEnumMacro(node: node, declaration: declaration, context: context) 90 | 91 | return [macro.makeMetaEnum()] 92 | } 93 | } 94 | 95 | extension EnumDeclSyntax { 96 | var caseElements: [EnumCaseElementSyntax] { 97 | memberBlock.members.flatMap { member in 98 | guard let caseDecl = member.decl.as(EnumCaseDeclSyntax.self) else { 99 | return [EnumCaseElementSyntax]() 100 | } 101 | 102 | return Array(caseDecl.elements) 103 | } 104 | } 105 | } 106 | 107 | enum CaseMacroDiagnostic { 108 | case notAnEnum(DeclGroupSyntax) 109 | } 110 | 111 | extension CaseMacroDiagnostic: DiagnosticMessage { 112 | var message: String { 113 | switch self { 114 | case .notAnEnum(let decl): 115 | return 116 | "'@MetaEnum' can only be attached to an enum, not \(decl.descriptiveDeclKind(withArticle: true))" 117 | } 118 | } 119 | 120 | var diagnosticID: MessageID { 121 | switch self { 122 | case .notAnEnum: 123 | return MessageID(domain: "MetaEnumDiagnostic", id: "notAnEnum") 124 | } 125 | } 126 | 127 | var severity: DiagnosticSeverity { 128 | switch self { 129 | case .notAnEnum: 130 | return .error 131 | } 132 | } 133 | 134 | func diagnose(at node: Syntax) -> Diagnostic { 135 | Diagnostic(node: node, message: self) 136 | } 137 | } 138 | 139 | extension DeclGroupSyntax { 140 | func descriptiveDeclKind(withArticle article: Bool = false) -> String { 141 | switch self { 142 | case is ActorDeclSyntax: 143 | return article ? "an actor" : "actor" 144 | case is ClassDeclSyntax: 145 | return article ? "a class" : "class" 146 | case is ExtensionDeclSyntax: 147 | return article ? "an extension" : "extension" 148 | case is ProtocolDeclSyntax: 149 | return article ? "a protocol" : "protocol" 150 | case is StructDeclSyntax: 151 | return article ? "a struct" : "struct" 152 | case is EnumDeclSyntax: 153 | return article ? "an enum" : "enum" 154 | default: 155 | fatalError("Unknown DeclGroupSyntax") 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/OptionSetMacroTests.swift: -------------------------------------------------------------------------------- 1 | import MacroTesting 2 | import XCTest 3 | 4 | final class OptionSetMacroTests: BaseTestCase { 5 | override func invokeTest() { 6 | withMacroTesting(macros: ["MyOptionSet": OptionSetMacro.self]) { 7 | super.invokeTest() 8 | } 9 | } 10 | 11 | func testExpansionOnStructWithNestedEnumAndStatics() { 12 | assertMacro { 13 | """ 14 | @MyOptionSet 15 | struct ShippingOptions { 16 | private enum Options: Int { 17 | case nextDay 18 | case secondDay 19 | case priority 20 | case standard 21 | } 22 | 23 | static let express: ShippingOptions = [.nextDay, .secondDay] 24 | static let all: ShippingOptions = [.express, .priority, .standard] 25 | } 26 | """ 27 | } expansion: { 28 | """ 29 | struct ShippingOptions { 30 | private enum Options: Int { 31 | case nextDay 32 | case secondDay 33 | case priority 34 | case standard 35 | } 36 | 37 | static let express: ShippingOptions = [.nextDay, .secondDay] 38 | static let all: ShippingOptions = [.express, .priority, .standard] 39 | 40 | typealias RawValue = UInt8 41 | 42 | var rawValue: RawValue 43 | 44 | init() { 45 | self.rawValue = 0 46 | } 47 | 48 | init(rawValue: RawValue) { 49 | self.rawValue = rawValue 50 | } 51 | 52 | static let nextDay: Self = 53 | Self(rawValue: 1 << Options.nextDay.rawValue) 54 | 55 | static let secondDay: Self = 56 | Self(rawValue: 1 << Options.secondDay.rawValue) 57 | 58 | static let priority: Self = 59 | Self(rawValue: 1 << Options.priority.rawValue) 60 | 61 | static let standard: Self = 62 | Self(rawValue: 1 << Options.standard.rawValue) 63 | } 64 | 65 | extension ShippingOptions: OptionSet { 66 | } 67 | """ 68 | } 69 | } 70 | 71 | func testExpansionOnPublicStructWithExplicitOptionSetConformance() { 72 | assertMacro { 73 | """ 74 | @MyOptionSet 75 | public struct ShippingOptions: OptionSet { 76 | private enum Options: Int { 77 | case nextDay 78 | case standard 79 | } 80 | } 81 | """ 82 | } expansion: { 83 | """ 84 | public struct ShippingOptions: OptionSet { 85 | private enum Options: Int { 86 | case nextDay 87 | case standard 88 | } 89 | 90 | public typealias RawValue = UInt8 91 | 92 | public var rawValue: RawValue 93 | 94 | public init() { 95 | self.rawValue = 0 96 | } 97 | 98 | public init(rawValue: RawValue) { 99 | self.rawValue = rawValue 100 | } 101 | 102 | public static let nextDay: Self = 103 | Self(rawValue: 1 << Options.nextDay.rawValue) 104 | 105 | public static let standard: Self = 106 | Self(rawValue: 1 << Options.standard.rawValue) 107 | } 108 | """ 109 | } 110 | } 111 | 112 | func testExpansionFailsOnEnumType() { 113 | assertMacro { 114 | """ 115 | @MyOptionSet 116 | enum Animal { 117 | case dog 118 | } 119 | """ 120 | } diagnostics: { 121 | """ 122 | @MyOptionSet 123 | ├─ 🛑 'OptionSet' macro can only be applied to a struct 124 | ╰─ 🛑 'OptionSet' macro can only be applied to a struct 125 | enum Animal { 126 | case dog 127 | } 128 | """ 129 | } 130 | } 131 | 132 | func testExpansionFailsWithoutNestedOptionsEnum() { 133 | assertMacro { 134 | """ 135 | @MyOptionSet 136 | struct ShippingOptions { 137 | static let express: ShippingOptions = [.nextDay, .secondDay] 138 | static let all: ShippingOptions = [.express, .priority, .standard] 139 | } 140 | """ 141 | } diagnostics: { 142 | """ 143 | @MyOptionSet 144 | ├─ 🛑 'OptionSet' macro requires nested options enum 'Options' 145 | ╰─ 🛑 'OptionSet' macro requires nested options enum 'Options' 146 | struct ShippingOptions { 147 | static let express: ShippingOptions = [.nextDay, .secondDay] 148 | static let all: ShippingOptions = [.express, .priority, .standard] 149 | } 150 | """ 151 | } 152 | } 153 | 154 | func testExpansionFailsWithoutSpecifiedRawType() { 155 | assertMacro { 156 | """ 157 | @MyOptionSet 158 | struct ShippingOptions { 159 | private enum Options: Int { 160 | case nextDay 161 | } 162 | } 163 | """ 164 | } diagnostics: { 165 | """ 166 | @MyOptionSet 167 | ┬─────────── 168 | ├─ 🛑 'OptionSet' macro requires a raw type 169 | ╰─ 🛑 'OptionSet' macro requires a raw type 170 | struct ShippingOptions { 171 | private enum Options: Int { 172 | case nextDay 173 | } 174 | } 175 | """ 176 | } 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/MacroExamples/ObservableMacro.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2014 - 2023 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 SwiftSyntax 14 | import SwiftSyntaxMacros 15 | 16 | extension DeclSyntaxProtocol { 17 | fileprivate var isObservableStoredProperty: Bool { 18 | if let property = self.as(VariableDeclSyntax.self), 19 | let binding = property.bindings.first, 20 | let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier, 21 | identifier.text != "_registrar", identifier.text != "_storage", 22 | binding.accessorBlock == nil 23 | { 24 | return true 25 | } 26 | 27 | return false 28 | } 29 | } 30 | 31 | public struct ObservableMacro: MemberMacro, MemberAttributeMacro { 32 | 33 | // MARK: - MemberMacro 34 | 35 | public static func expansion( 36 | of node: AttributeSyntax, 37 | providingMembersOf declaration: some DeclGroupSyntax, 38 | in context: some MacroExpansionContext 39 | ) throws -> [DeclSyntax] { 40 | guard let identified = declaration.asProtocol(NamedDeclSyntax.self) else { 41 | return [] 42 | } 43 | 44 | let parentName = identified.name 45 | 46 | let registrar: DeclSyntax = 47 | """ 48 | let _registrar = ObservationRegistrar<\(parentName)>() 49 | """ 50 | 51 | let addObserver: DeclSyntax = 52 | """ 53 | public nonisolated func addObserver(_ observer: some Observer<\(parentName)>) { 54 | _registrar.addObserver(observer) 55 | } 56 | """ 57 | 58 | let removeObserver: DeclSyntax = 59 | """ 60 | public nonisolated func removeObserver(_ observer: some Observer<\(parentName)>) { 61 | _registrar.removeObserver(observer) 62 | } 63 | """ 64 | 65 | let withTransaction: DeclSyntax = 66 | """ 67 | private func withTransaction(_ apply: () throws -> T) rethrows -> T { 68 | _registrar.beginAccess() 69 | defer { _registrar.endAccess() } 70 | return try apply() 71 | } 72 | """ 73 | 74 | let memberList = declaration.memberBlock.members.filter { 75 | $0.decl.isObservableStoredProperty 76 | } 77 | 78 | let storageStruct: DeclSyntax = 79 | """ 80 | private struct Storage { 81 | \(memberList) 82 | } 83 | """ 84 | 85 | let storage: DeclSyntax = 86 | """ 87 | private var _storage = Storage() 88 | """ 89 | 90 | return [ 91 | registrar, 92 | addObserver, 93 | removeObserver, 94 | withTransaction, 95 | storageStruct, 96 | storage, 97 | ] 98 | } 99 | 100 | // MARK: - MemberAttributeMacro 101 | 102 | public static func expansion( 103 | of node: AttributeSyntax, 104 | attachedTo declaration: some DeclGroupSyntax, 105 | providingAttributesFor member: some DeclSyntaxProtocol, 106 | in context: some MacroExpansionContext 107 | ) throws -> [SwiftSyntax.AttributeSyntax] { 108 | guard member.isObservableStoredProperty else { 109 | return [] 110 | } 111 | 112 | return [ 113 | AttributeSyntax( 114 | attributeName: IdentifierTypeSyntax( 115 | name: .identifier("ObservableProperty") 116 | ) 117 | ) 118 | ] 119 | } 120 | 121 | } 122 | 123 | extension ObservableMacro: ExtensionMacro { 124 | public static func expansion( 125 | of node: AttributeSyntax, 126 | attachedTo declaration: some DeclGroupSyntax, 127 | providingExtensionsOf type: some TypeSyntaxProtocol, 128 | conformingTo protocols: [TypeSyntax], 129 | in context: some MacroExpansionContext 130 | ) throws -> [ExtensionDeclSyntax] { 131 | [try ExtensionDeclSyntax("extension \(type): Observable {}")] 132 | } 133 | } 134 | 135 | public struct ObservablePropertyMacro: AccessorMacro { 136 | public static func expansion( 137 | of node: AttributeSyntax, 138 | providingAccessorsOf declaration: some DeclSyntaxProtocol, 139 | in context: some MacroExpansionContext 140 | ) throws -> [AccessorDeclSyntax] { 141 | guard let property = declaration.as(VariableDeclSyntax.self), 142 | let binding = property.bindings.first, 143 | let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier, 144 | binding.accessorBlock == nil 145 | else { 146 | return [] 147 | } 148 | 149 | let getAccessor: AccessorDeclSyntax = 150 | """ 151 | get { 152 | _registrar.beginAccess(\\.\(identifier)) 153 | defer { _registrar.endAccess() } 154 | return _storage.\(identifier) 155 | } 156 | """ 157 | 158 | let setAccessor: AccessorDeclSyntax = 159 | """ 160 | set { 161 | _registrar.beginAccess(\\.\(identifier)) 162 | _registrar.register(observable: self, willSet: \\.\(identifier), to: newValue) 163 | defer { 164 | _registrar.register(observable: self, didSet: \\.\(identifier)) 165 | _registrar.endAccess() 166 | } 167 | _storage.\(identifier) = newValue 168 | } 169 | """ 170 | 171 | return [getAccessor, setAccessor] 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/MacroExamples/AddCompletionHandlerMacro.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2014 - 2023 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 SwiftDiagnostics 14 | import SwiftSyntax 15 | import SwiftSyntaxMacros 16 | 17 | public struct AddCompletionHandlerMacro: PeerMacro { 18 | public static func expansion< 19 | Context: MacroExpansionContext, 20 | Declaration: DeclSyntaxProtocol 21 | >( 22 | of node: AttributeSyntax, 23 | providingPeersOf declaration: Declaration, 24 | in context: Context 25 | ) throws -> [DeclSyntax] { 26 | // Only on functions at the moment. We could handle initializers as well 27 | // with a bit of work. 28 | guard var funcDecl = declaration.as(FunctionDeclSyntax.self) else { 29 | throw CustomError.message("@addCompletionHandler only works on functions") 30 | } 31 | 32 | // This only makes sense for async functions. 33 | if funcDecl.signature.effectSpecifiers?.asyncSpecifier == nil { 34 | var newEffects: FunctionEffectSpecifiersSyntax 35 | if let existingEffects = funcDecl.signature.effectSpecifiers { 36 | newEffects = existingEffects 37 | newEffects.asyncSpecifier = .keyword(.async) 38 | } else { 39 | newEffects = FunctionEffectSpecifiersSyntax(asyncSpecifier: .keyword(.async)) 40 | } 41 | 42 | var newSignature = funcDecl.signature 43 | newSignature.effectSpecifiers = newEffects 44 | let messageID = MessageID(domain: "MacroExamples", id: "MissingAsync") 45 | 46 | let diag = Diagnostic( 47 | // Where the error should go (on the "+"). 48 | node: Syntax(funcDecl.funcKeyword), 49 | // The warning message and severity. 50 | message: SimpleDiagnosticMessage( 51 | message: "can only add a completion-handler variant to an 'async' function", 52 | diagnosticID: messageID, 53 | severity: .error 54 | ), 55 | fixIts: [ 56 | // Fix-It to replace the '+' with a '-'. 57 | FixIt( 58 | message: SimpleDiagnosticMessage( 59 | message: "add 'async'", 60 | diagnosticID: messageID, 61 | severity: .error 62 | ), 63 | changes: [ 64 | FixIt.Change.replace( 65 | oldNode: Syntax(funcDecl.signature), 66 | newNode: Syntax(newSignature) 67 | ) 68 | ] 69 | ) 70 | ] 71 | ) 72 | 73 | context.diagnose(diag) 74 | return [] 75 | } 76 | 77 | // Form the completion handler parameter. 78 | var resultType = funcDecl.signature.returnClause?.type 79 | resultType?.leadingTrivia = [] 80 | resultType?.trailingTrivia = [] 81 | 82 | let completionHandlerParam = 83 | FunctionParameterSyntax( 84 | firstName: .identifier("completionHandler"), 85 | colon: .colonToken(trailingTrivia: .space), 86 | type: "@escaping (\(resultType ?? "")) -> Void" as TypeSyntax 87 | ) 88 | 89 | // Add the completion handler parameter to the parameter list. 90 | let parameterList = funcDecl.signature.parameterClause.parameters 91 | var newParameterList = parameterList 92 | if var lastParam = parameterList.last { 93 | // We need to add a trailing comma to the preceding list. 94 | newParameterList.removeLast() 95 | lastParam.trailingComma = .commaToken(trailingTrivia: .space) 96 | newParameterList += [ 97 | lastParam, 98 | completionHandlerParam, 99 | ] 100 | } else { 101 | newParameterList.append(completionHandlerParam) 102 | } 103 | 104 | let callArguments: [String] = parameterList.map { param in 105 | let argName = param.secondName ?? param.firstName 106 | 107 | let paramName = param.firstName 108 | if paramName.text != "_" { 109 | return "\(paramName.text): \(argName.text)" 110 | } 111 | 112 | return "\(argName.text)" 113 | } 114 | 115 | let call: ExprSyntax = 116 | "\(funcDecl.name)(\(raw: callArguments.joined(separator: ", ")))" 117 | 118 | // FIXME: We should make CodeBlockSyntax ExpressibleByStringInterpolation, 119 | // so that the full body could go here. 120 | let newBody: ExprSyntax = 121 | """ 122 | 123 | Task { 124 | completionHandler(await \(call)) 125 | } 126 | 127 | """ 128 | 129 | // Drop the @addCompletionHandler attribute from the new declaration. 130 | let newAttributeList = funcDecl.attributes.filter { 131 | guard case let .attribute(attribute) = $0, 132 | let attributeType = attribute.attributeName.as(IdentifierTypeSyntax.self), 133 | let nodeType = node.attributeName.as(IdentifierTypeSyntax.self) 134 | else { 135 | return true 136 | } 137 | 138 | return attributeType.name.text != nodeType.name.text 139 | } 140 | 141 | // drop async 142 | funcDecl.signature.effectSpecifiers?.asyncSpecifier = nil 143 | 144 | // drop result type 145 | funcDecl.signature.returnClause = nil 146 | 147 | // add completion handler parameter 148 | funcDecl.signature.parameterClause.parameters = newParameterList 149 | funcDecl.signature.parameterClause.trailingTrivia = [] 150 | 151 | funcDecl.body = CodeBlockSyntax( 152 | leftBrace: .leftBraceToken(leadingTrivia: .space), 153 | statements: CodeBlockItemListSyntax( 154 | [CodeBlockItemSyntax(item: .expr(newBody))] 155 | ), 156 | rightBrace: .rightBraceToken(leadingTrivia: .newline) 157 | ) 158 | 159 | funcDecl.attributes = newAttributeList 160 | 161 | funcDecl.leadingTrivia = .newlines(2) 162 | 163 | return [DeclSyntax(funcDecl)] 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/MacroExamples/AddAsyncMacro.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2014 - 2023 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 SwiftSyntax 14 | import SwiftSyntaxMacros 15 | 16 | extension SyntaxCollection { 17 | mutating func removeLast() { 18 | self.remove(at: self.index(before: self.endIndex)) 19 | } 20 | } 21 | 22 | public struct AddAsyncMacro: PeerMacro { 23 | public static func expansion< 24 | Context: MacroExpansionContext, 25 | Declaration: DeclSyntaxProtocol 26 | >( 27 | of node: AttributeSyntax, 28 | providingPeersOf declaration: Declaration, 29 | in context: Context 30 | ) throws -> [DeclSyntax] { 31 | 32 | // Only on functions at the moment. 33 | guard var funcDecl = declaration.as(FunctionDeclSyntax.self) else { 34 | throw CustomError.message("@addAsync only works on functions") 35 | } 36 | 37 | // This only makes sense for non async functions. 38 | if funcDecl.signature.effectSpecifiers?.asyncSpecifier != nil { 39 | throw CustomError.message( 40 | "@addAsync requires an non async function" 41 | ) 42 | } 43 | 44 | // This only makes sense void functions 45 | if funcDecl.signature.returnClause?.type.as(IdentifierTypeSyntax.self)?.name.text != "Void" { 46 | throw CustomError.message( 47 | "@addAsync requires an function that returns void" 48 | ) 49 | } 50 | 51 | // Requires a completion handler block as last parameter 52 | guard 53 | let completionHandlerParameterAttribute = funcDecl.signature.parameterClause.parameters.last? 54 | .type.as(AttributedTypeSyntax.self), 55 | let completionHandlerParameter = completionHandlerParameterAttribute.baseType.as( 56 | FunctionTypeSyntax.self 57 | ) 58 | else { 59 | throw CustomError.message( 60 | "@addAsync requires an function that has a completion handler as last parameter" 61 | ) 62 | } 63 | 64 | // Completion handler needs to return Void 65 | if completionHandlerParameter.returnClause.type.as(IdentifierTypeSyntax.self)?.name.text 66 | != "Void" 67 | { 68 | throw CustomError.message( 69 | "@addAsync requires an function that has a completion handler that returns Void" 70 | ) 71 | } 72 | 73 | let returnType = completionHandlerParameter.parameters.first?.type 74 | let isResultReturn = returnType?.children(viewMode: .all).first?.description == "Result" 75 | 76 | let successReturnType: TypeSyntax? = { 77 | guard isResultReturn 78 | else { return returnType } 79 | 80 | let successType = returnType!.as(IdentifierTypeSyntax.self)!.genericArgumentClause?.arguments 81 | .first! 82 | .argument 83 | #if canImport(SwiftSyntax601) 84 | switch successType { 85 | case .none: 86 | return nil 87 | case .type(let syntax): 88 | return syntax 89 | default: 90 | return nil 91 | } 92 | #else 93 | return successType 94 | #endif 95 | }() 96 | 97 | // Remove completionHandler and comma from the previous parameter 98 | var newParameterList = funcDecl.signature.parameterClause.parameters 99 | newParameterList.removeLast() 100 | var newParameterListLastParameter = newParameterList.last! 101 | newParameterList.removeLast() 102 | newParameterListLastParameter.trailingTrivia = [] 103 | newParameterListLastParameter.trailingComma = nil 104 | newParameterList.append(newParameterListLastParameter) 105 | 106 | // Drop the @addAsync attribute from the new declaration. 107 | let newAttributeList = funcDecl.attributes.filter { 108 | guard case let .attribute(attribute) = $0, 109 | let attributeType = attribute.attributeName.as(IdentifierTypeSyntax.self), 110 | let nodeType = node.attributeName.as(IdentifierTypeSyntax.self) 111 | else { 112 | return true 113 | } 114 | 115 | return attributeType.name.text != nodeType.name.text 116 | } 117 | 118 | let callArguments: [String] = newParameterList.map { param in 119 | let argName = param.secondName ?? param.firstName 120 | 121 | let paramName = param.firstName 122 | if paramName.text != "_" { 123 | return "\(paramName.text): \(argName.text)" 124 | } 125 | 126 | return "\(argName.text)" 127 | } 128 | 129 | let switchBody: ExprSyntax = 130 | """ 131 | switch returnValue { 132 | case .success(let value): 133 | continuation.resume(returning: value) 134 | case .failure(let error): 135 | continuation.resume(throwing: error) 136 | } 137 | """ 138 | 139 | let newBody: ExprSyntax = 140 | """ 141 | 142 | \(raw: isResultReturn ? "try await withCheckedThrowingContinuation { continuation in" : "await withCheckedContinuation { continuation in") 143 | \(raw: funcDecl.name)(\(raw: callArguments.joined(separator: ", "))) { \(raw: returnType != nil ? "returnValue in" : "") 144 | 145 | \(raw: isResultReturn ? switchBody : "continuation.resume(returning: \(raw: returnType != nil ? "returnValue" : "()"))") 146 | } 147 | } 148 | 149 | """ 150 | 151 | // add async 152 | funcDecl.signature.effectSpecifiers = FunctionEffectSpecifiersSyntax( 153 | leadingTrivia: .space, 154 | asyncSpecifier: .keyword(.async), 155 | throwsClause: isResultReturn ? ThrowsClauseSyntax(throwsSpecifier: .keyword(.throws)) : nil 156 | ) 157 | 158 | // add result type 159 | if let successReturnType { 160 | funcDecl.signature.returnClause = ReturnClauseSyntax( 161 | leadingTrivia: .space, 162 | type: successReturnType.with(\.leadingTrivia, .space) 163 | ) 164 | } else { 165 | funcDecl.signature.returnClause = nil 166 | } 167 | 168 | // drop completion handler 169 | funcDecl.signature.parameterClause.parameters = newParameterList 170 | funcDecl.signature.parameterClause.trailingTrivia = [] 171 | 172 | funcDecl.body = CodeBlockSyntax( 173 | leftBrace: .leftBraceToken(leadingTrivia: .space), 174 | statements: CodeBlockItemListSyntax( 175 | [CodeBlockItemSyntax(item: .expr(newBody))] 176 | ), 177 | rightBrace: .rightBraceToken(leadingTrivia: .newline) 178 | ) 179 | 180 | funcDecl.attributes = newAttributeList 181 | 182 | funcDecl.leadingTrivia = .newlines(2) 183 | 184 | return [DeclSyntax(funcDecl)] 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Macro Testing 2 | 3 | [![CI](https://github.com/pointfreeco/swift-macro-testing/workflows/CI/badge.svg)](https://github.com/pointfreeco/swift-macro-testing/actions?query=workflow%3ACI) 4 | [![Slack](https://img.shields.io/badge/slack-chat-informational.svg?label=Slack&logo=slack)](https://www.pointfree.co/slack-invite) 5 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fpointfreeco%2Fswift-macro-testing%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/pointfreeco/swift-macro-testing) 6 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fpointfreeco%2Fswift-macro-testing%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/pointfreeco/swift-macro-testing) 7 | 8 | Magical testing tools for Swift macros. 9 | 10 | 11 | 12 | 13 | An animated demonstration of macro tests being inlined. 14 | 15 | 16 | ## Learn more 17 | 18 | This library was designed to support libraries and episodes produced for [Point-Free][point-free], a 19 | video series exploring the Swift programming language hosted by [Brandon Williams][mbrandonw] and 20 | [Stephen Celis][stephencelis]. 21 | 22 | You can watch all of the episodes [here][macro-testing-episodes]. 23 | 24 | 25 | video poster image 26 | 27 | 28 | ## Motivation 29 | 30 | This library comes with a tool for testing macros that is more powerful and ergonomic than the 31 | default tool that comes with SwiftSyntax. To use the tool, simply specify the macros that you want 32 | to expand as well as a string of Swift source code that makes use of the macro. 33 | 34 | For example, to test the `#stringify` macro that comes with SPM's macro template all one needs to 35 | do is write the following: 36 | 37 | ```swift 38 | import MacroTesting 39 | import Testing 40 | 41 | @Suite(.macros([StringifyMacro.self])) 42 | struct StringifyTests { 43 | @Test func stringify() { 44 | assertMacro { 45 | """ 46 | #stringify(a + b) 47 | """ 48 | } 49 | } 50 | } 51 | ``` 52 | 53 | When you run this test the library will automatically expand the macros in the source code string 54 | and write the expansion into the test file: 55 | 56 | ```swift 57 | @Suite(.macros([StringifyMacro.self])) 58 | struct StringifyTests { 59 | @Test func stringify() { 60 | assertMacro { 61 | """ 62 | #stringify(a + b) 63 | """ 64 | } expansion: { 65 | """ 66 | (a + b, "a + b") 67 | """ 68 | } 69 | } 70 | ``` 71 | 72 | That is all it takes. 73 | 74 | If in the future the macro's output changes, such as adding labels to the tuple's arguments, then 75 | running the test again will produce a nicely formatted message: 76 | 77 | ```diff 78 | ❌ Actual output (+) differed from expected output (−). Difference: … 79 | 80 | - (a + b, "a + b") 81 | + (result: a + b, code: "a + b") 82 | ``` 83 | 84 | You can even have the library automatically re-record the macro expansion directly into your test 85 | file by providing the `record` argument to ``Testing/Trait/macros(_:indentationWidth:record:)``: 86 | 87 | ```swift 88 | @Suite(.macros([StringifyMacro.self], record: .all)) 89 | ``` 90 | 91 | Now when you run the test again the freshest expanded macro will be written to the `expansion` 92 | trailing closure. 93 | 94 | Macro Testing can also test diagnostics, such as warnings, errors, notes, and fix-its. When a macro 95 | expansion emits a diagnostic, it will render inline in the test. For example, a macro that adds 96 | completion handler functions to async functions may emit an error and fix-it when it is applied to a 97 | non-async function. The resulting macro test will fully capture this information, including where 98 | the diagnostics are emitted, how the fix-its are applied, and how the final macro expands: 99 | 100 | ```swift 101 | func testNonAsyncFunctionDiagnostic() { 102 | assertMacro { 103 | """ 104 | @AddCompletionHandler 105 | func f(a: Int, for b: String) -> String { 106 | return b 107 | } 108 | """ 109 | } diagnostics: { 110 | """ 111 | @AddCompletionHandler 112 | func f(a: Int, for b: String) -> String { 113 | ┬─── 114 | ╰─ 🛑 can only add a completion-handler variant to an 'async' function 115 | ✏️ add 'async' 116 | return b 117 | } 118 | """ 119 | } fixes: { 120 | """ 121 | @AddCompletionHandler 122 | func f(a: Int, for b: String) async -> String { 123 | return b 124 | } 125 | """ 126 | } expansion: { 127 | """ 128 | func f(a: Int, for b: String) async -> String { 129 | return b 130 | } 131 | 132 | func f(a: Int, for b: String, completionHandler: @escaping (String) -> Void) { 133 | Task { 134 | completionHandler(await f(a: a, for: b, value)) 135 | } 136 | } 137 | """ 138 | } 139 | } 140 | ``` 141 | 142 | ## Integration with Swift Testing 143 | 144 | If you are using Swift's built-in Testing framework, this library also supports it. Instead of relying solely 145 | on XCTest, you can configure your tests using the `Trait` system provided by `swift-testing`. For example: 146 | 147 | ```swift 148 | import Testing 149 | import MacroTesting 150 | 151 | @Suite( 152 | .macros( 153 | ["stringify": StringifyMacro.self], 154 | record: .missing // Record only missing snapshots 155 | ) 156 | ) 157 | struct StringifyMacroSwiftTestingTests { 158 | @Test 159 | func testStringify() { 160 | assertMacro { 161 | """ 162 | #stringify(a + b) 163 | """ 164 | } expansion: { 165 | """ 166 | (a + b, "a + b") 167 | """ 168 | } 169 | } 170 | } 171 | ``` 172 | 173 | Additionally, the `record` parameter in `macros` can be used to control the recording behavior for all 174 | tests in the suite. This value can also be configured using the `SNAPSHOT_TESTING_RECORD` environment 175 | variable to dynamically adjust recording behavior based on your CI or local environment. 176 | 177 | ## Documentation 178 | 179 | The latest documentation for this library is available [here][macro-testing-docs]. 180 | 181 | ## License 182 | 183 | This library is released under the MIT license. See [LICENSE](LICENSE) for details. 184 | 185 | [macro-testing-docs]: https://swiftpackageindex.com/pointfreeco/swift-macro-testing/main/documentation/macrotesting 186 | [macro-testing-episodes]: https://www.pointfree.co/collections/macros 187 | [mbrandonw]: https://github.com/mbrandonw 188 | [point-free]: https://www.pointfree.co 189 | [stephencelis]: https://github.com/stephencelis 190 | -------------------------------------------------------------------------------- /Tests/MacroTestingTests/MacroExamples/OptionSetMacro.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2014 - 2023 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 SwiftDiagnostics 14 | import SwiftSyntax 15 | import SwiftSyntaxBuilder 16 | import SwiftSyntaxMacros 17 | 18 | enum OptionSetMacroDiagnostic { 19 | case requiresStruct 20 | case requiresStringLiteral(String) 21 | case requiresOptionsEnum(String) 22 | case requiresOptionsEnumRawType 23 | } 24 | 25 | extension OptionSetMacroDiagnostic: DiagnosticMessage { 26 | func diagnose(at node: some SyntaxProtocol) -> Diagnostic { 27 | Diagnostic(node: Syntax(node), message: self) 28 | } 29 | 30 | var message: String { 31 | switch self { 32 | case .requiresStruct: 33 | return "'OptionSet' macro can only be applied to a struct" 34 | 35 | case .requiresStringLiteral(let name): 36 | return "'OptionSet' macro argument \(name) must be a string literal" 37 | 38 | case .requiresOptionsEnum(let name): 39 | return "'OptionSet' macro requires nested options enum '\(name)'" 40 | 41 | case .requiresOptionsEnumRawType: 42 | return "'OptionSet' macro requires a raw type" 43 | } 44 | } 45 | 46 | var severity: DiagnosticSeverity { .error } 47 | 48 | var diagnosticID: MessageID { 49 | MessageID(domain: "Swift", id: "OptionSet.\(self)") 50 | } 51 | } 52 | 53 | /// The label used for the OptionSet macro argument that provides the name of 54 | /// the nested options enum. 55 | private let optionsEnumNameArgumentLabel = "optionsName" 56 | 57 | /// The default name used for the nested "Options" enum. This should 58 | /// eventually be overridable. 59 | private let defaultOptionsEnumName = "Options" 60 | 61 | extension LabeledExprListSyntax { 62 | /// Retrieve the first element with the given label. 63 | func first(labeled name: String) -> Element? { 64 | return first { element in 65 | if let label = element.label, label.text == name { 66 | return true 67 | } 68 | 69 | return false 70 | } 71 | } 72 | } 73 | 74 | public struct OptionSetMacro { 75 | /// Decodes the arguments to the macro expansion. 76 | /// 77 | /// - Returns: the important arguments used by the various roles of this 78 | /// macro inhabits, or nil if an error occurred. 79 | static func decodeExpansion( 80 | of attribute: AttributeSyntax, 81 | attachedTo decl: some DeclGroupSyntax, 82 | in context: some MacroExpansionContext 83 | ) -> (StructDeclSyntax, EnumDeclSyntax, TypeSyntax)? { 84 | // Determine the name of the options enum. 85 | let optionsEnumName: String 86 | if case let .argumentList(arguments) = attribute.arguments, 87 | let optionEnumNameArg = arguments.first(labeled: optionsEnumNameArgumentLabel) 88 | { 89 | // We have a options name; make sure it is a string literal. 90 | guard let stringLiteral = optionEnumNameArg.expression.as(StringLiteralExprSyntax.self), 91 | stringLiteral.segments.count == 1, 92 | case let .stringSegment(optionsEnumNameString)? = stringLiteral.segments.first 93 | else { 94 | context.diagnose( 95 | OptionSetMacroDiagnostic.requiresStringLiteral(optionsEnumNameArgumentLabel).diagnose( 96 | at: optionEnumNameArg.expression 97 | ) 98 | ) 99 | return nil 100 | } 101 | 102 | optionsEnumName = optionsEnumNameString.content.text 103 | } else { 104 | optionsEnumName = defaultOptionsEnumName 105 | } 106 | 107 | // Only apply to structs. 108 | guard let structDecl = decl.as(StructDeclSyntax.self) else { 109 | context.diagnose(OptionSetMacroDiagnostic.requiresStruct.diagnose(at: decl)) 110 | return nil 111 | } 112 | 113 | // Find the option enum within the struct. 114 | guard 115 | let optionsEnum = decl.memberBlock.members.compactMap({ member in 116 | if let enumDecl = member.decl.as(EnumDeclSyntax.self), 117 | enumDecl.name.text == optionsEnumName 118 | { 119 | return enumDecl 120 | } 121 | 122 | return nil 123 | }).first 124 | else { 125 | context.diagnose( 126 | OptionSetMacroDiagnostic.requiresOptionsEnum(optionsEnumName).diagnose(at: decl) 127 | ) 128 | return nil 129 | } 130 | 131 | // Retrieve the raw type from the attribute. 132 | guard 133 | let genericArgs = attribute.attributeName.as(IdentifierTypeSyntax.self)? 134 | .genericArgumentClause, 135 | let rawType = genericArgs.arguments.first?.argument 136 | else { 137 | context.diagnose(OptionSetMacroDiagnostic.requiresOptionsEnumRawType.diagnose(at: attribute)) 138 | return nil 139 | } 140 | 141 | let rawTypeSyntax: TypeSyntax? 142 | #if canImport(SwiftSyntax601) 143 | switch rawType { 144 | case .type(let typeSyntax): 145 | rawTypeSyntax = typeSyntax 146 | default: 147 | rawTypeSyntax = nil 148 | } 149 | #else 150 | rawTypeSyntax = rawType 151 | #endif 152 | 153 | guard let rawTypeSyntax 154 | else { 155 | context.diagnose(OptionSetMacroDiagnostic.requiresOptionsEnumRawType.diagnose(at: attribute)) 156 | return nil 157 | } 158 | 159 | return (structDecl, optionsEnum, rawTypeSyntax) 160 | } 161 | } 162 | 163 | extension OptionSetMacro: ExtensionMacro { 164 | public static func expansion( 165 | of node: AttributeSyntax, 166 | attachedTo declaration: some DeclGroupSyntax, 167 | providingExtensionsOf type: some TypeSyntaxProtocol, 168 | conformingTo protocols: [TypeSyntax], 169 | in context: some MacroExpansionContext 170 | ) throws -> [ExtensionDeclSyntax] { 171 | // Decode the expansion arguments. 172 | guard let (structDecl, _, _) = decodeExpansion(of: node, attachedTo: declaration, in: context) 173 | else { 174 | return [] 175 | } 176 | 177 | // If there is an explicit conformance to OptionSet already, don't add one. 178 | if let inheritedTypes = structDecl.inheritanceClause?.inheritedTypes, 179 | inheritedTypes.contains(where: { inherited in inherited.type.trimmedDescription == "OptionSet" 180 | }) 181 | { 182 | return [] 183 | } 184 | 185 | return [try ExtensionDeclSyntax("extension \(type): OptionSet {}")] 186 | } 187 | } 188 | 189 | extension OptionSetMacro: MemberMacro { 190 | public static func expansion( 191 | of attribute: AttributeSyntax, 192 | providingMembersOf decl: some DeclGroupSyntax, 193 | in context: some MacroExpansionContext 194 | ) throws -> [DeclSyntax] { 195 | // Decode the expansion arguments. 196 | guard 197 | let (_, optionsEnum, rawType) = decodeExpansion(of: attribute, attachedTo: decl, in: context) 198 | else { 199 | return [] 200 | } 201 | 202 | // Find all of the case elements. 203 | let caseElements = optionsEnum.memberBlock.members.flatMap { member in 204 | guard let caseDecl = member.decl.as(EnumCaseDeclSyntax.self) else { 205 | return [EnumCaseElementSyntax]() 206 | } 207 | 208 | return Array(caseDecl.elements) 209 | } 210 | 211 | // Dig out the access control keyword we need. 212 | let access = decl.modifiers.first(where: \.isNeededAccessLevelModifier) 213 | 214 | let staticVars = caseElements.map { (element) -> DeclSyntax in 215 | """ 216 | \(access) static let \(element.name): Self = 217 | Self(rawValue: 1 << \(optionsEnum.name).\(element.name).rawValue) 218 | """ 219 | } 220 | 221 | return [ 222 | "\(access)typealias RawValue = \(rawType)", 223 | "\(access)var rawValue: RawValue", 224 | "\(access)init() { self.rawValue = 0 }", 225 | "\(access)init(rawValue: RawValue) { self.rawValue = rawValue }", 226 | ] + staticVars 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /Sources/MacroTesting/Internal/Deprecations.swift: -------------------------------------------------------------------------------- 1 | import InlineSnapshotTesting 2 | @_spi(Internals) import SnapshotTesting 3 | import SwiftDiagnostics 4 | import SwiftOperators 5 | import SwiftParser 6 | import SwiftParserDiagnostics 7 | import SwiftSyntax 8 | import SwiftSyntaxMacroExpansion 9 | import SwiftSyntaxMacros 10 | import XCTest 11 | 12 | #if canImport(Testing) 13 | import Testing 14 | #endif 15 | 16 | // MARK: Deprecated after 0.6.0 17 | 18 | #if canImport(Testing) && compiler(>=6) 19 | extension Trait where Self == _MacrosTestTrait { 20 | 21 | @available(*, deprecated, message: "Use `macros(_:indentationWidth:record:)` instead") 22 | public static func macros( 23 | indentationWidth: Trivia? = nil, 24 | record: SnapshotTestingConfiguration.Record? = nil, 25 | macros: [String: Macro.Type]? = nil 26 | ) -> Self { 27 | Self.macros(macros, indentationWidth: indentationWidth, record: record) 28 | } 29 | 30 | @available(*, deprecated, message: "Use `macros(_:indentationWidth:record:)` instead") 31 | public static func macros( 32 | indentationWidth: Trivia? = nil, 33 | record: SnapshotTestingConfiguration.Record? = nil, 34 | macros: [Macro.Type]? = nil 35 | ) -> Self { 36 | Self.macros(macros, indentationWidth: indentationWidth, record: record) 37 | } 38 | } 39 | #endif 40 | 41 | // MARK: Deprecated after 0.4.2 42 | 43 | @available(iOS, deprecated, renamed: "withMacroTesting(indentationWidth:record:macros:operation:)") 44 | @available( 45 | macOS, 46 | deprecated, 47 | renamed: "withMacroTesting(indentationWidth:record:macros:operation:)" 48 | ) 49 | @available(tvOS, deprecated, renamed: "withMacroTesting(indentationWidth:record:macros:operation:)") 50 | @available( 51 | visionOS, 52 | deprecated, 53 | renamed: "withMacroTesting(indentationWidth:record:macros:operation:)" 54 | ) 55 | @available( 56 | watchOS, 57 | deprecated, 58 | renamed: "withMacroTesting(indentationWidth:record:macros:operation:)" 59 | ) 60 | @_disfavoredOverload 61 | public func withMacroTesting( 62 | indentationWidth: Trivia? = nil, 63 | isRecording: Bool? = nil, 64 | macros: [String: Macro.Type]? = nil, 65 | operation: () async throws -> R 66 | ) async rethrows { 67 | var configuration = MacroTestingConfiguration.current 68 | if let indentationWidth { configuration.indentationWidth = indentationWidth } 69 | let record: SnapshotTestingConfiguration.Record? = isRecording.map { $0 ? .all : .missing } 70 | if let macros { configuration.macros = macros } 71 | _ = try await withSnapshotTesting(record: record) { 72 | try await MacroTestingConfiguration.$current.withValue(configuration) { 73 | try await operation() 74 | } 75 | } 76 | } 77 | 78 | @available(iOS, deprecated, renamed: "withMacroTesting(indentationWidth:record:macros:operation:)") 79 | @available( 80 | macOS, 81 | deprecated, 82 | renamed: "withMacroTesting(indentationWidth:record:macros:operation:)" 83 | ) 84 | @available(tvOS, deprecated, renamed: "withMacroTesting(indentationWidth:record:macros:operation:)") 85 | @available( 86 | visionOS, 87 | deprecated, 88 | renamed: "withMacroTesting(indentationWidth:record:macros:operation:)" 89 | ) 90 | @available( 91 | watchOS, 92 | deprecated, 93 | renamed: "withMacroTesting(indentationWidth:record:macros:operation:)" 94 | ) 95 | @_disfavoredOverload 96 | public func withMacroTesting( 97 | indentationWidth: Trivia? = nil, 98 | isRecording: Bool? = nil, 99 | macros: [String: Macro.Type]? = nil, 100 | operation: () throws -> R 101 | ) rethrows { 102 | var configuration = MacroTestingConfiguration.current 103 | if let indentationWidth { configuration.indentationWidth = indentationWidth } 104 | let record: SnapshotTestingConfiguration.Record? = isRecording.map { $0 ? .all : .missing } 105 | if let macros { configuration.macros = macros } 106 | _ = try withSnapshotTesting(record: record) { 107 | try MacroTestingConfiguration.$current.withValue(configuration) { 108 | try operation() 109 | } 110 | } 111 | } 112 | 113 | @available(iOS, deprecated, renamed: "withMacroTesting(indentationWidth:record:macros:operation:)") 114 | @available( 115 | macOS, 116 | deprecated, 117 | renamed: "withMacroTesting(indentationWidth:record:macros:operation:)" 118 | ) 119 | @available(tvOS, deprecated, renamed: "withMacroTesting(indentationWidth:record:macros:operation:)") 120 | @available( 121 | visionOS, 122 | deprecated, 123 | renamed: "withMacroTesting(indentationWidth:record:macros:operation:)" 124 | ) 125 | @available( 126 | watchOS, 127 | deprecated, 128 | renamed: "withMacroTesting(indentationWidth:record:macros:operation:)" 129 | ) 130 | @_disfavoredOverload 131 | public func withMacroTesting( 132 | indentationWidth: Trivia? = nil, 133 | isRecording: Bool? = nil, 134 | macros: [Macro.Type], 135 | operation: () async throws -> R 136 | ) async rethrows { 137 | try await withMacroTesting( 138 | indentationWidth: indentationWidth, 139 | isRecording: isRecording, 140 | macros: Dictionary(macros: macros), 141 | operation: operation 142 | ) 143 | } 144 | 145 | @available(iOS, deprecated, renamed: "withMacroTesting(indentationWidth:record:macros:operation:)") 146 | @available( 147 | macOS, 148 | deprecated, 149 | renamed: "withMacroTesting(indentationWidth:record:macros:operation:)" 150 | ) 151 | @available(tvOS, deprecated, renamed: "withMacroTesting(indentationWidth:record:macros:operation:)") 152 | @available( 153 | visionOS, 154 | deprecated, 155 | renamed: "withMacroTesting(indentationWidth:record:macros:operation:)" 156 | ) 157 | @available( 158 | watchOS, 159 | deprecated, 160 | renamed: "withMacroTesting(indentationWidth:record:macros:operation:)" 161 | ) 162 | @_disfavoredOverload 163 | public func withMacroTesting( 164 | indentationWidth: Trivia? = nil, 165 | isRecording: Bool? = nil, 166 | macros: [Macro.Type], 167 | operation: () throws -> R 168 | ) rethrows { 169 | try withMacroTesting( 170 | indentationWidth: indentationWidth, 171 | isRecording: isRecording, 172 | macros: Dictionary(macros: macros), 173 | operation: operation 174 | ) 175 | } 176 | 177 | // MARK: Deprecated after 0.1.0 178 | 179 | @available(*, deprecated, message: "Re-record this assertion") 180 | public func assertMacro( 181 | _ macros: [String: Macro.Type]? = nil, 182 | record isRecording: Bool? = nil, 183 | of originalSource: () throws -> String, 184 | matches expandedOrDiagnosedSource: () -> String, 185 | fileID: StaticString = #fileID, 186 | file filePath: StaticString = #filePath, 187 | function: StaticString = #function, 188 | line: UInt = #line, 189 | column: UInt = #column 190 | ) { 191 | guard isRecording ?? (SnapshotTestingConfiguration.current?.record == .all) else { 192 | recordIssue( 193 | "Re-record this assertion", 194 | fileID: fileID, 195 | filePath: filePath, 196 | line: line, 197 | column: column 198 | ) 199 | return 200 | } 201 | assertMacro( 202 | macros, 203 | record: true, 204 | of: originalSource, 205 | file: filePath, 206 | function: function, 207 | line: line, 208 | column: column 209 | ) 210 | } 211 | 212 | @available(*, deprecated, message: "Re-record this assertion") 213 | public func assertMacro( 214 | _ macros: [Macro.Type], 215 | record isRecording: Bool? = nil, 216 | of originalSource: () throws -> String, 217 | matches expandedOrDiagnosedSource: () -> String, 218 | fileID: StaticString = #fileID, 219 | file filePath: StaticString = #filePath, 220 | function: StaticString = #function, 221 | line: UInt = #line, 222 | column: UInt = #column 223 | ) { 224 | assertMacro( 225 | Dictionary(macros: macros), 226 | record: isRecording, 227 | of: originalSource, 228 | matches: expandedOrDiagnosedSource, 229 | fileID: fileID, 230 | file: filePath, 231 | function: function, 232 | line: line, 233 | column: column 234 | ) 235 | } 236 | 237 | @available( 238 | *, 239 | deprecated, 240 | message: "Delete 'applyFixIts' and 'matches' and re-record this assertion" 241 | ) 242 | public func assertMacro( 243 | _ macros: [String: Macro.Type]? = nil, 244 | applyFixIts: Bool, 245 | record isRecording: Bool? = nil, 246 | of originalSource: () throws -> String, 247 | matches expandedOrDiagnosedSource: () -> String, 248 | fileID: StaticString = #fileID, 249 | file filePath: StaticString = #filePath, 250 | function: StaticString = #function, 251 | line: UInt = #line, 252 | column: UInt = #column 253 | ) { 254 | recordIssue( 255 | "Delete 'matches' and re-record this assertion", 256 | fileID: fileID, 257 | filePath: filePath, 258 | line: line, 259 | column: column 260 | ) 261 | } 262 | 263 | @available( 264 | *, 265 | deprecated, 266 | message: "Delete 'applyFixIts' and 'matches' and re-record this assertion" 267 | ) 268 | public func assertMacro( 269 | _ macros: [Macro.Type], 270 | applyFixIts: Bool, 271 | record isRecording: Bool? = nil, 272 | of originalSource: () throws -> String, 273 | matches expandedOrDiagnosedSource: () -> String, 274 | fileID: StaticString = #fileID, 275 | file filePath: StaticString = #filePath, 276 | function: StaticString = #function, 277 | line: UInt = #line, 278 | column: UInt = #column 279 | ) { 280 | assertMacro( 281 | Dictionary(macros: macros), 282 | applyFixIts: applyFixIts, 283 | record: isRecording, 284 | of: originalSource, 285 | matches: expandedOrDiagnosedSource, 286 | fileID: fileID, 287 | file: filePath, 288 | function: function, 289 | line: line, 290 | column: column 291 | ) 292 | } 293 | -------------------------------------------------------------------------------- /Sources/MacroTesting/SwiftDiagnostics/DiagnosticsFormatter.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2014 - 2023 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 SwiftDiagnostics 14 | import SwiftSyntax 15 | import SwiftSyntaxMacroExpansion 16 | 17 | extension Sequence where Element == Range { 18 | /// Given a set of ranges that are sorted in order of nondecreasing lower 19 | /// bound, merge any overlapping ranges to produce a sequence of 20 | /// nonoverlapping ranges. 21 | fileprivate func mergingOverlappingRanges() -> [Range] { 22 | var result: [Range] = [] 23 | 24 | var prior: Range? = nil 25 | for range in self { 26 | // If this is the first range we've seen, note it as the prior and 27 | // continue. 28 | guard let priorRange = prior else { 29 | prior = range 30 | continue 31 | } 32 | 33 | // If the ranges overlap, expand the prior range. 34 | precondition(priorRange.lowerBound <= range.lowerBound) 35 | if priorRange.overlaps(range) { 36 | let lower = priorRange.lowerBound 37 | let upper = Swift.max(priorRange.upperBound, range.upperBound) 38 | prior = lower.. String { 93 | let formatter = DiagnosticsFormatter(contextSize: contextSize, colorize: colorize) 94 | return formatter.annotatedSource(tree: tree, diags: diags, context: context) 95 | } 96 | 97 | /// Colorize the given source line by applying highlights from diagnostics. 98 | private func colorizeSourceLine( 99 | _ annotatedLine: AnnotatedSourceLine, 100 | lineNumber: Int, 101 | tree: some SyntaxProtocol, 102 | sourceLocationConverter slc: SourceLocationConverter 103 | ) -> String { 104 | guard colorize, !annotatedLine.diagnostics.isEmpty else { 105 | return annotatedLine.sourceString 106 | } 107 | 108 | // Compute the set of highlight ranges that land on this line. These 109 | // are column ranges, sorted in order of increasing starting column, and 110 | // with overlapping ranges merged. 111 | let highlightRanges: [Range] = annotatedLine.diagnostics.map { 112 | $0.highlights 113 | }.joined().compactMap { (highlight) -> Range? in 114 | if highlight.root != Syntax(tree) { 115 | return nil 116 | } 117 | 118 | let startLoc = highlight.startLocation(converter: slc, afterLeadingTrivia: true) 119 | let startLine = startLoc.line 120 | 121 | // Find the starting column. 122 | let startColumn: Int 123 | if startLine < lineNumber { 124 | startColumn = 1 125 | } else if startLine == lineNumber { 126 | startColumn = startLoc.column 127 | } else { 128 | return nil 129 | } 130 | 131 | // Find the ending column. 132 | let endLoc = highlight.endLocation(converter: slc, afterTrailingTrivia: false) 133 | let endLine = endLoc.line 134 | 135 | let endColumn: Int 136 | if endLine > lineNumber { 137 | endColumn = annotatedLine.sourceString.count 138 | } else if endLine == lineNumber { 139 | endColumn = endLoc.column 140 | } else { 141 | return nil 142 | } 143 | 144 | if startColumn == endColumn { 145 | return nil 146 | } 147 | 148 | return startColumn..] = highlightRanges.map { highlightRange in 156 | let startIndex = sourceStringUTF8.index( 157 | sourceStringUTF8.startIndex, offsetBy: highlightRange.lowerBound - 1) 158 | let endIndex = sourceStringUTF8.index(startIndex, offsetBy: highlightRange.count) 159 | return startIndex.. String { 197 | let slc = 198 | sourceLocationConverter ?? SourceLocationConverter(fileName: fileName ?? "", tree: tree) 199 | 200 | // First, we need to put each line and its diagnostics together 201 | var annotatedSourceLines = [AnnotatedSourceLine]() 202 | 203 | for (sourceLineIndex, sourceLine) in slc.sourceLines.enumerated() { 204 | let diagsForLine = diags.filter { diag in 205 | return diag.location(converter: slc).line == (sourceLineIndex + 1) 206 | } 207 | let suffixText = suffixTexts.compactMap { (position, text) in 208 | if slc.location(for: position).line == (sourceLineIndex + 1) { 209 | return text 210 | } 211 | 212 | return nil 213 | }.joined() 214 | 215 | annotatedSourceLines.append( 216 | AnnotatedSourceLine( 217 | diagnostics: diagsForLine, sourceString: sourceLine, suffixText: suffixText)) 218 | } 219 | 220 | // Only lines with diagnostic messages should be printed, but including some context 221 | let rangesToPrint = annotatedSourceLines.enumerated().compactMap { 222 | (lineIndex, sourceLine) -> Range? in 223 | let lineNumber = lineIndex + 1 224 | if !sourceLine.isFreeOfAnnotations { 225 | return Range( 226 | uncheckedBounds: (lower: lineNumber - contextSize, upper: lineNumber + contextSize + 1)) 227 | } 228 | return nil 229 | } 230 | 231 | var annotatedSource = "" 232 | 233 | // If there was a filename, add it first. 234 | if let fileName { 235 | let header = colorizeBufferOutline("===") 236 | let firstLine = 237 | 1 238 | + (annotatedSourceLines.enumerated().first { (lineIndex, sourceLine) in 239 | !sourceLine.isFreeOfAnnotations 240 | }?.offset ?? 0) 241 | 242 | annotatedSource.append("\(indentString)\(header) \(fileName):\(firstLine) \(header)\n") 243 | } 244 | 245 | /// Keep track if a line missing char should be printed 246 | var hasLineBeenSkipped = false 247 | 248 | let maxNumberOfDigits = String(annotatedSourceLines.count).count 249 | 250 | for (lineIndex, annotatedLine) in annotatedSourceLines.enumerated() { 251 | let lineNumber = lineIndex + 1 252 | guard 253 | rangesToPrint.contains(where: { range in 254 | range.contains(lineNumber) 255 | }) 256 | else { 257 | hasLineBeenSkipped = true 258 | continue 259 | } 260 | 261 | // line numbers should be right aligned 262 | let lineNumberString = String(lineNumber) 263 | let leadingSpaces = String(repeating: " ", count: maxNumberOfDigits - lineNumberString.count) 264 | let linePrefix = "\(leadingSpaces)\(colorizeBufferOutline("\(lineNumberString) │")) " 265 | 266 | // If necessary, print a line that indicates that there was lines skipped in the source code 267 | if hasLineBeenSkipped && !annotatedSource.isEmpty { 268 | let lineMissingInfoLine = 269 | indentString + String(repeating: " ", count: maxNumberOfDigits) 270 | + " \(colorizeBufferOutline("┆"))" 271 | annotatedSource.append("\(lineMissingInfoLine)\n") 272 | } 273 | hasLineBeenSkipped = false 274 | 275 | // add indentation 276 | annotatedSource.append(indentString) 277 | 278 | // print the source line 279 | annotatedSource.append(linePrefix) 280 | annotatedSource.append( 281 | colorizeSourceLine( 282 | annotatedLine, 283 | lineNumber: lineNumber, 284 | tree: tree, 285 | sourceLocationConverter: slc 286 | ) 287 | ) 288 | 289 | // If the line did not end with \n (e.g. the last line), append it manually 290 | if annotatedSource.last != "\n" { 291 | annotatedSource.append("\n") 292 | } 293 | 294 | let columnsWithDiagnostics = Set( 295 | annotatedLine.diagnostics.map { $0.location(converter: slc).column }) 296 | let diagsPerColumn = Dictionary(grouping: annotatedLine.diagnostics) { diag in 297 | diag.location(converter: slc).column 298 | }.sorted { lhs, rhs in 299 | lhs.key > rhs.key 300 | } 301 | 302 | let preMessagePrefix = 303 | indentString + String(repeating: " ", count: maxNumberOfDigits) + " " 304 | + colorizeBufferOutline("│") 305 | for (column, diags) in diagsPerColumn { 306 | // compute the string that is shown before each message 307 | var preMessage = preMessagePrefix 308 | for c in 0.. String { 351 | return annotatedSource( 352 | fileName: nil, 353 | tree: tree, 354 | diags: diags, 355 | context: context, 356 | indentString: "", 357 | suffixTexts: [:] 358 | ) 359 | } 360 | 361 | /// Annotates the given ``DiagnosticMessage`` with an appropriate ANSI color code (if the value of the `colorize` 362 | /// property is `true`) and returns the result as a printable string. 363 | private func colorizeIfRequested(_ message: DiagnosticMessage) -> String { 364 | switch message.severity { 365 | case .error: 366 | let annotation = ANSIAnnotation(color: .red, trait: .bold) 367 | return colorizeIfRequested("🛑 \(message.message)", annotation: annotation) 368 | 369 | case .warning: 370 | let color = ANSIAnnotation(color: .yellow) 371 | let prefix = colorizeIfRequested("⚠️ ", annotation: color.withTrait(.bold)) 372 | 373 | return prefix + colorizeIfRequested(message.message, annotation: color) 374 | default: 375 | let color = ANSIAnnotation(color: .default, trait: .bold) 376 | let prefix = colorizeIfRequested("ℹ️ ", annotation: color) 377 | return prefix + message.message 378 | } 379 | } 380 | 381 | /// Apply the given color and trait to the specified text, when we are 382 | /// supposed to color the output. 383 | private func colorizeIfRequested( 384 | _ text: String, 385 | annotation: ANSIAnnotation 386 | ) -> String { 387 | guard colorize, !text.isEmpty else { 388 | return text 389 | } 390 | 391 | return annotation.applied(to: text) 392 | } 393 | 394 | /// Colorize for the buffer outline and line numbers. 395 | func colorizeBufferOutline(_ text: String) -> String { 396 | colorizeIfRequested(text, annotation: .bufferOutline) 397 | } 398 | } 399 | 400 | struct ANSIAnnotation { 401 | enum Color: UInt8 { 402 | case normal = 0 403 | case black = 30 404 | case red = 31 405 | case green = 32 406 | case yellow = 33 407 | case blue = 34 408 | case magenta = 35 409 | case cyan = 36 410 | case white = 37 411 | case `default` = 39 412 | } 413 | 414 | enum Trait: UInt8 { 415 | case normal = 0 416 | case bold = 1 417 | case underline = 4 418 | } 419 | 420 | var color: Color 421 | var trait: Trait 422 | 423 | /// The textual representation of the annotation. 424 | var code: String { 425 | "\u{001B}[\(trait.rawValue);\(color.rawValue)m" 426 | } 427 | 428 | init(color: Color, trait: Trait = .normal) { 429 | self.color = color 430 | self.trait = trait 431 | } 432 | 433 | func withTrait(_ trait: Trait) -> Self { 434 | return ANSIAnnotation(color: self.color, trait: trait) 435 | } 436 | 437 | func applied(to message: String) -> String { 438 | // Resetting after the message ensures that we don't color unintended lines in the output 439 | return "\(code)\(message)\(ANSIAnnotation.normal.code)" 440 | } 441 | 442 | /// The "normal" or "reset" ANSI code used to unset any previously added annotation. 443 | static var normal: ANSIAnnotation { 444 | self.init(color: .normal, trait: .normal) 445 | } 446 | 447 | /// Annotation used for the outline and line numbers of a buffer. 448 | static var bufferOutline: ANSIAnnotation { 449 | ANSIAnnotation(color: .cyan, trait: .normal) 450 | } 451 | 452 | /// Annotation used for highlighting source text. 453 | static var sourceHighlight: ANSIAnnotation { 454 | ANSIAnnotation(color: .default, trait: .underline) 455 | } 456 | } 457 | --------------------------------------------------------------------------------