diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 398c546..4514bd3 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -14,11 +14,6 @@ jobs: runs-on: macos-latest steps: - - uses: swift-actions/setup-swift@v1 - with: - swift-version: '5.9' - - name: Get swift version - run: swift --version - uses: actions/checkout@v3 - name: Build run: swift build -v diff --git a/Examples/Search/Search/Search.swift b/Examples/Search/Search/Search.swift index 14d0285..eb5af8e 100644 --- a/Examples/Search/Search/Search.swift +++ b/Examples/Search/Search/Search.swift @@ -3,23 +3,23 @@ import Foundation // MARK: - Search feature domain struct Search: Equatable { - - var results: [GeocodingSearch.Result] = [] - var resultForecastRequestInFlight: GeocodingSearch.Result? - var searchQuery = "" - var weather: Weather? - - struct Weather: Equatable { - var id: GeocodingSearch.Result.ID - var days: [Day] - - struct Day: Equatable { - var date: Date - var temperatureMax: Double - var temperatureMaxUnit: String - var temperatureMin: Double - var temperatureMinUnit: String - } - } + var results: [GeocodingSearch.Result] = [] + var resultForecastRequestInFlight: GeocodingSearch.Result? + var searchQuery = "" + var weather: Weather? + + struct Weather: Equatable { + + var id: GeocodingSearch.Result.ID + var days: [Day] + + struct Day: Equatable { + var date: Date + var temperatureMax: Double + var temperatureMaxUnit: String + var temperatureMin: Double + var temperatureMinUnit: String + } + } } diff --git a/Examples/Search/Search/SearchActions.swift b/Examples/Search/Search/SearchActions.swift index 25f1f79..855f825 100644 --- a/Examples/Search/Search/SearchActions.swift +++ b/Examples/Search/Search/SearchActions.swift @@ -4,52 +4,52 @@ import VDStore @Actions extension Store { - func searchQueryChanged(query: String) { - state.searchQuery = query - cancel(Self.searchQueryChangeDebounced) - guard query.isEmpty else { return } - state.results = [] - state.weather = nil - } + func searchQueryChanged(query: String) { + state.searchQuery = query + cancel(Self.searchQueryChangeDebounced) + guard query.isEmpty else { return } + state.results = [] + state.weather = nil + } - func searchQueryChangeDebounced() async { - try? await Task.sleep(nanoseconds: NSEC_PER_SEC / 3) - guard !state.searchQuery.isEmpty, !Task.isCancelled else { - return - } - do { - let response = try await di.weatherClient.search(state.searchQuery) - guard !Task.isCancelled else { return } - state.results = response.results - } catch { - guard !Task.isCancelled, !(error is CancellationError) else { return } - state.results = [] - } - } + func searchQueryChangeDebounced() async { + try? await Task.sleep(nanoseconds: NSEC_PER_SEC / 3) + guard !state.searchQuery.isEmpty, !Task.isCancelled else { + return + } + do { + let response = try await di.weatherClient.search(state.searchQuery) + guard !Task.isCancelled else { return } + state.results = response.results + } catch { + guard !Task.isCancelled, !(error is CancellationError) else { return } + state.results = [] + } + } } @Actions extension Store { - func searchResultTapped(location: GeocodingSearch.Result) async { - state.resultForecastRequestInFlight = location - defer { state.resultForecastRequestInFlight = nil } - do { - let forecast = try await di.weatherClient.forecast(location) - state.weather = State.Weather( - id: location.id, - days: forecast.daily.time.indices.map { - State.Weather.Day( - date: forecast.daily.time[$0], - temperatureMax: forecast.daily.temperatureMax[$0], - temperatureMaxUnit: forecast.dailyUnits.temperatureMax, - temperatureMin: forecast.daily.temperatureMin[$0], - temperatureMinUnit: forecast.dailyUnits.temperatureMin - ) - } - ) - } catch { - state.weather = nil - } - } + func searchResultTapped(location: GeocodingSearch.Result) async { + state.resultForecastRequestInFlight = location + defer { state.resultForecastRequestInFlight = nil } + do { + let forecast = try await di.weatherClient.forecast(location) + state.weather = State.Weather( + id: location.id, + days: forecast.daily.time.indices.map { + State.Weather.Day( + date: forecast.daily.time[$0], + temperatureMax: forecast.daily.temperatureMax[$0], + temperatureMaxUnit: forecast.dailyUnits.temperatureMax, + temperatureMin: forecast.daily.temperatureMin[$0], + temperatureMinUnit: forecast.dailyUnits.temperatureMin + ) + } + ) + } catch { + state.weather = nil + } + } } diff --git a/Examples/Search/Search/SearchApp.swift b/Examples/Search/Search/SearchApp.swift index 8237207..a7e2a78 100644 --- a/Examples/Search/Search/SearchApp.swift +++ b/Examples/Search/Search/SearchApp.swift @@ -7,22 +7,22 @@ struct SearchApp: App { var body: some Scene { WindowGroup { SearchView() - .storeDIValues { - $0.middleware(LoggerMiddleware()) - } + .storeDIValues { + $0.middleware(LoggerMiddleware()) + } } } } struct LoggerMiddleware: StoreMiddleware { - - func execute( - _ args: Args, - context: Store.Action.Context, - dependencies: StoreDIValues, - next: (Args) -> Res - ) -> Res { - print("\(context.actionID) called from \(context.file):\(context.line) \(context.function)") - return next(args) - } + + func execute( + _ args: Args, + context: Store.Action.Context, + dependencies: StoreDIValues, + next: (Args) -> Res + ) -> Res { + print("\(context.actionID) called from \(context.file):\(context.line) \(context.function)") + return next(args) + } } diff --git a/Examples/Search/Search/SearchView.swift b/Examples/Search/Search/SearchView.swift index b10d4eb..87bcf2c 100644 --- a/Examples/Search/Search/SearchView.swift +++ b/Examples/Search/Search/SearchView.swift @@ -14,7 +14,7 @@ struct SearchView: View { @ViewStore var state = Search() var body: some View { - NavigationStack { + NavigationStack { VStack(alignment: .leading) { Text(readMe) .padding() @@ -39,9 +39,9 @@ struct SearchView: View { ForEach(state.results) { location in VStack(alignment: .leading) { Button { - Task { - await $state.searchResultTapped(location: location) - } + Task { + await $state.searchResultTapped(location: location) + } } label: { HStack { Text(location.name) @@ -68,7 +68,7 @@ struct SearchView: View { .navigationTitle("Search") } .task(id: state.searchQuery) { - await $state.searchQueryChangeDebounced() + await $state.searchQueryChangeDebounced() } } diff --git a/Package.swift b/Package.swift index 1555e7e..79b3211 100644 --- a/Package.swift +++ b/Package.swift @@ -18,6 +18,6 @@ let package = Package( ], targets: [ .target(name: "VDStore", dependencies: []), - .testTarget(name: "VDStoreTests", dependencies: ["VDStore"]), + .testTarget(name: "VDStoreTests", dependencies: ["VDStore"]), ] ) diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift index da20e39..ff85ab0 100644 --- a/Package@swift-5.9.swift +++ b/Package@swift-5.9.swift @@ -1,8 +1,8 @@ // swift-tools-version:5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. -import PackageDescription import CompilerPluginSupport +import PackageDescription let package = Package( name: "VDStore", @@ -16,17 +16,17 @@ let package = Package( .library(name: "VDStore", targets: ["VDStore"]), ], dependencies: [ - .package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.2") + .package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.2"), ], targets: [ .target(name: "VDStore", dependencies: ["VDStoreMacros"]), - .macro( - name: "VDStoreMacros", - dependencies: [ - .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), - .product(name: "SwiftCompilerPlugin", package: "swift-syntax") - ] - ), - .testTarget(name: "VDStoreTests", dependencies: ["VDStore"]), + .macro( + name: "VDStoreMacros", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ] + ), + .testTarget(name: "VDStoreTests", dependencies: ["VDStore"]), ] ) diff --git a/README.md b/README.md index 10813bd..a26a1d5 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ import PackageDescription let package = Package( name: "SomeProject", dependencies: [ - .package(url: "https://github.com/dankinsoid/VDStore.git", from: "0.17.4") + .package(url: "https://github.com/dankinsoid/VDStore.git", from: "0.18.0") ], targets: [ .target(name: "SomeProject", dependencies: ["VDStore"]) diff --git a/Sources/VDStore/Store.swift b/Sources/VDStore/Store.swift index 8752d70..48e83ff 100644 --- a/Sources/VDStore/Store.swift +++ b/Sources/VDStore/Store.swift @@ -116,7 +116,7 @@ public struct Store { } /// The publisher that emits before the state is going to be changed. Required by `SwiftUI`. - nonisolated var willSet: AnyPublisher { + nonisolated var willSet: AnyPublisher { box.willSet.eraseToAnyPublisher() } @@ -141,7 +141,7 @@ public struct Store { /// Creates a new `Store` with the initial state. public nonisolated init(_ state: State) { self.init( - box: StoreBox(state), + box: StoreBox(state), di: StoreDIValues() ) } @@ -150,7 +150,7 @@ public struct Store { box: StoreBox, di: StoreDIValues ) { - self.box = box + self.box = box diStorage = di } @@ -187,7 +187,7 @@ public struct Store { set setter: @escaping (inout State, ChildState) -> Void ) -> Store { Store( - box: StoreBox(parent: box, get: getter, set: setter), + box: StoreBox(parent: box, get: getter, set: setter), di: di ) } diff --git a/Sources/VDStore/Utils/StoreBox.swift b/Sources/VDStore/Utils/StoreBox.swift index 8038292..f693a1c 100644 --- a/Sources/VDStore/Utils/StoreBox.swift +++ b/Sources/VDStore/Utils/StoreBox.swift @@ -4,78 +4,79 @@ struct StoreBox: Publisher { typealias Failure = Never - var state: Output { - get { getter() } - nonmutating set { setter(newValue, true) } - } - var isUpdating: Bool { updatesCounter.wrappedValue > 0 } - var willSet: AnyPublisher { publisher(_willSet) } - private let getter: () -> Output - private let setter: (Output, _ sendWillSet: Bool) -> Void - private let _willSet: PassthroughSubject - private let updatesCounter: Ref - private let valuePublisher: AnyPublisher + var state: Output { + get { getter() } + nonmutating set { setter(newValue, true) } + } - init(_ value: Output) { - let willSet = PassthroughSubject() - self._willSet = willSet + var isUpdating: Bool { updatesCounter.wrappedValue > 0 } + var willSet: AnyPublisher { publisher(_willSet) } + private let getter: () -> Output + private let setter: (Output, _ sendWillSet: Bool) -> Void + private let _willSet: PassthroughSubject + private let updatesCounter: Ref + private let valuePublisher: AnyPublisher - let valuePublisher = CurrentValueSubject(value) - getter = { valuePublisher.value } - setter = { value, sendWillSet in - if sendWillSet { - willSet.send() - } - valuePublisher.send(value) - } - self.valuePublisher = valuePublisher.eraseToAnyPublisher() + init(_ value: Output) { + let willSet = PassthroughSubject() + _willSet = willSet - var updatesCounter: UInt = 0 - self.updatesCounter = Ref { - updatesCounter - } set: { - updatesCounter = $0 - } - } + let valuePublisher = CurrentValueSubject(value) + getter = { valuePublisher.value } + setter = { value, sendWillSet in + if sendWillSet { + willSet.send() + } + valuePublisher.send(value) + } + self.valuePublisher = valuePublisher.eraseToAnyPublisher() - init( - parent: StoreBox, - get: @escaping (T) -> Output, - set: @escaping (inout T, Output) -> Void - ) { - valuePublisher = parent.valuePublisher.map(get).eraseToAnyPublisher() - _willSet = parent._willSet - updatesCounter = parent.updatesCounter - getter = { get(parent.getter()) } - setter = { - var state = parent.getter() - set(&state, $0) - parent.setter(state, $1) - } - } + var updatesCounter: UInt = 0 + self.updatesCounter = Ref { + updatesCounter + } set: { + updatesCounter = $0 + } + } - func beforeUpdate() { - if updatesCounter.wrappedValue == 0 { - _willSet.send() - } - updatesCounter.wrappedValue &+= 1 - } + init( + parent: StoreBox, + get: @escaping (T) -> Output, + set: @escaping (inout T, Output) -> Void + ) { + valuePublisher = parent.valuePublisher.map(get).eraseToAnyPublisher() + _willSet = parent._willSet + updatesCounter = parent.updatesCounter + getter = { get(parent.getter()) } + setter = { + var state = parent.getter() + set(&state, $0) + parent.setter(state, $1) + } + } - func afterUpdate() { - updatesCounter.wrappedValue &-= 1 - if updatesCounter.wrappedValue == 0 { - setter(getter(), false) - } - } + func beforeUpdate() { + if updatesCounter.wrappedValue == 0 { + _willSet.send() + } + updatesCounter.wrappedValue &+= 1 + } + + func afterUpdate() { + updatesCounter.wrappedValue &-= 1 + if updatesCounter.wrappedValue == 0 { + setter(getter(), false) + } + } func receive(subscriber: S) where S: Subscriber, Never == S.Failure, Output == S.Input { - publisher(valuePublisher).receive(subscriber: subscriber) + publisher(valuePublisher).receive(subscriber: subscriber) } - private func publisher(_ publisher: P) -> AnyPublisher { - publisher.filter { [updatesCounter] _ in - updatesCounter.wrappedValue == 0 - } - .eraseToAnyPublisher() - } + private func publisher(_ publisher: P) -> AnyPublisher { + publisher.filter { [updatesCounter] _ in + updatesCounter.wrappedValue == 0 + } + .eraseToAnyPublisher() + } } diff --git a/Sources/VDStore/Utils/StorePublisher.swift b/Sources/VDStore/Utils/StorePublisher.swift index ef81fa8..f6d602c 100644 --- a/Sources/VDStore/Utils/StorePublisher.swift +++ b/Sources/VDStore/Utils/StorePublisher.swift @@ -1,31 +1,31 @@ -import Foundation import Combine +import Foundation /// A publisher of store state. @dynamicMemberLookup public struct StorePublisher: Publisher { - - public typealias Output = State - public typealias Failure = Never - - let upstream: AnyPublisher - - public func receive(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input { - upstream.receive(subscriber: subscriber) - } - - /// Returns the resulting publisher of a given key path. - public subscript( - dynamicMember keyPath: KeyPath - ) -> StorePublisher { - StorePublisher(upstream: upstream.map(keyPath).removeDuplicates().eraseToAnyPublisher()) - } - /// Returns the resulting publisher of a given key path. - @_disfavoredOverload - public subscript( - dynamicMember keyPath: KeyPath - ) -> StorePublisher { - StorePublisher(upstream: upstream.map(keyPath).eraseToAnyPublisher()) - } + public typealias Output = State + public typealias Failure = Never + + let upstream: AnyPublisher + + public func receive(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input { + upstream.receive(subscriber: subscriber) + } + + /// Returns the resulting publisher of a given key path. + public subscript( + dynamicMember keyPath: KeyPath + ) -> StorePublisher { + StorePublisher(upstream: upstream.map(keyPath).removeDuplicates().eraseToAnyPublisher()) + } + + /// Returns the resulting publisher of a given key path. + @_disfavoredOverload + public subscript( + dynamicMember keyPath: KeyPath + ) -> StorePublisher { + StorePublisher(upstream: upstream.map(keyPath).eraseToAnyPublisher()) + } } diff --git a/Sources/VDStoreMacros/ActionsMacro.swift b/Sources/VDStoreMacros/ActionsMacro.swift index 4b7898d..5293cce 100644 --- a/Sources/VDStoreMacros/ActionsMacro.swift +++ b/Sources/VDStoreMacros/ActionsMacro.swift @@ -8,209 +8,209 @@ import SwiftSyntaxMacroExpansion import SwiftSyntaxMacros public struct ActionsMacro: MemberAttributeMacro, MemberMacro { - - public static func expansion( - of node: AttributeSyntax, - providingMembersOf declaration: some DeclGroupSyntax, - in context: some MacroExpansionContext - ) throws -> [DeclSyntax] { - guard let extensionDecl = declaration.as(ExtensionDeclSyntax.self) else { - throw CustomError("@Actions only works on Store extension") - } - var result: [DeclSyntax] = [] - for member in extensionDecl.memberBlock.members { - if let function = member.decl.as(FunctionDeclSyntax.self) { - result += try VDStoreMacros.expansion(of: node, funcDecl: function, in: context) - } else { - throw CustomError("\(type(of: member))") - } - } - return result - } - - public static func expansion( - of node: AttributeSyntax, - attachedTo declaration: some DeclGroupSyntax, - providingAttributesFor member: some DeclSyntaxProtocol, - in context: some MacroExpansionContext - ) throws -> [AttributeSyntax] { - guard let extensionDecl = declaration.as(ExtensionDeclSyntax.self) else { - throw CustomError("@Actions only works on Store extension") - } - let type = extensionDecl.extendedType.trimmed.description - guard type.hasPrefix("Store<") && type.hasSuffix(">") else { - throw CustomError("@Actions only works on Store extension") - } - return ["@_disfavoredOverload"] - } + + public static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + guard let extensionDecl = declaration.as(ExtensionDeclSyntax.self) else { + throw CustomError("@Actions only works on Store extension") + } + var result: [DeclSyntax] = [] + for member in extensionDecl.memberBlock.members { + if let function = member.decl.as(FunctionDeclSyntax.self) { + result += try VDStoreMacros.expansion(of: node, funcDecl: function, in: context) + } else { + throw CustomError("\(type(of: member))") + } + } + return result + } + + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingAttributesFor member: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [AttributeSyntax] { + guard let extensionDecl = declaration.as(ExtensionDeclSyntax.self) else { + throw CustomError("@Actions only works on Store extension") + } + let type = extensionDecl.extendedType.trimmed.description + guard type.hasPrefix("Store<"), type.hasSuffix(">") else { + throw CustomError("@Actions only works on Store extension") + } + return ["@_disfavoredOverload"] + } } public struct ActionMacro: PeerMacro { - public static func expansion( - of node: AttributeSyntax, - providingPeersOf declaration: some DeclSyntaxProtocol, - in context: some MacroExpansionContext - ) throws -> [DeclSyntax] { - guard let funcDecl = declaration.as(FunctionDeclSyntax.self) else { - throw CustomError("@Action only works on functions") - } - return try VDStoreMacros.expansion(of: node, funcDecl: funcDecl, in: context) - } + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + guard let funcDecl = declaration.as(FunctionDeclSyntax.self) else { + throw CustomError("@Action only works on functions") + } + return try VDStoreMacros.expansion(of: node, funcDecl: funcDecl, in: context) + } } private func expansion( - of node: AttributeSyntax, - funcDecl: FunctionDeclSyntax, - in context: some MacroExpansionContext - ) throws -> [DeclSyntax] { - guard !funcDecl.modifiers.contains(where: { $0.trimmed.description == "private" }) else { - throw CustomError("Action functions must not be private") - } - - let isAsync = funcDecl.signature.effectSpecifiers?.asyncSpecifier != nil - let isThrows = funcDecl.signature.effectSpecifiers?.throwsSpecifier != nil - - let callPrefix = switch (isAsync, isThrows) { - case (true, true): "try await " - case (true, false): "await " - case (false, true): "try " - default: "" - } - - let callSuffix = switch (isAsync, isThrows) { - case (true, true): " async throws" - case (true, false): " async" - case (false, true): " throws" - default: "" - } - - let argsCount = funcDecl.signature.parameterClause.parameters.count - let args = funcDecl.signature.parameterClause.parameters.enumerated().map { - "$0\(argsCount > 1 ? ".\($0.offset)" : "")" - } - .joined(separator: ", ") - - let resultType = funcDecl.signature.returnClause?.type.trimmed.description ?? "Void" - var types = funcDecl.signature.parameterClause.parameters.map { - $0.type.description - }.joined(separator: ", ") - - let actionBody = """ - { store in - return {\(types.isEmpty ? " _ in" : "") - let action: @MainActor (\(types))\(callSuffix) -> \(resultType) = store.\(funcDecl.name.text) - return \(callPrefix)action(\(args)) - } - } - """ - if argsCount != 1 { - types = "(\(types))" - } - - var varType = "Action<\(types), \(resultType)>" - switch (isAsync, isThrows) { - case (true, true): varType += ".AsyncThrows" - case (true, false): varType += ".Async" - case (false, true): varType += ".Throws" - default: break - } - - let lineNumber = context.location(of: funcDecl)?.line.description ?? "#line" - let staticVarDecl = try VariableDeclSyntax(""" - static var \(raw: funcDecl.name.text): \(raw: varType) { - Action( - id: StoreActionID(name: "\(raw: funcDecl.name.text)", fileID: #fileID, line: \(raw: lineNumber)), - action: \(raw: actionBody) - ) - } - """) - - var executeDecl = funcDecl - executeDecl.remove(attribute: "Action") - executeDecl.remove(attribute: "_disfavoredOverload") -// executeDecl.modifiers.remove(at: privateIndex) - var parameterList = executeDecl.signature.parameterClause.parameters.map { - FunctionParameterSyntax( - leadingTrivia: .newline, - attributes: $0.attributes, - modifiers: $0.modifiers, - firstName: $0.firstName, - secondName: $0.secondName, - colon: .colonToken(trailingTrivia: .space), - type: $0.type, - ellipsis: $0.ellipsis, - defaultValue: $0.defaultValue, - trailingComma: .commaToken(), - trailingTrivia: nil - ) - } - executeDecl.signature.parameterClause.rightParen.leadingTrivia = .newline - - func parameter( - name: inout String, - type: TypeSyntax, - value: ExprSyntax - ) throws -> FunctionParameterSyntax? { - if let sameName = parameterList.first(where: { $0.defaultValue?.value.description == value.description }) { - name = (sameName.secondName ?? sameName.firstName).text - if sameName.type.trimmed.description != type.description { - throw CustomError("Use \(type) for \(value)") - } - return nil - } - - while parameterList.contains(where: { $0.firstName.text == name }) { - name = "_\(name)" - } - return FunctionParameterSyntax( - leadingTrivia: .newline, - firstName: .identifier(name), - colon: .colonToken(trailingTrivia: .space), - type: type, - defaultValue: InitializerClauseSyntax( - equal: .equalToken(leadingTrivia: .space, trailingTrivia: .space), - value: value - ), - trailingComma: .commaToken() - ) - } - - var file = "fileID" - var line = "line" - var function = "function" - let fileParam = try parameter(name: &file, type: "String", value: "#fileID") - let lineParam = try parameter(name: &line, type: "UInt", value: "#line") - let functionParam = try parameter(name: &function, type: "String", value: "#function") - - parameterList += [fileParam, lineParam, functionParam].compactMap { $0 } - - if var lastParam = parameterList.last { - // We need to remove a trailing comma from the last argument. - parameterList.removeLast() - lastParam.trailingComma = nil - parameterList.append(lastParam) - } - - var callArguments = executeDecl.signature.parameterClause.parameters.map { param in - (param.secondName ?? param.firstName).text - } - .joined(separator: ", ") - if executeDecl.signature.parameterClause.parameters.count != 1 { - callArguments = "(\(callArguments))" - } - - executeDecl.signature.parameterClause.parameters = FunctionParameterListSyntax(parameterList) - let body = CodeBlockItemSyntax(""" -\(raw: callPrefix)execute( - Self.\(raw: funcDecl.name.text), - with: \(raw: callArguments), - file: \(raw: file), - line: \(raw: line), - from: \(raw: function) -) -""") - executeDecl.body = CodeBlockSyntax(statements: [body]) - return [DeclSyntax(staticVarDecl), DeclSyntax(executeDecl)] - } + of node: AttributeSyntax, + funcDecl: FunctionDeclSyntax, + in context: some MacroExpansionContext +) throws -> [DeclSyntax] { + guard !funcDecl.modifiers.contains(where: { $0.trimmed.description == "private" }) else { + throw CustomError("Action functions must not be private") + } + + let isAsync = funcDecl.signature.effectSpecifiers?.asyncSpecifier != nil + let isThrows = funcDecl.signature.effectSpecifiers?.throwsSpecifier != nil + + let callPrefix = switch (isAsync, isThrows) { + case (true, true): "try await " + case (true, false): "await " + case (false, true): "try " + default: "" + } + + let callSuffix = switch (isAsync, isThrows) { + case (true, true): " async throws" + case (true, false): " async" + case (false, true): " throws" + default: "" + } + + let argsCount = funcDecl.signature.parameterClause.parameters.count + let args = funcDecl.signature.parameterClause.parameters.enumerated().map { + "$0\(argsCount > 1 ? ".\($0.offset)" : "")" + } + .joined(separator: ", ") + + let resultType = funcDecl.signature.returnClause?.type.trimmed.description ?? "Void" + var types = funcDecl.signature.parameterClause.parameters.map { + $0.type.description + }.joined(separator: ", ") + + let actionBody = """ + { store in + return {\(types.isEmpty ? " _ in" : "") + let action: @MainActor (\(types))\(callSuffix) -> \(resultType) = store.\(funcDecl.name.text) + return \(callPrefix)action(\(args)) + } + } + """ + if argsCount != 1 { + types = "(\(types))" + } + + var varType = "Action<\(types), \(resultType)>" + switch (isAsync, isThrows) { + case (true, true): varType += ".AsyncThrows" + case (true, false): varType += ".Async" + case (false, true): varType += ".Throws" + default: break + } + + let lineNumber = context.location(of: funcDecl)?.line.description ?? "#line" + let staticVarDecl = try VariableDeclSyntax(""" + static var \(raw: funcDecl.name.text): \(raw: varType) { + Action( + id: StoreActionID(name: "\(raw: funcDecl.name.text)", fileID: #fileID, line: \(raw: lineNumber)), + action: \(raw: actionBody) + ) + } + """) + + var executeDecl = funcDecl + executeDecl.remove(attribute: "Action") + executeDecl.remove(attribute: "_disfavoredOverload") + // executeDecl.modifiers.remove(at: privateIndex) + var parameterList = executeDecl.signature.parameterClause.parameters.map { + FunctionParameterSyntax( + leadingTrivia: .newline, + attributes: $0.attributes, + modifiers: $0.modifiers, + firstName: $0.firstName, + secondName: $0.secondName, + colon: .colonToken(trailingTrivia: .space), + type: $0.type, + ellipsis: $0.ellipsis, + defaultValue: $0.defaultValue, + trailingComma: .commaToken(), + trailingTrivia: nil + ) + } + executeDecl.signature.parameterClause.rightParen.leadingTrivia = .newline + + func parameter( + name: inout String, + type: TypeSyntax, + value: ExprSyntax + ) throws -> FunctionParameterSyntax? { + if let sameName = parameterList.first(where: { $0.defaultValue?.value.description == value.description }) { + name = (sameName.secondName ?? sameName.firstName).text + if sameName.type.trimmed.description != type.description { + throw CustomError("Use \(type) for \(value)") + } + return nil + } + + while parameterList.contains(where: { $0.firstName.text == name }) { + name = "_\(name)" + } + return FunctionParameterSyntax( + leadingTrivia: .newline, + firstName: .identifier(name), + colon: .colonToken(trailingTrivia: .space), + type: type, + defaultValue: InitializerClauseSyntax( + equal: .equalToken(leadingTrivia: .space, trailingTrivia: .space), + value: value + ), + trailingComma: .commaToken() + ) + } + + var file = "fileID" + var line = "line" + var function = "function" + let fileParam = try parameter(name: &file, type: "String", value: "#fileID") + let lineParam = try parameter(name: &line, type: "UInt", value: "#line") + let functionParam = try parameter(name: &function, type: "String", value: "#function") + + parameterList += [fileParam, lineParam, functionParam].compactMap { $0 } + + if var lastParam = parameterList.last { + // We need to remove a trailing comma from the last argument. + parameterList.removeLast() + lastParam.trailingComma = nil + parameterList.append(lastParam) + } + + var callArguments = executeDecl.signature.parameterClause.parameters.map { param in + (param.secondName ?? param.firstName).text + } + .joined(separator: ", ") + if executeDecl.signature.parameterClause.parameters.count != 1 { + callArguments = "(\(callArguments))" + } + + executeDecl.signature.parameterClause.parameters = FunctionParameterListSyntax(parameterList) + let body = CodeBlockItemSyntax(""" + \(raw: callPrefix)execute( + Self.\(raw: funcDecl.name.text), + with: \(raw: callArguments), + file: \(raw: file), + line: \(raw: line), + from: \(raw: function) + ) + """) + executeDecl.body = CodeBlockSyntax(statements: [body]) + return [DeclSyntax(staticVarDecl), DeclSyntax(executeDecl)] +} #endif diff --git a/Sources/VDStoreMacros/CustomError.swift b/Sources/VDStoreMacros/CustomError.swift index ecf9340..c52570d 100644 --- a/Sources/VDStoreMacros/CustomError.swift +++ b/Sources/VDStoreMacros/CustomError.swift @@ -1,12 +1,12 @@ import Foundation struct CustomError: LocalizedError, CustomStringConvertible { - - var errorDescription: String - var localizedDescription: String { errorDescription } - var description: String { errorDescription } - - init(_ errorDescription: String) { - self.errorDescription = errorDescription - } + + var errorDescription: String + var localizedDescription: String { errorDescription } + var description: String { errorDescription } + + init(_ errorDescription: String) { + self.errorDescription = errorDescription + } } diff --git a/Sources/VDStoreMacros/Extensions.swift b/Sources/VDStoreMacros/Extensions.swift index 8781499..c0640c0 100644 --- a/Sources/VDStoreMacros/Extensions.swift +++ b/Sources/VDStoreMacros/Extensions.swift @@ -9,24 +9,24 @@ import SwiftSyntaxMacros extension SyntaxCollection { - mutating func removeLast() { - self.remove(at: self.index(before: self.endIndex)) - } + mutating func removeLast() { + remove(at: index(before: endIndex)) + } } extension FunctionDeclSyntax { - - mutating func remove(attribute: String) { - if let i = attributes.firstIndex(where: { $0.as(AttributeSyntax.self)?.attributeName.description == attribute }) { - attributes.remove(at: i) - } - } + + mutating func remove(attribute: String) { + if let i = attributes.firstIndex(where: { $0.as(AttributeSyntax.self)?.attributeName.description == attribute }) { + attributes.remove(at: i) + } + } } extension MacroExpansionContext { - - func diagnose(_ type: DiagnosticSeverity = .error, node: SyntaxProtocol, _ message: String) { - diagnose(Diagnostic(node: Syntax(node), message: Feedback(type, message))) - } + + func diagnose(_ type: DiagnosticSeverity = .error, node: SyntaxProtocol, _ message: String) { + diagnose(Diagnostic(node: Syntax(node), message: Feedback(type, message))) + } } #endif diff --git a/Sources/VDStoreMacros/Feedback.swift b/Sources/VDStoreMacros/Feedback.swift index 71eb697..f35b944 100644 --- a/Sources/VDStoreMacros/Feedback.swift +++ b/Sources/VDStoreMacros/Feedback.swift @@ -1,23 +1,23 @@ #if canImport(SwiftCompilerPlugin) -import SwiftSyntax import SwiftDiagnostics +import SwiftSyntax struct Feedback: DiagnosticMessage { - static let noDefaultArgument = Feedback(.error, "No default value provided.") - static let missingAnnotation = Feedback(.error, "No annotation provided.") - static let notAnIdentifier = Feedback(.error, "Identifier is not valid.") + static let noDefaultArgument = Feedback(.error, "No default value provided.") + static let missingAnnotation = Feedback(.error, "No annotation provided.") + static let notAnIdentifier = Feedback(.error, "Identifier is not valid.") + + var message: String + var severity: DiagnosticSeverity + + init(_ severity: DiagnosticSeverity, _ message: String) { + self.severity = severity + self.message = message + } - var message: String - var severity: DiagnosticSeverity - - init(_ severity: DiagnosticSeverity, _ message: String) { - self.severity = severity - self.message = message - } - - var diagnosticID: MessageID { - MessageID(domain: "VDStoreMacros", id: message) - } + var diagnosticID: MessageID { + MessageID(domain: "VDStoreMacros", id: message) + } } #endif diff --git a/Sources/VDStoreMacros/StoreDependenciesMacros.swift b/Sources/VDStoreMacros/StoreDependenciesMacros.swift index e1a598e..037560b 100644 --- a/Sources/VDStoreMacros/StoreDependenciesMacros.swift +++ b/Sources/VDStoreMacros/StoreDependenciesMacros.swift @@ -1,67 +1,67 @@ #if canImport(SwiftCompilerPlugin) +import SwiftDiagnostics import SwiftSyntax import SwiftSyntaxMacros -import SwiftDiagnostics public struct StoreDIValueMacro: AccessorMacro { - public static func expansion( - of node: AttributeSyntax, - providingAccessorsOf declaration: some DeclSyntaxProtocol, - in context: some MacroExpansionContext - ) throws -> [AccessorDeclSyntax] { - - // Skip declarations other than variables - guard let varDecl = declaration.as(VariableDeclSyntax.self) else { - throw CustomError("@StoreDIValue only works on variables") - } - - guard var binding = varDecl.bindings.first?.as(PatternBindingSyntax.self)else { - throw CustomError("No annotation provided.") - } - - guard let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text else { - throw CustomError("Identifier is not valid.") - } - - binding.pattern = PatternSyntax(IdentifierPatternSyntax(identifier: .identifier("defaultValue"))) - - let isOptionalType = binding.typeAnnotation?.type.is(OptionalTypeSyntax.self) ?? false - let hasDefaultValue = binding.initializer != nil - - guard isOptionalType || hasDefaultValue else { - throw CustomError("No default value provided.") - } - - let defaultValue = binding.initializer?.value.trimmed.description ?? "nil" - - return [ - """ - get { self[\\.\(raw: identifier).self] ?? \(raw: defaultValue) } - """, - """ - set { self[\\.\(raw: identifier).self] = newValue } - """ - ] - } + public static func expansion( + of node: AttributeSyntax, + providingAccessorsOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [AccessorDeclSyntax] { + + // Skip declarations other than variables + guard let varDecl = declaration.as(VariableDeclSyntax.self) else { + throw CustomError("@StoreDIValue only works on variables") + } + + guard var binding = varDecl.bindings.first?.as(PatternBindingSyntax.self) else { + throw CustomError("No annotation provided.") + } + + guard let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text else { + throw CustomError("Identifier is not valid.") + } + + binding.pattern = PatternSyntax(IdentifierPatternSyntax(identifier: .identifier("defaultValue"))) + + let isOptionalType = binding.typeAnnotation?.type.is(OptionalTypeSyntax.self) ?? false + let hasDefaultValue = binding.initializer != nil + + guard isOptionalType || hasDefaultValue else { + throw CustomError("No default value provided.") + } + + let defaultValue = binding.initializer?.value.trimmed.description ?? "nil" + + return [ + """ + get { self[\\.\(raw: identifier).self] ?? \(raw: defaultValue) } + """, + """ + set { self[\\.\(raw: identifier).self] = newValue } + """, + ] + } } public struct StoreDIValuesMacro: MemberAttributeMacro { - public static func expansion( - of node: AttributeSyntax, - attachedTo declaration: some DeclGroupSyntax, - providingAttributesFor member: some DeclSyntaxProtocol, - in context: some MacroExpansionContext - ) throws -> [AttributeSyntax] { - - // Only attach macro if member is a variable. - // Otherwise, it will also get attached to the structs generated by @EnvironmentValue - guard member.is(VariableDeclSyntax.self) else { - return [] - } + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingAttributesFor member: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [AttributeSyntax] { + + // Only attach macro if member is a variable. + // Otherwise, it will also get attached to the structs generated by @EnvironmentValue + guard member.is(VariableDeclSyntax.self) else { + return [] + } - return ["@StoreDIValue"] - } + return ["@StoreDIValue"] + } } #endif diff --git a/Sources/VDStoreMacros/VDStoreMacrosPlugin.swift b/Sources/VDStoreMacros/VDStoreMacrosPlugin.swift index 0efa722..190daa4 100644 --- a/Sources/VDStoreMacros/VDStoreMacrosPlugin.swift +++ b/Sources/VDStoreMacros/VDStoreMacrosPlugin.swift @@ -9,11 +9,11 @@ import SwiftSyntaxMacros @main struct VDStoreMacrosPlugin: CompilerPlugin { - - let providingMacros: [Macro.Type] = [ - ActionsMacro.self, - StoreDIValueMacro.self, - StoreDIValuesMacro.self - ] + + let providingMacros: [Macro.Type] = [ + ActionsMacro.self, + StoreDIValueMacro.self, + StoreDIValuesMacro.self, + ] } #endif diff --git a/Tests/VDStoreTests/VDStoreTests.swift b/Tests/VDStoreTests/VDStoreTests.swift index 96098d5..747bc0d 100644 --- a/Tests/VDStoreTests/VDStoreTests.swift +++ b/Tests/VDStoreTests/VDStoreTests.swift @@ -4,118 +4,118 @@ import XCTest @MainActor final class VDStoreTests: XCTestCase { - - /// Test that initializing a Store with a given state sets the initial state correctly. - func testInitialState() { - let initialCounter = Counter(counter: 10) - let store = Store(initialCounter) - XCTAssertEqual(store.state.counter, 10) - } - - /// Test that a state mutation updates the state as expected. - func testStateMutation() { - let store = Store(Counter()) - store.add() - - XCTAssertEqual(store.state.counter, 1) - } - - /// Test dependency injection, ensuring that a service or di is correctly injected into a Store. - func testDependencyInjection() { - let service: SomeService = MockSomeService() - let store = Store(Counter()).di(\.someService, service) - - XCTAssert(store.di.someService === service) - } - - /// Test that scoped stores correctly inherit dependencies from their parent. - func testScopedStoreInheritsDependencies() { - let service: SomeService = MockSomeService() - let parentStore = Store(Counter()).di(\.someService, service) - let childStore = parentStore.scope(\.counter) - XCTAssert(childStore.di.someService === service) - } - - /// Test that a Store can use a mock di correctly. - func testMockDIValue() { - let mockService = MockSomeService() - let store = Store(Counter()).di(\.someService, mockService) - - XCTAssert(store.di.someService is MockSomeService) - } - - /// Test that state mutations are thread-safe. - func testThreadSafety() async { - let store = Store(Counter()) - let isMainThread = await Task.detached { - await store.check { - Thread.isMainThread - } - }.value - XCTAssertEqual(isMainThread, true) - } - - func testTasksCancel() async { - let store = Store(Counter()) - let id = "id" - let value = await store.task(id: id) { - for i in 0..<10 { - guard !Task.isCancelled else { return i } - if i == 5 { - await store.cancel(id: id) - } - } - return 10 - } - .value - XCTAssertEqual(value, 6) - } - -#if swift(>=5.9) - /// Test that the publisher property of a Store sends updates when the state changes. - func testPublisherUpdates() async { - let initialCounter = Counter(counter: 0) - let store = Store(initialCounter) - let expectation = expectation(description: "State updated") - var bag = Set() - - store.publisher.sink { newState in - if newState.counter == 1 { - expectation.fulfill() - } - } - .store(in: &bag) - - store.add() - await fulfillment(of: [expectation], timeout: 0.1) - } - - func testTasksMacroCancel() async { - let store = Store(Counter()) - let value = await store.asyncTask() - XCTAssertEqual(value, 6) - } - - func testNumberOfUpdates() async { - let store = Store(Counter()) - let publisher = store.publisher - var count = 0 - let expectation = self.expectation(description: "Counter") - let cancellable = publisher - .sink { i in - count += 1 - if i.counter == 10 { - expectation.fulfill() - } - } - cancellable.store(in: &store.di.cancellableSet) - for _ in 0..<10 { - store.add() - } - await fulfillment(of: [expectation], timeout: 0.1) - XCTAssertEqual(count, 2) - } -#endif + + /// Test that initializing a Store with a given state sets the initial state correctly. + func testInitialState() { + let initialCounter = Counter(counter: 10) + let store = Store(initialCounter) + XCTAssertEqual(store.state.counter, 10) + } + + /// Test that a state mutation updates the state as expected. + func testStateMutation() { + let store = Store(Counter()) + store.add() + + XCTAssertEqual(store.state.counter, 1) + } + + /// Test dependency injection, ensuring that a service or di is correctly injected into a Store. + func testDependencyInjection() { + let service: SomeService = MockSomeService() + let store = Store(Counter()).di(\.someService, service) + + XCTAssert(store.di.someService === service) + } + + /// Test that scoped stores correctly inherit dependencies from their parent. + func testScopedStoreInheritsDependencies() { + let service: SomeService = MockSomeService() + let parentStore = Store(Counter()).di(\.someService, service) + let childStore = parentStore.scope(\.counter) + XCTAssert(childStore.di.someService === service) + } + + /// Test that a Store can use a mock di correctly. + func testMockDIValue() { + let mockService = MockSomeService() + let store = Store(Counter()).di(\.someService, mockService) + + XCTAssert(store.di.someService is MockSomeService) + } + + /// Test that state mutations are thread-safe. + func testThreadSafety() async { + let store = Store(Counter()) + let isMainThread = await Task.detached { + await store.check { + Thread.isMainThread + } + }.value + XCTAssertEqual(isMainThread, true) + } + + func testTasksCancel() async { + let store = Store(Counter()) + let id = "id" + let value = await store.task(id: id) { + for i in 0 ..< 10 { + guard !Task.isCancelled else { return i } + if i == 5 { + await store.cancel(id: id) + } + } + return 10 + } + .value + XCTAssertEqual(value, 6) + } + + #if swift(>=5.9) + /// Test that the publisher property of a Store sends updates when the state changes. + func testPublisherUpdates() async { + let initialCounter = Counter(counter: 0) + let store = Store(initialCounter) + let expectation = expectation(description: "State updated") + var bag = Set() + + store.publisher.sink { newState in + if newState.counter == 1 { + expectation.fulfill() + } + } + .store(in: &bag) + + store.add() + await fulfillment(of: [expectation], timeout: 0.1) + } + + func testTasksMacroCancel() async { + let store = Store(Counter()) + let value = await store.asyncTask() + XCTAssertEqual(value, 6) + } + + func testNumberOfUpdates() async { + let store = Store(Counter()) + let publisher = store.publisher + var count = 0 + let expectation = expectation(description: "Counter") + let cancellable = publisher + .sink { i in + count += 1 + if i.counter == 10 { + expectation.fulfill() + } + } + cancellable.store(in: &store.di.cancellableSet) + for _ in 0 ..< 10 { + store.add() + } + await fulfillment(of: [expectation], timeout: 0.1) + XCTAssertEqual(count, 2) + } + #endif } struct Counter: Equatable { @@ -126,7 +126,7 @@ struct Counter: Equatable { extension Store { func add() { - state.counter += 1 + state.counter += 1 } func check(_ operation: () -> T) -> T { @@ -138,15 +138,15 @@ extension Store { @Actions extension Store { - func asyncTask() async -> Int { - for i in 0..<10 { - guard !Task.isCancelled else { return i } - if i == 5 { - cancel(Self.asyncTask) - } - } - return 10 - } + func asyncTask() async -> Int { + for i in 0 ..< 10 { + guard !Task.isCancelled else { return i } + if i == 5 { + cancel(Self.asyncTask) + } + } + return 10 + } } #endif @@ -158,7 +158,7 @@ class MockSomeService: SomeService {} extension StoreDIValues { var someService: SomeService { - get { self[\.someService] ?? MockSomeService() } - set { self[\.someService] = newValue } + get { self[\.someService] ?? MockSomeService() } + set { self[\.someService] = newValue } } }