diff --git a/README.md b/README.md index 82b0c65..d7e355a 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ import PackageDescription let package = Package( name: "SomeProject", dependencies: [ - .package(url: "https://github.com/dankinsoid/VDFlow.git", from: "4.21.0") + .package(url: "https://github.com/dankinsoid/VDFlow.git", from: "4.22.0") ], targets: [ .target(name: "SomeProject", dependencies: ["VDFlow"]) diff --git a/Sources/VDFlow/MutateID.swift b/Sources/VDFlow/MutateID.swift index 9783bd0..2f16727 100644 --- a/Sources/VDFlow/MutateID.swift +++ b/Sources/VDFlow/MutateID.swift @@ -2,29 +2,28 @@ import Foundation public struct MutateID: Comparable, Hashable, Codable, Sendable { - var mutationDate: UInt64? + var mutationDate: UInt64? - public init() { + public init() {} + + public init(from decoder: Decoder) throws { + let date = try UInt64(from: decoder) + mutationDate = date == 0 ? nil : date } - - public init(from decoder: Decoder) throws { - let date = try UInt64(from: decoder) - mutationDate = date == 0 ? nil : date - } - public func encode(to encoder: Encoder) throws { - try (mutationDate ?? 0).encode(to: encoder) - } + public func encode(to encoder: Encoder) throws { + try (mutationDate ?? 0).encode(to: encoder) + } public mutating func _update() { - mutationDate = DispatchTime.now().uptimeNanoseconds + mutationDate = DispatchTime.now().uptimeNanoseconds } public static func < (lhs: MutateID, rhs: MutateID) -> Bool { (lhs.mutationDate ?? 0) < (rhs.mutationDate ?? 0) } - - var optional: MutateID? { - mutationDate.map { _ in self } - } + + var optional: MutateID? { + mutationDate.map { _ in self } + } } diff --git a/Sources/VDFlow/NavigationSteps.swift b/Sources/VDFlow/NavigationSteps.swift index 7fb337a..1f2ebb2 100644 --- a/Sources/VDFlow/NavigationSteps.swift +++ b/Sources/VDFlow/NavigationSteps.swift @@ -10,7 +10,7 @@ import SwiftUI /// case phone, smsCode, emailAndPassword, emailCode /// } /// -///@State private var currentAuthStep: AuthStep = .phone +/// @State private var currentAuthStep: AuthStep = .phone /// ``` /// Append a tag to each of content views using the `stepTag(_:)` or `step(_:)` view modifiers so that the type of each selection matches the type of the bound state variable. /// ```swift @@ -47,302 +47,302 @@ import SwiftUI /// ``` @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) public struct NavigationSteps: View { - - let content: Content - @StateOrBinding var selection: Selection? - @State private var pop: PopAction = EnvironmentValues.NavigationPopKey.defaultValue - - public init(selection: Binding, @ViewBuilder content: () -> Content) { - self.content = content() - self._selection = .binding(selection) - } - - public var body: some View { - _VariadicView.Tree(Root(base: self)) { - content - } - .environment(\.pop, pop) - } - - private struct Root: _VariadicView.UnaryViewRoot { - - let base: NavigationSteps - - func body(children: _VariadicView.Children) -> some View { - NavigationStackWrapper( - selection: base.$selection, - popAction: base.$pop, - children: children - ) - } - } + + let content: Content + @StateOrBinding var selection: Selection? + @State private var pop: PopAction = EnvironmentValues.NavigationPopKey.defaultValue + + public init(selection: Binding, @ViewBuilder content: () -> Content) { + self.content = content() + _selection = .binding(selection) + } + + public var body: some View { + _VariadicView.Tree(Root(base: self)) { + content + } + .environment(\.pop, pop) + } + + private struct Root: _VariadicView.UnaryViewRoot { + + let base: NavigationSteps + + func body(children: _VariadicView.Children) -> some View { + NavigationStackWrapper( + selection: base.$selection, + popAction: base.$pop, + children: children + ) + } + } } @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) public extension NavigationSteps { - init(@ViewBuilder content: () -> Content) { - self._selection = StateOrBinding(wrappedValue: nil) - self.content = content() - } + init(@ViewBuilder content: () -> Content) { + _selection = StateOrBinding(wrappedValue: nil) + self.content = content() + } } @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) public extension NavigationSteps where Selection == Int { - - init(selection: Binding, @ViewBuilder content: () -> Content) { - self.init( - selection: Binding { - selection.wrappedValue - } set: { - selection.wrappedValue = $0 ?? 0 - }, - content: content - ) - } + + init(selection: Binding, @ViewBuilder content: () -> Content) { + self.init( + selection: Binding { + selection.wrappedValue + } set: { + selection.wrappedValue = $0 ?? 0 + }, + content: content + ) + } } private struct StepTag: _ViewTraitKey { - - static var defaultValue: AnyHashable = Optional.none + + static var defaultValue: AnyHashable = Optional.none } public extension _VariadicView.Children.Element { - - var stepTag: AnyHashable { - self[StepTag.self] - } + + var stepTag: AnyHashable { + self[StepTag.self] + } } public extension View { - func stepTag(_ value: Value) -> some View { - _trait(StepTag.self, value) - } + func stepTag(_ value: Value) -> some View { + _trait(StepTag.self, value) + } } @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) private struct NavigationStackWrapper: View { - - @Binding var selection: Selection? - @Binding var popAction: PopAction - let children: _VariadicView.Children - - var body: some View { - NavigationStack( - path: Binding { - guard let selectedIndex, selectedIndex > 0 else { return NavigationPath() } - return NavigationPath( - (1...selectedIndex).compactMap { - tag(of: children[$0], $0) - } - ) - } set: { path in - guard path.count < children.count else { return } - let i = path.count - if let tag = tag(of: children[i], i) { - selection = tag - } else if path.isEmpty { - selection = nil - } - } - ) { - if !children.isEmpty { - children[0] - .navigationDestination(for: Selection.self) { tag in - if let child = children.enumerated().first(where: { self.tag(of: $0.element, $0.offset) == tag })?.element { - child - } - } - } - } - .onAppear { - EnvironmentValues.NavigationPopKey._defaultValue = pop - popAction = PopAction(pop) - } - .onChange(of: selection) { _ in - popAction = PopAction(pop) - } - } - - func tag(of child: _VariadicView.Children.Element, _ i: Int) -> Selection? { - (child.stepTag.base as? Selection) ?? (i as? Selection) - } - - func pop(offset: Int) { - guard let selectedIndex else { return } - let newIndex = max(0, min(selectedIndex - offset, children.count - 1)) - guard let tag = tag(of: children[newIndex], newIndex) else { - if newIndex == 0 { - selection = nil - } - return - } - selection = tag - } - - var selectedIndex: Int? { - guard !children.isEmpty else { - return nil - } - guard let selection else { - return 0 - } - let tags = children.enumerated().map { - (tag(of: $0.element, $0.offset), $0.offset) - } - guard let i = tags.first(where: { $0.0 == selection })?.1 else { - return nil - } - return i - } + + @Binding var selection: Selection? + @Binding var popAction: PopAction + let children: _VariadicView.Children + + var body: some View { + NavigationStack( + path: Binding { + guard let selectedIndex, selectedIndex > 0 else { return NavigationPath() } + return NavigationPath( + (1 ... selectedIndex).compactMap { + tag(of: children[$0], $0) + } + ) + } set: { path in + guard path.count < children.count else { return } + let i = path.count + if let tag = tag(of: children[i], i) { + selection = tag + } else if path.isEmpty { + selection = nil + } + } + ) { + if !children.isEmpty { + children[0] + .navigationDestination(for: Selection.self) { tag in + if let child = children.enumerated().first(where: { self.tag(of: $0.element, $0.offset) == tag })?.element { + child + } + } + } + } + .onAppear { + EnvironmentValues.NavigationPopKey._defaultValue = pop + popAction = PopAction(pop) + } + .onChange(of: selection) { _ in + popAction = PopAction(pop) + } + } + + func tag(of child: _VariadicView.Children.Element, _ i: Int) -> Selection? { + (child.stepTag.base as? Selection) ?? (i as? Selection) + } + + func pop(offset: Int) { + guard let selectedIndex else { return } + let newIndex = max(0, min(selectedIndex - offset, children.count - 1)) + guard let tag = tag(of: children[newIndex], newIndex) else { + if newIndex == 0 { + selection = nil + } + return + } + selection = tag + } + + var selectedIndex: Int? { + guard !children.isEmpty else { + return nil + } + guard let selection else { + return 0 + } + let tags = children.enumerated().map { + (tag(of: $0.element, $0.offset), $0.offset) + } + guard let i = tags.first(where: { $0.0 == selection })?.1 else { + return nil + } + return i + } } extension EnvironmentValues { - - enum NavigationPopKey: EnvironmentKey { - static var _defaultValue: (Int) -> Void = { _ in } - static let defaultValue = PopAction { _defaultValue($0) } - } - - /// Use `Environment(\.pop)` environment value to control the pop/push actions: - /// ```swift - /// @Environment(\.pop) var pop - /// //... - /// Button("Go back") { - /// pop() - /// } - /// Button("Go forward") { - /// pop(-1) - /// } - /// Button("Go to start") { - /// pop.toRoot() - /// } - /// ``` - public var pop: PopAction { - get { self[NavigationPopKey.self] } - set { self[NavigationPopKey.self] = newValue } - } + + enum NavigationPopKey: EnvironmentKey { + static var _defaultValue: (Int) -> Void = { _ in } + static let defaultValue = PopAction { _defaultValue($0) } + } + + /// Use `Environment(\.pop)` environment value to control the pop/push actions: + /// ```swift + /// @Environment(\.pop) var pop + /// //... + /// Button("Go back") { + /// pop() + /// } + /// Button("Go forward") { + /// pop(-1) + /// } + /// Button("Go to start") { + /// pop.toRoot() + /// } + /// ``` + public var pop: PopAction { + get { self[NavigationPopKey.self] } + set { self[NavigationPopKey.self] = newValue } + } } /// A type that represents a pop action. public struct PopAction { - - private let pop: (Int) -> Void - - public init(_ pop: @escaping (Int) -> Void) { - self.pop = pop - } - - public func callAsFunction(_ offset: Int) { - pop(offset) - } - - public func callAsFunction() { - pop(1) - } - - public func toRoot() { - pop(.max) - } + + private let pop: (Int) -> Void + + public init(_ pop: @escaping (Int) -> Void) { + self.pop = pop + } + + public func callAsFunction(_ offset: Int) { + pop(offset) + } + + public func callAsFunction() { + pop(1) + } + + public func toRoot() { + pop(.max) + } } private var stackIDKey = 0 private var stackTagKey = 0 private final class IDWrapper { - var id: AnyHashable - - init(_ id: AnyHashable) { - self.id = id - } + var id: AnyHashable + + init(_ id: AnyHashable) { + self.id = id + } } @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) enum NavStackPreview: PreviewProvider { - - static var previews: Previews { - Previews() - } - - struct Previews: View { - - @State var selection: Int = 0 - - var body: some View { - NavigationSteps(selection: $selection) { - ForEach(0..<10) { i in - Page(i: i, selection: $selection) - } - } - .previewOverlay() - } - } - - struct Page: View { - @Environment(\.pop) var pop - let i: Int - @Binding var selection: Int - - var body: some View { - VStack { - HStack { - if selection > 0 { - Button("Pop") { - pop() - } - } - Spacer() - Button("\(i) == \(selection)") { - selection = .random(in: 0..<10) - } - Spacer() - if selection < 9 { - Button("Push") { - pop(-1) - } - } - } - } - .padding() - } - } + + static var previews: Previews { + Previews() + } + + struct Previews: View { + + @State var selection = 0 + + var body: some View { + NavigationSteps(selection: $selection) { + ForEach(0 ..< 10) { i in + Page(i: i, selection: $selection) + } + } + .previewOverlay() + } + } + + struct Page: View { + @Environment(\.pop) var pop + let i: Int + @Binding var selection: Int + + var body: some View { + VStack { + HStack { + if selection > 0 { + Button("Pop") { + pop() + } + } + Spacer() + Button("\(i) == \(selection)") { + selection = .random(in: 0 ..< 10) + } + Spacer() + if selection < 9 { + Button("Push") { + pop(-1) + } + } + } + } + .padding() + } + } } final class PreviewPrintObject: ObservableObject { - - static let shared = PreviewPrintObject() - - @Published var views: [AnyView] = [] + + static let shared = PreviewPrintObject() + + @Published var views: [AnyView] = [] } func printPreview(@ViewBuilder _ view: () -> some View) { - PreviewPrintObject.shared.views.append(AnyView(view())) - PreviewPrintObject.shared.views = Array(PreviewPrintObject.shared.views.suffix(5)) + PreviewPrintObject.shared.views.append(AnyView(view())) + PreviewPrintObject.shared.views = Array(PreviewPrintObject.shared.views.suffix(5)) } func printPreview(_ items: Any?...) { - printPreview { Text("\(items)" as String) } + printPreview { Text("\(items)" as String) } } extension View { - func previewOverlay() -> some View { - modifier(PreviewPrintModifier()) - } + func previewOverlay() -> some View { + modifier(PreviewPrintModifier()) + } } struct PreviewPrintModifier: ViewModifier { - - @ObservedObject private var object = PreviewPrintObject.shared - - func body(content: Content) -> some View { - content.overlay( - VStack(spacing: 0) { - ForEach(Array(object.views.enumerated()), id: \.offset) { offset, view in - view - } - }, - alignment: .bottom - ) - } + + @ObservedObject private var object = PreviewPrintObject.shared + + func body(content: Content) -> some View { + content.overlay( + VStack(spacing: 0) { + ForEach(Array(object.views.enumerated()), id: \.offset) { _, view in + view + } + }, + alignment: .bottom + ) + } } diff --git a/Sources/VDFlow/StateStep.swift b/Sources/VDFlow/StateStep.swift index ce2b2df..e157c1b 100644 --- a/Sources/VDFlow/StateStep.swift +++ b/Sources/VDFlow/StateStep.swift @@ -19,10 +19,10 @@ public struct StateStep: DynamicProperty { @Environment(\.[StepKey()]) private var stepBinding public var projectedValue: Binding { - return switch _defaultValue { - case let .binding(binding): binding - case let .state(state): stepBinding ?? state.projectedValue - } + switch _defaultValue { + case let .binding(binding): binding + case let .state(state): stepBinding ?? state.projectedValue + } } public init(wrappedValue: Value) { @@ -43,8 +43,8 @@ public struct StateStep: DynamicProperty { } } -//public extension StateStep where Value: StepsCollection { -// +// public extension StateStep where Value: StepsCollection { +// // subscript(dynamicMember keyPath: WritableKeyPath>) -> Binding> { // Binding { // wrappedValue[keyPath] @@ -52,7 +52,7 @@ public struct StateStep: DynamicProperty { // wrappedValue[keyPath] = $0 // } // } -//} +// } public extension StateStep where Value == EmptyStep { @@ -62,23 +62,23 @@ public extension StateStep where Value == EmptyStep { } extension EnvironmentValues { - - subscript(stepKey: StateStep.StepKey) -> StateStep.StepKey.Value { - get { self[StateStep.StepKey.self] } - set { self[StateStep.StepKey.self] = newValue } - } + + subscript(stepKey: StateStep.StepKey) -> StateStep.StepKey.Value { + get { self[StateStep.StepKey.self] } + set { self[StateStep.StepKey.self] = newValue } + } } public extension View { func step( - _ binding: Binding> + _ binding: Binding> ) -> some View { stepEnvironment( - binding[dynamicMember: \.wrappedValue] + binding[dynamicMember: \.wrappedValue] ) .tag(binding.wrappedValue.id) - .stepTag(binding.wrappedValue.id) + .stepTag(binding.wrappedValue.id) } func stepEnvironment(_ binding: Binding) -> some View { diff --git a/Sources/VDFlow/StepSelection.swift b/Sources/VDFlow/StepSelection.swift index b793b6b..8c3caa2 100644 --- a/Sources/VDFlow/StepSelection.swift +++ b/Sources/VDFlow/StepSelection.swift @@ -1,8 +1,8 @@ -//import Foundation +// import Foundation +// +// @dynamicMemberLookup +// public struct StepSelection: Identifiable { // -//@dynamicMemberLookup -//public struct StepSelection: Identifiable { -// // public var parent: Parent // public var value: Value { // get { parent[keyPath: keyPath].wrappedValue } @@ -15,45 +15,45 @@ // get { parent[keyPath: keyPath] } // set { parent[keyPath: keyPath] = newValue } // } -// +// // @_disfavoredOverload // public var isSelected: Bool { // parent.selected == parent[keyPath: keyPath].id // } -// +// // public mutating func select() { // parent[keyPath: keyPath].select() // } -// +// // public mutating func select(with value: Value) { // parent[keyPath: keyPath].select(with: value) // } -//} +// } +// +// extension StepSelection where Parent.AllSteps: ExpressibleByNilLiteral { // -//extension StepSelection where Parent.AllSteps: ExpressibleByNilLiteral { -// // public var isSelected: Bool { // get { parent.selected == parent[keyPath: keyPath].id } // set { parent.selected = newValue ? parent[keyPath: keyPath].id : nil } // } -// +// // public mutating func deselect() { // parent.selected = nil // } -//} +// } // -//extension StepSelection where Value: StepsCollection { +// extension StepSelection where Value: StepsCollection { // // public subscript(dynamicMember keyPath: WritableKeyPath>) -> StepSelection { // get { StepSelection(parent: value, keyPath: keyPath) } // set { value = newValue.parent } // } -//} +// } +// +// extension StepsCollection { // -//extension StepsCollection { -// // public subscript(_ keyPath: WritableKeyPath>) -> StepSelection { // get { StepSelection(parent: self, keyPath: keyPath) } // set { self = newValue.parent } // } -//} +// } diff --git a/Sources/VDFlow/StepWrapper.swift b/Sources/VDFlow/StepWrapper.swift index 8537c7b..0b8211d 100644 --- a/Sources/VDFlow/StepWrapper.swift +++ b/Sources/VDFlow/StepWrapper.swift @@ -8,8 +8,8 @@ public extension StepsCollection { @propertyWrapper public struct StepWrapper: Identifiable { - public let id: Parent.AllSteps - public var _mutateID = MutateID() + public let id: Parent.AllSteps + public var _mutateID = MutateID() public var wrappedValue: Value public var projectedValue: StepWrapper { get { self } @@ -24,18 +24,18 @@ public struct StepWrapper: Identifiable { self.id = id } - public mutating func select() { - _mutateID._update() - } + public mutating func select() { + _mutateID._update() + } - public mutating func select(with value: Value) { - wrappedValue = value - select() - } + public mutating func select(with value: Value) { + wrappedValue = value + select() + } - public var _lastMutateID: (Parent.AllSteps, MutateID)? { - ((wrappedValue as? any StepsCollection)?._lastMutateID?.optional ?? _mutateID.optional).map { (id, $0) } - } + public var _lastMutateID: (Parent.AllSteps, MutateID)? { + ((wrappedValue as? any StepsCollection)?._lastMutateID?.optional ?? _mutateID.optional).map { (id, $0) } + } } public extension StepWrapper where Value == EmptyStep { diff --git a/Sources/VDFlow/StepsCollection.swift b/Sources/VDFlow/StepsCollection.swift index a91de6d..d178b4a 100644 --- a/Sources/VDFlow/StepsCollection.swift +++ b/Sources/VDFlow/StepsCollection.swift @@ -2,7 +2,7 @@ import Foundation public protocol StepsCollection { - associatedtype AllSteps: Hashable & Codable & Sendable + associatedtype AllSteps: Hashable & Codable & Sendable var selected: AllSteps { get set } - var _lastMutateID: MutateID? { get } + var _lastMutateID: MutateID? { get } } diff --git a/Sources/VDFlowMacros/StepsMacro.swift b/Sources/VDFlowMacros/StepsMacro.swift index a107154..ca1d37c 100644 --- a/Sources/VDFlowMacros/StepsMacro.swift +++ b/Sources/VDFlowMacros/StepsMacro.swift @@ -1,4 +1,5 @@ #if canImport(SwiftCompilerPlugin) +import Foundation import SwiftCompilerPlugin import SwiftDiagnostics import SwiftOperators @@ -6,7 +7,6 @@ import SwiftSyntax import SwiftSyntaxBuilder import SwiftSyntaxMacroExpansion import SwiftSyntaxMacros -import Foundation @main struct VDFlowMacrosPlugin: CompilerPlugin { @@ -23,12 +23,12 @@ public struct StepsMacro: MemberAttributeMacro, ExtensionMacro, MemberMacro, Acc providingAccessorsOf declaration: some DeclSyntaxProtocol, in context: some MacroExpansionContext ) throws -> [AccessorDeclSyntax] { - guard - let variable = declaration.as(VariableDeclSyntax.self), - variable.storedVarName != nil - else { - throw MacroError("@Step can only be applied to stored properties") - } + guard + let variable = declaration.as(VariableDeclSyntax.self), + variable.storedVarName != nil + else { + throw MacroError("@Step can only be applied to stored properties") + } return [] } @@ -38,21 +38,21 @@ public struct StepsMacro: MemberAttributeMacro, ExtensionMacro, MemberMacro, Acc providingAttributesFor member: some DeclSyntaxProtocol, in context: some MacroExpansionContext ) throws -> [AttributeSyntax] { - if !declaration.is(StructDeclSyntax.self) { - return [] - } - guard let variable = member.as(VariableDeclSyntax.self), let name = variable.storedVarName else { return [] } - let hasStepVars = declaration.memberBlock.members.contains(where: \.decl.hasStepAttribute) - guard hasStepVars ? variable.hasStepAttribute : true else { return [] } - if - let binding = variable.bindings.first, - let type = binding.typeAnnotation?.type.trimmed.description, - binding.initializer == nil, - type != "EmptyStep", - !type.isOptional - { - throw MacroError("`\(name): \(type)` must have default value or be optional") - } + if !declaration.is(StructDeclSyntax.self) { + return [] + } + guard let variable = member.as(VariableDeclSyntax.self), let name = variable.storedVarName else { return [] } + let hasStepVars = declaration.memberBlock.members.contains(where: \.decl.hasStepAttribute) + guard hasStepVars ? variable.hasStepAttribute : true else { return [] } + if + let binding = variable.bindings.first, + let type = binding.typeAnnotation?.type.trimmed.description, + binding.initializer == nil, + type != "EmptyStep", + !type.isOptional + { + throw MacroError("`\(name): \(type)` must have default value or be optional") + } return ["@StepID(.\(raw: name))"] } @@ -64,347 +64,347 @@ public struct StepsMacro: MemberAttributeMacro, ExtensionMacro, MemberMacro, Acc in context: some MacroExpansionContext ) throws -> [ExtensionDeclSyntax] { [ - ExtensionDeclSyntax( - extendedType: type, - inheritanceClause: InheritanceClauseSyntax( - inheritedTypes: InheritedTypeListSyntax { - InheritedTypeSyntax( - type: TypeSyntax( - stringLiteral: "StepsCollection" - ) - ) - } - ), - memberBlock: MemberBlockSyntax(members: MemberBlockItemListSyntax()) - ) - ] + ExtensionDeclSyntax( + extendedType: type, + inheritanceClause: InheritanceClauseSyntax( + inheritedTypes: InheritedTypeListSyntax { + InheritedTypeSyntax( + type: TypeSyntax( + stringLiteral: "StepsCollection" + ) + ) + } + ), + memberBlock: MemberBlockSyntax(members: MemberBlockItemListSyntax()) + ), + ] + } + + public static func enumExpansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + guard declaration.is(EnumDeclSyntax.self) else { + if declaration.is(StructDeclSyntax.self) { + return try expansion(of: node, providingMembersOf: declaration, in: context) + } + throw MacroError("Steps macro can only be applied to enums or structs") + } + let cases = declaration.memberBlock.members.flatMap { + $0.decl.as(EnumCaseDeclSyntax.self)?.elements ?? [] + } + guard cases.contains(where: \.hasParameters) else { + return [""" + public typealias AllSteps = Self + + public var selected: Self { + get { self } + set { self = newValue } + } + """] + } + var isOptional = false + var parameters: [String: [(type: String, value: String, name: String?)]] = [:] + for caseItem in cases { + if caseItem.name.text == "none" { + isOptional = true + } + guard + let caseParameters = caseItem.parameterClause?.parameters, + !caseParameters.isEmpty + else { + continue + } + var params: [(type: String, value: String, name: String?)] = [] + for parameter in caseParameters { + let type = parameter.type.trimmed.description + var value = "" + if let defaultValue = parameter.defaultValue?.value { + value = defaultValue.description + } else { + if type.isOptional { + value = "nil" + } else { + throw MacroError("All parameters of a case must have a default value or be optional") + } + } + params.append((type, value, parameter.firstName?.text)) + } + if !params.isEmpty { + parameters[caseItem.name.text] = params + } + } + let selected: DeclSyntax = + """ + public var selected: Steps { + get { + switch self { + \(raw: cases.map { "case .\($0.name.text): return .\($0.name.text)" }.joined(separator: "\n")) + } + } + set { + switch newValue { + \(raw: cases.map { "case .\($0.name.text): self = .\($0.name.text)\(parameters[$0.name.text] == nil ? "" : "()")" }.joined(separator: "\n")) + } + } + } + """ + let stepsEnum: DeclSyntax = + """ + public enum Steps: String, CaseIterable, Codable, Sendable\(raw: isOptional ? ", OptionalStep" : "") { + case \(raw: cases.map(\.name.text).joined(separator: ", ")) + } + """ + var result = [selected, stepsEnum] + for caseItem in cases { + let name = caseItem.name.text + let params = parameters[name] ?? [] + guard !params.isEmpty else { + result.append( + """ + public var \(raw: name): EmptyStep { + get { EmptyStep() } + set {} + } + """ + ) + continue + } + var type = params.map(\.type).joined(separator: ", ") + if params.count > 1 { + type = "(\(type))" + } + let isOptional = params.contains(where: \.value.isEmpty) + if isOptional { + type += "?" + } + var value = isOptional ? "nil" : params.map(\.value).joined(separator: ", ") + if params.count > 1 { + value = "(\(value))" + } + let args = params.indices.map { "arg\($0)" }.joined(separator: ", ") + let newArgs = params.count == 1 + ? "newValue" + : params.indices + .map { "\(params[$0].name.map { "\($0): " } ?? "")newValue.\($0)" } + .joined(separator: ", ") + let caseVar: DeclSyntax = + """ + public var \(raw: name): \(raw: type) { + get { + if case let .\(raw: name)(\(raw: args)) = self { + return \(raw: params.count > 1 ? "(\(args))" : "arg0") + } + return \(raw: value) + } + set { + if case .\(raw: name) = self\(raw: isOptional ? ", let newValue" : "") { + self = .\(raw: name)(\(raw: newArgs)) + } + } + } + """ + result.append(caseVar) + } + return result + } + + public static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + guard declaration.is(StructDeclSyntax.self) else { + throw MacroError("Steps macro can only be applied to structs") + } + var isOptional = false + var cases: [String] = [] + var functions: [String: String] = [:] + var hasStepVars = false + var hasVarWithoutStep = false + for member in declaration.memberBlock.members { + if member.decl.hasStepAttribute { + hasStepVars = true + } else { + hasVarWithoutStep = true + } + } + for member in declaration.memberBlock.members { + guard + let variable = member.decl.as(VariableDeclSyntax.self), + !hasStepVars || variable.hasStepAttribute, + !variable.isStatic + else { + continue + } + guard + let name = variable.storedVarName + else { + throw MacroError("Steps macro can contain only stored properties") + } + + if name == "none" { + isOptional = true + } + + cases.append(name) + + guard !hasStepVars || !hasVarWithoutStep else { continue } + + if let binding = variable.bindings.first { + var type = binding.typeAnnotation?.type.description ?? "" + if type.isEmpty { + if let value = binding.initializer?.value { + if value.is(StringLiteralExprSyntax.self) { + type = "String" + } else if value.is(BooleanLiteralExprSyntax.self) { + type = "Bool" + } else if value.is(IntegerLiteralExprSyntax.self) { + type = "Int" + } else if value.is(FloatLiteralExprSyntax.self) { + type = "Double" + } else { + throw MacroError("Type of `\(name)` must be provided explicitly with `:`") + } + } else { + functions[name] = "" + } + } + var defaultValue = binding.initializer?.value.description + if defaultValue == nil { + if type.isOptional { + defaultValue = "nil" + } else if !type.isEmpty { + throw MacroError("Default value of `\(name)` must be provided") + } + } + if functions[name] == nil { + functions[name] = "(_ value: \(type)\(defaultValue.map { " = \($0)" } ?? ""))" + } + } + } + + guard !cases.isEmpty, cases != ["none"] else { + throw MacroError("Steps must have at least one stored variable") + } + + var result: [DeclSyntax] = [] + let canInitWithSelected = !hasStepVars || !hasVarWithoutStep + + var hasDeselected = false + if !canInitWithSelected, !isOptional { + isOptional = true + hasDeselected = true + let deselectedVar: DeclSyntax = + """ + var _deselectedMutateID = MutateID() + """ + result.append(deselectedVar) + } + let stepsType = "Steps" + (isOptional ? "?" : "") + + let lastMutateID: DeclSyntax = "public var _lastMutateID: MutateID? { lastMutateStepID?.1 }" + result.append(lastMutateID) + + let defaultValue = canInitWithSelected ? "initialSelected" : "nil" + let selected: DeclSyntax = + """ + public var selected: \(raw: stepsType) { + get { if let result = lastMutateStepID { return result.0 } else { return \(raw: defaultValue) } } + set { + guard let keyPath = Self._mutateIDs[newValue] else { + \(raw: hasDeselected ? "_deselectedMutateID._update()" : "") + return + } + self[keyPath: keyPath]._update() + } + } + """ + result.append(selected) + + let typealiasDecl: DeclSyntax = "public typealias AllSteps = \(raw: stepsType)" + result.append(typealiasDecl) + + let stepsEnum: DeclSyntax = + """ + public enum Steps: String, CaseIterable, Codable, Sendable, Hashable { + case \(raw: cases.filter { $0 != "none" }.joined(separator: ", ")) + } + """ + result.append(stepsEnum) + + let mutateIDs: DeclSyntax = + """ + private static var _mutateIDs: [AllSteps: WritableKeyPath] { + [\(raw: cases.map { ".\($0): \\.$\($0)._mutateID" }.joined(separator: ", "))] + } + """ + result.append(mutateIDs) + + let _lastMutateStepID: DeclSyntax = + """ + private var lastMutateStepID: (\(raw: stepsType), MutateID)? { + [ + \(raw: (cases.map { "_\($0)._lastMutateID" } + (hasDeselected ? ["(nil, _deselectedMutateID)"] : [])).joined(separator: ",\n")) + ] + .compactMap { $0 } + .sorted(by: { $0.1 > $1.1 }) + .first + } + """ + result.append(_lastMutateStepID) + + guard canInitWithSelected else { return result } + + let initialSelected: DeclSyntax = + """ + private let initialSelected: \(raw: stepsType) + """ + result.append(initialSelected) + + let initDecl: DeclSyntax = + """ + private init(_ selected: \(raw: stepsType)) { + initialSelected = selected + } + """ + result.append(initDecl) + + result += cases.map { + let function = functions[$0] ?? "" + let isVar = function.isEmpty + + var funcString = "public static \(isVar ? "var" : "func") \($0)\(function)\(isVar ? ":" : " ->") Self {" + if isVar { + funcString += "\nSelf.init(.\($0))\n" + } else { + funcString += + """ + var result = Self.init(.\($0)) + result.\($0) = value + return result + """ + } + funcString += "}" + return DeclSyntax(stringLiteral: funcString) + } + return result } - - public static func enumExpansion( - of node: AttributeSyntax, - providingMembersOf declaration: some DeclGroupSyntax, - in context: some MacroExpansionContext - ) throws -> [DeclSyntax] { - guard declaration.is(EnumDeclSyntax.self) else { - if declaration.is(StructDeclSyntax.self) { - return try expansion(of: node, providingMembersOf: declaration, in: context) - } - throw MacroError("Steps macro can only be applied to enums or structs") - } - let cases = declaration.memberBlock.members.flatMap { - $0.decl.as(EnumCaseDeclSyntax.self)?.elements ?? [] - } - guard cases.contains(where: \.hasParameters) else { - return [""" - public typealias AllSteps = Self - - public var selected: Self { - get { self } - set { self = newValue } - } - """] - } - var isOptional = false - var parameters: [String: [(type: String, value: String, name: String?)]] = [:] - for caseItem in cases { - if caseItem.name.text == "none" { - isOptional = true - } - guard - let caseParameters = caseItem.parameterClause?.parameters, - !caseParameters.isEmpty - else { - continue - } - var params: [(type: String, value: String, name: String?)] = [] - for parameter in caseParameters { - let type = parameter.type.trimmed.description - var value = "" - if let defaultValue = parameter.defaultValue?.value { - value = defaultValue.description - } else { - if type.isOptional { - value = "nil" - } else { - throw MacroError("All parameters of a case must have a default value or be optional") - } - } - params.append((type, value, parameter.firstName?.text)) - } - if !params.isEmpty { - parameters[caseItem.name.text] = params - } - } - let selected: DeclSyntax = - """ - public var selected: Steps { - get { - switch self { - \(raw: cases.map { "case .\($0.name.text): return .\($0.name.text)" }.joined(separator: "\n")) - } - } - set { - switch newValue { - \(raw: cases.map { "case .\($0.name.text): self = .\($0.name.text)\(parameters[$0.name.text] == nil ? "" : "()")" }.joined(separator: "\n")) - } - } - } - """ - let stepsEnum: DeclSyntax = - """ - public enum Steps: String, CaseIterable, Codable, Sendable\(raw: isOptional ? ", OptionalStep" : "") { - case \(raw: cases.map(\.name.text).joined(separator: ", ")) - } - """ - var result = [selected, stepsEnum] - for caseItem in cases { - let name = caseItem.name.text - let params = parameters[name] ?? [] - guard !params.isEmpty else { - result.append( - """ - public var \(raw: name): EmptyStep { - get { EmptyStep() } - set {} - } - """ - ) - continue - } - var type = params.map(\.type).joined(separator: ", ") - if params.count > 1 { - type = "(\(type))" - } - let isOptional = params.contains(where: \.value.isEmpty) - if isOptional { - type += "?" - } - var value = isOptional ? "nil" : params.map(\.value).joined(separator: ", ") - if params.count > 1 { - value = "(\(value))" - } - let args = params.indices.map { "arg\($0)" }.joined(separator: ", ") - let newArgs = params.count == 1 - ? "newValue" - : params.indices - .map { "\(params[$0].name.map { "\($0): " } ?? "")newValue.\($0)" } - .joined(separator: ", ") - let caseVar: DeclSyntax = - """ - public var \(raw: name): \(raw: type) { - get { - if case let .\(raw: name)(\(raw: args)) = self { - return \(raw: params.count > 1 ? "(\(args))" : "arg0") - } - return \(raw: value) - } - set { - if case .\(raw: name) = self\(raw: isOptional ? ", let newValue" : "") { - self = .\(raw: name)(\(raw: newArgs)) - } - } - } - """ - result.append(caseVar) - } - return result - } - - public static func expansion( - of node: AttributeSyntax, - providingMembersOf declaration: some DeclGroupSyntax, - in context: some MacroExpansionContext - ) throws -> [DeclSyntax] { - guard declaration.is(StructDeclSyntax.self) else { - throw MacroError("Steps macro can only be applied to structs") - } - var isOptional = false - var cases: [String] = [] - var functions: [String: String] = [:] - var hasStepVars = false - var hasVarWithoutStep = false - for member in declaration.memberBlock.members { - if member.decl.hasStepAttribute { - hasStepVars = true - } else { - hasVarWithoutStep = true - } - } - for member in declaration.memberBlock.members { - guard - let variable = member.decl.as(VariableDeclSyntax.self), - !hasStepVars || variable.hasStepAttribute, - !variable.isStatic - else { - continue - } - guard - let name = variable.storedVarName - else { - throw MacroError("Steps macro can contain only stored properties") - } - - if name == "none" { - isOptional = true - } - - cases.append(name) - - guard !hasStepVars || !hasVarWithoutStep else { continue } - - if let binding = variable.bindings.first { - var type = binding.typeAnnotation?.type.description ?? "" - if type.isEmpty { - if let value = binding.initializer?.value { - if value.is(StringLiteralExprSyntax.self) { - type = "String" - } else if value.is(BooleanLiteralExprSyntax.self) { - type = "Bool" - } else if value.is(IntegerLiteralExprSyntax.self) { - type = "Int" - } else if value.is(FloatLiteralExprSyntax.self) { - type = "Double" - } else { - throw MacroError("Type of `\(name)` must be provided explicitly with `:`") - } - } else { - functions[name] = "" - } - } - var defaultValue = binding.initializer?.value.description - if defaultValue == nil { - if type.isOptional { - defaultValue = "nil" - } else if !type.isEmpty { - throw MacroError("Default value of `\(name)` must be provided") - } - } - if functions[name] == nil { - functions[name] = "(_ value: \(type)\(defaultValue.map { " = \($0)" } ?? ""))" - } - } - } - - guard !cases.isEmpty, cases != ["none"] else { - throw MacroError("Steps must have at least one stored variable") - } - - var result: [DeclSyntax] = [] - let canInitWithSelected = !hasStepVars || !hasVarWithoutStep - - var hasDeselected = false - if !canInitWithSelected, !isOptional { - isOptional = true - hasDeselected = true - let deselectedVar: DeclSyntax = - """ - var _deselectedMutateID = MutateID() - """ - result.append(deselectedVar) - } - let stepsType = "Steps" + (isOptional ? "?" : "") - - let lastMutateID: DeclSyntax = "public var _lastMutateID: MutateID? { lastMutateStepID?.1 }" - result.append(lastMutateID) - - let defaultValue = canInitWithSelected ? "initialSelected" : "nil" - let selected: DeclSyntax = - """ - public var selected: \(raw: stepsType) { - get { if let result = lastMutateStepID { return result.0 } else { return \(raw: defaultValue) } } - set { - guard let keyPath = Self._mutateIDs[newValue] else { - \(raw: hasDeselected ? "_deselectedMutateID._update()" : "") - return - } - self[keyPath: keyPath]._update() - } - } - """ - result.append(selected) - - let typealiasDecl: DeclSyntax = "public typealias AllSteps = \(raw: stepsType)" - result.append(typealiasDecl) - - let stepsEnum: DeclSyntax = - """ - public enum Steps: String, CaseIterable, Codable, Sendable, Hashable { - case \(raw: cases.filter({ $0 != "none" }).joined(separator: ", ")) - } - """ - result.append(stepsEnum) - - let mutateIDs: DeclSyntax = - """ - private static var _mutateIDs: [AllSteps: WritableKeyPath] { - [\(raw: cases.map { ".\($0): \\.$\($0)._mutateID" }.joined(separator: ", "))] - } - """ - result.append(mutateIDs) - - let _lastMutateStepID: DeclSyntax = - """ - private var lastMutateStepID: (\(raw: stepsType), MutateID)? { - [ - \(raw: (cases.map { "_\($0)._lastMutateID" } + (hasDeselected ? ["(nil, _deselectedMutateID)"] : [])).joined(separator: ",\n")) - ] - .compactMap { $0 } - .sorted(by: { $0.1 > $1.1 }) - .first - } - """ - result.append(_lastMutateStepID) - - guard canInitWithSelected else { return result } - - let initialSelected: DeclSyntax = - """ - private let initialSelected: \(raw: stepsType) - """ - result.append(initialSelected) - - let initDecl: DeclSyntax = - """ - private init(_ selected: \(raw: stepsType)) { - initialSelected = selected - } - """ - result.append(initDecl) - - result += cases.map { - let function = functions[$0] ?? "" - let isVar = function.isEmpty - - var funcString = "public static \(isVar ? "var" : "func") \($0)\(function)\(isVar ? ":" : " ->") Self {" - if isVar { - funcString += "\nSelf.init(.\($0))\n" - } else { - funcString += - """ - var result = Self.init(.\($0)) - result.\($0) = value - return result - """ - } - funcString += "}" - return DeclSyntax(stringLiteral: funcString) - } - return result - } } extension EnumCaseElementSyntax { - var hasParameters: Bool { - (parameterClause?.parameters.count ?? 0) > 0 - } + var hasParameters: Bool { + (parameterClause?.parameters.count ?? 0) > 0 + } } extension DeclSyntaxProtocol { var hasStepAttribute: Bool { if let variable = self.as(VariableDeclSyntax.self), - variable.attributes.contains(where: { $0.as(AttributeSyntax.self)?.attributeName.trimmed.description == "Step" }) + variable.attributes.contains(where: { $0.as(AttributeSyntax.self)?.attributeName.trimmed.description == "Step" }) { return true } @@ -437,10 +437,10 @@ extension VariableDeclSyntax { } return name } - - var isStatic: Bool { - modifiers.contains { $0.name.tokenKind == .keyword(.static) } - } + + var isStatic: Bool { + modifiers.contains { $0.name.tokenKind == .keyword(.static) } + } } extension TokenSyntax { @@ -450,24 +450,24 @@ extension TokenSyntax { } var isStaticOrLazyOrLet: Bool { - [.keyword(.static), .keyword(.lazy), .keyword(.let)].contains(tokenKind) + [.keyword(.static), .keyword(.lazy), .keyword(.let)].contains(tokenKind) } } private extension String { - - var isOptional: Bool { - hasSuffix("?") || hasPrefix("Optional<") - } + + var isOptional: Bool { + hasSuffix("?") || hasPrefix("Optional<") + } } private struct MacroError: LocalizedError, CustomStringConvertible { - - var errorDescription: String - var description: String { errorDescription } - - init(_ errorDescription: String) { - self.errorDescription = errorDescription - } + + var errorDescription: String + var description: String { errorDescription } + + init(_ errorDescription: String) { + self.errorDescription = errorDescription + } } #endif diff --git a/Tests/VDFlowTests/VDFlowTests.swift b/Tests/VDFlowTests/VDFlowTests.swift index 540d813..9d50101 100644 --- a/Tests/VDFlowTests/VDFlowTests.swift +++ b/Tests/VDFlowTests/VDFlowTests.swift @@ -5,99 +5,97 @@ import XCTest final class VDFlowTestsCase: XCTestCase { - func testSelectedOfStaticFunc() { - var value: TabSteps = .tab3(.screen2(.text2)) - XCTAssertEqual(value.selected, .tab3) - - value = .tab1 - XCTAssertEqual(value.selected, .tab1) - - value = .tab3(.screen2(.text2)) - XCTAssertEqual(value.selected, .tab3) - XCTAssertEqual(value.tab3.selected, .screen2) - XCTAssertEqual(value.tab3.screen2.selected, .text2) - } - - func testSelectedProperty() { - var value: TabSteps = .tab3(.screen2(.text2)) - value.selected = .tab2 - XCTAssertEqual(value.selected, .tab2) - } - - func testSelectedPropertyUpdatesParent() { - var value: TabSteps = .tab2 - value.tab3.screen2.selected = .text1 - XCTAssertEqual(value.selected, .tab3) - } - - func testStaticFuncDoesNotUpdateParent() { - var value: TabSteps = .tab2 - value.tab3.screen2 = .text1 - XCTAssertEqual(value.selected, .tab2) - } - - func testNestedSelectFuncUpdatesParent() { - var value: TabSteps = .tab2 - value.tab3.screen2.$text1.select() - XCTAssertEqual(value.selected, .tab3) - XCTAssertEqual(value.tab3.selected, .screen2) - - value.tab3.$none.select() - XCTAssertEqual(value.tab3.selected, nil) + func testSelectedOfStaticFunc() { + var value: TabSteps = .tab3(.screen2(.text2)) + XCTAssertEqual(value.selected, .tab3) + + value = .tab1 + XCTAssertEqual(value.selected, .tab1) + + value = .tab3(.screen2(.text2)) + XCTAssertEqual(value.selected, .tab3) + XCTAssertEqual(value.tab3.selected, .screen2) + XCTAssertEqual(value.tab3.screen2.selected, .text2) + } + + func testSelectedProperty() { + var value: TabSteps = .tab3(.screen2(.text2)) + value.selected = .tab2 + XCTAssertEqual(value.selected, .tab2) + } + + func testSelectedPropertyUpdatesParent() { + var value: TabSteps = .tab2 + value.tab3.screen2.selected = .text1 + XCTAssertEqual(value.selected, .tab3) + } + + func testStaticFuncDoesNotUpdateParent() { + var value: TabSteps = .tab2 + value.tab3.screen2 = .text1 + XCTAssertEqual(value.selected, .tab2) + } + + func testNestedSelectFuncUpdatesParent() { + var value: TabSteps = .tab2 + value.tab3.screen2.$text1.select() + XCTAssertEqual(value.selected, .tab3) + XCTAssertEqual(value.tab3.selected, .screen2) + + value.tab3.$none.select() + XCTAssertEqual(value.tab3.selected, nil) + } + + func testStepMacro() { + var value = OneStepSteps(someString: "SomeString") + XCTAssertEqual(value.selected, nil) + value.selected = .someStep + XCTAssertEqual(value.selected, .someStep) + value.selected = nil + XCTAssertEqual(value.selected, nil) + value.someStep.$text1.select() + XCTAssertEqual(value.selected, .someStep) } - - func testStepMacro() { - var value = OneStepSteps(someString: "SomeString") - XCTAssertEqual(value.selected, nil) - value.selected = .someStep - XCTAssertEqual(value.selected, .someStep) - value.selected = nil - XCTAssertEqual(value.selected, nil) - value.someStep.$text1.select() - XCTAssertEqual(value.selected, .someStep) - } } @Steps public struct TabSteps { - public var tab1 - public var tab2 - public var tab3: NavigationSteps = .screen1 + public var tab1 + public var tab2 + public var tab3: NavigationSteps = .screen1 } @Steps public struct NavigationSteps { - public var screen1 - public var screen2: PickerSteps = .text1 - public var none + public var screen1 + public var screen2: PickerSteps = .text1 + public var none } @Steps public struct PickerSteps { - public var text1 - public var text2 + public var text1 + public var text2 } @Steps public struct OneStepSteps { - public lazy var someLazyInt = 0 - public var someString: String - @Step var someStep: PickerSteps = .text1 + public lazy var someLazyInt = 0 + public var someString: String + @Step var someStep: PickerSteps = .text1 } struct SomeView: View { - - @StateStep var tabs: TabSteps = .tab1 - - var body: some View { - Picker(selection: $tabs.selected) { - Text("Tab 1").step($tabs.$tab1) - Text("Tab 2").step($tabs.$tab2) - Text("Tab 3").step($tabs.$tab3) - } label: { - } - .sheet(isPresented: $tabs.tab3.isSelected(.screen2)) { - } - } + + @StateStep var tabs: TabSteps = .tab1 + + var body: some View { + Picker(selection: $tabs.selected) { + Text("Tab 1").step($tabs.$tab1) + Text("Tab 2").step($tabs.$tab2) + Text("Tab 3").step($tabs.$tab3) + } label: {} + .sheet(isPresented: $tabs.tab3.isSelected(.screen2)) {} + } }