diff --git a/.github/workflows/SyntaxKit.yml b/.github/workflows/SyntaxKit.yml index dd72dfa..2c9de80 100644 --- a/.github/workflows/SyntaxKit.yml +++ b/.github/workflows/SyntaxKit.yml @@ -122,7 +122,7 @@ jobs: restore-keys: | ${{ runner.os }}-mint- - name: Install mint - if: steps.cache-mint.outputs.cache-hit != 'true' + if: steps.cache-mint.outputs.cache-hit == '' run: | git clone https://github.com/yonaskolb/Mint.git cd Mint diff --git a/.swiftlint.yml b/.swiftlint.yml index e4222bf..6236845 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -116,6 +116,7 @@ excluded: - .build - Mint - Examples + - Macros indentation_width: indentation_width: 2 file_name: diff --git a/Macros/Options/.github/workflows/Options.yml b/Macros/Options/.github/workflows/Options.yml new file mode 100644 index 0000000..1f13ac5 --- /dev/null +++ b/Macros/Options/.github/workflows/Options.yml @@ -0,0 +1,242 @@ +name: macOS +on: + push: + branches-ignore: + - '*WIP' +env: + PACKAGE_NAME: Options +jobs: + build-ubuntu: + name: Build on Ubuntu + env: + PACKAGE_NAME: Options + SWIFT_VER: ${{ matrix.swift-version }} + runs-on: ${{ matrix.runs-on }} + if: "!contains(github.event.head_commit.message, 'ci skip')" + strategy: + matrix: + runs-on: [ubuntu-20.04, ubuntu-22.04] + swift-version: ["5.7.1", "5.8.1", "5.9", "5.9.2", "5.10"] + steps: + - uses: actions/checkout@v4 + - name: Set Ubuntu Release DOT + run: echo "RELEASE_DOT=$(lsb_release -sr)" >> $GITHUB_ENV + - name: Set Ubuntu Release NUM + run: echo "RELEASE_NUM=${RELEASE_DOT//[-._]/}" >> $GITHUB_ENV + - name: Set Ubuntu Codename + run: echo "RELEASE_NAME=$(lsb_release -sc)" >> $GITHUB_ENV + - name: Cache swift package modules + id: cache-spm-linux + uses: actions/cache@v4 + env: + cache-name: cache-spm + with: + path: .build + key: ${{ runner.os }}-${{ env.RELEASE_DOT }}-${{ env.cache-name }}-${{ matrix.swift-version }}-${{ hashFiles('Package.resolved') }} + restore-keys: | + ${{ runner.os }}-${{ env.RELEASE_DOT }}-${{ env.cache-name }}-${{ matrix.swift-version }}- + ${{ runner.os }}-${{ env.RELEASE_DOT }}-${{ env.cache-name }}- + - name: Cache swift + id: cache-swift-linux + uses: actions/cache@v4 + env: + cache-name: cache-swift + with: + path: swift-${{ env.SWIFT_VER }}-RELEASE-ubuntu${{ env.RELEASE_DOT }} + key: ${{ runner.os }}-${{ env.cache-name }}-${{ matrix.swift-version }}-${{ env.RELEASE_DOT }} + restore-keys: | + ${{ runner.os }}-${{ env.cache-name }}-${{ matrix.swift-version }}- + - name: Download Swift + if: steps.cache-swift-linux.outputs.cache-hit != 'true' + run: curl -O https://download.swift.org/swift-${SWIFT_VER}-release/ubuntu${RELEASE_NUM}/swift-${SWIFT_VER}-RELEASE/swift-${SWIFT_VER}-RELEASE-ubuntu${RELEASE_DOT}.tar.gz + - name: Extract Swift + if: steps.cache-swift-linux.outputs.cache-hit != 'true' + run: tar xzf swift-${SWIFT_VER}-RELEASE-ubuntu${RELEASE_DOT}.tar.gz + - name: Add Path + run: echo "$GITHUB_WORKSPACE/swift-${SWIFT_VER}-RELEASE-ubuntu${RELEASE_DOT}/usr/bin" >> $GITHUB_PATH + - name: Test + run: swift test --enable-code-coverage + - uses: sersoft-gmbh/swift-coverage-action@v4 + id: coverage-files + with: + fail-on-empty-output: true + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: true + flags: swift-${{ matrix.swift-version }},ubuntu-${{ matrix.RELEASE_DOT }} + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ join(fromJSON(steps.coverage-files.outputs.files), ',') }} + build-macos: + name: Build on macOS + runs-on: ${{ matrix.os }} + if: "!contains(github.event.head_commit.message, 'ci skip')" + env: + PACKAGE_NAME: Options + strategy: + matrix: + include: + - xcode: "/Applications/Xcode_14.1.app" + os: macos-12 + iOSVersion: "16.1" + watchOSVersion: "9.0" + watchName: "Apple Watch Series 5 - 40mm" + iPhoneName: "iPhone 12 mini" + - xcode: "/Applications/Xcode_14.2.app" + os: macos-12 + iOSVersion: "16.2" + watchOSVersion: "9.1" + watchName: "Apple Watch Ultra (49mm)" + iPhoneName: "iPhone 14" + - xcode: "/Applications/Xcode_15.0.1.app" + os: macos-13 + iOSVersion: "17.0.1" + watchOSVersion: "10.0" + watchName: "Apple Watch Series 9 (41mm)" + iPhoneName: "iPhone 15" + - xcode: "/Applications/Xcode_15.1.app" + os: macos-13 + iOSVersion: "17.2" + watchOSVersion: "10.2" + watchName: "Apple Watch Series 9 (45mm)" + iPhoneName: "iPhone 15 Plus" + - xcode: "/Applications/Xcode_15.2.app" + os: macos-14 + iOSVersion: "17.2" + watchOSVersion: "10.2" + watchName: "Apple Watch Ultra (49mm)" + iPhoneName: "iPhone 15 Pro" + - xcode: "/Applications/Xcode_15.3.app" + os: macos-14 + iOSVersion: "17.4" + watchOSVersion: "10.4" + watchName: "Apple Watch Ultra 2 (49mm)" + iPhoneName: "iPhone 15 Pro Max" + steps: + - uses: actions/checkout@v4 + - name: Cache swift package modules + id: cache-spm-macos + uses: actions/cache@v4 + env: + cache-name: cache-spm + with: + path: .build + key: ${{ matrix.os }}-build-${{ env.cache-name }}-${{ matrix.xcode }}-${{ hashFiles('Package.resolved') }} + restore-keys: | + ${{ matrix.os }}-build-${{ env.cache-name }}-${{ matrix.xcode }}- + - name: Cache mint + if: startsWith(matrix.xcode,'/Applications/Xcode_15.3') + id: cache-mint + uses: actions/cache@v4 + env: + cache-name: cache-mint + with: + path: .mint + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('Mintfile') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}- + ${{ runner.os }}-build- + ${{ runner.os }}- + - name: Set Xcode Name + run: echo "XCODE_NAME=$(basename -- ${{ matrix.xcode }} | sed 's/\.[^.]*$//' | cut -d'_' -f2)" >> $GITHUB_ENV + - name: Setup Xcode + run: sudo xcode-select -s ${{ matrix.xcode }}/Contents/Developer + - name: Install mint + if: startsWith(matrix.xcode,'/Applications/Xcode_15.3') + run: | + brew update + brew install mint + - name: Build + run: swift build + - name: Run Swift Package tests + run: swift test --enable-code-coverage + - uses: sersoft-gmbh/swift-coverage-action@v4 + id: coverage-files-spm + with: + fail-on-empty-output: true + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + with: + files: ${{ join(fromJSON(steps.coverage-files-spm.outputs.files), ',') }} + token: ${{ secrets.CODECOV_TOKEN }} + flags: macOS,${{ env.XCODE_NAME }},${{ matrix.runs-on }} + - name: Clean up spm build directory + run: rm -rf .build + - name: Lint + run: ./scripts/lint.sh + if: startsWith(matrix.xcode,'/Applications/Xcode_15.3') + # - name: Run iOS target tests + # run: xcodebuild test -scheme ${{ env.PACKAGE_NAME }} -sdk "iphonesimulator" -destination 'platform=iOS Simulator,name=${{ matrix.iPhoneName }},OS=${{ matrix.iOSVersion }}' -enableCodeCoverage YES build test + # - uses: sersoft-gmbh/swift-coverage-action@v4 + # id: coverage-files-iOS + # with: + # fail-on-empty-output: true + # - name: Upload coverage to Codecov + # uses: codecov/codecov-action@v4 + # with: + # fail_ci_if_error: true + # verbose: true + # token: ${{ secrets.CODECOV_TOKEN }} + # files: ${{ join(fromJSON(steps.coverage-files-iOS.outputs.files), ',') }} + # flags: iOS,iOS${{ matrix.iOSVersion }},macOS,${{ env.XCODE_NAME }} + # - name: Run watchOS target tests + # run: xcodebuild test -scheme ${{ env.PACKAGE_NAME }} -sdk "watchsimulator" -destination 'platform=watchOS Simulator,name=${{ matrix.watchName }},OS=${{ matrix.watchOSVersion }}' -enableCodeCoverage YES build test + # - uses: sersoft-gmbh/swift-coverage-action@v4 + # id: coverage-files-watchOS + # with: + # fail-on-empty-output: true + # - name: Upload coverage to Codecov + # uses: codecov/codecov-action@v4 + # with: + # fail_ci_if_error: true + # verbose: true + # token: ${{ secrets.CODECOV_TOKEN }} + # files: ${{ join(fromJSON(steps.coverage-files-watchOS.outputs.files), ',') }} + # flags: watchOS,watchOS${{ matrix.watchOSVersion }},macOS,${{ env.XCODE_NAME }} + build-self: + name: Build on Self-Hosting macOS + runs-on: [self-hosted, macOS] + if: github.event.repository.owner.login == github.event.organization.login && !contains(github.event.head_commit.message, 'ci skip') + steps: + - uses: actions/checkout@v4 + - name: Cache swift package modules + id: cache-spm-macos + uses: actions/cache@v4 + env: + cache-name: cache-spm + with: + path: .build + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('Package.resolved') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}- + ${{ runner.os }}-build- + ${{ runner.os }}- + - name: Cache mint + id: cache-mint + uses: actions/cache@v4 + env: + cache-name: cache-mint + with: + path: .mint + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('Mintfile') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}- + ${{ runner.os }}-build- + ${{ runner.os }}- + - name: Build + run: swift build + - name: Run Swift Package tests + run: swift test --enable-code-coverage + - uses: sersoft-gmbh/swift-coverage-action@v4 + with: + fail-on-empty-output: true + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: macOS,${{ env.XCODE_NAME }} + - name: Clean up spm build directory + run: rm -rf .build + - name: Lint + run: ./scripts/lint.sh diff --git a/Macros/Options/.gitignore b/Macros/Options/.gitignore new file mode 100644 index 0000000..008465e --- /dev/null +++ b/Macros/Options/.gitignore @@ -0,0 +1,133 @@ +# Created by https://www.toptal.com/developers/gitignore/api/swift,swiftpm,swiftpackagemanager,xcode,macos +# Edit at https://www.toptal.com/developers/gitignore?templates=swift,swiftpm,swiftpackagemanager,xcode,macos + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Swift ### +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +*.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +.swiftpm + +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ + +.mint +Output + +# Due to support for 5.10 and below +Package.resolved \ No newline at end of file diff --git a/Macros/Options/.gitrepo b/Macros/Options/.gitrepo new file mode 100644 index 0000000..394e5c2 --- /dev/null +++ b/Macros/Options/.gitrepo @@ -0,0 +1,12 @@ +; DO NOT EDIT (unless you know what you are doing) +; +; This subdirectory is a git "subrepo", and this file is maintained by the +; git-subrepo command. See https://github.com/ingydotnet/git-subrepo#readme +; +[subrepo] + remote = git@github.com:brightdigit/Options.git + branch = syntaxkit-sample + commit = a2fd9e31d5fdf1a0e9d61fe76ab5a4461d10b08a + parent = 12b377f8e1df18994e1c9693f6c6399e7f9ddeb2 + method = merge + cmdver = 0.4.9 diff --git a/Macros/Options/.hound.yml b/Macros/Options/.hound.yml new file mode 100644 index 0000000..6941f63 --- /dev/null +++ b/Macros/Options/.hound.yml @@ -0,0 +1,2 @@ +swiftlint: + config_file: .swiftlint.yml diff --git a/Macros/Options/.periphery.yml b/Macros/Options/.periphery.yml new file mode 100644 index 0000000..85b884a --- /dev/null +++ b/Macros/Options/.periphery.yml @@ -0,0 +1 @@ +retain_public: true diff --git a/Macros/Options/.spi.yml b/Macros/Options/.spi.yml new file mode 100644 index 0000000..2c312f8 --- /dev/null +++ b/Macros/Options/.spi.yml @@ -0,0 +1,4 @@ +version: 1 +builder: + configs: + - documentation_targets: [Options] diff --git a/Macros/Options/.swift-version b/Macros/Options/.swift-version new file mode 100644 index 0000000..760606e --- /dev/null +++ b/Macros/Options/.swift-version @@ -0,0 +1 @@ +5.7 diff --git a/Macros/Options/.swiftformat b/Macros/Options/.swiftformat new file mode 100644 index 0000000..c510d49 --- /dev/null +++ b/Macros/Options/.swiftformat @@ -0,0 +1,7 @@ +--indent 2 +--header "\n .*?\.swift\n SimulatorServices\n\n Created by Leo Dion.\n Copyright © {year} BrightDigit.\n\n Permission is hereby granted, free of charge, to any person\n obtaining a copy of this software and associated documentation\n files (the “Software”), to deal in the Software without\n restriction, including without limitation the rights to use,\n copy, modify, merge, publish, distribute, sublicense, and/or\n sell copies of the Software, and to permit persons to whom the\n Software is furnished to do so, subject to the following\n conditions:\n \n The above copyright notice and this permission notice shall be\n included in all copies or substantial portions of the Software.\n\n THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,\n EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES\n OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\n NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT\n HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,\n WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\n FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR\n OTHER DEALINGS IN THE SOFTWARE.\n" +--commas inline +--disable wrapMultilineStatementBraces, redundantInternal +--extensionacl on-declarations +--decimalgrouping 3,4 +--exclude .build, DerivedData, .swiftpm diff --git a/Macros/Options/.swiftlint.yml b/Macros/Options/.swiftlint.yml new file mode 100644 index 0000000..6be46e8 --- /dev/null +++ b/Macros/Options/.swiftlint.yml @@ -0,0 +1,118 @@ +opt_in_rules: + - array_init + - attributes + - closure_body_length + - closure_end_indentation + - closure_spacing + - collection_alignment + - conditional_returns_on_newline + - contains_over_filter_count + - contains_over_filter_is_empty + - contains_over_first_not_nil + - contains_over_range_nil_comparison + - convenience_type + - discouraged_object_literal + - discouraged_optional_boolean + - empty_collection_literal + - empty_count + - empty_string + - empty_xctest_method + - enum_case_associated_values_count + - expiring_todo + - explicit_acl + - explicit_init + - explicit_top_level_acl + - fallthrough + - fatal_error_message + - file_name + - file_name_no_space + - file_types_order + - first_where + - flatmap_over_map_reduce + - force_unwrapping + - function_default_parameter_at_end + - ibinspectable_in_extension + - identical_operands + - implicit_return + - implicitly_unwrapped_optional + - indentation_width + - joined_default_parameter + - last_where + - legacy_multiple + - legacy_random + - literal_expression_end_indentation + - lower_acl_than_parent + - missing_docs + - modifier_order + - multiline_arguments + - multiline_arguments_brackets + - multiline_function_chains + - multiline_literal_brackets + - multiline_parameters + - nimble_operator + - nslocalizedstring_key + - nslocalizedstring_require_bundle + - number_separator + - object_literal + - operator_usage_whitespace + - optional_enum_case_matching + - overridden_super_call + - override_in_extension + - pattern_matching_keywords + - prefer_self_type_over_type_of_self + - prefer_zero_over_explicit_init + - private_action + - private_outlet + - prohibited_interface_builder + - prohibited_super_call + - quick_discouraged_call + - quick_discouraged_focused_test + - quick_discouraged_pending_test + - reduce_into + - redundant_nil_coalescing + - redundant_type_annotation + - required_enum_case + - single_test_class + - sorted_first_last + - sorted_imports + - static_operator + - strong_iboutlet + - toggle_bool + - trailing_closure + - type_contents_order + - unavailable_function + - unneeded_parentheses_in_closure_argument + - unowned_variable_capture + - untyped_error_in_catch + - vertical_parameter_alignment_on_call + - vertical_whitespace_closing_braces + - vertical_whitespace_opening_braces + - xct_specific_matcher + - yoda_condition +analyzer_rules: + - explicit_self + - unused_declaration + - unused_import +type_body_length: + - 100 + - 200 +file_length: + - 200 + - 300 +function_body_length: + - 18 + - 40 +function_parameter_count: 8 +line_length: + - 90 + - 90 +identifier_name: + excluded: + - id +excluded: + - Tests + - DerivedData + - .build + - .swiftpm +indentation_width: + indentation_width: 2 diff --git a/Macros/Options/LICENSE b/Macros/Options/LICENSE new file mode 100644 index 0000000..59f9701 --- /dev/null +++ b/Macros/Options/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2020 Bright Digit, LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Macros/Options/Mintfile b/Macros/Options/Mintfile new file mode 100644 index 0000000..c1dc548 --- /dev/null +++ b/Macros/Options/Mintfile @@ -0,0 +1,2 @@ +nicklockwood/SwiftFormat@0.53.5 +realm/SwiftLint@0.54.0 \ No newline at end of file diff --git a/Macros/Options/Package.swift b/Macros/Options/Package.swift new file mode 100644 index 0000000..5646b68 --- /dev/null +++ b/Macros/Options/Package.swift @@ -0,0 +1,34 @@ +// swift-tools-version: 5.7.1 + +// swiftlint:disable explicit_top_level_acl +// swiftlint:disable prefixed_toplevel_constant +// swiftlint:disable explicit_acl + +import PackageDescription + +let package = Package( + name: "Options", + products: [ + .library( + name: "Options", + targets: ["Options"] + ) + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + ], + targets: [ + .target( + name: "Options", + dependencies: [] + ), + .testTarget( + name: "OptionsTests", + dependencies: ["Options"] + ) + ] +) + +// swiftlint:enable explicit_top_level_acl +// swiftlint:enable prefixed_toplevel_constant +// swiftlint:enable explicit_acl diff --git a/Macros/Options/Package@swift-5.10.swift b/Macros/Options/Package@swift-5.10.swift new file mode 100644 index 0000000..da62733 --- /dev/null +++ b/Macros/Options/Package@swift-5.10.swift @@ -0,0 +1,65 @@ +// swift-tools-version: 5.10 + +// swiftlint:disable explicit_top_level_acl +// swiftlint:disable prefixed_toplevel_constant +// swiftlint:disable explicit_acl + +import CompilerPluginSupport +import PackageDescription + +let swiftSettings = [ + SwiftSetting.enableUpcomingFeature("BareSlashRegexLiterals"), + SwiftSetting.enableUpcomingFeature("ConciseMagicFile"), + SwiftSetting.enableUpcomingFeature("ExistentialAny"), + SwiftSetting.enableUpcomingFeature("ForwardTrailingClosures"), + SwiftSetting.enableUpcomingFeature("ImplicitOpenExistentials"), + SwiftSetting.enableUpcomingFeature("StrictConcurrency"), + SwiftSetting.enableUpcomingFeature("DisableOutwardActorInference"), + SwiftSetting.enableExperimentalFeature("StrictConcurrency") +] + +let package = Package( + name: "Options", + platforms: [ + .macOS(.v10_15), + .iOS(.v13), + .tvOS(.v13), + .watchOS(.v6), + .macCatalyst(.v13), + .visionOS(.v1) + ], + products: [ + .library( + name: "Options", + targets: ["Options"] + ) + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-syntax", from: "510.0.0") + // Dependencies declare other packages that this package depends on. + // .package(url: /* package url */, from: "1.0.0") + ], + targets: [ + .target( + name: "Options", + dependencies: ["OptionsMacros"], + swiftSettings: swiftSettings + ), + .macro( + name: "OptionsMacros", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax") + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "OptionsTests", + dependencies: ["Options"] + ) + ] +) + +// swiftlint:enable explicit_top_level_acl +// swiftlint:enable prefixed_toplevel_constant +// swiftlint:enable explicit_acl diff --git a/Macros/Options/Package@swift-6.1.swift b/Macros/Options/Package@swift-6.1.swift new file mode 100644 index 0000000..402bd68 --- /dev/null +++ b/Macros/Options/Package@swift-6.1.swift @@ -0,0 +1,54 @@ +// swift-tools-version: 6.1 + +// swiftlint:disable explicit_top_level_acl +// swiftlint:disable prefixed_toplevel_constant +// swiftlint:disable explicit_acl + +import CompilerPluginSupport +import PackageDescription + + +let package = Package( + name: "Options", + platforms: [ + .macOS(.v13), + .iOS(.v13), + .watchOS(.v6), + .tvOS(.v13), + .visionOS(.v1) + ], + products: [ + .library( + name: "Options", + targets: ["Options"] + ) + ], + dependencies: [ + .package(path: "../.."), + .package(url: "https://github.com/apple/swift-syntax.git", from: "601.0.1") + // Dependencies declare other packages that this package depends on. + // .package(url: /* package url */, from: "1.0.0") + ], + targets: [ + .target( + name: "Options", + dependencies: ["OptionsMacros"] + ), + .macro( + name: "OptionsMacros", + dependencies: [ + .product(name: "SyntaxKit", package: "SyntaxKit"), + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax") + ] + ), + .testTarget( + name: "OptionsTests", + dependencies: ["Options"] + ) + ] +) + +// swiftlint:enable explicit_top_level_acl +// swiftlint:enable prefixed_toplevel_constant +// swiftlint:enable explicit_acl diff --git a/Macros/Options/README.md b/Macros/Options/README.md new file mode 100644 index 0000000..40ed2ed --- /dev/null +++ b/Macros/Options/README.md @@ -0,0 +1,182 @@ + +

