diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 158a6bb..f441094 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -1,25 +1,116 @@ name: Pull request on: pull_request +env: + XCODE_VERSION: "16.3" + jobs: - pull-request: + prepare: runs-on: macos-15 - + outputs: + platforms: ${{ steps.platforms.outputs.platforms }} + scheme: ${{ steps.scheme.outputs.scheme }} steps: - uses: actions/checkout@v4 + - name: Setup Xcode + run: sudo xcode-select -s /Applications/Xcode_$XCODE_VERSION.app - - name: Setup + - name: Setup mise env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | curl https://mise.run | sh mise install - - - name: Lint + - name: Run linters run: mise lint - - name: Build + - name: Extract platforms + id: platforms + run: | + platforms=$(swift package dump-package | jq -r '[.platforms[].platformName] | unique | @json') + echo "Platforms: $platforms" + echo "platforms=$platforms" >> $GITHUB_OUTPUT + + - name: Extract scheme + id: scheme + run: | + repo=$(basename $GITHUB_REPOSITORY) + schemes=$(xcodebuild -list) + echo "$schemes" + + if echo "$schemes" | grep -q "$repo-Package"; then + scheme="$repo-Package" + elif echo "$schemes" | grep -q "$repo"; then + scheme="$repo" + else + echo "Unable to select a scheme" + exit 1 + fi + + echo "Selected scheme: $scheme" + echo "scheme=$scheme" >> $GITHUB_OUTPUT + + build-and-test: + needs: prepare + runs-on: macos-15 + strategy: + fail-fast: false + matrix: + platform: ${{ fromJSON(needs.prepare.outputs.platforms) }} + steps: + - uses: actions/checkout@v4 + - name: Setup Xcode + run: sudo xcode-select -s /Applications/Xcode_$XCODE_VERSION.app + + - name: Map destinations + if: ${{ matrix.platform != 'macos' }} + id: destination + run: | + case "${{ matrix.platform }}" in + ios) + destination="platform=iOS Simulator,name=iPhone 16 Pro Max,OS=latest" + ;; + maccatalyst) + destination="platform=macOS,variant=Mac Catalyst" + ;; + tvos) + destination="platform=tvOS Simulator,name=Apple TV 4K (3rd generation),OS=latest" + ;; + visionos) + destination="platform=visionOS Simulator,name=Apple Vision Pro,OS=latest" + ;; + watchos) + destination="platform=watchOS Simulator,name=Apple Watch Series 10 (46mm),OS=latest" + ;; + *) + echo "Unknown platform: ${{ matrix.platform }}" + exit 1 + ;; + esac + echo "destination=$destination" >> $GITHUB_OUTPUT + + - name: Build (SPM) + if: ${{ matrix.platform == 'macos' }} run: swift build + - name: Build (Xcode) + if: ${{ matrix.platform != 'macos' }} + run: | + set -o pipefail + xcodebuild build \ + -scheme ${{ needs.prepare.outputs.scheme }} \ + -destination "${{ steps.destination.outputs.destination }}" | \ + xcbeautify --renderer github-actions - - name: Test - run: swift test \ No newline at end of file + - name: Test (SPM) + if: ${{ matrix.platform == 'macos' }} + run: | + set -o pipefail + swift test | xcbeautify --renderer github-actions + - name: Test (Xcode) + if: ${{ matrix.platform != 'macos' }} + run: | + set -o pipefail + xcodebuild test \ + -scheme ${{ needs.prepare.outputs.scheme }} \ + -destination "${{ steps.destination.outputs.destination }}" | \ + xcbeautify --renderer github-actions diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c38e287..bce3182 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,21 +1,43 @@ name: Release - on: push: tags: - '*' +env: + XCODE_VERSION: "16.3" + jobs: release: - runs-on: ubuntu-latest - + runs-on: macos-15 steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Xcode + run: sudo xcode-select -s /Applications/Xcode_$XCODE_VERSION.app + + - name: Verify changes + run: | + current_tag=${GITHUB_REF#refs/tags/} + previous_tag=$(git tag --sort=-v:refname | head -n 2 | tail -n 1) + + current_major=$(echo "$current_tag" | cut -d '.' -f 1) + previous_major=$(echo "$previous_tag" | cut -d '.' -f 1) + echo "Comparing $current_tag with $previous_tag..." + + if [ "$current_major" -gt "$previous_major" ]; then + swift package diagnose-api-breaking-changes "$previous_tag" || true + else + swift package diagnose-api-breaking-changes "$previous_tag" + fi + - name: Draft release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - TAG_NAME=${GITHUB_REF#refs/tags/} - gh release create "$TAG_NAME" \ + current_tag=${GITHUB_REF#refs/tags/} + gh release create "$current_tag" \ --repo="$GITHUB_REPOSITORY" \ --generate-notes \ - --draft \ No newline at end of file + --draft diff --git a/.swiftlint.yml b/.swiftlint.yml index ee18cc7..7724a67 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -166,6 +166,9 @@ file_length: identifier_name: excluded: [id, x, y, z] +line_length: + ignores_comments: true + nesting: type_level: 2 @@ -192,4 +195,4 @@ custom_rules: empty_line_after_type_declaration: name: "Empty line after type declaration" message: "Type declaration should start with an empty line." - regex: "( |^)(actor|class|struct|enum|protocol|extension) (?!var)[^\\{]*? \\{(?!\\n*\\}) *\\n? *\\S" + regex: "( |^)(actor|class|struct|enum|protocol|extension) (?!var)[^\\{]*? \\{(?!\\s*\\}) *\\n? *\\S" diff --git a/Package.resolved b/Package.resolved index 6b4ad0b..dda8b70 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "908df360b8c2dfd8f054b79926bb65d2ec9386cc68cf7c1d9329b54ee52423a9", + "originHash" : "99431853c87a6b5650f35f74288f2d84c2781ee280b91e34ae7d502413704b4b", "pins" : [ { "identity" : "principle", "kind" : "remoteSourceControl", "location" : "https://github.com/NSFatalError/Principle", "state" : { - "revision" : "3a0ce9bef0828a948b12dc6fb30589a4cfd85ef3", - "version" : "0.0.3" + "revision" : "3db0c92aa564f3b6451063a3374e6ad177cdfebf", + "version" : "1.0.0" } }, { diff --git a/Package.swift b/Package.swift index b690cac..64768e1 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.0 +// swift-tools-version: 6.1 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,12 +6,12 @@ import PackageDescription let package = Package( name: "PrincipleMacros", platforms: [ - .macOS(.v13), - .macCatalyst(.v16), - .iOS(.v16), - .tvOS(.v16), - .watchOS(.v9), - .visionOS(.v1) + .macOS(.v15), + .macCatalyst(.v18), + .iOS(.v18), + .tvOS(.v18), + .watchOS(.v11), + .visionOS(.v2) ], products: [ .library( @@ -22,7 +22,7 @@ let package = Package( dependencies: [ .package( url: "https://github.com/NSFatalError/Principle", - from: "0.0.1" + from: "1.0.0" ), .package( url: "https://github.com/swiftlang/swift-syntax", diff --git a/README.md b/README.md index cdbe1ce..eb5c0f0 100644 --- a/README.md +++ b/README.md @@ -1 +1,19 @@ # PrincipleMacros + +![Swift](https://img.shields.io/badge/Swift-6.0-EF5239?logo=swift&labelColor=white) + +Essential tools that extend the capabilities of `SwiftSyntax`, simplifying the implementation of custom macros. + +> [!WARNING] +> This package is considered an implementation detail of some of my other open-source projects. +> While it follows semantic versioning rules and has decent test coverage, it is currently undocumented, +> and future releases may introduce multiple breaking changes. Use it at your own discretion. + +## Installation + +```swift +.package( + url: "https://github.com/NSFatalError/PrincipleMacros", + from: "1.0.0" +) +``` diff --git a/Sources/PrincipleMacros/Builders/Expressions/SwitchExprBuilder.swift b/Sources/PrincipleMacros/Builders/Expressions/SwitchExprBuilder.swift index 9476dae..9814a87 100644 --- a/Sources/PrincipleMacros/Builders/Expressions/SwitchExprBuilder.swift +++ b/Sources/PrincipleMacros/Builders/Expressions/SwitchExprBuilder.swift @@ -25,16 +25,21 @@ public struct SwitchExprBuilder: ExprBuilder { } public func build() -> SwitchExprSyntax { - SwitchExprSyntax(subject: subject, cases: switchCases()) + SwitchExprSyntax( + subject: subject.withLeadingSpace.withTrailingSpace, + leftBrace: .leftBraceToken().withTrailingNewline, + cases: switchCases() + ) } private func switchCases() -> SwitchCaseListSyntax { SwitchCaseListSyntax( cases.map { enumCase in .switchCase( - SwitchCaseSyntax( - "case \(switchCase(for: enumCase)): \(statementsBuilder(enumCase))" - ) + SwitchCaseSyntax(""" + case \(switchCase(for: enumCase)): + \(statementsBuilder(enumCase))\n + """) ) } ) @@ -60,7 +65,7 @@ public struct SwitchExprBuilder: ExprBuilder { return SwitchCaseItemSyntax( pattern: ValueBindingPatternSyntax( - bindingSpecifier: .keyword(.let), + bindingSpecifier: .keyword(.let).withTrailingSpace, pattern: ExpressionPatternSyntax( expression: FunctionCallExprSyntax( calledExpression: memberAccessExpression, diff --git a/Sources/PrincipleMacros/Imports.swift b/Sources/PrincipleMacros/Imports.swift index 7187504..f98437a 100644 --- a/Sources/PrincipleMacros/Imports.swift +++ b/Sources/PrincipleMacros/Imports.swift @@ -6,8 +6,8 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -@_exported import SwiftBasicFormat -@_exported import SwiftDiagnostics -@_exported import SwiftSyntax -@_exported import SwiftSyntaxBuilder -@_exported import SwiftSyntaxMacros +@_documentation(visibility: private) @_exported import SwiftBasicFormat +@_documentation(visibility: private) @_exported import SwiftDiagnostics +@_documentation(visibility: private) @_exported import SwiftSyntax +@_documentation(visibility: private) @_exported import SwiftSyntaxBuilder +@_documentation(visibility: private) @_exported import SwiftSyntaxMacros diff --git a/Tests/PrincipleMacrosTests/Builders/EnumCaseCallExprBuilderTests.swift b/Tests/PrincipleMacrosTests/Builders/EnumCaseCallExprBuilderTests.swift new file mode 100644 index 0000000..bafe81d --- /dev/null +++ b/Tests/PrincipleMacrosTests/Builders/EnumCaseCallExprBuilderTests.swift @@ -0,0 +1,51 @@ +// +// EnumCaseCallExprBuilderTests.swift +// PrincipleMacros +// +// Created by Kamil Strzelecki on 05/04/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +@testable import PrincipleMacros +import Testing + +internal struct EnumCaseCallExprBuilderTests { + + func makeEnumCase(from decl: DeclSyntax) throws -> EnumCase { + let enumCaseDecl = try #require(EnumCaseDeclSyntax(decl)) + let enumElement = try #require(enumCaseDecl.elements.first) + return EnumCase(declaration: enumCaseDecl, element: enumElement) + } + + @Test + func testCallWithoutAssociatedValues() throws { + let enumCase = try makeEnumCase(from: "case first") + let builder = EnumCaseCallExprBuilder(for: enumCase) { _ in + Issue.record() + return "" as ExprSyntax + } + #expect(builder.build().description == ".first") + } + + @Test + func testCallWithUnnamedAssociatedValue() throws { + let enumCase = try makeEnumCase(from: "case second(Int)") + let builder = EnumCaseCallExprBuilder(for: enumCase) { _ in + "123" as ExprSyntax + } + #expect(builder.build().description == ".second(123)") + } + + @Test + func testCallWithMultipleAssociatedValues() throws { + let enumCase = try makeEnumCase(from: "case third(arg: String, Int)") + let builder = EnumCaseCallExprBuilder(for: enumCase) { associatedValue in + if associatedValue.standardizedName.description == "arg" { + "argument" as ExprSyntax + } else { + "123" as ExprSyntax + } + } + #expect(builder.build().description == ".third(arg: argument, 123)") + } +} diff --git a/Tests/PrincipleMacrosTests/Builders/SwitchExprBuilderTests.swift b/Tests/PrincipleMacrosTests/Builders/SwitchExprBuilderTests.swift new file mode 100644 index 0000000..3a71932 --- /dev/null +++ b/Tests/PrincipleMacrosTests/Builders/SwitchExprBuilderTests.swift @@ -0,0 +1,45 @@ +// +// SwitchExprBuilderTests.swift +// PrincipleMacros +// +// Created by Kamil Strzelecki on 05/04/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +@testable import PrincipleMacros +import Testing + +internal struct SwitchExprBuilderTests { + + func makeEnumCase(from decl: DeclSyntax) throws -> EnumCase { + let enumCaseDecl = try #require(EnumCaseDeclSyntax(decl)) + let enumElement = try #require(enumCaseDecl.elements.first) + return EnumCase(declaration: enumCaseDecl, element: enumElement) + } + + @Test + func testSwitchExpression() throws { + let enumCases = try EnumCasesList([ + makeEnumCase(from: "case first"), + makeEnumCase(from: "case second(Int)"), + makeEnumCase(from: "case third(arg: String, Int)") + ]) + + let builder = SwitchExprBuilder(for: enumCases, over: "subject") { enumCase in + "return \(raw: enumCase.associatedValues.count)" + } + + let expectation = """ + switch subject { + case .first: + return 0 + case let .second(_0): + return 1 + case let .third(arg, _1): + return 2 + } + """ + + #expect(builder.build().description == expectation) + } +} diff --git a/Tests/PrincipleMacrosTests/Parameters/ParameterExtractorTests.swift b/Tests/PrincipleMacrosTests/Parameters/ParameterExtractorTests.swift new file mode 100644 index 0000000..4f05175 --- /dev/null +++ b/Tests/PrincipleMacrosTests/Parameters/ParameterExtractorTests.swift @@ -0,0 +1,73 @@ +// +// ParameterExtractorTests.swift +// PrincipleMacros +// +// Created by Kamil Strzelecki on 05/04/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +@testable import PrincipleMacros +import Testing + +internal struct ParameterExtractorTests { + + private func makeExtractor(from expr: ExprSyntax) throws -> ParameterExtractor { + let macro = try #require(MacroExpansionExprSyntax(expr)) + return ParameterExtractor(from: macro) + } + + @Test + func testExpressionExtraction() throws { + let extractor = try makeExtractor(from: "#MyMacro(value: Type.make())") + let extracted = try extractor.expression(withLabel: "value") + let expected: ExprSyntax = "Type.make()" + #expect(extracted.description == expected.description) + } + + @Test + func testUnnamedExpressionExtraction() throws { + let extractor = try makeExtractor(from: "#MyMacro(value: Type.make(), 123)") + let extracted = try extractor.expression(withLabel: nil) + let expected: ExprSyntax = "123" + #expect(extracted.description == expected.description) + } + + @Test + func testTrailingClosureExtraction() throws { + let extractor = try makeExtractor(from: "#MyMacro { _ in }") + let extracted = try extractor.trailingClosure(withLabel: "operation") + let expected: ExprSyntax = "{ _ in }" + #expect(extracted.description == expected.description) + } + + @Test + func testTrailingClosureReferenceExtraction() throws { + let extractor = try makeExtractor(from: "#MyMacro(operation: perform)") + let extracted = try extractor.trailingClosure(withLabel: "operation") + let expected: ExprSyntax = "perform" + #expect(extracted.description == expected.description) + } + + @Test + func testRawStringExtraction() throws { + let extractor = try makeExtractor(from: #"#MyMacro(string: "arg")"#) + let extracted = try extractor.rawString(withLabel: "string") + #expect(extracted == "arg") + } + + @Test + func testUnexpectedSyntaxTypeError() throws { + let extractor = try makeExtractor(from: #"#MyMacro(string: reference.arg)"#) + #expect(throws: ParameterExtractionError.unexpectedSyntaxType) { + try extractor.rawString(withLabel: "string") + } + } + + @Test + func testNotFoundError() throws { + let extractor = try makeExtractor(from: #"#MyMacro(string: "arg")"#) + #expect(throws: ParameterExtractionError.notFound) { + try extractor.rawString(withLabel: "value") + } + } +} diff --git a/Tests/PrincipleMacrosTests/Syntax/ClosureExprSyntaxTests.swift b/Tests/PrincipleMacrosTests/Syntax/ClosureExprSyntaxTests.swift index ff766b9..e778ea5 100644 --- a/Tests/PrincipleMacrosTests/Syntax/ClosureExprSyntaxTests.swift +++ b/Tests/PrincipleMacrosTests/Syntax/ClosureExprSyntaxTests.swift @@ -20,7 +20,7 @@ internal struct ClosureExprSyntaxTests { """ @Test - func testInterpolation() throws { + func testExpansion() throws { let closure = expr.expanded(nestingLevel: 2) let interpolation: ExprSyntax = """ .init( @@ -53,7 +53,7 @@ internal struct ClosureExprSyntaxTests { """ @Test - func testInterpolation() throws { + func testExpansion() throws { let closure = expr.expanded(nestingLevel: 2) let interpolation: ExprSyntax = """ .init( @@ -94,7 +94,7 @@ internal struct ClosureExprSyntaxTests { """ @Test - func testInterpolation() throws { + func testExpansion() throws { let closure = expr.expanded(nestingLevel: 2) let interpolation: ExprSyntax = """ .init( diff --git a/Tests/PrincipleMacrosTests/Syntax/ExprSyntaxTests.swift b/Tests/PrincipleMacrosTests/Syntax/ExprSyntaxTests.swift index b41b18c..ac625c4 100644 --- a/Tests/PrincipleMacrosTests/Syntax/ExprSyntaxTests.swift +++ b/Tests/PrincipleMacrosTests/Syntax/ExprSyntaxTests.swift @@ -106,9 +106,9 @@ internal struct ExprSyntaxTests { ) ] ) - func testComposition(_ composition: (String, String)) { - let expr: ExprSyntax = "\(raw: composition.0)" - #expect(expr.inferredType?.description == composition.1) + func testComposition(expr: String, expectation: String) { + let expr: ExprSyntax = "\(raw: expr)" + #expect(expr.inferredType?.description == expectation) } } } diff --git a/Tests/PrincipleMacrosTests/Syntax/TypeSyntaxTests.swift b/Tests/PrincipleMacrosTests/Syntax/TypeSyntaxTests.swift index 3cc5960..e252843 100644 --- a/Tests/PrincipleMacrosTests/Syntax/TypeSyntaxTests.swift +++ b/Tests/PrincipleMacrosTests/Syntax/TypeSyntaxTests.swift @@ -15,56 +15,56 @@ internal struct TypeSyntaxTests { @Test func testOptionalLiteral() { - let expr: TypeSyntax = "Int?" - #expect(expr.standardized.description == "Optional") + let type: TypeSyntax = "Int?" + #expect(type.standardized.description == "Optional") } @Test func testImplicitlyUnwrappedOptionalLiteral() { - let expr: TypeSyntax = "String!" - #expect(expr.standardized.description == "Optional") + let type: TypeSyntax = "String!" + #expect(type.standardized.description == "Optional") } @Test func testArrayLiteral() { - let expr: TypeSyntax = "[String]" - #expect(expr.standardized.description == "Array") + let type: TypeSyntax = "[String]" + #expect(type.standardized.description == "Array") } @Test func testDictionaryLiteral() { - let expr: TypeSyntax = "[String: Int]" - #expect(expr.standardized.description == "Dictionary") + let type: TypeSyntax = "[String: Int]" + #expect(type.standardized.description == "Dictionary") } @Test func testBasicType() { - let expr: TypeSyntax = "UIView" - #expect(expr.standardized.description == "UIView") + let type: TypeSyntax = "UIView" + #expect(type.standardized.description == "UIView") } @Test func testMemberType() { - let expr: TypeSyntax = "UIView.Constraints" - #expect(expr.standardized.description == "UIView.Constraints") + let type: TypeSyntax = "UIView.Constraints" + #expect(type.standardized.description == "UIView.Constraints") } @Test func testGenericType() { - let expr: TypeSyntax = "Cache" - #expect(expr.standardized.description == "Cache") + let type: TypeSyntax = "Cache" + #expect(type.standardized.description == "Cache") } @Test func testVoidType() { - let expr: TypeSyntax = "()" - #expect(expr.standardized.description == "Void") + let type: TypeSyntax = "()" + #expect(type.standardized.description == "Void") } @Test func testTupleType() { - let expr: TypeSyntax = "(_ first: String, second: Int, Bool)" - #expect(expr.standardized.description == "(_ first: String, second: Int, Bool)") + let type: TypeSyntax = "(_ first: String, second: Int, Bool)" + #expect(type.standardized.description == "(_ first: String, second: Int, Bool)") } } @@ -82,9 +82,9 @@ internal struct TypeSyntaxTests { ) ] ) - func testComposition(_ composition: (String, String)) { - let expr: TypeSyntax = "\(raw: composition.0)" - #expect(expr.standardized.description == composition.1) + func testComposition(type: String, expectation: String) { + let type: TypeSyntax = "\(raw: type)" + #expect(type.standardized.description == expectation) } } }