From 8e2e98e96221b70fec668109719c866de9e54584 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Sun, 20 Sep 2020 20:04:06 +0300 Subject: [PATCH] Initial commit --- .gitignore | 5 + .../contents.xcworkspacedata | 7 + LICENSE | 21 +++ Package.swift | 67 +++++++++ README.md | 136 ++++++++++++++++++ .../DeclarativeConfiguration/Exports.swift | 5 + Sources/FunctionalBuilder/Builder.swift | 126 ++++++++++++++++ .../FunctionalBuilder/BuilderProvider.swift | 8 ++ .../FunctionalConfigurator/Configurator.swift | 131 +++++++++++++++++ .../FunctionalKeyPath/FunctionalKeyPath.swift | 90 ++++++++++++ .../FunctionalModification/Modification.swift | 13 ++ .../BuilderTests.swift | 59 ++++++++ .../ConfiguratorTests.swift | 31 ++++ .../ModificationTests.swift | 50 +++++++ 14 files changed, 749 insertions(+) create mode 100644 .gitignore create mode 100644 .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata create mode 100644 LICENSE create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/DeclarativeConfiguration/Exports.swift create mode 100644 Sources/FunctionalBuilder/Builder.swift create mode 100644 Sources/FunctionalBuilder/BuilderProvider.swift create mode 100644 Sources/FunctionalConfigurator/Configurator.swift create mode 100644 Sources/FunctionalKeyPath/FunctionalKeyPath.swift create mode 100644 Sources/FunctionalModification/Modification.swift create mode 100644 Tests/FunctionalConfigurationTests/BuilderTests.swift create mode 100644 Tests/FunctionalConfigurationTests/ConfiguratorTests.swift create mode 100644 Tests/FunctionalConfigurationTests/ModificationTests.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95c4320 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..83c6f76 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Maxim Krouk + +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..1b1f28b --- /dev/null +++ b/Package.swift @@ -0,0 +1,67 @@ +// swift-tools-version:5.3 + +import PackageDescription + +let package = Package( + name: "swift-declarative-configuration", + products: [ + .library( + name: "DeclarativeConfiguration", + targets: ["DeclarativeConfiguration"] + ), + .library( + name: "FunctionalBuilder", + targets: ["FunctionalBuilder"] + ), + .library( + name: "FunctionalConfigurator", + targets: ["FunctionalConfigurator"] + ), + .library( + name: "FunctionalKeyPath", + targets: ["FunctionalKeyPath"] + ), + .library( + name: "FunctionalModification", + targets: ["FunctionalModification"] + ), + ], + dependencies: [ + .package(url: "https://github.com/pointfreeco/swift-case-paths.git", from: "0.1.2") + ], + targets: [ + .target( + name: "DeclarativeConfiguration", + dependencies: [ + .target(name: "FunctionalBuilder"), + .target(name: "FunctionalConfigurator"), + .target(name: "FunctionalKeyPath"), + .target(name: "FunctionalModification"), + .product(name: "CasePaths", package: "swift-case-paths") + ] + ), + .target( + name: "FunctionalBuilder", + dependencies: [ + .target(name: "FunctionalConfigurator"), + .target(name: "FunctionalKeyPath"), + .target(name: "FunctionalModification") + ] + ), + .target( + name: "FunctionalConfigurator", + dependencies: [ + .target(name: "FunctionalKeyPath"), + .target(name: "FunctionalModification") + ] + ), + .target(name: "FunctionalKeyPath"), + .target(name: "FunctionalModification"), + .testTarget( + name: "FunctionalConfigurationTests", + dependencies: [ + .target(name: "FunctionalBuilder") + ] + ), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..47c4a5f --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +# Swift Declarative Configuration + +[![Swift 5.3](https://img.shields.io/badge/swift-5.3-ED523F.svg?style=flat)](https://swift.org/download/) [![SwiftPM](https://img.shields.io/badge/SwiftPM-ED523F.svg?style=flat)](https://swift.org/package-manager/) [![@maximkrouk](https://img.shields.io/badge/contact-@maximkrouk-ED523F.svg?style=flat)](https://twitter.com/maximkrouk) + +Swift Declarative Configuration (SDC, for short) is a tiny library, that enables you to configure your objects in a declarative, consistent and understandable way, with ergonomics in mind. It can be used to configure any objects on any platform, including server-side-swift. + +## Products + +- **[FunctionalModification](./Sources/FunctionalModification)** + + Provides modification functions for copying and modifying immutable stuff. It is useful for self-configuring objects like builder, when modificating methods should return modified `self` + +- **[FunctionalKeyPath](./Sources/FunctionalKeyPath)** & **[CasePaths](https://github.com/pointfreeco/swift-case-paths)** + + KeyPath functional wrappers, one is generalized and the other is for enums. [CasePath is a dependency](https://github.com/pointfreeco/swift-case-paths). + +- **[FunctionalConfigurator](./Sources/FunctionalConfigurator)** + + Funtional configurator for anything, enables you to specify modification of an object and to apply the modification later. + +- **[FunctionalBuilder](./Sources/FunctionalBuilder)** + + Functional builder for anything, enables you to modify object instances in a declarative way. Also contains BuilderProvider protocol with a computed `builder` property and implements that protocol on NSObject type. + +- **[DeclarativeConfiguration](./Sources/DeclarativeConfiguration)** + + Wraps and exports all the products. + +## Basic Usage + +### UIKit & FunctionalConfigurator + +Maybe it worth to make another abstraction over configurator for UI setup, but for example I'll be using pure version. + +```swift +import FunctionalConfigurator + +class ImageViewController: UIViewController { + enum StyleSheet { + static let imageView = Configurator + .contentMode(.scaleAspectFit) + .backgroundColor(.black) + .layer.masksToBounds(true) + .layer.cornerRadius(10) + } + + let imageView: UIImageView = .init() + + override func loadView() { + self.view = imageView + } + + override func viewDidLoad() { + super.viewDidLoad() + StyleSheet.imageView.configure(imageView) + } +} +``` + +### UIKit & FunctionalBuilder +```swift +import FunctionalBuilder + +class ImageViewController: UIViewController { + let imageView = UIImageView().builder + .contentMode(.scaleAspectFit) + .backgroundColor(.black) + .layer.masksToBounds(true) + .layer.cornerRadius(10) + .build() + + override func loadView() { + self.view = imageView + } +} +``` + +### Modification + +```swift +import FunctionalModification + +struct MyModel { + var value1 = 0 + init() {} +} + +let model_0 = MyModel() +let model_1 = modification(of: model_0) { $0.value = 1 } + +import UIKit + +extension UIView { + @discardableResult + func cornerRadius(_ value: CGFloat) -> Self { + modification(of: self) { view in + view.layer.cornerRadius = value + view.layer.masksToBounds = true + } + } +} +``` + +## Installation + +### Basic + +You can add DeclarativeConfiguration to an Xcode project by adding it as a package dependency. + +1. From the **File** menu, select **Swift Packages › Add Package Dependency…** +2. Enter "https://github.com/makeupstudio/swift-declarative-configuration" into the package repository URL text field +3. Choose products you need to link them to your project. + +### Recommended + +If you use SwiftPM for your project, you can add DeclarativeConfiguration to your package file. Also my advice will be to use SSH. + +```swift +.package( + url: "git@github.com:makeupstudio/swift-declarative-configuration.git", + from: "0.0.1" +) +``` + +Do not forget about target dependencies: + +```swift +.product( + name: "DeclarativeConfiguration", + package: "swift-declarative-configuration" +) +``` + +## License + +This library is released under the MIT license. See [LICENSE](./LICENSE) for details. \ No newline at end of file diff --git a/Sources/DeclarativeConfiguration/Exports.swift b/Sources/DeclarativeConfiguration/Exports.swift new file mode 100644 index 0000000..60ce949 --- /dev/null +++ b/Sources/DeclarativeConfiguration/Exports.swift @@ -0,0 +1,5 @@ +@_exported import FunctionalBuilder +@_exported import FunctionalConfigurator +@_exported import FunctionalKeyPath +@_exported import FunctionalModification +@_exported import CasePaths diff --git a/Sources/FunctionalBuilder/Builder.swift b/Sources/FunctionalBuilder/Builder.swift new file mode 100644 index 0000000..eadbf48 --- /dev/null +++ b/Sources/FunctionalBuilder/Builder.swift @@ -0,0 +1,126 @@ +import FunctionalConfigurator +import FunctionalKeyPath + +@dynamicMemberLookup +public struct Builder { + private var _initialValue: () -> Base + private var _configurator: Configurator + + public func build() -> Base { _configurator.configure(_initialValue()) } + + @inlinable + public func apply() where Base: AnyObject { _ = build() } + + @inlinable + public func reinforce(_ transform: @escaping (inout Base) -> Void) -> Builder { + Builder(build()).set(transform) + } + + public init(_ initialValue: @escaping @autoclosure () -> Base) { + self.init( + initialValue, + Configurator() + ) + } + + private init( + _ initialValue: @escaping () -> Base, + _ configurator: Configurator + ) { + _initialValue = initialValue + _configurator = configurator + } + + public func set( + _ transform: @escaping (inout Base) -> Void + ) -> Builder { + Builder( + _initialValue, + _configurator.set(transform) + ) + } + + public subscript( + dynamicMember keyPath: WritableKeyPath + ) -> CallableBlock { + .init( + builder: self, + keyPath: .init(keyPath) + ) + } + + public subscript( + dynamicMember keyPath: KeyPath + ) -> NonCallableBlock where Base: AnyObject, Value: AnyObject { + .init( + builder: self, + keyPath: .getonly(keyPath) + ) + } + +} + +extension Builder { + @dynamicMemberLookup + public struct CallableBlock { + private var _block: NonCallableBlock + + init( + builder: Builder, + keyPath: FunctionalKeyPath + ) { + self._block = .init( + builder: builder, + keyPath: keyPath + ) + } + + public func callAsFunction(_ value: @escaping @autoclosure () -> Value) -> Builder { + Builder( + _block.builder._initialValue, + _block.builder._configurator.appendingConfiguration { base in + _block.keyPath.embed(value(), in: base) + } + ) + } + + public subscript( + dynamicMember keyPath: WritableKeyPath + ) -> CallableBlock { + .init( + builder: _block.builder, + keyPath: _block.keyPath.appending(path: .init(keyPath)) + ) + } + + public subscript( + dynamicMember keyPath: KeyPath + ) -> NonCallableBlock where Value: AnyObject, LocalValue: AnyObject { + _block[dynamicMember: keyPath] + } + } + + @dynamicMemberLookup + public struct NonCallableBlock { + var builder: Builder + var keyPath: FunctionalKeyPath + + public subscript( + dynamicMember keyPath: WritableKeyPath + ) -> CallableBlock where Value: AnyObject { + .init( + builder: self.builder, + keyPath: self.keyPath.appending(path: .init(keyPath)) + ) + } + + public subscript( + dynamicMember keyPath: KeyPath + ) -> NonCallableBlock where Value: AnyObject, LocalValue: AnyObject { + .init( + builder: self.builder, + keyPath: self.keyPath.appending(path: .getonly(keyPath)) + ) + } + } +} diff --git a/Sources/FunctionalBuilder/BuilderProvider.swift b/Sources/FunctionalBuilder/BuilderProvider.swift new file mode 100644 index 0000000..38e20d1 --- /dev/null +++ b/Sources/FunctionalBuilder/BuilderProvider.swift @@ -0,0 +1,8 @@ +import Foundation + +public protocol BuilderProvider {} +extension BuilderProvider { + public var builder: Builder { .init(self) } +} + +extension NSObject: BuilderProvider {} diff --git a/Sources/FunctionalConfigurator/Configurator.swift b/Sources/FunctionalConfigurator/Configurator.swift new file mode 100644 index 0000000..bd29385 --- /dev/null +++ b/Sources/FunctionalConfigurator/Configurator.swift @@ -0,0 +1,131 @@ +import FunctionalKeyPath +import FunctionalModification + +@dynamicMemberLookup +public struct Configurator { + private var _configure: (Base) -> Base + public init() { _configure = { $0 } } + + @discardableResult + public func configure(_ base: Base) -> Base { + _configure(base) + } + + public func configure(_ base: Base) where Base: AnyObject { + _ = _configure(base) + } + + public func set(_ transform: @escaping (inout Base) -> Void) -> Configurator { + appendingConfiguration { base in + modification(of: _configure(base), with: transform) + } + } + + @inlinable + public func appending(_ configurator: Configurator) -> Configurator { + appendingConfiguration(configurator.configure) + } + + public func appendingConfiguration(_ configuration: @escaping (Base) -> Base) -> Configurator { + modification(of: self) { _self in + _self._configure = { configuration(_configure($0)) } + } + } + + public subscript( + dynamicMember keyPath: WritableKeyPath + ) -> CallableBlock { + .init( + configurator: self, + keyPath: .init(keyPath) + ) + } + + public subscript( + dynamicMember keyPath: KeyPath + ) -> NonCallableBlock where Base: AnyObject { + .init( + configurator: self, + keyPath: .getonly(keyPath) + ) + } + + public static subscript( + dynamicMember keyPath: WritableKeyPath + ) -> CallableBlock { + .init( + configurator: .init(), + keyPath: .init(keyPath) + ) + } + + public static subscript( + dynamicMember keyPath: KeyPath + ) -> NonCallableBlock where Base: AnyObject, Value: AnyObject { + .init( + configurator: .init(), + keyPath: .getonly(keyPath) + ) + } + +} + +extension Configurator { + @dynamicMemberLookup + public struct CallableBlock { + private var _block: NonCallableBlock + + init( + configurator: Configurator, + keyPath: FunctionalKeyPath + ) { + self._block = .init( + configurator: configurator, + keyPath: keyPath + ) + } + + public func callAsFunction(_ value: Value) -> Configurator { + _block.configurator.appendingConfiguration { _block.keyPath.embed(value, in: $0) } + } + + public subscript( + dynamicMember keyPath: WritableKeyPath + ) -> CallableBlock { + .init( + configurator: _block.configurator, + keyPath: _block.keyPath.appending(path: .init(keyPath)) + ) + } + + public subscript( + dynamicMember keyPath: KeyPath + ) -> NonCallableBlock where Value: AnyObject, LocalValue: AnyObject { + _block[dynamicMember: keyPath] + } + } + + @dynamicMemberLookup + public struct NonCallableBlock { + var configurator: Configurator + var keyPath: FunctionalKeyPath + + public subscript( + dynamicMember keyPath: WritableKeyPath + ) -> CallableBlock where Value: AnyObject { + .init( + configurator: self.configurator, + keyPath: self.keyPath.appending(path: .init(keyPath)) + ) + } + + public subscript( + dynamicMember keyPath: KeyPath + ) -> NonCallableBlock where Value: AnyObject, LocalValue: AnyObject { + .init( + configurator: self.configurator, + keyPath: self.keyPath.appending(path: .getonly(keyPath)) + ) + } + } +} diff --git a/Sources/FunctionalKeyPath/FunctionalKeyPath.swift b/Sources/FunctionalKeyPath/FunctionalKeyPath.swift new file mode 100644 index 0000000..6049c1d --- /dev/null +++ b/Sources/FunctionalKeyPath/FunctionalKeyPath.swift @@ -0,0 +1,90 @@ +// Source: https://gist.github.com/maximkrouk/6287fb56321a21e8180d5fe044e642e4 + +/// A path that supports embedding a value in a root and attempting to extract a root's embedded +/// value. +/// +/// This type defines key path-like semantics for enum cases. +public struct FunctionalKeyPath { + private let _embed: (Value, Root) -> Root + private let _extract: (Root) -> Value + + /// Creates a case path with a pair of functions. + /// + /// - Parameters: + /// - embed: A function that always succeeds in embedding a value in a root. + /// - extract: A function that can optionally fail in extracting a value from a root. + public init(embed: @escaping (Value, Root) -> Root, extract: @escaping (Root) -> Value) { + self._embed = embed + self._extract = extract + } + + /// Creates a case path with a writableKeyPath + @inlinable + public init(_ keyPath: WritableKeyPath) { + self.init( + embed: { value, root in + var root = root + root[keyPath: keyPath] = value + return root + }, extract: { root in + root[keyPath: keyPath] + } + ) + } + + /// Creates a case path with a keyPath + /// + /// Ignores embed function call + @inlinable + public static func getonly(_ keyPath: KeyPath) -> FunctionalKeyPath { + .init(embed: { value, root in + return root + }, extract: { root in + root[keyPath: keyPath] + }) + } + + /// Returns a root by embedding a value. + /// + /// Note: Value will not be embed if FunctionalKeyPath was initialized by default (non-writable) `KeyPath` via `getonly` function + /// + /// - Parameter value: A value to embed. + /// - Returns: A root that embeds `value`. + public func embed(_ value: Value, in root: Root) -> Root { + self._embed(value, root) + } + + /// Attempts to extract a value from a root. + /// + /// - Parameter root: A root to extract from. + /// - Returns: A value iff it can be extracted from the given root, otherwise `nil`. + public func extract(from root: Root) -> Value { + self._extract(root) + } + + /// Returns a new case path created by appending the given case path to this one. + /// + /// Use this method to extend this case path to the value type of another case path. + /// + /// - Parameter path: The case path to append. + /// - Returns: A case path from the root of this case path to the value type of `path`. + @inlinable + public func appending(path: FunctionalKeyPath) -> FunctionalKeyPath { + FunctionalKeyPath( + embed: { appendedValue, root in + self.embed( + path.embed( + appendedValue, + in: self.extract(from: root) + ), + in: root + ) + }, + extract: { root in + path.extract( + from: self.extract(from: root) + ) + } + ) + } +} diff --git a/Sources/FunctionalModification/Modification.swift b/Sources/FunctionalModification/Modification.swift new file mode 100644 index 0000000..0a337c7 --- /dev/null +++ b/Sources/FunctionalModification/Modification.swift @@ -0,0 +1,13 @@ +/// Modifies an object. +/// +/// Returns a new instance for value types +/// Returns modified reference for reference types +@inlinable +public func modification( + of object: Object, + with transform: (inout Object) -> Void +) -> Object { + var _object = object + transform(&_object) + return _object +} diff --git a/Tests/FunctionalConfigurationTests/BuilderTests.swift b/Tests/FunctionalConfigurationTests/BuilderTests.swift new file mode 100644 index 0000000..44b0ac9 --- /dev/null +++ b/Tests/FunctionalConfigurationTests/BuilderTests.swift @@ -0,0 +1,59 @@ +import XCTest +@testable import FunctionalBuilder + +final class BuilderTests: XCTestCase { + func testBuilder() { + struct TestBuildable: Equatable { + struct Wrapped: Equatable { + var value = 0 + } + + var value = false + var wrapped = Wrapped() + } + + let expected: TestBuildable = { + var test = TestBuildable() + test.value = true + test.wrapped.value = 1 + return test + }() + + let actual = Builder(TestBuildable()) + .wrapped.value(1) + .value(true) + .build() + + XCTAssertNotEqual(actual, TestBuildable()) + XCTAssertEqual(actual, expected) + } + + func testReinforce() { + struct TestBuildable: Equatable { + struct Wrapped: Equatable { + var value = 0 + } + + var value = false + var wrapped = Wrapped() + } + + let expected: TestBuildable = { + var test = TestBuildable() + test.wrapped.value = 1 + return test + }() + + var flag = false + + _ = Builder(TestBuildable()) + .wrapped.value(1) + .reinforce { actual in + flag = true + XCTAssertNotEqual(actual, TestBuildable()) + XCTAssertEqual(actual, expected) + } + + XCTAssertEqual(flag, false, "Reinforce transform wasn't called") + } +} diff --git a/Tests/FunctionalConfigurationTests/ConfiguratorTests.swift b/Tests/FunctionalConfigurationTests/ConfiguratorTests.swift new file mode 100644 index 0000000..3b27906 --- /dev/null +++ b/Tests/FunctionalConfigurationTests/ConfiguratorTests.swift @@ -0,0 +1,31 @@ +import XCTest +@testable import FunctionalConfigurator + +final class ConfiguratorTests: XCTestCase { + func testConfiguration() { + struct TestConfigurable: Equatable { + struct Wrapped: Equatable { + var value = 0 + } + + var value = false + var wrapped = Wrapped() + } + + let wrappedConfiguator = Configurator + .wrapped.value(1) + + let valueConfigurator = Configurator + .value(true) + + let configurator = wrappedConfiguator + .appending(valueConfigurator) + + let initial = TestConfigurable() + let expected = TestConfigurable(value: true, wrapped: .init(value: 1)) + let actual = configurator.configure(initial) + + XCTAssertNotEqual(actual, initial) + XCTAssertEqual(actual, expected) + } +} diff --git a/Tests/FunctionalConfigurationTests/ModificationTests.swift b/Tests/FunctionalConfigurationTests/ModificationTests.swift new file mode 100644 index 0000000..d5b1d1f --- /dev/null +++ b/Tests/FunctionalConfigurationTests/ModificationTests.swift @@ -0,0 +1,50 @@ +import XCTest +@testable import FunctionalModification + +final class ModificationTests: XCTestCase { + func testValueModificationForValueTypes() { + struct _Test { var value = 0 } + let initial = _Test(value: 0) + let expected = _Test(value: 1) + let actual = modification(of: initial) { $0.value = expected.value } + XCTAssertNotEqual(initial.value, expected.value) + XCTAssertEqual(actual.value, expected.value) + } + + func testInstanceModificationForValueTypes() { + struct _Test { var value = 0 } + let initial = _Test(value: 0) + let expected = _Test(value: 1) + let actual = modification(of: initial) { $0 = expected } + XCTAssertNotEqual(initial.value, expected.value) + XCTAssertEqual(actual.value, expected.value) + } + + func testValueModificationForReferenceTypes() { + class _Test { var value = 0 } + let initial = _Test() + let expected: _Test = { + let test = _Test() + test.value = 1 + return test + }() + + let actual = modification(of: initial) { $0.value = expected.value } + XCTAssertEqual(ObjectIdentifier(actual), ObjectIdentifier(initial)) + XCTAssertEqual(actual.value, expected.value) + } + + func testInstanceModificationForReferenceTypes() { + class _Test { var value = 0 } + let initial = _Test() + let expected: _Test = { + let test = _Test() + test.value = 1 + return test + }() + + let actual = modification(of: initial) { $0 = expected } + XCTAssertNotEqual(ObjectIdentifier(initial), ObjectIdentifier(expected)) + XCTAssertEqual(ObjectIdentifier(actual), ObjectIdentifier(expected)) + } +}