+ Options +

+

Options

+ +More powerful options for `Enum` and `OptionSet` types. + +[![SwiftPM](https://img.shields.io/badge/SPM-Linux%20%7C%20iOS%20%7C%20macOS%20%7C%20watchOS%20%7C%20tvOS-success?logo=swift)](https://swift.org) +[![Twitter](https://img.shields.io/badge/twitter-@brightdigit-blue.svg?style=flat)](http://twitter.com/brightdigit) +![GitHub](https://img.shields.io/github/license/brightdigit/Options) +![GitHub issues](https://img.shields.io/github/issues/brightdigit/Options) +![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/brightdigit/Options/Options.yml?label=actions&logo=github&?branch=main) + +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fbrightdigit%2FOptions%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/brightdigit/Options) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fbrightdigit%2FOptions%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/brightdigit/Options) + +[![Codecov](https://img.shields.io/codecov/c/github/brightdigit/Options)](https://codecov.io/gh/brightdigit/Options) +[![CodeFactor Grade](https://img.shields.io/codefactor/grade/github/brightdigit/Options)](https://www.codefactor.io/repository/github/brightdigit/Options) +[![codebeat badge](https://codebeat.co/badges/c47b7e58-867c-410b-80c5-57e10140ba0f)](https://codebeat.co/projects/github-com-brightdigit-mistkit-main) +[![Code Climate maintainability](https://img.shields.io/codeclimate/maintainability/brightdigit/Options)](https://codeclimate.com/github/brightdigit/Options) +[![Code Climate technical debt](https://img.shields.io/codeclimate/tech-debt/brightdigit/Options?label=debt)](https://codeclimate.com/github/brightdigit/Options) +[![Code Climate issues](https://img.shields.io/codeclimate/issues/brightdigit/Options)](https://codeclimate.com/github/brightdigit/Options) +[![Reviewed by Hound](https://img.shields.io/badge/Reviewed_by-Hound-8E64B0.svg)](https://houndci.com) + + +# Table of Contents + + * [Introduction](#introduction) + * [Requirements](#requirements) + * [Installation](#installation) + * [Usage](#usage) + * [Versatile Options with Enums and OptionSets](#versatile-options-with-enums-and-optionsets) + * [Multiple Value Types](#multiple-value-types) + * [Creating an OptionSet](#creating-an-optionset) + * [Further Code Documentation](#further-code-documentation) + * [License](#license) + +# Introduction + +**Options** provides a powerful set of features for `Enum` and `OptionSet` types: + +- Providing additional representations for `Enum` types besides the `RawType rawValue` +- Being able to interchange between `Enum` and `OptionSet` types +- Using an additional value type for a `Codable` `OptionSet` + +# Requirements + +**Apple Platforms** + +- Xcode 14.1 or later +- Swift 5.7.1 or later +- iOS 16 / watchOS 9 / tvOS 16 / macOS 12 or later deployment targets + +**Linux** + +- Ubuntu 20.04 or later +- Swift 5.7.1 or later + +# Installation + +Use the Swift Package Manager to install this library via the repository url: + +``` +https://github.com/brightdigit/Options.git +``` + +Use version up to `1.0`. + +# Usage + +## Versatile Options + +Let's say we are using an `Enum` for a list of popular social media networks: + +```swift +enum SocialNetwork : Int { + case digg + case aim + case bebo + case delicious + case eworld + case googleplus + case itunesping + case jaiku + case miiverse + case musically + case orkut + case posterous + case stumbleupon + case windowslive + case yahoo +} +``` + +We'll be using this as a way to define a particular social handle: + +```swift +struct SocialHandle { + let name : String + let network : SocialNetwork +} +``` + +However we also want to provide a way to have a unique set of social networks available: + +```swift +struct SocialNetworkSet : Int, OptionSet { +... +} + +let user : User +let networks : SocialNetworkSet = user.availableNetworks() +``` + +We can then simply use ``Options()`` macro to generate both these types: + +```swift +@Options +enum SocialNetwork : Int { + case digg + case aim + case bebo + case delicious + case eworld + case googleplus + case itunesping + case jaiku + case miiverse + case musically + case orkut + case posterous + case stumbleupon + case windowslive + case yahoo +} +``` + +Now we can use the newly create `SocialNetworkSet` type to store a set of values: + +```swift +let networks : SocialNetworkSet +networks = [.aim, .delicious, .googleplus, .windowslive] +``` + +## Multiple Value Types + +With the ``Options()`` macro, we add the ability to encode and decode values not only from their raw value but also from a another type such as a string. This is useful for when you want to store the values in JSON format. + +For instance, with a type like `SocialNetwork` we need need to store the value as an Integer: + +```json +5 +``` + +However by adding the ``Options()`` macro we can also decode from a String: + +``` +"googleplus" +``` + +## Creating an OptionSet + +We can also have a new `OptionSet` type created. ``Options()`` create a new `OptionSet` type with the suffix `-Set`. This new `OptionSet` will automatically work with your enum to create a distinct set of values. Additionally it will decode and encode your values as an Array of String. This means the value: + +```swift +[.aim, .delicious, .googleplus, .windowslive] +``` + +is encoded as: + +```json +["aim", "delicious", "googleplus", "windowslive"] +``` + +# Further Code Documentation + +[Documentation Here](https://swiftpackageindex.com/brightdigit/Options/main/documentation/options) + +# License + +This code is distributed under the MIT license. See the [LICENSE](LICENSE) file for more info. diff --git a/Macros/Options/Scripts/docc.sh b/Macros/Options/Scripts/docc.sh new file mode 100755 index 0000000..3e4c918 --- /dev/null +++ b/Macros/Options/Scripts/docc.sh @@ -0,0 +1,3 @@ +#!/bin/sh +xcodebuild docbuild -scheme SimulatorServices -derivedDataPath DerivedData -destination 'platform=macOS' +$(xcrun --find docc) process-archive transform-for-static-hosting DerivedData/Build/Products/Debug/SimulatorServices.doccarchive --output-path Output \ No newline at end of file diff --git a/Macros/Options/Scripts/gh-md-toc b/Macros/Options/Scripts/gh-md-toc new file mode 100755 index 0000000..03b5ddd --- /dev/null +++ b/Macros/Options/Scripts/gh-md-toc @@ -0,0 +1,421 @@ +#!/usr/bin/env bash + +# +# Steps: +# +# 1. Download corresponding html file for some README.md: +# curl -s $1 +# +# 2. Discard rows where no substring 'user-content-' (github's markup): +# awk '/user-content-/ { ... +# +# 3.1 Get last number in each row like ' ... sitemap.js.*<\/h/)+2, RLENGTH-5) +# +# 5. Find anchor and insert it inside "(...)": +# substr($0, match($0, "href=\"[^\"]+?\" ")+6, RLENGTH-8) +# + +gh_toc_version="0.10.0" + +gh_user_agent="gh-md-toc v$gh_toc_version" + +# +# Download rendered into html README.md by its url. +# +# +gh_toc_load() { + local gh_url=$1 + + if type curl &>/dev/null; then + curl --user-agent "$gh_user_agent" -s "$gh_url" + elif type wget &>/dev/null; then + wget --user-agent="$gh_user_agent" -qO- "$gh_url" + else + echo "Please, install 'curl' or 'wget' and try again." + exit 1 + fi +} + +# +# Converts local md file into html by GitHub +# +# -> curl -X POST --data '{"text": "Hello world github/linguist#1 **cool**, and #1!"}' https://api.github.com/markdown +#

Hello world github/linguist#1 cool, and #1!

'" +gh_toc_md2html() { + local gh_file_md=$1 + local skip_header=$2 + + URL=https://api.github.com/markdown/raw + + if [ -n "$GH_TOC_TOKEN" ]; then + TOKEN=$GH_TOC_TOKEN + else + TOKEN_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/token.txt" + if [ -f "$TOKEN_FILE" ]; then + TOKEN="$(cat "$TOKEN_FILE")" + fi + fi + if [ -n "${TOKEN}" ]; then + AUTHORIZATION="Authorization: token ${TOKEN}" + fi + + local gh_tmp_file_md=$gh_file_md + if [ "$skip_header" = "yes" ]; then + if grep -Fxq "" "$gh_src"; then + # cut everything before the toc + gh_tmp_file_md=$gh_file_md~~ + sed '1,//d' "$gh_file_md" > "$gh_tmp_file_md" + fi + fi + + # echo $URL 1>&2 + OUTPUT=$(curl -s \ + --user-agent "$gh_user_agent" \ + --data-binary @"$gh_tmp_file_md" \ + -H "Content-Type:text/plain" \ + -H "$AUTHORIZATION" \ + "$URL") + + rm -f "${gh_file_md}~~" + + if [ "$?" != "0" ]; then + echo "XXNetworkErrorXX" + fi + if [ "$(echo "${OUTPUT}" | awk '/API rate limit exceeded/')" != "" ]; then + echo "XXRateLimitXX" + else + echo "${OUTPUT}" + fi +} + + +# +# Is passed string url +# +gh_is_url() { + case $1 in + https* | http*) + echo "yes";; + *) + echo "no";; + esac +} + +# +# TOC generator +# +gh_toc(){ + local gh_src=$1 + local gh_src_copy=$1 + local gh_ttl_docs=$2 + local need_replace=$3 + local no_backup=$4 + local no_footer=$5 + local indent=$6 + local skip_header=$7 + + if [ "$gh_src" = "" ]; then + echo "Please, enter URL or local path for a README.md" + exit 1 + fi + + + # Show "TOC" string only if working with one document + if [ "$gh_ttl_docs" = "1" ]; then + + echo "Table of Contents" + echo "=================" + echo "" + gh_src_copy="" + + fi + + if [ "$(gh_is_url "$gh_src")" == "yes" ]; then + gh_toc_load "$gh_src" | gh_toc_grab "$gh_src_copy" "$indent" + if [ "${PIPESTATUS[0]}" != "0" ]; then + echo "Could not load remote document." + echo "Please check your url or network connectivity" + exit 1 + fi + if [ "$need_replace" = "yes" ]; then + echo + echo "!! '$gh_src' is not a local file" + echo "!! Can't insert the TOC into it." + echo + fi + else + local rawhtml + rawhtml=$(gh_toc_md2html "$gh_src" "$skip_header") + if [ "$rawhtml" == "XXNetworkErrorXX" ]; then + echo "Parsing local markdown file requires access to github API" + echo "Please make sure curl is installed and check your network connectivity" + exit 1 + fi + if [ "$rawhtml" == "XXRateLimitXX" ]; then + echo "Parsing local markdown file requires access to github API" + echo "Error: You exceeded the hourly limit. See: https://developer.github.com/v3/#rate-limiting" + TOKEN_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/token.txt" + echo "or place GitHub auth token here: ${TOKEN_FILE}" + exit 1 + fi + local toc + toc=`echo "$rawhtml" | gh_toc_grab "$gh_src_copy" "$indent"` + echo "$toc" + if [ "$need_replace" = "yes" ]; then + if grep -Fxq "" "$gh_src" && grep -Fxq "" "$gh_src"; then + echo "Found markers" + else + echo "You don't have or in your file...exiting" + exit 1 + fi + local ts="<\!--ts-->" + local te="<\!--te-->" + local dt + dt=$(date +'%F_%H%M%S') + local ext=".orig.${dt}" + local toc_path="${gh_src}.toc.${dt}" + local toc_createdby="" + local toc_footer + toc_footer="" + # http://fahdshariff.blogspot.ru/2012/12/sed-mutli-line-replacement-between-two.html + # clear old TOC + sed -i"${ext}" "/${ts}/,/${te}/{//!d;}" "$gh_src" + # create toc file + echo "${toc}" > "${toc_path}" + if [ "${no_footer}" != "yes" ]; then + echo -e "\n${toc_createdby}\n${toc_footer}\n" >> "$toc_path" + fi + + # insert toc file + if ! sed --version > /dev/null 2>&1; then + sed -i "" "/${ts}/r ${toc_path}" "$gh_src" + else + sed -i "/${ts}/r ${toc_path}" "$gh_src" + fi + echo + if [ "${no_backup}" = "yes" ]; then + rm "$toc_path" "$gh_src$ext" + fi + echo "!! TOC was added into: '$gh_src'" + if [ -z "${no_backup}" ]; then + echo "!! Origin version of the file: '${gh_src}${ext}'" + echo "!! TOC added into a separate file: '${toc_path}'" + fi + echo + fi + fi +} + +# +# Grabber of the TOC from rendered html +# +# $1 - a source url of document. +# It's need if TOC is generated for multiple documents. +# $2 - number of spaces used to indent. +# +gh_toc_grab() { + + href_regex="/href=\"[^\"]+?\"/" + common_awk_script=' + modified_href = "" + split(href, chars, "") + for (i=1;i <= length(href); i++) { + c = chars[i] + res = "" + if (c == "+") { + res = " " + } else { + if (c == "%") { + res = "\\x" + } else { + res = c "" + } + } + modified_href = modified_href res + } + print sprintf("%*s", (level-1)*'"$2"', "") "* [" text "](" gh_url modified_href ")" + ' + if [ "`uname -s`" == "OS/390" ]; then + grepcmd="pcregrep -o" + echoargs="" + awkscript='{ + level = substr($0, 3, 1) + text = substr($0, match($0, /<\/span><\/a>[^<]*<\/h/)+11, RLENGTH-14) + href = substr($0, match($0, '$href_regex')+6, RLENGTH-7) + '"$common_awk_script"' + }' + else + grepcmd="grep -Eo" + echoargs="-e" + awkscript='{ + level = substr($0, 3, 1) + text = substr($0, match($0, /">.*<\/h/)+2, RLENGTH-5) + href = substr($0, match($0, '$href_regex')+6, RLENGTH-7) + '"$common_awk_script"' + }' + fi + + # if closed is on the new line, then move it on the prev line + # for example: + # was: The command foo1 + # + # became: The command foo1 + sed -e ':a' -e 'N' -e '$!ba' -e 's/\n<\/h/<\/h/g' | + + # Sometimes a line can start with . Fix that. + sed -e ':a' -e 'N' -e '$!ba' -e 's/\n//g' | sed 's/<\/code>//g' | + + # remove g-emoji + sed 's/]*[^<]*<\/g-emoji> //g' | + + # now all rows are like: + #

title

.. + # format result line + # * $0 - whole string + # * last element of each row: "/dev/null; then + $tool --version | head -n 1 + else + echo "not installed" + fi + done +} + +show_help() { + local app_name + app_name=$(basename "$0") + echo "GitHub TOC generator ($app_name): $gh_toc_version" + echo "" + echo "Usage:" + echo " $app_name [options] src [src] Create TOC for a README file (url or local path)" + echo " $app_name - Create TOC for markdown from STDIN" + echo " $app_name --help Show help" + echo " $app_name --version Show version" + echo "" + echo "Options:" + echo " --indent Set indent size. Default: 3." + echo " --insert Insert new TOC into original file. For local files only. Default: false." + echo " See https://github.com/ekalinin/github-markdown-toc/issues/41 for details." + echo " --no-backup Remove backup file. Set --insert as well. Default: false." + echo " --hide-footer Do not write date & author of the last TOC update. Set --insert as well. Default: false." + echo " --skip-header Hide entry of the topmost headlines. Default: false." + echo " See https://github.com/ekalinin/github-markdown-toc/issues/125 for details." + echo "" +} + +# +# Options handlers +# +gh_toc_app() { + local need_replace="no" + local indent=3 + + if [ "$1" = '--help' ] || [ $# -eq 0 ] ; then + show_help + return + fi + + if [ "$1" = '--version' ]; then + show_version + return + fi + + if [ "$1" = '--indent' ]; then + indent="$2" + shift 2 + fi + + if [ "$1" = "-" ]; then + if [ -z "$TMPDIR" ]; then + TMPDIR="/tmp" + elif [ -n "$TMPDIR" ] && [ ! -d "$TMPDIR" ]; then + mkdir -p "$TMPDIR" + fi + local gh_tmp_md + if [ "`uname -s`" == "OS/390" ]; then + local timestamp + timestamp=$(date +%m%d%Y%H%M%S) + gh_tmp_md="$TMPDIR/tmp.$timestamp" + else + gh_tmp_md=$(mktemp "$TMPDIR/tmp.XXXXXX") + fi + while read -r input; do + echo "$input" >> "$gh_tmp_md" + done + gh_toc_md2html "$gh_tmp_md" | gh_toc_grab "" "$indent" + return + fi + + if [ "$1" = '--insert' ]; then + need_replace="yes" + shift + fi + + if [ "$1" = '--no-backup' ]; then + need_replace="yes" + no_backup="yes" + shift + fi + + if [ "$1" = '--hide-footer' ]; then + need_replace="yes" + no_footer="yes" + shift + fi + + if [ "$1" = '--skip-header' ]; then + skip_header="yes" + shift + fi + + + for md in "$@" + do + echo "" + gh_toc "$md" "$#" "$need_replace" "$no_backup" "$no_footer" "$indent" "$skip_header" + done + + echo "" + echo "" +} + +# +# Entry point +# +gh_toc_app "$@" \ No newline at end of file diff --git a/Macros/Options/Scripts/lint.sh b/Macros/Options/Scripts/lint.sh new file mode 100755 index 0000000..31c3fa9 --- /dev/null +++ b/Macros/Options/Scripts/lint.sh @@ -0,0 +1,44 @@ +#!/bin/sh + +if [ -z "$SRCROOT" ]; then + SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) + PACKAGE_DIR="${SCRIPT_DIR}/.." +else + PACKAGE_DIR="${SRCROOT}" +fi + +if [ -z "$GITHUB_ACTION" ]; then + MINT_CMD="/opt/homebrew/bin/mint" +else + MINT_CMD="mint" +fi + +export MINT_PATH="$PACKAGE_DIR/.mint" +MINT_ARGS="-n -m $PACKAGE_DIR/Mintfile --silent" +MINT_RUN="$MINT_CMD run $MINT_ARGS" + +pushd $PACKAGE_DIR + +$MINT_CMD bootstrap -m Mintfile + +if [ "$LINT_MODE" == "NONE" ]; then + exit +elif [ "$LINT_MODE" == "STRICT" ]; then + SWIFTFORMAT_OPTIONS="" + SWIFTLINT_OPTIONS="--strict" +else + SWIFTFORMAT_OPTIONS="" + SWIFTLINT_OPTIONS="" +fi + +pushd $PACKAGE_DIR + +if [ -z "$CI" ]; then + $MINT_RUN swiftformat . + $MINT_RUN swiftlint --fix +fi + +$MINT_RUN swiftformat --lint $SWIFTFORMAT_OPTIONS . +$MINT_RUN swiftlint lint $SWIFTLINT_OPTIONS + +popd diff --git a/Macros/Options/Sources/Options/Array.swift b/Macros/Options/Sources/Options/Array.swift new file mode 100644 index 0000000..0c4db74 --- /dev/null +++ b/Macros/Options/Sources/Options/Array.swift @@ -0,0 +1,58 @@ +// +// Array.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +// swiftlint:disable:next line_length +@available(*, deprecated, renamed: "MappedValueGenericRepresented", message: "Use MappedValueGenericRepresented instead.") +public protocol MappedValueCollectionRepresented: MappedValueRepresented + where MappedValueType: Sequence {} + +extension Array: MappedValues where Element: Equatable {} + +extension Collection where Element: Equatable, Self: MappedValues { + /// Get the index based on the value passed. + /// - Parameter value: Value to search. + /// - Returns: Index found. + public func key(value: Element) throws -> Self.Index { + guard let index = firstIndex(of: value) else { + throw MappedValueRepresentableError.valueNotFound + } + + return index + } + + /// Gets the value based on the index. + /// - Parameter key: The index. + /// - Returns: The value at index. + public func value(key: Self.Index) throws -> Element { + guard key < endIndex, key >= startIndex else { + throw MappedValueRepresentableError.valueNotFound + } + return self[key] + } +} diff --git a/Macros/Options/Sources/Options/CodingOptions.swift b/Macros/Options/Sources/Options/CodingOptions.swift new file mode 100644 index 0000000..e26ad68 --- /dev/null +++ b/Macros/Options/Sources/Options/CodingOptions.swift @@ -0,0 +1,49 @@ +// +// CodingOptions.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Options for how a ``MappedValueRepresentable`` type is encoding and decoded. +public struct CodingOptions: OptionSet, Sendable { + /// Allow decoding from String + public static let allowMappedValueDecoding: CodingOptions = .init(rawValue: 1) + + /// Encode the value as a String. + public static let encodeAsMappedValue: CodingOptions = .init(rawValue: 2) + + /// Default options. + public static let `default`: CodingOptions = + [.allowMappedValueDecoding, encodeAsMappedValue] + + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } +} diff --git a/Macros/Options/Sources/Options/Dictionary.swift b/Macros/Options/Sources/Options/Dictionary.swift new file mode 100644 index 0000000..a7a3b31 --- /dev/null +++ b/Macros/Options/Sources/Options/Dictionary.swift @@ -0,0 +1,51 @@ +// +// Dictionary.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +// swiftlint:disable:next line_length +@available(*, deprecated, renamed: "MappedValueGenericRepresented", message: "Use MappedValueGenericRepresented instead.") +public protocol MappedValueDictionaryRepresented: MappedValueRepresented + where MappedValueType == [Int: MappedType] {} + +extension Dictionary: MappedValues where Value: Equatable { + public func key(value: Value) throws -> Key { + let pair = first { $0.value == value } + guard let key = pair?.key else { + throw MappedValueRepresentableError.valueNotFound + } + + return key + } + + public func value(key: Key) throws -> Value { + guard let value = self[key] else { + throw MappedValueRepresentableError.valueNotFound + } + return value + } +} diff --git a/Macros/Options/Sources/Options/Documentation.docc/Documentation.md b/Macros/Options/Sources/Options/Documentation.docc/Documentation.md new file mode 100644 index 0000000..994798d --- /dev/null +++ b/Macros/Options/Sources/Options/Documentation.docc/Documentation.md @@ -0,0 +1,162 @@ +# ``Options`` + +More powerful options for `Enum` and `OptionSet` types. + +## Overview + +**Options** provides a powerful set of features for `Enum` and `OptionSet` types: + +- Providing additional representations for `Enum` types besides the `RawType rawValue` +- Being able to interchange between `Enum` and `OptionSet` types +- Using an additional value type for a `Codable` `OptionSet` + +### Requirements + +**Apple Platforms** + +- Xcode 14.1 or later +- Swift 5.7.1 or later +- iOS 16 / watchOS 9 / tvOS 16 / macOS 12 or later deployment targets + +**Linux** + +- Ubuntu 20.04 or later +- Swift 5.7.1 or later + +### Installation + +Use the Swift Package Manager to install this library via the repository url: + +``` +https://github.com/brightdigit/Options.git +``` + +Use version up to `1.0`. + +### Versatile Options + +Let's say we are using an `Enum` for a list of popular social media networks: + +```swift +enum SocialNetwork : Int { + case digg + case aim + case bebo + case delicious + case eworld + case googleplus + case itunesping + case jaiku + case miiverse + case musically + case orkut + case posterous + case stumbleupon + case windowslive + case yahoo +} +``` + +We'll be using this as a way to define a particular social handle: + +```swift +struct SocialHandle { + let name : String + let network : SocialNetwork +} +``` + +However we also want to provide a way to have a unique set of social networks available: + +```swift +struct SocialNetworkSet : Int, OptionSet { +... +} + +let user : User +let networks : SocialNetworkSet = user.availableNetworks() +``` + +We can then simply use ``Options()`` macro to generate both these types: + +```swift +@Options +enum SocialNetwork : Int { + case digg + case aim + case bebo + case delicious + case eworld + case googleplus + case itunesping + case jaiku + case miiverse + case musically + case orkut + case posterous + case stumbleupon + case windowslive + case yahoo +} +``` + +Now we can use the newly create `SocialNetworkSet` type to store a set of values: + +```swift +let networks : SocialNetworkSet +networks = [.aim, .delicious, .googleplus, .windowslive] +``` + +### Multiple Value Types + +With the ``Options()`` macro, we add the ability to encode and decode values not only from their raw value but also from a another type such as a string. This is useful for when you want to store the values in JSON format. + +For instance, with a type like `SocialNetwork` we need need to store the value as an Integer: + +```json +5 +``` + +However by adding the ``Options()`` macro we can also decode from a String: + +``` +"googleplus" +``` + +### Creating an OptionSet + +We can also have a new `OptionSet` type created. ``Options()`` create a new `OptionSet` type with the suffix `-Set`. This new `OptionSet` will automatically work with your enum to create a distinct set of values. Additionally it will decode and encode your values as an Array of String. This means the value: + +```swift +[.aim, .delicious, .googleplus, .windowslive] +``` + +is encoded as: + +```json +["aim", "delicious", "googleplus", "windowslive"] +``` + +## Topics + +### Options conformance + +- ``Options()`` +- ``MappedValueRepresentable`` +- ``MappedValueRepresented`` +- ``EnumSet`` + +### Advanced customization + +- ``CodingOptions`` +- ``MappedValues`` + +### Errors + +- ``MappedValueRepresentableError-2k4ki`` + +### Deprecated + +- ``MappedValueCollectionRepresented`` +- ``MappedValueDictionaryRepresented`` +- ``MappedEnum`` diff --git a/Macros/Options/Sources/Options/EnumSet.swift b/Macros/Options/Sources/Options/EnumSet.swift new file mode 100644 index 0000000..c2e447e --- /dev/null +++ b/Macros/Options/Sources/Options/EnumSet.swift @@ -0,0 +1,139 @@ +/// Generic struct for using Enums with `RawValue`. +/// +/// If you have an `enum` such as: +/// ```swift +/// @Options +/// enum SocialNetwork : Int { +/// case digg +/// case aim +/// case bebo +/// case delicious +/// case eworld +/// case googleplus +/// case itunesping +/// case jaiku +/// case miiverse +/// case musically +/// case orkut +/// case posterous +/// case stumbleupon +/// case windowslive +/// case yahoo +/// } +/// ``` +/// An ``EnumSet`` could be used to store multiple values as an `OptionSet`: +/// ```swift +/// let socialNetworks : EnumSet = +/// [.digg, .aim, .yahoo, .miiverse] +/// ``` +public struct EnumSet: + OptionSet, Sendable, ExpressibleByArrayLiteral + where EnumType.RawValue: FixedWidthInteger & Sendable { + public typealias RawValue = EnumType.RawValue + + /// Raw Value of the OptionSet + public let rawValue: RawValue + + /// Creates the EnumSet based on the `rawValue` + /// - Parameter rawValue: Integer raw value of the OptionSet + public init(rawValue: RawValue) { + self.rawValue = rawValue + } + + public init(arrayLiteral elements: EnumType...) { + self.init(values: elements) + } + + /// Creates the EnumSet based on the values in the array. + /// - Parameter values: Array of enum values. + public init(values: [EnumType]) { + let set = Set(values.map(\.rawValue)) + rawValue = Self.cumulativeValue(basedOnRawValues: set) + } + + internal static func cumulativeValue( + basedOnRawValues rawValues: Set) -> RawValue { + rawValues.map { 1 << $0 }.reduce(0, |) + } +} + +extension FixedWidthInteger { + fileprivate static var one: Self { + 1 + } +} + +extension EnumSet where EnumType: CaseIterable { + internal static func enums(basedOnRawValue rawValue: RawValue) -> [EnumType] { + let cases = EnumType.allCases.sorted { $0.rawValue < $1.rawValue } + var values = [EnumType]() + var current = rawValue + for item in cases { + guard current > 0 else { + break + } + let rawValue = RawValue.one << item.rawValue + if current & rawValue != .zero { + values.append(item) + current -= rawValue + } + } + return values + } + + /// Returns an array of the enum values based on the OptionSet + /// - Returns: Array for each value represented by the enum. + public func array() -> [EnumType] { + Self.enums(basedOnRawValue: rawValue) + } +} + +#if swift(>=5.9) + extension EnumSet: Codable + where EnumType: MappedValueRepresentable, EnumType.MappedType: Codable { + /// Decodes the EnumSet based on an Array of MappedTypes. + /// - Parameter decoder: Decoder which contains info as an array of MappedTypes. + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let values = try container.decode([EnumType.MappedType].self) + let rawValues = try values.map(EnumType.rawValue(basedOn:)) + let set = Set(rawValues) + rawValue = Self.cumulativeValue(basedOnRawValues: set) + } + + /// Encodes the EnumSet based on an Array of MappedTypes. + /// - Parameter encoder: Encoder which will contain info as an array of MappedTypes. + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + let values = Self.enums(basedOnRawValue: rawValue) + let mappedValues = try values + .map(\.rawValue) + .map(EnumType.mappedValue(basedOn:)) + try container.encode(mappedValues) + } + } +#else + extension EnumSet: Codable + where EnumType: MappedValueRepresentable, EnumType.MappedType: Codable { + /// Decodes the EnumSet based on an Array of MappedTypes. + /// - Parameter decoder: Decoder which contains info as an array of MappedTypes. + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let values = try container.decode([EnumType.MappedType].self) + let rawValues = try values.map(EnumType.rawValue(basedOn:)) + let set = Set(rawValues) + rawValue = Self.cumulativeValue(basedOnRawValues: set) + } + + /// Encodes the EnumSet based on an Array of MappedTypes. + /// - Parameter encoder: Encoder which will contain info as an array of MappedTypes. + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + let values = Self.enums(basedOnRawValue: rawValue) + let mappedValues = try values + .map(\.rawValue) + .map(EnumType.mappedValue(basedOn:)) + try container.encode(mappedValues) + } + } +#endif diff --git a/Macros/Options/Sources/Options/Macro.swift b/Macros/Options/Sources/Options/Macro.swift new file mode 100644 index 0000000..ab7ff7b --- /dev/null +++ b/Macros/Options/Sources/Options/Macro.swift @@ -0,0 +1,42 @@ +// +// Macro.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +#if swift(>=5.10) + /// Sets an enumeration up to implement + /// ``MappedValueRepresentable`` and ``MappedValueRepresented``. + @attached( + extension, + conformances: MappedValueRepresentable, MappedValueRepresented, + names: named(MappedType), named(mappedValues) + ) + @attached(peer, names: suffixed(Set)) + public macro Options() = #externalMacro(module: "OptionsMacros", type: "OptionsMacro") +#endif diff --git a/Macros/Options/Sources/Options/MappedEnum.swift b/Macros/Options/Sources/Options/MappedEnum.swift new file mode 100644 index 0000000..9298ada --- /dev/null +++ b/Macros/Options/Sources/Options/MappedEnum.swift @@ -0,0 +1,66 @@ +/// A generic struct for enumerations which allow for additional values attached. +@available( + *, + deprecated, + renamed: "MappedValueRepresentable", + message: "Use `MappedValueRepresentable` with `CodingOptions`." +) +public struct MappedEnum: Codable, Sendable + where EnumType.MappedType: Codable { + /// Base Enumeraion value. + public let value: EnumType + + /// Creates an instance based on the base enumeration value. + /// - Parameter value: Base Enumeration value. + public init(value: EnumType) { + self.value = value + } +} + +#if swift(>=5.9) + extension MappedEnum { + /// Decodes the value based on the mapped value. + /// - Parameter decoder: Decoder. + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let label = try container.decode(EnumType.MappedType.self) + let rawValue = try EnumType.rawValue(basedOn: label) + guard let value = EnumType(rawValue: rawValue) else { + assertionFailure("Every mappedValue should always return a valid rawValue.") + throw DecodingError.invalidRawValue(rawValue) + } + self.value = value + } + + /// Encodes the value based on the mapped value. + /// - Parameter encoder: Encoder. + public func encode(to encoder: any Encoder) throws { + let string = try EnumType.mappedValue(basedOn: value.rawValue) + var container = encoder.singleValueContainer() + try container.encode(string) + } + } +#else + extension MappedEnum { + /// Decodes the value based on the mapped value. + /// - Parameter decoder: Decoder. + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let label = try container.decode(EnumType.MappedType.self) + let rawValue = try EnumType.rawValue(basedOn: label) + guard let value = EnumType(rawValue: rawValue) else { + assertionFailure("Every mappedValue should always return a valid rawValue.") + throw DecodingError.invalidRawValue(rawValue) + } + self.value = value + } + + /// Encodes the value based on the mapped value. + /// - Parameter encoder: Encoder. + public func encode(to encoder: Encoder) throws { + let string = try EnumType.mappedValue(basedOn: value.rawValue) + var container = encoder.singleValueContainer() + try container.encode(string) + } + } +#endif diff --git a/Macros/Options/Sources/Options/MappedValueRepresentable+Codable.swift b/Macros/Options/Sources/Options/MappedValueRepresentable+Codable.swift new file mode 100644 index 0000000..550f7c6 --- /dev/null +++ b/Macros/Options/Sources/Options/MappedValueRepresentable+Codable.swift @@ -0,0 +1,97 @@ +// +// MappedValueRepresentable+Codable.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +extension DecodingError { + internal static func invalidRawValue(_ rawValue: some Any) -> DecodingError { + .dataCorrupted( + .init(codingPath: [], debugDescription: "Raw Value \(rawValue) is invalid.") + ) + } +} + +extension SingleValueDecodingContainer { + fileprivate func decodeAsRawValue() throws -> T + where T.RawValue: Decodable { + let rawValue = try decode(T.RawValue.self) + guard let value = T(rawValue: rawValue) else { + throw DecodingError.invalidRawValue(rawValue) + } + return value + } + + fileprivate func decodeAsMappedType() throws -> T + where T.RawValue: Decodable, T.MappedType: Decodable { + let mappedValues: T.MappedType + do { + mappedValues = try decode(T.MappedType.self) + } catch { + return try decodeAsRawValue() + } + + let rawValue = try T.rawValue(basedOn: mappedValues) + + guard let value = T(rawValue: rawValue) else { + assertionFailure("Every mappedValue should always return a valid rawValue.") + throw DecodingError.invalidRawValue(rawValue) + } + + return value + } +} + +extension MappedValueRepresentable + where Self: Decodable, MappedType: Decodable, RawValue: Decodable { + /// Decodes the type. + /// - Parameter decoder: Decoder. + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + + if Self.codingOptions.contains(.allowMappedValueDecoding) { + self = try container.decodeAsMappedType() + } else { + self = try container.decodeAsRawValue() + } + } +} + +extension MappedValueRepresentable + where Self: Encodable, MappedType: Encodable, RawValue: Encodable { + /// Encoding the type. + /// - Parameter decoder: Encodes. + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + if Self.codingOptions.contains(.encodeAsMappedValue) { + try container.encode(mappedValue()) + } else { + try container.encode(rawValue) + } + } +} diff --git a/Macros/Options/Sources/Options/MappedValueRepresentable.swift b/Macros/Options/Sources/Options/MappedValueRepresentable.swift new file mode 100644 index 0000000..896f4a9 --- /dev/null +++ b/Macros/Options/Sources/Options/MappedValueRepresentable.swift @@ -0,0 +1,68 @@ +// +// MappedValueRepresentable.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// An enum which has an additional value attached. +/// - Note: ``Options()`` macro will automatically set this up for you. +public protocol MappedValueRepresentable: RawRepresentable, CaseIterable, Sendable { + /// The additional value type. + associatedtype MappedType = String + + /// Options for how the enum should be decoded or encoded. + static var codingOptions: CodingOptions { + get + } + + /// Gets the raw value based on the MappedType. + /// - Parameter value: MappedType value. + /// - Returns: The raw value of the enumeration based on the `MappedType `value. + static func rawValue(basedOn string: MappedType) throws -> RawValue + + /// Gets the `MappedType` value based on the `rawValue`. + /// - Parameter rawValue: The raw value of the enumeration. + /// - Returns: The Mapped Type value based on the `rawValue`. + static func mappedValue(basedOn rawValue: RawValue) throws -> MappedType +} + +extension MappedValueRepresentable { + /// Options regarding how the type can be decoded or encoded. + public static var codingOptions: CodingOptions { + .default + } + + /// Gets the mapped value of the enumeration. + /// - Parameter rawValue: The raw value of the enumeration + /// which pretains to its index in the `mappedValues` Array. + /// - Throws: `MappedValueCollectionRepresentedError.valueNotFound` + /// if the raw value (i.e. index) is outside the range of the `mappedValues` array. + /// - Returns: + /// The Mapped Type value based on the value in the array at the raw value index. + public func mappedValue() throws -> MappedType { + try Self.mappedValue(basedOn: rawValue) + } +} diff --git a/Macros/Options/Sources/Options/MappedValueRepresentableError.swift b/Macros/Options/Sources/Options/MappedValueRepresentableError.swift new file mode 100644 index 0000000..d303174 --- /dev/null +++ b/Macros/Options/Sources/Options/MappedValueRepresentableError.swift @@ -0,0 +1,47 @@ +// +// MappedValueRepresentableError.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +// swiftlint:disable file_types_order +#if swift(>=5.10) + /// An Error thrown when the `MappedType` value or `RawType` value + /// are invalid for an `Enum`. + public enum MappedValueRepresentableError: Error, Sendable { + /// Whenever a value or key cannot be found. + case valueNotFound + } +#else + /// An Error thrown when the `MappedType` value or `RawType` value + /// are invalid for an `Enum`. + public enum MappedValueRepresentableError: Error { + case valueNotFound + } +#endif +// swiftlint:enable file_types_order diff --git a/Macros/Options/Sources/Options/MappedValueRepresented.swift b/Macros/Options/Sources/Options/MappedValueRepresented.swift new file mode 100644 index 0000000..26ef4e5 --- /dev/null +++ b/Macros/Options/Sources/Options/MappedValueRepresented.swift @@ -0,0 +1,62 @@ +// +// MappedValueRepresented.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Protocol which simplifies ``MappedValueRepresentable``by using a ``MappedValues``. +public protocol MappedValueRepresented: MappedValueRepresentable + where MappedType: Equatable { + /// A object to lookup values and keys for mapped values. + associatedtype MappedValueType: MappedValues + /// An array of the mapped values which lines up with each case. + static var mappedValues: MappedValueType { get } +} + +extension MappedValueRepresented { + /// Gets the raw value based on the MappedType by finding the index of the mapped value. + /// - Parameter value: MappedType value. + /// - Throws: `MappedValueCollectionRepresentedError.valueNotFound` + /// If the value was not found in the array + /// - Returns: + /// The raw value of the enumeration + /// based on the index the MappedType value was found at. + public static func rawValue(basedOn value: MappedType) throws -> RawValue { + try mappedValues.key(value: value) + } + + /// Gets the mapped value based on the rawValue + /// by access the array at the raw value subscript. + /// - Parameter rawValue: The raw value of the enumeration + /// which pretains to its index in the `mappedValues` Array. + /// - Throws: `MappedValueCollectionRepresentedError.valueNotFound` + /// if the raw value (i.e. index) is outside the range of the `mappedValues` array. + /// - Returns: + /// The Mapped Type value based on the value in the array at the raw value index. + public static func mappedValue(basedOn rawValue: RawValue) throws -> MappedType { + try mappedValues.value(key: rawValue) + } +} diff --git a/Macros/Options/Sources/Options/MappedValues.swift b/Macros/Options/Sources/Options/MappedValues.swift new file mode 100644 index 0000000..67d0a45 --- /dev/null +++ b/Macros/Options/Sources/Options/MappedValues.swift @@ -0,0 +1,42 @@ +// +// MappedValues.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Protocol which provides a method for ``MappedValueRepresented`` to pull values. +public protocol MappedValues { + /// Raw Value Type + associatedtype Value: Equatable + /// Key Value Type + associatedtype Key: Equatable + /// get the key vased on the value. + func key(value: Value) throws -> Key + /// get the value based on the key/index. + func value(key: Key) throws -> Value +} diff --git a/Macros/Options/Sources/OptionsMacros/Extensions/Array.swift b/Macros/Options/Sources/OptionsMacros/Extensions/Array.swift new file mode 100644 index 0000000..78275fe --- /dev/null +++ b/Macros/Options/Sources/OptionsMacros/Extensions/Array.swift @@ -0,0 +1,42 @@ +// +// Array.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +extension Array { + internal init?(keyValues: KeyValues) where Element == String { + self.init() + for key in 0 ..< keyValues.count { + guard let value = keyValues.get(key) else { + return nil + } + append(value) + } + } +} diff --git a/Macros/Options/Sources/OptionsMacros/Extensions/ArrayExprSyntax.swift b/Macros/Options/Sources/OptionsMacros/Extensions/ArrayExprSyntax.swift new file mode 100644 index 0000000..20e52d7 --- /dev/null +++ b/Macros/Options/Sources/OptionsMacros/Extensions/ArrayExprSyntax.swift @@ -0,0 +1,46 @@ +// +// ArrayExprSyntax.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import SwiftSyntax + + +#if !canImport(SyntaxKit) +extension ArrayExprSyntax { + internal init( + from items: some Collection, + _ closure: @escaping @Sendable (T) -> some ExprSyntaxProtocol + ) { + let values = items.map(closure).map { ArrayElementSyntax(expression: $0) } + let arrayElement = ArrayElementListSyntax { + .init(values) + } + self.init(elements: arrayElement) + } +} +#endif diff --git a/Macros/Options/Sources/OptionsMacros/Extensions/DeclModifierListSyntax.swift b/Macros/Options/Sources/OptionsMacros/Extensions/DeclModifierListSyntax.swift new file mode 100644 index 0000000..bc36764 --- /dev/null +++ b/Macros/Options/Sources/OptionsMacros/Extensions/DeclModifierListSyntax.swift @@ -0,0 +1,45 @@ +// +// DeclModifierListSyntax.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import SwiftSyntax + + +#if !canImport(SyntaxKit) +extension DeclModifierListSyntax { + internal init(keywordModifier: Keyword?) { + if let keywordModifier { + self.init { + DeclModifierSyntax(name: .keyword(keywordModifier)) + } + } else { + self.init([]) + } + } +} +#endif diff --git a/Macros/Options/Sources/OptionsMacros/Extensions/DeclModifierSyntax.swift b/Macros/Options/Sources/OptionsMacros/Extensions/DeclModifierSyntax.swift new file mode 100644 index 0000000..49d781c --- /dev/null +++ b/Macros/Options/Sources/OptionsMacros/Extensions/DeclModifierSyntax.swift @@ -0,0 +1,41 @@ +// +// DeclModifierSyntax.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import SwiftSyntax + +#if !canImport(SyntaxKit) +extension DeclModifierSyntax { + internal var isNeededAccessLevelModifier: Bool { + switch name.tokenKind { + case .keyword(.public): return true + default: return false + } + } +} +#endif diff --git a/Macros/Options/Sources/OptionsMacros/Extensions/DictionaryElementSyntax.swift b/Macros/Options/Sources/OptionsMacros/Extensions/DictionaryElementSyntax.swift new file mode 100644 index 0000000..810087b --- /dev/null +++ b/Macros/Options/Sources/OptionsMacros/Extensions/DictionaryElementSyntax.swift @@ -0,0 +1,50 @@ +// +// DictionaryElementSyntax.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import SwiftSyntax + + +#if !canImport(SyntaxKit) +extension DictionaryElementSyntax { + internal init(pair: (key: Int, value: String)) { + self.init(key: pair.key, value: pair.value) + } + + internal init(key: Int, value: String) { + self.init( + key: IntegerLiteralExprSyntax(integerLiteral: key), + value: StringLiteralExprSyntax( + openingQuote: .stringQuoteToken(), + segments: .init([.stringSegment(.init(content: .stringSegment(value)))]), + closingQuote: .stringQuoteToken() + ) + ) + } +} +#endif diff --git a/Macros/Options/Sources/OptionsMacros/Extensions/DictionaryExprSyntax.swift b/Macros/Options/Sources/OptionsMacros/Extensions/DictionaryExprSyntax.swift new file mode 100644 index 0000000..025638d --- /dev/null +++ b/Macros/Options/Sources/OptionsMacros/Extensions/DictionaryExprSyntax.swift @@ -0,0 +1,44 @@ +// +// DictionaryExprSyntax.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import SwiftSyntax + + +#if !canImport(SyntaxKit) +extension DictionaryExprSyntax { + internal init(keyValues: KeyValues) { + let dictionaryElements = keyValues.dictionary.map(DictionaryElementSyntax.init(pair:)) + + let list = DictionaryElementListSyntax { + .init(dictionaryElements) + } + self.init(content: .elements(list)) + } +} +#endif diff --git a/Macros/Options/Sources/OptionsMacros/Extensions/EnumDeclSyntax.swift b/Macros/Options/Sources/OptionsMacros/Extensions/EnumDeclSyntax.swift new file mode 100644 index 0000000..9ca3717 --- /dev/null +++ b/Macros/Options/Sources/OptionsMacros/Extensions/EnumDeclSyntax.swift @@ -0,0 +1,45 @@ +// +// EnumDeclSyntax.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import SwiftSyntax + + +#if !canImport(SyntaxKit) +extension EnumDeclSyntax { + internal var caseElements: [EnumCaseElementSyntax] { + memberBlock.members.flatMap { member in + guard let caseDecl = member.decl.as(EnumCaseDeclSyntax.self) else { + return [EnumCaseElementSyntax]() + } + + return Array(caseDecl.elements) + } + } +} +#endif diff --git a/Macros/Options/Sources/OptionsMacros/Extensions/ExtensionDeclSyntax.swift b/Macros/Options/Sources/OptionsMacros/Extensions/ExtensionDeclSyntax.swift new file mode 100644 index 0000000..8f42eae --- /dev/null +++ b/Macros/Options/Sources/OptionsMacros/Extensions/ExtensionDeclSyntax.swift @@ -0,0 +1,59 @@ +// +// ExtensionDeclSyntax.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import SwiftSyntax + +#if !canImport(SyntaxKit) +extension ExtensionDeclSyntax { + internal init( + enumDecl: EnumDeclSyntax, + conformingTo protocols: [SwiftSyntax.TypeSyntax] + ) throws { + let typeName = enumDecl.name + + let access = enumDecl.modifiers.first(where: \.isNeededAccessLevelModifier) + + let mappedValues = try VariableDeclSyntax.mappedValuesDeclarationForCases( + enumDecl.caseElements + ) + + self.init( + modifiers: DeclModifierListSyntax([access].compactMap { $0 }), + extendedType: IdentifierTypeSyntax(name: typeName), + inheritanceClause: InheritanceClauseSyntax(protocols: protocols), + memberBlock: MemberBlockSyntax( + members: MemberBlockItemListSyntax { + TypeAliasDeclSyntax(name: "MappedType", for: "String") + mappedValues + } + ) + ) + } +} +#endif diff --git a/Macros/Options/Sources/OptionsMacros/Extensions/InheritanceClauseSyntax.swift b/Macros/Options/Sources/OptionsMacros/Extensions/InheritanceClauseSyntax.swift new file mode 100644 index 0000000..84156e9 --- /dev/null +++ b/Macros/Options/Sources/OptionsMacros/Extensions/InheritanceClauseSyntax.swift @@ -0,0 +1,47 @@ +// +// InheritanceClauseSyntax.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import SwiftSyntax + + +#if !canImport(SyntaxKit) +extension InheritanceClauseSyntax { + internal init(protocols: [SwiftSyntax.TypeSyntax]) { + self.init( + inheritedTypes: .init { + .init( + protocols.map { typeSyntax in + InheritedTypeSyntax(type: typeSyntax) + } + ) + } + ) + } +} +#endif diff --git a/Macros/Options/Sources/OptionsMacros/Extensions/KeyValues.swift b/Macros/Options/Sources/OptionsMacros/Extensions/KeyValues.swift new file mode 100644 index 0000000..7800a73 --- /dev/null +++ b/Macros/Options/Sources/OptionsMacros/Extensions/KeyValues.swift @@ -0,0 +1,70 @@ +// +// KeyValues.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import SwiftSyntax + +internal struct KeyValues { + internal private(set) var lastKey: Int? + internal private(set) var dictionary = [Int: String]() + + internal var count: Int { + dictionary.count + } + + internal var nextKey: Int { + (lastKey ?? -1) + 1 + } + + internal mutating func append(value: String, withKey key: Int? = nil) throws { + let key = key ?? nextKey + guard dictionary[key] == nil else { + throw InvalidDeclError.rawValue(key) + } + lastKey = key + dictionary[key] = value + } + + internal func get(_ key: Int) -> String? { + dictionary[key] + } +} + +extension KeyValues { + internal init(caseElements: [EnumCaseElementSyntax]) throws { + self.init() + for caseElement in caseElements { + let intText = caseElement.rawValue?.value + .as(IntegerLiteralExprSyntax.self)?.literal.text + let key = intText.flatMap { Int($0) } + let value = + caseElement.name.trimmed.text + try append(value: value, withKey: key) + } + } +} diff --git a/Macros/Options/Sources/OptionsMacros/Extensions/TypeAliasDeclSyntax.swift b/Macros/Options/Sources/OptionsMacros/Extensions/TypeAliasDeclSyntax.swift new file mode 100644 index 0000000..f3e24b2 --- /dev/null +++ b/Macros/Options/Sources/OptionsMacros/Extensions/TypeAliasDeclSyntax.swift @@ -0,0 +1,42 @@ +// +// TypeAliasDeclSyntax.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import SwiftSyntax + + +#if !canImport(SyntaxKit) +extension TypeAliasDeclSyntax { + internal init(name: TokenSyntax, for initializerTypeName: TokenSyntax) { + self.init( + name: name, + initializer: .init(value: IdentifierTypeSyntax(name: initializerTypeName)) + ) + } +} +#endif diff --git a/Macros/Options/Sources/OptionsMacros/Extensions/VariableDeclSyntax.swift b/Macros/Options/Sources/OptionsMacros/Extensions/VariableDeclSyntax.swift new file mode 100644 index 0000000..9b221a6 --- /dev/null +++ b/Macros/Options/Sources/OptionsMacros/Extensions/VariableDeclSyntax.swift @@ -0,0 +1,84 @@ +// +// VariableDeclSyntax.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import SwiftSyntax + + +#if !canImport(SyntaxKit) +extension VariableDeclSyntax { + internal init( + keywordModifier: Keyword?, + bindingKeyword: Keyword, + variableName: String, + initializerExpression: (some ExprSyntaxProtocol)? + ) { + let modifiers = DeclModifierListSyntax(keywordModifier: keywordModifier) + + let initializer: InitializerClauseSyntax? = + initializerExpression.map { .init(value: $0) } + + self.init( + modifiers: modifiers, + bindingSpecifier: .keyword(bindingKeyword), + bindings: .init { + PatternBindingSyntax( + pattern: IdentifierPatternSyntax(identifier: .identifier(variableName)), + initializer: initializer + ) + } + ) + } + + internal static func initializerExpression( + from caseElements: [EnumCaseElementSyntax] + ) throws -> any ExprSyntaxProtocol { + let keyValues = try KeyValues(caseElements: caseElements) + if let array = Array(keyValues: keyValues) { + return ArrayExprSyntax(from: array) { value in + StringLiteralExprSyntax(content: value) + } + } else { + return DictionaryExprSyntax(keyValues: keyValues) + } + } + + internal static func mappedValuesDeclarationForCases( + _ caseElements: [EnumCaseElementSyntax] + ) throws -> VariableDeclSyntax { + let arrayExpression = try Self.initializerExpression(from: caseElements) + + return VariableDeclSyntax( + keywordModifier: .static, + bindingKeyword: .let, + variableName: "mappedValues", + initializerExpression: arrayExpression + ) + } +} +#endif diff --git a/Macros/Options/Sources/OptionsMacros/InvalidDeclError.swift b/Macros/Options/Sources/OptionsMacros/InvalidDeclError.swift new file mode 100644 index 0000000..ea31645 --- /dev/null +++ b/Macros/Options/Sources/OptionsMacros/InvalidDeclError.swift @@ -0,0 +1,35 @@ +// +// InvalidDeclError.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +@preconcurrency import SwiftSyntax + +internal enum InvalidDeclError: Error, Sendable { + case kind(SyntaxKind) + case rawValue(Int) +} diff --git a/Macros/Options/Sources/OptionsMacros/MacrosPlugin.swift b/Macros/Options/Sources/OptionsMacros/MacrosPlugin.swift new file mode 100644 index 0000000..1bc8833 --- /dev/null +++ b/Macros/Options/Sources/OptionsMacros/MacrosPlugin.swift @@ -0,0 +1,39 @@ +// +// MacrosPlugin.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import SwiftCompilerPlugin +import SwiftSyntax +import SwiftSyntaxMacros + +@main +internal struct MacrosPlugin: CompilerPlugin { + internal let providingMacros: [any Macro.Type] = [ + OptionsMacro.self + ] +} diff --git a/Macros/Options/Sources/OptionsMacros/OptionsMacro.swift b/Macros/Options/Sources/OptionsMacros/OptionsMacro.swift new file mode 100644 index 0000000..aa17029 --- /dev/null +++ b/Macros/Options/Sources/OptionsMacros/OptionsMacro.swift @@ -0,0 +1,250 @@ +// +// OptionsMacro.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import SwiftSyntax +import SwiftSyntaxMacros + +#if canImport(SyntaxKit) +import SyntaxKit +public struct OptionsMacro: ExtensionMacro, PeerMacro { + public static func expansion(of node: SwiftSyntax.AttributeSyntax, attachedTo declaration: some SwiftSyntax.DeclGroupSyntax, providingExtensionsOf type: some SwiftSyntax.TypeSyntaxProtocol, conformingTo protocols: [SwiftSyntax.TypeSyntax], in context: some SwiftSyntaxMacros.MacroExpansionContext) throws -> [SwiftSyntax.ExtensionDeclSyntax] { + guard let enumDecl = declaration.as(EnumDeclSyntax.self) else { + throw InvalidDeclError.kind(declaration.kind) + } + + // Extract the type name + let typeName = enumDecl.name + + // Extract all EnumCaseElementSyntax from the enum + let caseElements: [EnumCaseElementSyntax] = enumDecl.memberBlock.members.flatMap { (member) -> [EnumCaseElementSyntax] in + guard let caseDecl = member.decl.as(EnumCaseDeclSyntax.self) else { + return [EnumCaseElementSyntax]() + } + return Array(caseDecl.elements) + } + + // Build mappedValues variable declaration (static let mappedValues = [...]) + // Check if any case has a raw value to determine if we need a dictionary + let hasRawValues = caseElements.contains { $0.rawValue != nil } + + let mappedValuesExpr: ExprSyntax + if hasRawValues { + // Create dictionary mapping raw values to case names + let dictionaryElements = caseElements.compactMap { element -> DictionaryElementSyntax? in + guard let rawValue = element.rawValue?.value.as(IntegerLiteralExprSyntax.self)?.literal.text, + let key = Int(rawValue) else { + return nil + } + let value = element.name.trimmed.text + return DictionaryElementSyntax( + key: IntegerLiteralExprSyntax(digits: .integerLiteral(String(key))), + value: StringLiteralExprSyntax( + openingQuote: .stringQuoteToken(), + segments: .init([.stringSegment(.init(content: .stringSegment(value)))]), + closingQuote: .stringQuoteToken() + ) + ) + } + + let dictionaryList = DictionaryElementListSyntax( + dictionaryElements.enumerated().map { (idx, element) in + var dictElement = element + if idx < dictionaryElements.count - 1 { + dictElement = dictElement.with(\.trailingComma, TokenSyntax.commaToken(trailingTrivia: .space)) + } + return dictElement + } + ) + + mappedValuesExpr = ExprSyntax(DictionaryExprSyntax( + content: .elements(dictionaryList) + )) + } else { + // Create array of case names + mappedValuesExpr = ExprSyntax(ArrayExprSyntax( + elements: ArrayElementListSyntax( + caseElements.enumerated().map { (idx, element) in + ArrayElementSyntax( + expression: ExprSyntax(StringLiteralExprSyntax( + openingQuote: .stringQuoteToken(), + segments: .init([ + .stringSegment(.init(content: .stringSegment(element.name.trimmed.text))) + ]), + closingQuote: .stringQuoteToken() + )), + trailingComma: idx < caseElements.count - 1 ? TokenSyntax.commaToken(trailingTrivia: .space) : nil + ) + } + ) + )) + } + + let mappedValuesDecl = VariableDeclSyntax( + modifiers: DeclModifierListSyntax { + DeclModifierSyntax(name: .keyword(.static, trailingTrivia: .space)) + }, + bindingSpecifier: .keyword(.let, trailingTrivia: .space), + bindings: PatternBindingListSyntax { + PatternBindingSyntax( + pattern: IdentifierPatternSyntax(identifier: .identifier("mappedValues")), + initializer: InitializerClauseSyntax( + equal: .equalToken(leadingTrivia: .space, trailingTrivia: .space), + value: mappedValuesExpr + ) + ) + } + ) + + // Build typealias MappedType = String + let mappedTypeAlias = TypeAliasDeclSyntax( + typealiasKeyword: .keyword(.typealias, trailingTrivia: .space), + name: .identifier("MappedType", trailingTrivia: .space), + initializer: TypeInitializerClauseSyntax( + equal: .equalToken(leadingTrivia: .space, trailingTrivia: .space), + value: IdentifierTypeSyntax(name: .identifier("String")) + ) + ) + + // Build member block + let memberBlock = MemberBlockSyntax( + members: MemberBlockItemListSyntax { + MemberBlockItemSyntax(decl: DeclSyntax(mappedTypeAlias), trailingTrivia: .newline) + MemberBlockItemSyntax(decl: DeclSyntax(mappedValuesDecl), trailingTrivia: .newline) + } + ) + + // Build inheritance clause from `protocols` argument + let inheritanceClause: InheritanceClauseSyntax? = protocols.isEmpty ? nil : InheritanceClauseSyntax( + colon: .colonToken(), + inheritedTypes: InheritedTypeListSyntax( + protocols.enumerated().map { idx, proto in + var inherited = InheritedTypeSyntax(type: proto) + if idx < protocols.count - 1 { + inherited = inherited.with(\.trailingComma, TokenSyntax.commaToken(trailingTrivia: .space)) + } + return inherited + } + ) + ) + + // Assemble extension + let extDecl = ExtensionDeclSyntax( + modifiers: DeclModifierListSyntax([]), + extendedType: IdentifierTypeSyntax(name: typeName), + inheritanceClause: inheritanceClause, + memberBlock: memberBlock + ) + + //return [extDecl] + + // NOTE: Once SyntaxKit is properly added as a dependency, this could be simplified to: + + let mappedValuesVariable: Variable + if hasRawValues { + let keyValues: [Int: String] = caseElements.reduce(into: [:]) { (result, element) in + guard let rawValue = element.rawValue?.value.as(IntegerLiteralExprSyntax.self)?.literal.text, + let key = Int(rawValue) else { + return + } + result[key] = element.name.trimmed.text + } + mappedValuesVariable = Variable(.let, name: "mappedValues", equals: keyValues).static() + } else { + let caseNames: [String] = caseElements.map { element in + element.name.trimmed.text + } + mappedValuesVariable = Variable(.let, name: "mappedValues", equals: caseNames).static() + } + + let extensionDecl = Extension(typeName.trimmed.text) { + TypeAlias("MappedType", equals: "String") + mappedValuesVariable + }.inherits("MappedValueRepresentable", "MappedValueRepresented") + + return [extensionDecl.syntax.as(ExtensionDeclSyntax.self)!] + + } + + public static func expansion(of node: SwiftSyntax.AttributeSyntax, providingPeersOf declaration: some SwiftSyntax.DeclSyntaxProtocol, in context: some SwiftSyntaxMacros.MacroExpansionContext) throws -> [SwiftSyntax.DeclSyntax] { + guard let enumDecl = declaration.as(EnumDeclSyntax.self) else { + throw InvalidDeclError.kind(declaration.kind) + } + + let typeName = enumDecl.name + let aliasName = "\(typeName.trimmed)Set" + let aliasDecl = TypeAlias(aliasName, equals: "EnumSet<\(typeName)>").syntax + + guard let declSyntax : DeclSyntax = DeclSyntax(aliasDecl.as(TypeAliasDeclSyntax.self)) else { + throw InvalidDeclError.kind(declaration.kind) + } + return [ + declSyntax + ] + } + +} +#else +public struct OptionsMacro: ExtensionMacro, PeerMacro { + public static func expansion( + of _: SwiftSyntax.AttributeSyntax, + providingPeersOf declaration: some SwiftSyntax.DeclSyntaxProtocol, + in _: some SwiftSyntaxMacros.MacroExpansionContext + ) throws -> [SwiftSyntax.DeclSyntax] { + guard let enumDecl = declaration.as(EnumDeclSyntax.self) else { + throw InvalidDeclError.kind(declaration.kind) + } + let typeName = enumDecl.name + + let aliasName: TokenSyntax = "\(typeName.trimmed)Set" + + let initializerName: TokenSyntax = "EnumSet<\(typeName)>" + + return [ + .init(TypeAliasDeclSyntax(name: aliasName, for: initializerName)) + ] + } + + public static func expansion( + of _: SwiftSyntax.AttributeSyntax, + attachedTo declaration: some SwiftSyntax.DeclGroupSyntax, + providingExtensionsOf _: some SwiftSyntax.TypeSyntaxProtocol, + conformingTo protocols: [SwiftSyntax.TypeSyntax], + in _: some SwiftSyntaxMacros.MacroExpansionContext + ) throws -> [SwiftSyntax.ExtensionDeclSyntax] { + guard let enumDecl = declaration.as(EnumDeclSyntax.self) else { + throw InvalidDeclError.kind(declaration.kind) + } + + let extensionDecl = try ExtensionDeclSyntax( + enumDecl: enumDecl, conformingTo: protocols + ) + return [extensionDecl] + } +} +#endif diff --git a/Macros/Options/Tests/OptionsTests/EnumSetTests.swift b/Macros/Options/Tests/OptionsTests/EnumSetTests.swift new file mode 100644 index 0000000..5e55439 --- /dev/null +++ b/Macros/Options/Tests/OptionsTests/EnumSetTests.swift @@ -0,0 +1,90 @@ +// +// EnumSetTests.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +@testable import Options +import XCTest + +internal final class EnumSetTests: XCTestCase { + private static let text = "[\"a\",\"b\",\"c\"]" + + internal func testDecoder() { + // swiftlint:disable:next force_unwrapping + let data = Self.text.data(using: .utf8)! + let decoder = JSONDecoder() + let actual: EnumSet + do { + actual = try decoder.decode(EnumSet.self, from: data) + } catch { + XCTAssertNil(error) + return + } + XCTAssertEqual(actual.rawValue, 7) + } + + internal func testEncoder() { + let enumSet = EnumSet(values: [.a, .b, .c]) + let encoder = JSONEncoder() + let data: Data + do { + data = try encoder.encode(enumSet) + } catch { + XCTAssertNil(error) + return + } + + let dataText = String(bytes: data, encoding: .utf8) + + guard let text = dataText else { + XCTAssertNotNil(dataText) + return + } + + XCTAssertEqual(text, Self.text) + } + + internal func testInitValue() { + let set = EnumSet(rawValue: 7) + XCTAssertEqual(set.rawValue, 7) + } + + internal func testInitValues() { + let values: [MockCollectionEnum] = [.a, .b, .c] + let setA = EnumSet(values: values) + XCTAssertEqual(setA.rawValue, 7) + let setB: MockCollectionEnumSet = [.a, .b, .c] + XCTAssertEqual(setB.rawValue, 7) + } + + internal func testArray() { + let expected: [MockCollectionEnum] = [.b, .d] + let enumSet = EnumSet(values: expected) + let actual = enumSet.array() + XCTAssertEqual(actual, expected) + } +} diff --git a/Macros/Options/Tests/OptionsTests/MappedEnumTests.swift b/Macros/Options/Tests/OptionsTests/MappedEnumTests.swift new file mode 100644 index 0000000..1e19650 --- /dev/null +++ b/Macros/Options/Tests/OptionsTests/MappedEnumTests.swift @@ -0,0 +1,69 @@ +// +// MappedEnumTests.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +@testable import Options +import XCTest + +internal final class MappedEnumTests: XCTestCase { + private static let text = "\"a\"" + internal func testDecoder() throws { + // swiftlint:disable:next force_unwrapping + let data = Self.text.data(using: .utf8)! + let decoder = JSONDecoder() + let actual: MappedEnum + do { + actual = try decoder.decode(MappedEnum.self, from: data) + } catch { + XCTAssertNil(error) + return + } + XCTAssertEqual(actual.value, .a) + } + + internal func testEncoder() throws { + let encoder = JSONEncoder() + let describedEnum: MappedEnum = .init(value: .a) + let data: Data + do { + data = try encoder.encode(describedEnum) + } catch { + XCTAssertNil(error) + return + } + + let dataText = String(bytes: data, encoding: .utf8) + + guard let text = dataText else { + XCTAssertNotNil(dataText) + return + } + + XCTAssertEqual(text, Self.text) + } +} diff --git a/Macros/Options/Tests/OptionsTests/MappedValueCollectionRepresentedTests.swift b/Macros/Options/Tests/OptionsTests/MappedValueCollectionRepresentedTests.swift new file mode 100644 index 0000000..98565ea --- /dev/null +++ b/Macros/Options/Tests/OptionsTests/MappedValueCollectionRepresentedTests.swift @@ -0,0 +1,157 @@ +// +// MappedValueCollectionRepresentedTests.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +@testable import Options +import XCTest + +internal final class MappedValueCollectionRepresentedTests: XCTestCase { + internal func testRawValue() { + try XCTAssertEqual(MockCollectionEnum.rawValue(basedOn: "a"), 0) + try XCTAssertEqual(MockCollectionEnum.rawValue(basedOn: "b"), 1) + try XCTAssertEqual(MockCollectionEnum.rawValue(basedOn: "c"), 2) + try XCTAssertEqual(MockCollectionEnum.rawValue(basedOn: "d"), 3) + } + + internal func testString() { + try XCTAssertEqual(MockCollectionEnum.mappedValue(basedOn: 0), "a") + try XCTAssertEqual(MockCollectionEnum.mappedValue(basedOn: 1), "b") + try XCTAssertEqual(MockCollectionEnum.mappedValue(basedOn: 2), "c") + try XCTAssertEqual(MockCollectionEnum.mappedValue(basedOn: 3), "d") + } + + internal func testRawValueFailure() { + let caughtError: MappedValueRepresentableError? + do { + _ = try MockCollectionEnum.rawValue(basedOn: "e") + caughtError = nil + } catch let error as MappedValueRepresentableError { + caughtError = error + } catch { + XCTAssertNil(error) + caughtError = nil + } + + XCTAssertEqual(caughtError, .valueNotFound) + } + + internal func testStringFailure() { + let caughtError: MappedValueRepresentableError? + do { + _ = try MockCollectionEnum.mappedValue(basedOn: .max) + caughtError = nil + } catch let error as MappedValueRepresentableError { + caughtError = error + } catch { + XCTAssertNil(error) + caughtError = nil + } + + XCTAssertEqual(caughtError, .valueNotFound) + } + + internal func testCodingOptions() { + XCTAssertEqual(MockDictionaryEnum.codingOptions, .default) + } + + internal func testInvalidRaw() throws { + let rawValue = Int.random(in: 5 ... 1_000) + + let rawValueJSON = "\(rawValue)" + + let rawValueJSONData = rawValueJSON.data(using: .utf8)! + + let decodingError: DecodingError + do { + let value = try Self.decoder.decode(MockCollectionEnum.self, from: rawValueJSONData) + XCTAssertNil(value) + return + } catch let error as DecodingError { + decodingError = error + } + + XCTAssertNotNil(decodingError) + } + + internal func testCodable() throws { + let argumentSets = MockCollectionEnum.allCases.flatMap { + [($0, true), ($0, false)] + }.flatMap { + [($0.0, $0.1, true), ($0.0, $0.1, false)] + } + + for arguments in argumentSets { + try codableTest(value: arguments.0, allowMappedValue: arguments.1, encodeAsMappedValue: arguments.2) + } + } + + static let encoder = JSONEncoder() + static let decoder = JSONDecoder() + + private func codableTest(value: MockCollectionEnum, allowMappedValue: Bool, encodeAsMappedValue: Bool) throws { + let mappedValue = try value.mappedValue() + let rawValue = value.rawValue + + let mappedValueJSON = "\"\(mappedValue)\"" + let rawValueJSON = "\(rawValue)" + + let mappedValueJSONData = mappedValueJSON.data(using: .utf8)! + let rawValueJSONData = rawValueJSON.data(using: .utf8)! + + let oldOptions = MockCollectionEnum.codingOptions + MockCollectionEnum.codingOptions = .init([ + allowMappedValue ? CodingOptions.allowMappedValueDecoding : nil, + encodeAsMappedValue ? CodingOptions.encodeAsMappedValue : nil + ].compactMap { $0 }) + + defer { + MockCollectionEnum.codingOptions = oldOptions + } + + let mappedDecodeResult = Result { + try Self.decoder.decode(MockCollectionEnum.self, from: mappedValueJSONData) + } + + let actualRawValueDecoded = try Self.decoder.decode(MockCollectionEnum.self, from: rawValueJSONData) + + let actualEncodedJSON = try Self.encoder.encode(value) + + switch (allowMappedValue, mappedDecodeResult) { + case (true, let .success(actualMappedDecodedValue)): + XCTAssertEqual(actualMappedDecodedValue, value) + case (false, let .failure(error)): + XCTAssert(error is DecodingError) + default: + XCTFail("Unmatched situation \(allowMappedValue): \(mappedDecodeResult)") + } + + XCTAssertEqual(actualRawValueDecoded, value) + + XCTAssertEqual(actualEncodedJSON, encodeAsMappedValue ? mappedValueJSONData : rawValueJSONData) + } +} diff --git a/Macros/Options/Tests/OptionsTests/MappedValueDictionaryRepresentedTests.swift b/Macros/Options/Tests/OptionsTests/MappedValueDictionaryRepresentedTests.swift new file mode 100644 index 0000000..8aca268 --- /dev/null +++ b/Macros/Options/Tests/OptionsTests/MappedValueDictionaryRepresentedTests.swift @@ -0,0 +1,77 @@ +// +// MappedValueDictionaryRepresentedTests.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +@testable import Options +import XCTest + +internal final class MappedValueDictionaryRepresentedTests: XCTestCase { + internal func testRawValue() { + try XCTAssertEqual(MockDictionaryEnum.rawValue(basedOn: "a"), 2) + try XCTAssertEqual(MockDictionaryEnum.rawValue(basedOn: "b"), 5) + try XCTAssertEqual(MockDictionaryEnum.rawValue(basedOn: "c"), 6) + try XCTAssertEqual(MockDictionaryEnum.rawValue(basedOn: "d"), 12) + } + + internal func testString() { + try XCTAssertEqual(MockDictionaryEnum.mappedValue(basedOn: 2), "a") + try XCTAssertEqual(MockDictionaryEnum.mappedValue(basedOn: 5), "b") + try XCTAssertEqual(MockDictionaryEnum.mappedValue(basedOn: 6), "c") + try XCTAssertEqual(MockDictionaryEnum.mappedValue(basedOn: 12), "d") + } + + internal func testRawValueFailure() { + let caughtError: MappedValueRepresentableError? + do { + _ = try MockDictionaryEnum.rawValue(basedOn: "e") + caughtError = nil + } catch let error as MappedValueRepresentableError { + caughtError = error + } catch { + XCTAssertNil(error) + caughtError = nil + } + + XCTAssertEqual(caughtError, .valueNotFound) + } + + internal func testStringFailure() { + let caughtError: MappedValueRepresentableError? + do { + _ = try MockDictionaryEnum.mappedValue(basedOn: 0) + caughtError = nil + } catch let error as MappedValueRepresentableError { + caughtError = error + } catch { + XCTAssertNil(error) + caughtError = nil + } + + XCTAssertEqual(caughtError, .valueNotFound) + } +} diff --git a/Macros/Options/Tests/OptionsTests/MappedValueRepresentableTests.swift b/Macros/Options/Tests/OptionsTests/MappedValueRepresentableTests.swift new file mode 100644 index 0000000..e400539 --- /dev/null +++ b/Macros/Options/Tests/OptionsTests/MappedValueRepresentableTests.swift @@ -0,0 +1,40 @@ +// +// MappedValueRepresentableTests.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +@testable import Options +import XCTest + +internal final class MappedValueRepresentableTests: XCTestCase { + internal func testStringValue() { + try XCTAssertEqual(MockCollectionEnum.a.mappedValue(), "a") + try XCTAssertEqual(MockCollectionEnum.b.mappedValue(), "b") + try XCTAssertEqual(MockCollectionEnum.c.mappedValue(), "c") + try XCTAssertEqual(MockCollectionEnum.d.mappedValue(), "d") + } +} diff --git a/Macros/Options/Tests/OptionsTests/Mocks/MockCollectionEnum.swift b/Macros/Options/Tests/OptionsTests/Mocks/MockCollectionEnum.swift new file mode 100644 index 0000000..6dc0e00 --- /dev/null +++ b/Macros/Options/Tests/OptionsTests/Mocks/MockCollectionEnum.swift @@ -0,0 +1,61 @@ +// +// MockCollectionEnum.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Options + +#if swift(>=5.10) + // swiftlint:disable identifier_name + @Options + internal enum MockCollectionEnum: Int, Sendable, Codable { + case a + case b + case c + case d + + nonisolated(unsafe) static var codingOptions: CodingOptions = .default + } +#else + // swiftlint:disable identifier_name + internal enum MockCollectionEnum: Int, MappedValueCollectionRepresented, Codable { + case a + case b + case c + case d + internal typealias MappedType = String + internal static let mappedValues = [ + "a", + "b", + "c", + "d" + ] + static var codingOptions: CodingOptions = .default + } + + typealias MockCollectionEnumSet = EnumSet +#endif diff --git a/Macros/Options/Tests/OptionsTests/Mocks/MockDictionaryEnum.swift b/Macros/Options/Tests/OptionsTests/Mocks/MockDictionaryEnum.swift new file mode 100644 index 0000000..0cb80da --- /dev/null +++ b/Macros/Options/Tests/OptionsTests/Mocks/MockDictionaryEnum.swift @@ -0,0 +1,58 @@ +// +// MockDictionaryEnum.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Options + +#if swift(>=5.10) + // swiftlint:disable identifier_name + @Options + internal enum MockDictionaryEnum: Int, Sendable { + case a = 2 + case b = 5 + case c = 6 + case d = 12 + } +#else + // swiftlint:disable identifier_name + internal enum MockDictionaryEnum: Int, MappedValueDictionaryRepresented, Codable { + case a = 2 + case b = 5 + case c = 6 + case d = 12 + internal typealias MappedType = String + internal static var mappedValues = [ + 2: "a", + 5: "b", + 6: "c", + 12: "d" + ] + } + + typealias MockDictionaryEnumSet = EnumSet +#endif diff --git a/Macros/Options/Tests/OptionsTests/Mocks/MockError.swift b/Macros/Options/Tests/OptionsTests/Mocks/MockError.swift new file mode 100644 index 0000000..9915aeb --- /dev/null +++ b/Macros/Options/Tests/OptionsTests/Mocks/MockError.swift @@ -0,0 +1,34 @@ +// +// MockError.swift +// SimulatorServices +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +internal struct MockError: Error { + internal let value: T +} diff --git a/Macros/Options/codecov.yml b/Macros/Options/codecov.yml new file mode 100644 index 0000000..951b97b --- /dev/null +++ b/Macros/Options/codecov.yml @@ -0,0 +1,2 @@ +ignore: + - "Tests" diff --git a/Macros/Options/logo.png b/Macros/Options/logo.png new file mode 100644 index 0000000..02c0f98 Binary files /dev/null and b/Macros/Options/logo.png differ diff --git a/Macros/Options/logo.svg b/Macros/Options/logo.svg new file mode 100644 index 0000000..0345444 --- /dev/null +++ b/Macros/Options/logo.svg @@ -0,0 +1,551 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Macros/Options/project.yml b/Macros/Options/project.yml new file mode 100644 index 0000000..abfabb5 --- /dev/null +++ b/Macros/Options/project.yml @@ -0,0 +1,13 @@ +name: Options +settings: + LINT_MODE: ${LINT_MODE} +packages: + StealthyStash: + path: . +aggregateTargets: + Lint: + buildScripts: + - path: Scripts/lint.sh + name: Lint + basedOnDependencyAnalysis: false + schemes: {} \ No newline at end of file diff --git a/Macros/SKSampleMacro/.gitignore b/Macros/SKSampleMacro/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/Macros/SKSampleMacro/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Macros/SKSampleMacro/Package.resolved b/Macros/SKSampleMacro/Package.resolved new file mode 100644 index 0000000..25225ed --- /dev/null +++ b/Macros/SKSampleMacro/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "9eace0238bc49301f22ac682c8f3e981d6f1a63573efd4e1a727b71527c4ebb0", + "pins" : [ + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", + "version" : "601.0.1" + } + } + ], + "version" : 3 +} diff --git a/Macros/SKSampleMacro/Package.swift b/Macros/SKSampleMacro/Package.swift new file mode 100644 index 0000000..b0688ff --- /dev/null +++ b/Macros/SKSampleMacro/Package.swift @@ -0,0 +1,59 @@ +// swift-tools-version: 6.2 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription +import CompilerPluginSupport + +let package = Package( + name: "SKSampleMacro", + platforms: [ + .macOS(.v13), + .iOS(.v13), + .watchOS(.v6), + .tvOS(.v13), + .visionOS(.v1) + ], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "SKSampleMacro", + targets: ["SKSampleMacro"] + ), + .executable( + name: "SKSampleMacroClient", + targets: ["SKSampleMacroClient"] + ), + ], + dependencies: [ + .package(path: "../.."), + .package(url: "https://github.com/apple/swift-syntax.git", from: "601.0.1") + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + // Macro implementation that performs the source transformation of a macro. + .macro( + name: "SKSampleMacroMacros", + dependencies: [ + .product(name: "SyntaxKit", package: "SyntaxKit"), + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax") + ] + ), + + // Library that exposes a macro as part of its API, which is used in client programs. + .target(name: "SKSampleMacro", dependencies: ["SKSampleMacroMacros"]), + + // A client of the library, which is able to use the macro in its own code. + .executableTarget(name: "SKSampleMacroClient", dependencies: ["SKSampleMacro"]), + + // A test target used to develop the macro implementation. + .testTarget( + name: "SKSampleMacroTests", + dependencies: [ + "SKSampleMacroMacros", + .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), + ] + ), + ] +) diff --git a/Macros/SKSampleMacro/Sources/SKSampleMacro/SKSampleMacro.swift b/Macros/SKSampleMacro/Sources/SKSampleMacro/SKSampleMacro.swift new file mode 100644 index 0000000..d812512 --- /dev/null +++ b/Macros/SKSampleMacro/Sources/SKSampleMacro/SKSampleMacro.swift @@ -0,0 +1,11 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book + +/// A macro that produces both a value and a string containing the +/// source code that generated the value. For example, +/// +/// #stringify(x + y) +/// +/// produces a tuple `(x + y, "x + y")`. +@freestanding(expression) +public macro stringify(_ lhs: T, _ rhs: T) -> (T, String) = #externalMacro(module: "SKSampleMacroMacros", type: "StringifyMacro") diff --git a/Macros/SKSampleMacro/Sources/SKSampleMacroClient/main.swift b/Macros/SKSampleMacro/Sources/SKSampleMacroClient/main.swift new file mode 100644 index 0000000..1fdb254 --- /dev/null +++ b/Macros/SKSampleMacro/Sources/SKSampleMacroClient/main.swift @@ -0,0 +1,8 @@ +import SKSampleMacro + +let a = 17 +let b = 25 + +let (result, code) = #stringify(a, b) + +print("The value \(result) was produced by the code \"\(code)\"") diff --git a/Macros/SKSampleMacro/Sources/SKSampleMacroMacros/SKSampleMacroMacro.swift b/Macros/SKSampleMacro/Sources/SKSampleMacroMacros/SKSampleMacroMacro.swift new file mode 100644 index 0000000..014fb7e --- /dev/null +++ b/Macros/SKSampleMacro/Sources/SKSampleMacroMacros/SKSampleMacroMacro.swift @@ -0,0 +1,42 @@ +import SwiftCompilerPlugin +import SwiftSyntax +//import SwiftSyntaxBuilder +import SwiftSyntaxMacros +import SyntaxKit + +/// Implementation of the `stringify` macro, which takes an expression +/// of any type and produces a tuple containing the value of that expression +/// and the source code that produced the value. For example +/// +/// #stringify(x + y) +/// +/// will expand to +/// +/// (x + y, "x + y") +public struct StringifyMacro: ExpressionMacro { + public static func expansion( + of node: some FreestandingMacroExpansionSyntax, + in context: some MacroExpansionContext + ) -> ExprSyntax { + let first = node.arguments.first?.expression + let second = node.arguments.last?.expression + guard let first, let second else { + fatalError("compiler bug: the macro does not have any arguments") + } + + return Tuple{ + Infix("+") { + VariableExp(first.description) + VariableExp(second.description) + } + Literal.string("\(first.description) + \(second.description)") + }.expr + } +} + +@main +struct SKSampleMacroPlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + StringifyMacro.self, + ] +} diff --git a/Macros/SKSampleMacro/Tests/SKSampleMacroTests/SKSampleMacroTests.swift b/Macros/SKSampleMacro/Tests/SKSampleMacroTests/SKSampleMacroTests.swift new file mode 100644 index 0000000..ca69367 --- /dev/null +++ b/Macros/SKSampleMacro/Tests/SKSampleMacroTests/SKSampleMacroTests.swift @@ -0,0 +1,48 @@ +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import XCTest + +// Macro implementations build for the host, so the corresponding module is not available when cross-compiling. Cross-compiled tests may still make use of the macro itself in end-to-end tests. +#if canImport(SKSampleMacroMacros) +import SKSampleMacroMacros + +let testMacros: [String: Macro.Type] = [ + "stringify": StringifyMacro.self, +] +#endif + +final class SKSampleMacroTests: XCTestCase { + func testMacro() throws { + #if canImport(SKSampleMacroMacros) + assertMacroExpansion( + """ + #stringify(a + b) + """, + expandedSource: """ + (a + b, "a + b") + """, + macros: testMacros + ) + #else + throw XCTSkip("macros are only supported when running tests for the host platform") + #endif + } + + func testMacroWithStringLiteral() throws { + #if canImport(SKSampleMacroMacros) + assertMacroExpansion( + #""" + #stringify("Hello, \(name)") + """#, + expandedSource: #""" + ("Hello, \(name)", #""Hello, \(name)""#) + """#, + macros: testMacros + ) + #else + throw XCTSkip("macros are only supported when running tests for the host platform") + #endif + } +} diff --git a/Package.resolved b/Package.resolved index 9a2ef48..fc91c87 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b5881f7ff763cf360a3639d99029de2b3ab4a457e0d8f08ce744bae51c2bf670", + "originHash" : "79e6b2b96efe3d22ee340e623938e0363d6ce8fb0edfd7ccdf90e98b766f59ec", "pins" : [ { "identity" : "swift-syntax", diff --git a/Sources/SyntaxKit/CodeBlock+ExprSyntax.swift b/Sources/SyntaxKit/CodeBlock+ExprSyntax.swift new file mode 100644 index 0000000..b8b9f30 --- /dev/null +++ b/Sources/SyntaxKit/CodeBlock+ExprSyntax.swift @@ -0,0 +1,51 @@ +// +// CodeBlock+ExprSyntax.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import SwiftSyntax + +extension CodeBlock { + /// Attempts to treat this `CodeBlock` as an expression and return its `ExprSyntax` form. + /// + /// If the underlying syntax already *is* an `ExprSyntax`, it is returned directly. If the + /// underlying syntax is a bare `TokenSyntax` (commonly the case for `VariableExp` which + /// produces an identifier token), we wrap it in a `DeclReferenceExprSyntax` so that it becomes + /// a valid expression node. Any other kind of syntax results in a runtime error, because it + /// cannot be represented as an expression (e.g. declarations or statements). + public var expr: ExprSyntax { + if let expr = self.syntax.as(ExprSyntax.self) { + return expr + } + + if let token = self.syntax.as(TokenSyntax.self) { + return ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier(token.text))) + } + + fatalError("CodeBlock of type \(type(of: self.syntax)) cannot be represented as ExprSyntax") + } +} diff --git a/Sources/SyntaxKit/Extension.swift b/Sources/SyntaxKit/Extension.swift new file mode 100644 index 0000000..522c5fc --- /dev/null +++ b/Sources/SyntaxKit/Extension.swift @@ -0,0 +1,96 @@ +// +// Extension.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import SwiftSyntax + +/// A Swift `extension` declaration. +public struct Extension: CodeBlock { + private let extendedType: String + private let members: [CodeBlock] + private var inheritance: [String] = [] + + /// Creates an `extension` declaration. + /// - Parameters: + /// - extendedType: The name of the type being extended. + /// - content: A ``CodeBlockBuilder`` that provides the members of the extension. + public init(_ extendedType: String, @CodeBlockBuilderResult _ content: () -> [CodeBlock]) { + self.extendedType = extendedType + self.members = content() + } + + /// Sets the inheritance for the extension. + /// - Parameter types: The types to inherit from. + /// - Returns: A copy of the extension with the inheritance set. + public func inherits(_ types: String...) -> Self { + var copy = self + copy.inheritance = types + return copy + } + + public var syntax: SyntaxProtocol { + let extensionKeyword = TokenSyntax.keyword(.extension, trailingTrivia: .space) + let identifier = TokenSyntax.identifier(extendedType, trailingTrivia: .space) + + var inheritanceClause: InheritanceClauseSyntax? + if !inheritance.isEmpty { + let inheritedTypes = inheritance.map { type in + InheritedTypeSyntax(type: IdentifierTypeSyntax(name: .identifier(type))) + } + inheritanceClause = InheritanceClauseSyntax( + colon: .colonToken(), + inheritedTypes: InheritedTypeListSyntax( + inheritedTypes.enumerated().map { idx, inherited in + var type = inherited + if idx < inheritedTypes.count - 1 { + type = type.with(\.trailingComma, TokenSyntax.commaToken(trailingTrivia: .space)) + } + return type + } + ) + ) + } + + let memberBlock = MemberBlockSyntax( + leftBrace: .leftBraceToken(leadingTrivia: .space, trailingTrivia: .newline), + members: MemberBlockItemListSyntax( + members.compactMap { member in + guard let syntax = member.syntax.as(DeclSyntax.self) else { return nil } + return MemberBlockItemSyntax(decl: syntax, trailingTrivia: .newline) + }), + rightBrace: .rightBraceToken(leadingTrivia: .newline) + ) + + return ExtensionDeclSyntax( + extensionKeyword: extensionKeyword, + extendedType: IdentifierTypeSyntax(name: identifier), + inheritanceClause: inheritanceClause, + memberBlock: memberBlock + ) + } +} diff --git a/Sources/SyntaxKit/Infix.swift b/Sources/SyntaxKit/Infix.swift new file mode 100644 index 0000000..ad18e0d --- /dev/null +++ b/Sources/SyntaxKit/Infix.swift @@ -0,0 +1,70 @@ +// +// Infix.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import SwiftSyntax + +/// A generic binary (infix) operator expression, e.g. `a + b`. +public struct Infix: CodeBlock { + private let op: String + private let operands: [CodeBlock] + + /// Creates an infix operator expression. + /// - Parameters: + /// - op: The operator symbol as it should appear in source (e.g. "+", "-", "&&"). + /// - content: A ``CodeBlockBuilder`` that supplies the two operand expressions. + /// + /// Exactly two operands must be supplied – a left-hand side and a right-hand side. + public init(_ op: String, @CodeBlockBuilderResult _ content: () -> [CodeBlock]) { + self.op = op + self.operands = content() + } + + public var syntax: SyntaxProtocol { + guard operands.count == 2 else { + fatalError("Infix expects exactly two operands, got \(operands.count).") + } + + let left = operands[0].expr + let right = operands[1].expr + + let operatorExpr = ExprSyntax( + BinaryOperatorExprSyntax( + operator: .binaryOperator(op, leadingTrivia: .space, trailingTrivia: .space) + ) + ) + + return SequenceExprSyntax( + elements: ExprListSyntax([ + left, + operatorExpr, + right, + ]) + ) + } +} diff --git a/Sources/SyntaxKit/Literal.swift b/Sources/SyntaxKit/Literal.swift index 7e0e1dd..0cec486 100644 --- a/Sources/SyntaxKit/Literal.swift +++ b/Sources/SyntaxKit/Literal.swift @@ -29,6 +29,15 @@ import SwiftSyntax +/// A protocol for types that can be represented as literal values in Swift code. +public protocol LiteralValue { + /// The Swift type name for this literal value. + var typeName: String { get } + + /// Renders this value as a Swift literal string. + var literalString: String { get } +} + /// A literal value. public enum Literal: CodeBlock { /// A string literal. @@ -64,3 +73,43 @@ public enum Literal: CodeBlock { } } } + +// MARK: - LiteralValue Implementations + +extension Array: LiteralValue where Element == String { + public var typeName: String { "[String]" } + + public var literalString: String { + let elements = self.map { element in + // Escape quotes and newlines + let escaped = + element + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + .replacingOccurrences(of: "\t", with: "\\t") + return "\"\(escaped)\"" + }.joined(separator: ", ") + return "[\(elements)]" + } +} + +extension Dictionary: LiteralValue where Key == Int, Value == String { + public var typeName: String { "[Int: String]" } + + public var literalString: String { + let elements = self.map { key, value in + // Escape quotes and newlines + let escaped = + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + .replacingOccurrences(of: "\t", with: "\\t") + return "\(key): \"\(escaped)\"" + }.joined(separator: ", ") + return "[\(elements)]" + } +} diff --git a/Sources/SyntaxKit/Tuple.swift b/Sources/SyntaxKit/Tuple.swift new file mode 100644 index 0000000..6179f7c --- /dev/null +++ b/Sources/SyntaxKit/Tuple.swift @@ -0,0 +1,71 @@ +// +// Tuple.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import SwiftSyntax + +/// A tuple expression, e.g. `(a, b, c)`. +public struct Tuple: CodeBlock { + private let elements: [CodeBlock] + + /// Creates a tuple expression comprising the supplied elements. + /// - Parameter content: A ``CodeBlockBuilder`` producing the tuple elements **in order**. + /// Elements may be any `CodeBlock` that can be represented as an expression (see + /// `CodeBlock.expr`). + public init(@CodeBlockBuilderResult _ content: () -> [CodeBlock]) { + self.elements = content() + } + + public var syntax: SyntaxProtocol { + guard !elements.isEmpty else { + fatalError("Tuple must contain at least one element.") + } + + let list = TupleExprElementListSyntax( + elements.enumerated().map { index, block in + let elementExpr = block.expr + return TupleExprElementSyntax( + label: nil, + colon: nil, + expression: elementExpr, + trailingComma: index < elements.count - 1 ? .commaToken(trailingTrivia: .space) : nil + ) + } + ) + + let tupleExpr = ExprSyntax( + TupleExprSyntax( + leftParen: .leftParenToken(), + elements: list, + rightParen: .rightParenToken() + ) + ) + + return tupleExpr + } +} diff --git a/Sources/SyntaxKit/TypeAlias.swift b/Sources/SyntaxKit/TypeAlias.swift new file mode 100644 index 0000000..7f8672b --- /dev/null +++ b/Sources/SyntaxKit/TypeAlias.swift @@ -0,0 +1,65 @@ +// +// TypeAlias.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import SwiftSyntax + +/// A Swift `typealias` declaration. +public struct TypeAlias: CodeBlock { + private let name: String + private let existingType: String + + /// Creates a `typealias` declaration. + /// - Parameters: + /// - name: The new name that will alias the existing type. + /// - type: The existing type that is being aliased. + public init(_ name: String, equals type: String) { + self.name = name + self.existingType = type + } + + public var syntax: SyntaxProtocol { + // `typealias` keyword token + let keyword = TokenSyntax.keyword(.typealias, trailingTrivia: .space) + + // Alias identifier + let identifier = TokenSyntax.identifier(name, trailingTrivia: .space) + + // Initializer clause – `= ExistingType` + let initializer = TypeInitializerClauseSyntax( + equal: .equalToken(leadingTrivia: .space, trailingTrivia: .space), + value: IdentifierTypeSyntax(name: .identifier(existingType)) + ) + + return TypeAliasDeclSyntax( + typealiasKeyword: keyword, + name: identifier, + initializer: initializer + ) + } +} diff --git a/Sources/SyntaxKit/Variable.swift b/Sources/SyntaxKit/Variable.swift index b37ec0d..40140ba 100644 --- a/Sources/SyntaxKit/Variable.swift +++ b/Sources/SyntaxKit/Variable.swift @@ -35,6 +35,7 @@ public struct Variable: CodeBlock { private let name: String private let type: String private let defaultValue: String? + private var isStatic: Bool = false /// Creates a `let` or `var` declaration with an explicit type. /// - Parameters: @@ -50,6 +51,26 @@ public struct Variable: CodeBlock { self.defaultValue = defaultValue } + /// Creates a `let` or `var` declaration with a literal value. + /// - Parameters: + /// - kind: The kind of variable, either ``VariableKind/let`` or ``VariableKind/var``. + /// - name: The name of the variable. + /// - value: A literal value that conforms to ``LiteralValue``. + public init(_ kind: VariableKind, name: String, equals value: T) { + self.kind = kind + self.name = name + self.type = value.typeName + self.defaultValue = value.literalString + } + + /// Marks the variable as `static`. + /// - Returns: A copy of the variable marked as `static`. + public func `static`() -> Self { + var copy = self + copy.isStatic = true + return copy + } + public var syntax: SyntaxProtocol { let bindingKeyword = TokenSyntax.keyword(kind == .let ? .let : .var, trailingTrivia: .space) let identifier = TokenSyntax.identifier(name, trailingTrivia: .space) @@ -65,7 +86,15 @@ public struct Variable: CodeBlock { ) } + var modifiers: DeclModifierListSyntax = [] + if isStatic { + modifiers = DeclModifierListSyntax([ + DeclModifierSyntax(name: .keyword(.static, trailingTrivia: .space)) + ]) + } + return VariableDeclSyntax( + modifiers: modifiers, bindingSpecifier: bindingKeyword, bindings: PatternBindingListSyntax([ PatternBindingSyntax( diff --git a/Tests/SyntaxKitTests/ExtensionTests.swift b/Tests/SyntaxKitTests/ExtensionTests.swift new file mode 100644 index 0000000..b04190d --- /dev/null +++ b/Tests/SyntaxKitTests/ExtensionTests.swift @@ -0,0 +1,180 @@ +// +// ExtensionTests.swift +// SyntaxKitTests +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@testable import SyntaxKit + +struct ExtensionTests { + // MARK: - Basic Extension Tests + + @Test func testBasicExtension() { + let extensionDecl = Extension("String") { + Variable(.let, name: "test", type: "Int", equals: "42") + } + + let generated = extensionDecl.generateCode().normalize() + + #expect(generated.contains("extension String")) + #expect(generated.contains("let test: Int = 42")) + } + + @Test func testExtensionWithMultipleMembers() { + let extensionDecl = Extension("Array") { + Variable(.let, name: "isEmpty", type: "Bool", equals: "true") + Variable(.let, name: "count", type: "Int", equals: "0") + } + + let generated = extensionDecl.generateCode().normalize() + + #expect(generated.contains("extension Array")) + #expect(generated.contains("let isEmpty: Bool = true")) + #expect(generated.contains("let count: Int = 0")) + } + + // MARK: - Extension with Inheritance Tests + + @Test func testExtensionWithSingleInheritance() { + let extensionDecl = Extension("MyEnum") { + TypeAlias("MappedType", equals: "String") + }.inherits("MappedValueRepresentable") + + let generated = extensionDecl.generateCode().normalize() + + #expect(generated.contains("extension MyEnum: MappedValueRepresentable")) + #expect(generated.contains("typealias MappedType = String")) + } + + @Test func testExtensionWithMultipleInheritance() { + let extensionDecl = Extension("MyEnum") { + TypeAlias("MappedType", equals: "String") + }.inherits("MappedValueRepresentable", "MappedValueRepresented") + + let generated = extensionDecl.generateCode().normalize() + + #expect( + generated.contains("extension MyEnum: MappedValueRepresentable, MappedValueRepresented")) + #expect(generated.contains("typealias MappedType = String")) + } + + @Test func testExtensionWithoutInheritance() { + let extensionDecl = Extension("MyType") { + Variable(.let, name: "constant", type: "String", equals: "value") + } + + let generated = extensionDecl.generateCode().normalize() + + #expect(generated.contains("extension MyType")) + #expect(!generated.contains("extension MyType:")) + #expect(generated.contains("let constant: String = value")) + } + + // MARK: - Extension with Complex Members Tests + + @Test func testExtensionWithStaticVariables() { + let array: [String] = ["a", "b", "c"] + let dict: [Int: String] = [1: "one", 2: "two"] + + let extensionDecl = Extension("TestEnum") { + TypeAlias("MappedType", equals: "String") + Variable(.let, name: "mappedValues", equals: array).static() + Variable(.let, name: "lookup", equals: dict).static() + }.inherits("MappedValueRepresentable", "MappedValueRepresented") + + let generated = extensionDecl.generateCode().normalize() + + #expect( + generated.contains("extension TestEnum: MappedValueRepresentable, MappedValueRepresented")) + #expect(generated.contains("typealias MappedType = String")) + #expect(generated.contains("static let mappedValues: [String] = [\"a\", \"b\", \"c\"]")) + #expect(generated.contains("static let lookup: [Int: String]")) + #expect(generated.contains("1: \"one\"")) + #expect(generated.contains("2: \"two\"")) + } + + @Test func testExtensionWithFunctions() { + let extensionDecl = Extension("String") { + Function("uppercasedFirst", returns: "String") { + Return { + VariableExp("self.prefix(1).uppercased() + self.dropFirst()") + } + } + } + + let generated = extensionDecl.generateCode().normalize() + + #expect(generated.contains("extension String")) + #expect(generated.contains("func uppercasedFirst() -> String")) + #expect(generated.contains("return self.prefix(1).uppercased() + self.dropFirst()")) + } + + // MARK: - Edge Cases + + @Test func testExtensionWithEmptyBody() { + let extensionDecl = Extension("EmptyType") { + // Empty body + } + + let generated = extensionDecl.generateCode().normalize() + + #expect(generated.contains("extension EmptyType")) + #expect(generated.contains("{")) + #expect(generated.contains("}")) + } + + @Test func testExtensionWithSpecialCharactersInName() { + let extensionDecl = Extension("MyType") { + Variable(.let, name: "generic", type: "T", equals: "nil") + } + + let generated = extensionDecl.generateCode().normalize() + + #expect(generated.contains("extension MyType")) + #expect(generated.contains("let generic: T = nil")) + } + + @Test func testInheritsMethodReturnsNewInstance() { + let original = Extension("Test") { + Variable(.let, name: "value", type: "Int", equals: "42") + } + + let withInheritance = original.inherits("Protocol1", "Protocol2") + + // Should be different instances + #expect(original.generateCode() != withInheritance.generateCode()) + + // Original should not have inheritance + let originalGenerated = original.generateCode().normalize() + #expect(!originalGenerated.contains("extension Test:")) + + // With inheritance should have inheritance + let inheritedGenerated = withInheritance.generateCode().normalize() + #expect(inheritedGenerated.contains(": Protocol1, Protocol2")) + } +} diff --git a/Tests/SyntaxKitTests/LiteralValueTests.swift b/Tests/SyntaxKitTests/LiteralValueTests.swift new file mode 100644 index 0000000..1517dcb --- /dev/null +++ b/Tests/SyntaxKitTests/LiteralValueTests.swift @@ -0,0 +1,111 @@ +// +// LiteralValueTests.swift +// SyntaxKitTests +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@testable import SyntaxKit + +struct LiteralValueTests { + // MARK: - Array LiteralValue Tests + + @Test func testArrayStringTypeName() { + let array: [String] = ["a", "b", "c"] + #expect(array.typeName == "[String]") + } + + @Test func testArrayStringLiteralString() { + let array: [String] = ["a", "b", "c"] + #expect(array.literalString == "[\"a\", \"b\", \"c\"]") + } + + @Test func testEmptyArrayStringLiteralString() { + let array: [String] = [] + #expect(array.literalString == "[]") + } + + @Test func testArrayStringWithSpecialCharacters() { + let array: [String] = ["hello world", "test\"quote", "line\nbreak"] + #expect(array.literalString == "[\"hello world\", \"test\\\"quote\", \"line\\nbreak\"]") + } + + // MARK: - Dictionary LiteralValue Tests + + @Test func testDictionaryIntStringTypeName() { + let dict: [Int: String] = [1: "a", 2: "b"] + #expect(dict.typeName == "[Int: String]") + } + + @Test func testDictionaryIntStringLiteralString() { + let dict: [Int: String] = [1: "a", 2: "b", 3: "c"] + let literal = dict.literalString + + // Dictionary order is not guaranteed, so check that all elements are present + #expect(literal.contains("1: \"a\"")) + #expect(literal.contains("2: \"b\"")) + #expect(literal.contains("3: \"c\"")) + #expect(literal.hasPrefix("[")) + #expect(literal.hasSuffix("]")) + } + + @Test func testEmptyDictionaryLiteralString() { + let dict: [Int: String] = [:] + #expect(dict.literalString == "[]") + } + + @Test func testDictionaryWithSpecialCharacters() { + let dict: [Int: String] = [1: "hello world", 2: "test\"quote"] + let literal = dict.literalString + + // Dictionary order is not guaranteed, so check that all elements are present + #expect(literal.contains("1: \"hello world\"")) + #expect(literal.contains("2: \"test\\\"quote\"")) + #expect(literal.hasPrefix("[")) + #expect(literal.hasSuffix("]")) + } + + // MARK: - Dictionary Ordering Tests + + @Test func testDictionaryOrderingIsConsistent() { + let dict1: [Int: String] = [2: "b", 1: "a", 3: "c"] + let dict2: [Int: String] = [1: "a", 2: "b", 3: "c"] + + // Both should produce the same literal string regardless of insertion order + let literal1 = dict1.literalString + let literal2 = dict2.literalString + + // The exact order depends on the dictionary's internal ordering, + // but both should be valid Swift dictionary literals + #expect(literal1.contains("1: \"a\"")) + #expect(literal1.contains("2: \"b\"")) + #expect(literal1.contains("3: \"c\"")) + #expect(literal2.contains("1: \"a\"")) + #expect(literal2.contains("2: \"b\"")) + #expect(literal2.contains("3: \"c\"")) + } +} diff --git a/Tests/SyntaxKitTests/OptionsMacroIntegrationTests.swift b/Tests/SyntaxKitTests/OptionsMacroIntegrationTests.swift new file mode 100644 index 0000000..b013daa --- /dev/null +++ b/Tests/SyntaxKitTests/OptionsMacroIntegrationTests.swift @@ -0,0 +1,228 @@ +// +// OptionsMacroIntegrationTests.swift +// SyntaxKitTests +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@testable import SyntaxKit + +struct OptionsMacroIntegrationTests { + // MARK: - Enum with Raw Values (Dictionary) Tests + + @Test func testEnumWithRawValuesCreatesDictionary() { + // Simulate the Options macro expansion for an enum with raw values + let keyValues: [Int: String] = [2: "a", 5: "b", 6: "c", 12: "d"] + + let extensionDecl = Extension("MockDictionaryEnum") { + TypeAlias("MappedType", equals: "String") + Variable(.let, name: "mappedValues", equals: keyValues).static() + }.inherits("MappedValueRepresentable", "MappedValueRepresented") + + let generated = extensionDecl.generateCode().normalize() + + #expect( + generated.contains( + "extension MockDictionaryEnum: MappedValueRepresentable, MappedValueRepresented")) + #expect(generated.contains("typealias MappedType = String")) + #expect(generated.contains("static let mappedValues: [Int: String]")) + #expect(generated.contains("2: \"a\"")) + #expect(generated.contains("5: \"b\"")) + #expect(generated.contains("6: \"c\"")) + #expect(generated.contains("12: \"d\"")) + } + + @Test func testEnumWithoutRawValuesCreatesArray() { + // Simulate the Options macro expansion for an enum without raw values + let caseNames: [String] = ["red", "green", "blue"] + + let extensionDecl = Extension("Color") { + TypeAlias("MappedType", equals: "String") + Variable(.let, name: "mappedValues", equals: caseNames).static() + }.inherits("MappedValueRepresentable", "MappedValueRepresented") + + let generated = extensionDecl.generateCode().normalize() + + #expect(generated.contains("extension Color: MappedValueRepresentable, MappedValueRepresented")) + #expect(generated.contains("typealias MappedType = String")) + #expect( + generated.contains("static let mappedValues: [String] = [\"red\", \"green\", \"blue\"]")) + } + + // MARK: - Complex Integration Tests + + @Test func testCompleteOptionsMacroWorkflow() { + // This test demonstrates the complete workflow that the Options macro would use + + // Step 1: Determine if enum has raw values (simulated) + let hasRawValues = true + let enumName = "TestEnum" + + // Step 2: Create the appropriate mappedValues variable + let mappedValuesVariable: Variable + if hasRawValues { + let keyValues: [Int: String] = [1: "first", 2: "second", 3: "third"] + mappedValuesVariable = Variable(.let, name: "mappedValues", equals: keyValues).static() + } else { + let caseNames: [String] = ["first", "second", "third"] + mappedValuesVariable = Variable(.let, name: "mappedValues", equals: caseNames).static() + } + + // Step 3: Create the extension + let extensionDecl = Extension(enumName) { + TypeAlias("MappedType", equals: "String") + mappedValuesVariable + }.inherits("MappedValueRepresentable", "MappedValueRepresented") + + let generated = extensionDecl.generateCode().normalize() + + // Verify the complete extension + #expect( + generated.contains("extension TestEnum: MappedValueRepresentable, MappedValueRepresented")) + #expect(generated.contains("typealias MappedType = String")) + #expect(generated.contains("static let mappedValues: [Int: String]")) + #expect(generated.contains("1: \"first\"")) + #expect(generated.contains("2: \"second\"")) + #expect(generated.contains("3: \"third\"")) + } + + @Test func testOptionsMacroWorkflowWithoutRawValues() { + // Test the workflow for enums without raw values + + let hasRawValues = false + let enumName = "SimpleEnum" + + let mappedValuesVariable: Variable + if hasRawValues { + let keyValues: [Int: String] = [1: "first", 2: "second"] + mappedValuesVariable = Variable(.let, name: "mappedValues", equals: keyValues).static() + } else { + let caseNames: [String] = ["first", "second"] + mappedValuesVariable = Variable(.let, name: "mappedValues", equals: caseNames).static() + } + + let extensionDecl = Extension(enumName) { + TypeAlias("MappedType", equals: "String") + mappedValuesVariable + }.inherits("MappedValueRepresentable", "MappedValueRepresented") + + let generated = extensionDecl.generateCode().normalize() + + #expect( + generated.contains("extension SimpleEnum: MappedValueRepresentable, MappedValueRepresented")) + #expect(generated.contains("typealias MappedType = String")) + #expect(generated.contains("static let mappedValues: [String] = [\"first\", \"second\"]")) + } + + // MARK: - Edge Cases + + @Test func testEmptyEnumCases() { + let caseNames: [String] = [] + + let extensionDecl = Extension("EmptyEnum") { + TypeAlias("MappedType", equals: "String") + Variable(.let, name: "mappedValues", equals: caseNames).static() + }.inherits("MappedValueRepresentable", "MappedValueRepresented") + + let generated = extensionDecl.generateCode().normalize() + + #expect( + generated.contains("extension EmptyEnum: MappedValueRepresentable, MappedValueRepresented")) + #expect(generated.contains("typealias MappedType = String")) + #expect(generated.contains("static let mappedValues: [String] = []")) + } + + @Test func testEmptyDictionary() { + let keyValues: [Int: String] = [:] + + let extensionDecl = Extension("EmptyDictEnum") { + TypeAlias("MappedType", equals: "String") + Variable(.let, name: "mappedValues", equals: keyValues).static() + }.inherits("MappedValueRepresentable", "MappedValueRepresented") + + let generated = extensionDecl.generateCode().normalize() + + #expect( + generated.contains( + "extension EmptyDictEnum: MappedValueRepresentable, MappedValueRepresented")) + #expect(generated.contains("typealias MappedType = String")) + #expect(generated.contains("static let mappedValues: [Int: String] = []")) + } + + @Test func testSpecialCharactersInCaseNames() { + let caseNames: [String] = ["case_with_underscore", "case-with-dash", "caseWithCamelCase"] + + let extensionDecl = Extension("SpecialEnum") { + TypeAlias("MappedType", equals: "String") + Variable(.let, name: "mappedValues", equals: caseNames).static() + }.inherits("MappedValueRepresentable", "MappedValueRepresented") + + let generated = extensionDecl.generateCode().normalize() + + #expect( + generated.contains("extension SpecialEnum: MappedValueRepresentable, MappedValueRepresented")) + #expect(generated.contains("typealias MappedType = String")) + #expect(generated.contains("static let mappedValues: [String]")) + #expect(generated.contains("\"case_with_underscore\"")) + #expect(generated.contains("\"case-with-dash\"")) + #expect(generated.contains("\"caseWithCamelCase\"")) + } + + // MARK: - API Validation Tests + + @Test func testNewSyntaxKitAPICompleteness() { + // Verify that all the new API components work together correctly + + // Test LiteralValue protocol + let array: [String] = ["a", "b", "c"] + #expect(array.typeName == "[String]") + #expect(array.literalString == "[\"a\", \"b\", \"c\"]") + + let dict: [Int: String] = [1: "a", 2: "b"] + #expect(dict.typeName == "[Int: String]") + #expect(dict.literalString.contains("1: \"a\"")) + #expect(dict.literalString.contains("2: \"b\"")) + + // Test Variable with static support + let staticVar = Variable(.let, name: "test", equals: array).static() + let staticGenerated = staticVar.generateCode().normalize() + #expect(staticGenerated.contains("static let test: [String] = [\"a\", \"b\", \"c\"]")) + + // Test Extension with inheritance + let ext = Extension("Test") { + // Empty content + }.inherits("Protocol1", "Protocol2") + + let extGenerated = ext.generateCode().normalize() + #expect(extGenerated.contains("extension Test: Protocol1, Protocol2")) + + // Test TypeAlias + let alias = TypeAlias("MyType", equals: "String") + let aliasGenerated = alias.generateCode().normalize() + #expect(aliasGenerated.contains("typealias MyType = String")) + } +} diff --git a/Tests/SyntaxKitTests/TypeAliasTests.swift b/Tests/SyntaxKitTests/TypeAliasTests.swift new file mode 100644 index 0000000..c67c4eb --- /dev/null +++ b/Tests/SyntaxKitTests/TypeAliasTests.swift @@ -0,0 +1,176 @@ +// +// TypeAliasTests.swift +// SyntaxKitTests +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@testable import SyntaxKit + +struct TypeAliasTests { + // MARK: - Basic TypeAlias Tests + + @Test func testBasicTypeAlias() { + let typeAlias = TypeAlias("MappedType", equals: "String") + let generated = typeAlias.generateCode().normalize() + + #expect(generated.contains("typealias MappedType = String")) + } + + @Test func testTypeAliasWithComplexType() { + let typeAlias = TypeAlias("ResultType", equals: "Result") + let generated = typeAlias.generateCode().normalize() + + #expect(generated.contains("typealias ResultType = Result")) + } + + @Test func testTypeAliasWithGenericType() { + let typeAlias = TypeAlias("ArrayType", equals: "Array") + let generated = typeAlias.generateCode().normalize() + + #expect(generated.contains("typealias ArrayType = Array")) + } + + @Test func testTypeAliasWithOptionalType() { + let typeAlias = TypeAlias("OptionalString", equals: "String?") + let generated = typeAlias.generateCode().normalize() + + #expect(generated.contains("typealias OptionalString = String?")) + } + + // MARK: - TypeAlias in Context Tests + + @Test func testTypeAliasInExtension() { + let extensionDecl = Extension("MyEnum") { + TypeAlias("MappedType", equals: "String") + Variable(.let, name: "test", type: "MappedType", equals: "value") + } + + let generated = extensionDecl.generateCode().normalize() + + #expect(generated.contains("extension MyEnum")) + #expect(generated.contains("typealias MappedType = String")) + #expect(generated.contains("let test: MappedType = value")) + } + + @Test func testTypeAliasInStruct() { + let structDecl = Struct("Container") { + TypeAlias("ElementType", equals: "String") + Variable(.let, name: "element", type: "ElementType") + } + + let generated = structDecl.generateCode().normalize() + + #expect(generated.contains("struct Container")) + #expect(generated.contains("typealias ElementType = String")) + #expect(generated.contains("let element: ElementType")) + } + + @Test func testTypeAliasInEnum() { + let enumDecl = Enum("Result") { + TypeAlias("SuccessType", equals: "String") + TypeAlias("FailureType", equals: "Error") + EnumCase("success") + EnumCase("failure") + } + + let generated = enumDecl.generateCode().normalize() + + #expect(generated.contains("enum Result")) + #expect(generated.contains("typealias SuccessType = String")) + #expect(generated.contains("typealias FailureType = Error")) + #expect(generated.contains("case success")) + #expect(generated.contains("case failure")) + } + + // MARK: - Edge Cases + + @Test func testTypeAliasWithSpecialCharacters() { + let typeAlias = TypeAlias("GenericType", equals: "Array") + let generated = typeAlias.generateCode().normalize() + + #expect(generated.contains("typealias GenericType = Array")) + } + + @Test func testTypeAliasWithProtocolComposition() { + let typeAlias = TypeAlias("ProtocolType", equals: "Protocol1 & Protocol2") + let generated = typeAlias.generateCode().normalize() + + #expect(generated.contains("typealias ProtocolType = Protocol1 & Protocol2")) + } + + @Test func testTypeAliasWithFunctionType() { + let typeAlias = TypeAlias("Handler", equals: "(String) -> Void") + let generated = typeAlias.generateCode().normalize() + + #expect(generated.contains("typealias Handler = (String) -> Void")) + } + + @Test func testTypeAliasWithTupleType() { + let typeAlias = TypeAlias("Coordinate", equals: "(x: Double, y: Double)") + let generated = typeAlias.generateCode().normalize() + + #expect(generated.contains("typealias Coordinate = (x: Double, y: Double)")) + } + + @Test func testTypeAliasWithClosureType() { + let typeAlias = TypeAlias("Callback", equals: "@escaping (Result) -> Void") + let generated = typeAlias.generateCode().normalize() + + #expect(generated.contains("typealias Callback = @escaping (Result) -> Void")) + } + + // MARK: - Integration Tests + + @Test func testTypeAliasWithStaticVariable() { + let extensionDecl = Extension("MyEnum") { + TypeAlias("MappedType", equals: "String") + Variable(.let, name: "mappedValues", equals: ["a", "b", "c"]).static() + }.inherits("MappedValueRepresentable") + + let generated = extensionDecl.generateCode().normalize() + + #expect(generated.contains("extension MyEnum: MappedValueRepresentable")) + #expect(generated.contains("typealias MappedType = String")) + #expect(generated.contains("static let mappedValues: [String] = [\"a\", \"b\", \"c\"]")) + } + + @Test func testTypeAliasWithDictionaryVariable() { + let extensionDecl = Extension("MyEnum") { + TypeAlias("MappedType", equals: "String") + Variable(.let, name: "mappedValues", equals: [1: "a", 2: "b"]).static() + }.inherits("MappedValueRepresentable") + + let generated = extensionDecl.generateCode().normalize() + + #expect(generated.contains("extension MyEnum: MappedValueRepresentable")) + #expect(generated.contains("typealias MappedType = String")) + #expect(generated.contains("static let mappedValues: [Int: String]")) + #expect(generated.contains("1: \"a\"")) + #expect(generated.contains("2: \"b\"")) + } +} diff --git a/Tests/SyntaxKitTests/VariableStaticTests.swift b/Tests/SyntaxKitTests/VariableStaticTests.swift new file mode 100644 index 0000000..38e1936 --- /dev/null +++ b/Tests/SyntaxKitTests/VariableStaticTests.swift @@ -0,0 +1,154 @@ +// +// VariableStaticTests.swift +// SyntaxKitTests +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@testable import SyntaxKit + +struct VariableStaticTests { + // MARK: - Static Variable Tests + + @Test func testStaticVariableWithStringLiteral() { + let variable = Variable(.let, name: "test", type: "String", equals: "hello").static() + let generated = variable.generateCode().normalize() + + #expect(generated.contains("static let test: String = hello")) + } + + @Test func testStaticVariableWithArrayLiteral() { + let array: [String] = ["a", "b", "c"] + let variable = Variable(.let, name: "mappedValues", equals: array).static() + let generated = variable.generateCode().normalize() + + #expect(generated.contains("static let mappedValues: [String] = [\"a\", \"b\", \"c\"]")) + } + + @Test func testStaticVariableWithDictionaryLiteral() { + let dict: [Int: String] = [1: "a", 2: "b", 3: "c"] + let variable = Variable(.let, name: "mappedValues", equals: dict).static() + let generated = variable.generateCode().normalize() + + #expect(generated.contains("static let mappedValues: [Int: String]")) + #expect(generated.contains("1: \"a\"")) + #expect(generated.contains("2: \"b\"")) + #expect(generated.contains("3: \"c\"")) + } + + @Test func testStaticVariableWithVar() { + let variable = Variable(.var, name: "counter", type: "Int", equals: "0").static() + let generated = variable.generateCode().normalize() + + #expect(generated.contains("static var counter: Int = 0")) + } + + // MARK: - Non-Static Variable Tests + + @Test func testNonStaticVariableWithLiteral() { + let array: [String] = ["x", "y", "z"] + let variable = Variable(.let, name: "values", equals: array) + let generated = variable.generateCode().normalize() + + #expect(generated.contains("let values: [String] = [\"x\", \"y\", \"z\"]")) + #expect(!generated.contains("static")) + } + + @Test func testNonStaticVariableWithDictionary() { + let dict: [Int: String] = [10: "ten", 20: "twenty"] + let variable = Variable(.let, name: "lookup", equals: dict) + let generated = variable.generateCode().normalize() + + #expect(generated.contains("let lookup: [Int: String]")) + #expect(generated.contains("10: \"ten\"")) + #expect(generated.contains("20: \"twenty\"")) + #expect(!generated.contains("static")) + } + + // MARK: - Static Method Tests + + @Test func testStaticMethodReturnsNewInstance() { + let original = Variable(.let, name: "test", type: "String", equals: "value") + let staticVersion = original.static() + + // Should be different instances + #expect(original.generateCode() != staticVersion.generateCode()) + + // Original should not be static + let originalGenerated = original.generateCode().normalize() + #expect(!originalGenerated.contains("static")) + + // Static version should be static + let staticGenerated = staticVersion.generateCode().normalize() + #expect(staticGenerated.contains("static")) + } + + @Test func testStaticMethodPreservesOtherProperties() { + let original = Variable(.var, name: "test", type: "String", equals: "value") + let staticVersion = original.static() + + let originalGenerated = original.generateCode().normalize() + let staticGenerated = staticVersion.generateCode().normalize() + + // Both should have the same name and value + #expect(originalGenerated.contains("test")) + #expect(staticGenerated.contains("test")) + #expect(originalGenerated.contains("value")) + #expect(staticGenerated.contains("value")) + + // Both should be var + #expect(originalGenerated.contains("var")) + #expect(staticGenerated.contains("var")) + } + + // MARK: - Edge Cases + + @Test func testEmptyArrayLiteral() { + let array: [String] = [] + let variable = Variable(.let, name: "empty", equals: array).static() + let generated = variable.generateCode().normalize() + + #expect(generated.contains("static let empty: [String] = []")) + } + + @Test func testEmptyDictionaryLiteral() { + let dict: [Int: String] = [:] + let variable = Variable(.let, name: "empty", equals: dict).static() + let generated = variable.generateCode().normalize() + + #expect(generated.contains("static let empty: [Int: String] = []")) + } + + @Test func testMultipleStaticCalls() { + let variable = Variable(.let, name: "test", type: "String", equals: "value").static().static() + let generated = variable.generateCode().normalize() + + // Should still only have one "static" keyword + let staticCount = generated.components(separatedBy: "static").count - 1 + #expect(staticCount == 1) + } +} diff --git a/project.yml b/project.yml index d39d844..959c793 100644 --- a/project.yml +++ b/project.yml @@ -4,6 +4,10 @@ settings: packages: SyntaxKit: path: . + SKSampleMacro: + path: Macros/SKSampleMacro + Options: + path: Macros/Options aggregateTargets: Lint: buildScripts: