diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9cdb480 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI +on: [push, pull_request] + +jobs: + test: + strategy: + matrix: + os: [macos-latest, ubuntu-latest] + runs-on: ${{matrix.os}} + steps: + - uses: actions/checkout@v2 + - name: Tests + run: swift test --enable-code-coverage + - name: codecov + run: | + os=$(echo "${{ matrix.os }}" | cut -f1 -d"-") + + if [ "$os" = "ubuntu" ]; then + sudo ln -s /usr/lib/llvm-9/bin/llvm-cov /usr/local/bin/llvm-cov + object=".build/debug/ComposePackageTests.xctest" + elif [ "$os" = "macos" ]; then + sudo ln -s /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/llvm-cov /usr/local/bin/llvm-cov + object=".build/debug/ComposePackageTests.xctest/Contents/MacOS/ComposePackageTests" + fi + + llvm-cov export -format="lcov" --object "$object" -instr-profile .build/debug/codecov/default.profdata > info.lcov + bash <(curl https://codecov.io/bash) -cF "$os" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc71f21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,141 @@ + +# Created by https://www.gitignore.io/api/xcode,macos,swift,swiftpm,swiftpackagemanager +# Edit at https://www.gitignore.io/?templates=xcode,macos,swift,swiftpm,swiftpackagemanager + +### 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 + +### Swift ### +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## Build generated +build/ +DerivedData/ + +## Various settings +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata/ + +## Other +*.moved-aside +*.xccheckout +*.xcscmblueprint + +## Obj-C/Swift specific +*.hmap +*.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 +.build/ +# Add this line if you want to avoid checking in Xcode SPM integration. +.swiftpm/xcode + +# 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/ + +### SwiftPackageManager ### +Packages +xcuserdata +*.xcodeproj + + +### SwiftPM ### + + +### Xcode ### +# Xcode +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) + +## Xcode Patch +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcworkspace/contents.xcworkspacedata +/*.gcno + +### Xcode Patch ### +**/xcshareddata/WorkspaceSettings.xcsettings + +# End of https://www.gitignore.io/api/xcode,macos,swift,swiftpm,swiftpackagemanager \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6c0ca6a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Andrés Cecilia Luque + +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/Package.swift b/Package.swift new file mode 100644 index 0000000..2c1a0bb --- /dev/null +++ b/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version:5.0 + +import PackageDescription + +let package = Package( + name: "Compose", + products: [ + .library(name: "Compose", targets: ["Compose"]), + ], + dependencies: [], + targets: [ + .target( + name: "Compose", + dependencies: [] + ), + .testTarget( + name: "ComposeTests", + dependencies: ["Compose"] + ), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..fe6e4c1 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# Compose + +[![CI](https://github.com/acecilia/Compose/workflows/CI/badge.svg?branch=master)](https://github.com/acecilia/Compose/actions) +[![CI](https://codecov.io/gh/acecilia/Compose/branch/master/graph/badge.svg)](https://codecov.io/github/acecilia/Compose) + +## What is this? + +A Swift library for composing structs from other structs, which relies on the `KeyPath` and `@dynamicMemberLookup` features to provide a clean and typesafe API: + +```swift +import Compose + +struct Developer: Codable, Hashable { + var name: String + var age: Int +} + +struct RemoteLocation: Codable, Hashable { + var country: String + var city: String +} + +typealias RemoteDeveloper = Compose + +let remoteDeveloper = RemoteDeveloper( + .init(name: "Andres", age: 26), + .init(country: "Spain", city: "Madrid") +) +print(remoteDeveloper.name) // Andres +print(remoteDeveloper.city) // Madrid +``` + +For an in depth explanation please [follow this link](https://www.sforswift.com/posts/composition-using-keypath-and-dynamic-member-lookup/). + +## Installation + +### Swift Package Manager + +Add the following to the dependencies inside your `Package.swift` file: + +```swift +.package(url: "https://github.com/acecilia/Compose.git", .upToNextMajor(from: "0.0.1")), +``` + +## License + +Compose is licensed under the MIT license. See [LICENSE](LICENSE) for more info. diff --git a/Sources/Compose/Compose.swift b/Sources/Compose/Compose.swift new file mode 100644 index 0000000..208b7b6 --- /dev/null +++ b/Sources/Compose/Compose.swift @@ -0,0 +1,76 @@ +import Foundation + +@dynamicMemberLookup +public struct Compose { + public var element1: Element1 + public var element2: Element2 + + public subscript(dynamicMember keyPath: WritableKeyPath) -> T { + get { element1[keyPath: keyPath] } + set { element1[keyPath: keyPath] = newValue } + } + + public subscript(dynamicMember keyPath: WritableKeyPath) -> T { + get { element2[keyPath: keyPath] } + set { element2[keyPath: keyPath] = newValue } + } + + public init(_ element1: Element1, _ element2: Element2) { + self.element1 = element1 + self.element2 = element2 + } +} + +// MARK - Conformances + +extension Compose: Encodable where Element1: Encodable, Element2: Encodable { + public func encode(to encoder: Encoder) throws { + try element1.encode(to: encoder) + try element2.encode(to: encoder) + } +} + +extension Compose: Decodable where Element1: Decodable, Element2: Decodable { + public init(from decoder: Decoder) throws { + self.element1 = try Element1(from: decoder) + self.element2 = try Element2(from: decoder) + } +} + +extension Compose: Equatable where Element1: Equatable, Element2: Equatable { } + +extension Compose: Hashable where Element1: Hashable, Element2: Hashable { } + +extension Compose: Error where Element1: Error, Element2: Error { } + +extension Compose: LocalizedError where Element1: LocalizedError, Element2: LocalizedError { + public var errorDescription: String? { + [element1.errorDescription, element2.errorDescription].filterAndJoin() + } + public var failureReason: String? { + [element1.failureReason, element2.failureReason].filterAndJoin() + } + public var recoverySuggestion: String? { + [element1.recoverySuggestion, element2.recoverySuggestion].filterAndJoin() + } + public var helpAnchor: String? { + [element1.helpAnchor, element2.helpAnchor].filterAndJoin() + } +} + +private extension Array where Element == String? { + func filterAndJoin() -> String { + return compactMap { $0 }.joined(separator: "\n") + } +} + +// MARK - typealiases for composition with multiple types + +public typealias Compose3 = + Compose> + +public typealias Compose4 = + Compose, Compose> + +public typealias Compose5 = + Compose, Compose>> diff --git a/Tests/ComposeTests/ComposeMultipleTests.swift b/Tests/ComposeTests/ComposeMultipleTests.swift new file mode 100644 index 0000000..553dd7e --- /dev/null +++ b/Tests/ComposeTests/ComposeMultipleTests.swift @@ -0,0 +1,47 @@ +import XCTest +import Foundation +import Compose + +final class ComposeMultipleTests: XCTestCase { + func testGetSet() { + let expectedName = "Kike" + let expectedCity = "Segovia" + let expectedTeam = "Android" + + var mutable = remoteTeamLead + mutable.name = expectedName + mutable.city = expectedCity + mutable.team = expectedTeam + + XCTAssertEqual(mutable.name, expectedName) + XCTAssertEqual(mutable.city, expectedCity) + XCTAssertEqual(mutable.team, expectedTeam) + } + + func testCodable() throws { + guard #available(OSX 10.13, *) else { + XCTFail() + return + } + + let remoteTeamLeadJson = """ + { + "age" : 26, + "city" : "Madrid", + "country" : "Spain", + "name" : "Andres", + "salary" : 1000000, + "team" : "iOS" + } + """ + + let decoder = JSONDecoder() + let remoteDeveloper = try decoder.decode(RemoteTeamLead.self, from: Data(remoteTeamLeadJson.utf8)) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(remoteDeveloper) + + XCTAssertEqual(remoteTeamLeadJson, String(data: data, encoding: .utf8)) + } +} diff --git a/Tests/ComposeTests/ComposeTests.swift b/Tests/ComposeTests/ComposeTests.swift new file mode 100644 index 0000000..04308b7 --- /dev/null +++ b/Tests/ComposeTests/ComposeTests.swift @@ -0,0 +1,64 @@ +import XCTest +import Foundation +import Compose + +final class ComposeTests: XCTestCase { + func testGetSet() { + let expectedName = "Kike" + let expectedCity = "Segovia" + + var mutable = remoteDeveloper + mutable.name = expectedName + mutable.city = expectedCity + + XCTAssertEqual(mutable.name, expectedName) + XCTAssertEqual(mutable.city, expectedCity) + } + + func testHashable() { + let dict = [remoteDeveloper: "Handsome dude"] + XCTAssertEqual(remoteDeveloper, dict.keys.first) + } + + func testEquatable() { + let remoteDeveloperCopy = remoteDeveloper + XCTAssertEqual(remoteDeveloper, remoteDeveloperCopy) + } + + func testLocalizedError() { + let expected = """ + noInternet + elementNotFound + """ + + XCTAssertEqual(appError.localizedDescription, expected) + XCTAssertEqual(appError.failureReason, expected) + XCTAssertEqual(appError.recoverySuggestion, expected) + XCTAssertEqual(appError.helpAnchor, expected) + } + + func testCodable() throws { + guard #available(OSX 10.13, *) else { + XCTFail() + return + } + + let remoteDeveloperJson = """ + { + "age" : 26, + "city" : "Madrid", + "country" : "Spain", + "name" : "Andres" + } + """ + + let decoder = JSONDecoder() + let remoteDeveloper = try decoder.decode(RemoteDeveloper.self, from: Data(remoteDeveloperJson.utf8)) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(remoteDeveloper) + + XCTAssertEqual(remoteDeveloperJson, String(data: data, encoding: .utf8)) + } +} diff --git a/Tests/ComposeTests/TestStructs.swift b/Tests/ComposeTests/TestStructs.swift new file mode 100644 index 0000000..6883885 --- /dev/null +++ b/Tests/ComposeTests/TestStructs.swift @@ -0,0 +1,45 @@ +import Foundation +import Compose + +struct Developer: Codable, Hashable { + var name: String + var age: Int +} + +struct RemoteLocation: Codable, Hashable { + var country: String + var city: String +} + +struct TeamLead: Codable, Hashable { + var team: String + var salary: Int +} + +typealias RemoteDeveloper = Compose +typealias RemoteTeamLead = Compose3 + +let remoteDeveloper = RemoteDeveloper(.init(name: "Andres", age: 26), .init(country: "Spain", city: "Madrid")) +let remoteTeamLead = RemoteTeamLead(.init(team: "iOS", salary: 1000000), remoteDeveloper) + +enum NetworkError: String, LocalizedError { + case noInternet + + var errorDescription: String? { rawValue } + var failureReason: String? { rawValue } + var recoverySuggestion: String? { rawValue } + var helpAnchor: String? { rawValue } +} + +enum DatabaseError: String, LocalizedError { + case elementNotFound + + var errorDescription: String? { rawValue } + var failureReason: String? { rawValue } + var recoverySuggestion: String? { rawValue } + var helpAnchor: String? { rawValue } +} + +typealias AppError = Compose + +let appError = AppError(.noInternet, .elementNotFound) diff --git a/Tests/ComposeTests/XCTestManifests.swift b/Tests/ComposeTests/XCTestManifests.swift new file mode 100644 index 0000000..6fa74b4 --- /dev/null +++ b/Tests/ComposeTests/XCTestManifests.swift @@ -0,0 +1,32 @@ +#if !canImport(ObjectiveC) +import XCTest + +extension ComposeMultipleTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__ComposeMultipleTests = [ + ("testCodable", testCodable), + ("testGetSet", testGetSet), + ] +} + +extension ComposeTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__ComposeTests = [ + ("testCodable", testCodable), + ("testEquatable", testEquatable), + ("testGetSet", testGetSet), + ("testHashable", testHashable), + ] +} + +public func __allTests() -> [XCTestCaseEntry] { + return [ + testCase(ComposeMultipleTests.__allTests__ComposeMultipleTests), + testCase(ComposeTests.__allTests__ComposeTests), + ] +} +#endif diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift new file mode 100644 index 0000000..4cc6066 --- /dev/null +++ b/Tests/LinuxMain.swift @@ -0,0 +1,8 @@ +import XCTest + +import ComposeTests + +var tests = [XCTestCaseEntry]() +tests += ComposeTests.__allTests() + +XCTMain(tests) diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..9a4da8b --- /dev/null +++ b/codecov.yml @@ -0,0 +1,2 @@ +ignore: +- Tests \ No newline at end of file