diff --git a/Sources/SlotExamples/ActionButton.swift b/Sources/SlotExamples/ActionButton.swift index a55817b..c249d03 100644 --- a/Sources/SlotExamples/ActionButton.swift +++ b/Sources/SlotExamples/ActionButton.swift @@ -1,7 +1,7 @@ import Slots import SwiftUI -@Slots public struct ActionButton: View { +@Slots(.trailingViewBuilders) public struct ActionButton: View { var action: () -> Void @Slot(.text, .unlabeled) var label: Label diff --git a/Sources/SlotExamples/Previews.swift b/Sources/SlotExamples/Previews.swift index 9aaef07..78bbec9 100644 --- a/Sources/SlotExamples/Previews.swift +++ b/Sources/SlotExamples/Previews.swift @@ -47,10 +47,10 @@ struct Examples_Previews: PreviewProvider { // ToolbarRow examples ToolbarRow(title: "Inbox") ToolbarRow( - title: "Details", leading: { Button(action: {}) { Image(systemName: "chevron.left") } }, + title: "Details", trailing: { Button(action: {}) { Image(systemName: "ellipsis") } }) diff --git a/Sources/SlotMacros/SlotMacro.swift b/Sources/SlotMacros/SlotMacro.swift index aa15e4f..6a7482c 100644 --- a/Sources/SlotMacros/SlotMacro.swift +++ b/Sources/SlotMacros/SlotMacro.swift @@ -15,6 +15,7 @@ public struct SlotMacro: MemberMacro, ExtensionMacro { in context: some MacroExpansionContext ) throws -> [DeclSyntax] { let access = accessModifier(of: declaration) + let slotsOptions = parseSlotsOptions(from: node) let plain = collectPlainProperties(from: declaration) let slots = collectSlots(from: declaration, node: node, context: context) guard !slots.isEmpty else { return [] } @@ -33,7 +34,7 @@ public struct SlotMacro: MemberMacro, ExtensionMacro { declarationIndex: slot.declarationIndex ) } - entries.sort { ($0.tier, $0.declarationIndex) < ($1.tier, $1.declarationIndex) } + sortEntries(&entries, trailingViewBuilders: slotsOptions.trailingViewBuilders) let params = entries.map(\.param).joined(separator: ", ") let assignments = entries.map(\.assignment).joined(separator: "\n ") let accessPrefix = access.map { "\($0) " } ?? "" @@ -57,6 +58,7 @@ public struct SlotMacro: MemberMacro, ExtensionMacro { in context: some MacroExpansionContext ) throws -> [ExtensionDeclSyntax] { let access = accessModifier(of: declaration) + let slotsOptions = parseSlotsOptions(from: node) let plain = collectPlainProperties(from: declaration) let slots = collectSlots(from: declaration, node: node, context: context) guard !slots.isEmpty else { return [] } @@ -70,7 +72,9 @@ public struct SlotMacro: MemberMacro, ExtensionMacro { return [] } - let groups = extensionGroups(for: slots, plain: plain, access: access) + let groups = extensionGroups( + for: slots, plain: plain, access: access, + trailingViewBuilders: slotsOptions.trailingViewBuilders) return groups.compactMap { (whereClause, specs) in buildExtension(type: type, whereClause: whereClause, specs: specs) } @@ -214,10 +218,30 @@ private func isFunctionType(_ type: TypeSyntax) -> Bool { return false } +// MARK: - Parsing @Slots options + +private struct SlotsOptions { + var trailingViewBuilders = false +} + +private func parseSlotsOptions(from attr: AttributeSyntax) -> SlotsOptions { + var result = SlotsOptions() + guard case .argumentList(let args) = attr.arguments else { return result } + for arg in args { + if arg.expression.as(MemberAccessExprSyntax.self)?.declName.baseName.text + == "trailingViewBuilders" + { + result.trailingViewBuilders = true + } + } + return result +} + // MARK: - Parameter ordering -/// Parameters are sorted by tier to match SwiftUI conventions: -/// value params first, then closures, then @ViewBuilder closures last. +/// When `trailingViewBuilders` is enabled, parameters are sorted by tier to match +/// SwiftUI conventions: value params first, then closures, then @ViewBuilder closures last. +/// Otherwise, declaration order is preserved. private enum ParamTier: Comparable { case value case closure @@ -231,6 +255,14 @@ private struct ParamEntry { let declarationIndex: Int } +private func sortEntries(_ entries: inout [ParamEntry], trailingViewBuilders: Bool) { + if trailingViewBuilders { + entries.sort { ($0.tier, $0.declarationIndex) < ($1.tier, $1.declarationIndex) } + } else { + entries.sort { $0.declarationIndex < $1.declarationIndex } + } +} + private func paramEntry(for p: PlainProperty) -> ParamEntry { if p.isGenericView { return ParamEntry( @@ -483,11 +515,13 @@ private func allCombinations(for slots: [SlotDescriptor]) -> [[SlotMode]] { /// Groups all non-trivial combinations by their where-clause key so that `.text` and `.string` /// variants for the same set of fixed generics land in the same extension. -/// Parameters are sorted by tier: value params, then closures, then @ViewBuilder closures. +/// When `trailingViewBuilders` is true, parameters are sorted by tier: value params, then +/// closures, then @ViewBuilder closures. Otherwise, declaration order is preserved. private func extensionGroups( for slots: [SlotDescriptor], plain: [PlainProperty] = [], - access: String? = nil + access: String? = nil, + trailingViewBuilders: Bool = false ) -> [(whereClause: String, specs: [InitSpec])] { // Use an ordered structure to preserve natural combo order var order: [String] = [] @@ -606,7 +640,7 @@ private func extensionGroups( } } - entries.sort { ($0.tier, $0.declarationIndex) < ($1.tier, $1.declarationIndex) } + sortEntries(&entries, trailingViewBuilders: trailingViewBuilders) let key = constraints.joined(separator: ", ") let spec = InitSpec( diff --git a/Sources/Slots/Slots.swift b/Sources/Slots/Slots.swift index 45035f3..39528a1 100644 --- a/Sources/Slots/Slots.swift +++ b/Sources/Slots/Slots.swift @@ -17,4 +17,4 @@ import SwiftUI /// ``` @attached(member, names: named(init)) @attached(extension, names: named(init)) -public macro Slots() = #externalMacro(module: "SlotMacros", type: "SlotMacro") +public macro Slots(_ options: SlotsOption...) = #externalMacro(module: "SlotMacros", type: "SlotMacro") diff --git a/Sources/Slots/SlotsOption.swift b/Sources/Slots/SlotsOption.swift new file mode 100644 index 0000000..5aff1cc --- /dev/null +++ b/Sources/Slots/SlotsOption.swift @@ -0,0 +1,13 @@ +import SwiftUI + +public struct SlotsOption: Sendable, Equatable { + private let id: Int + + private init(id: Int) { + self.id = id + } + + /// Reorder generated init parameters so `@ViewBuilder` closures appear last, + /// enabling trailing closure syntax. + public static let trailingViewBuilders = SlotsOption(id: 0) +} diff --git a/Tests/SlotTests/SlotIntegrationTests.swift b/Tests/SlotTests/SlotIntegrationTests.swift index cb494fb..b5c48d8 100644 --- a/Tests/SlotTests/SlotIntegrationTests.swift +++ b/Tests/SlotTests/SlotIntegrationTests.swift @@ -119,7 +119,7 @@ final class SlotIntegrationTests: XCTestCase { isSelected: false, leading: { Image(systemName: "star") }, content: { Text("hi") }, trailing: { Text("→") }) // LocalizedStringResource content, generic leading + trailing let _: Row = Row( - isSelected: true, content: "hi", leading: { Image(systemName: "star") }, trailing: { Text("→") }) + isSelected: true, leading: { Image(systemName: "star") }, content: "hi", trailing: { Text("→") }) // image leading, LocalizedStringResource content, generic trailing let _: Row = Row( isSelected: false, leadingSystemName: "star", content: "hi", trailing: { Text("→") }) diff --git a/Tests/SlotTests/SlotTests.swift b/Tests/SlotTests/SlotTests.swift index 173f627..b93ac9e 100644 --- a/Tests/SlotTests/SlotTests.swift +++ b/Tests/SlotTests/SlotTests.swift @@ -417,31 +417,31 @@ final class SlotTests: XCTestCase { } extension Card where Subtitle == Text { - init(subtitle: LocalizedStringResource, @ViewBuilder title: () -> Title, @ViewBuilder actions: () -> Actions) { - self.subtitle = Text(subtitle) + init(@ViewBuilder title: () -> Title, subtitle: LocalizedStringResource, @ViewBuilder actions: () -> Actions) { self.title = title() + self.subtitle = Text(subtitle) self.actions = Optional(actions()) } @_disfavoredOverload - init(subtitle: String, @ViewBuilder title: () -> Title, @ViewBuilder actions: () -> Actions) { - self.subtitle = Text(subtitle) + init(@ViewBuilder title: () -> Title, subtitle: String, @ViewBuilder actions: () -> Actions) { self.title = title() + self.subtitle = Text(subtitle) self.actions = Optional(actions()) } } extension Card where Subtitle == Text, Actions == Never { - init(subtitle: LocalizedStringResource, @ViewBuilder title: () -> Title) { - self.subtitle = Text(subtitle) + init(@ViewBuilder title: () -> Title, subtitle: LocalizedStringResource) { self.title = title() + self.subtitle = Text(subtitle) self.actions = nil } @_disfavoredOverload - init(subtitle: String, @ViewBuilder title: () -> Title) { - self.subtitle = Text(subtitle) + init(@ViewBuilder title: () -> Title, subtitle: String) { self.title = title() + self.subtitle = Text(subtitle) self.actions = nil } } @@ -578,35 +578,35 @@ final class SlotTests: XCTestCase { } extension Card where Body == Text { - init(body_: LocalizedStringResource, @ViewBuilder title: () -> Title, @ViewBuilder subtitle: () -> Subtitle, @ViewBuilder footer: () -> Footer) { - self.body_ = Text(body_) + init(@ViewBuilder title: () -> Title, @ViewBuilder subtitle: () -> Subtitle, body_: LocalizedStringResource, @ViewBuilder footer: () -> Footer) { self.title = title() self.subtitle = Optional(subtitle()) + self.body_ = Text(body_) self.footer = Optional(footer()) } @_disfavoredOverload - init(body_: String, @ViewBuilder title: () -> Title, @ViewBuilder subtitle: () -> Subtitle, @ViewBuilder footer: () -> Footer) { - self.body_ = Text(body_) + init(@ViewBuilder title: () -> Title, @ViewBuilder subtitle: () -> Subtitle, body_: String, @ViewBuilder footer: () -> Footer) { self.title = title() self.subtitle = Optional(subtitle()) + self.body_ = Text(body_) self.footer = Optional(footer()) } } extension Card where Body == Text, Footer == Never { - init(body_: LocalizedStringResource, @ViewBuilder title: () -> Title, @ViewBuilder subtitle: () -> Subtitle) { - self.body_ = Text(body_) + init(@ViewBuilder title: () -> Title, @ViewBuilder subtitle: () -> Subtitle, body_: LocalizedStringResource) { self.title = title() self.subtitle = Optional(subtitle()) + self.body_ = Text(body_) self.footer = nil } @_disfavoredOverload - init(body_: String, @ViewBuilder title: () -> Title, @ViewBuilder subtitle: () -> Subtitle) { - self.body_ = Text(body_) + init(@ViewBuilder title: () -> Title, @ViewBuilder subtitle: () -> Subtitle, body_: String) { self.title = title() self.subtitle = Optional(subtitle()) + self.body_ = Text(body_) self.footer = nil } } @@ -630,34 +630,34 @@ final class SlotTests: XCTestCase { } extension Card where Subtitle == Never, Body == Text { - init(body_: LocalizedStringResource, @ViewBuilder title: () -> Title, @ViewBuilder footer: () -> Footer) { - self.body_ = Text(body_) + init(@ViewBuilder title: () -> Title, body_: LocalizedStringResource, @ViewBuilder footer: () -> Footer) { self.title = title() + self.body_ = Text(body_) self.footer = Optional(footer()) self.subtitle = nil } @_disfavoredOverload - init(body_: String, @ViewBuilder title: () -> Title, @ViewBuilder footer: () -> Footer) { - self.body_ = Text(body_) + init(@ViewBuilder title: () -> Title, body_: String, @ViewBuilder footer: () -> Footer) { self.title = title() + self.body_ = Text(body_) self.footer = Optional(footer()) self.subtitle = nil } } extension Card where Subtitle == Never, Body == Text, Footer == Never { - init(body_: LocalizedStringResource, @ViewBuilder title: () -> Title) { - self.body_ = Text(body_) + init(@ViewBuilder title: () -> Title, body_: LocalizedStringResource) { self.title = title() + self.body_ = Text(body_) self.subtitle = nil self.footer = nil } @_disfavoredOverload - init(body_: String, @ViewBuilder title: () -> Title) { - self.body_ = Text(body_) + init(@ViewBuilder title: () -> Title, body_: String) { self.title = title() + self.body_ = Text(body_) self.subtitle = nil self.footer = nil } @@ -698,67 +698,67 @@ final class SlotTests: XCTestCase { } extension Card where Title == Text, Body == Text { - init(title: LocalizedStringResource, body_: LocalizedStringResource, @ViewBuilder subtitle: () -> Subtitle, @ViewBuilder footer: () -> Footer) { + init(title: LocalizedStringResource, @ViewBuilder subtitle: () -> Subtitle, body_: LocalizedStringResource, @ViewBuilder footer: () -> Footer) { self.title = Text(title) - self.body_ = Text(body_) self.subtitle = Optional(subtitle()) + self.body_ = Text(body_) self.footer = Optional(footer()) } @_disfavoredOverload - init(title: LocalizedStringResource, body_: String, @ViewBuilder subtitle: () -> Subtitle, @ViewBuilder footer: () -> Footer) { + init(title: LocalizedStringResource, @ViewBuilder subtitle: () -> Subtitle, body_: String, @ViewBuilder footer: () -> Footer) { self.title = Text(title) - self.body_ = Text(body_) self.subtitle = Optional(subtitle()) + self.body_ = Text(body_) self.footer = Optional(footer()) } @_disfavoredOverload - init(title: String, body_: LocalizedStringResource, @ViewBuilder subtitle: () -> Subtitle, @ViewBuilder footer: () -> Footer) { + init(title: String, @ViewBuilder subtitle: () -> Subtitle, body_: LocalizedStringResource, @ViewBuilder footer: () -> Footer) { self.title = Text(title) - self.body_ = Text(body_) self.subtitle = Optional(subtitle()) + self.body_ = Text(body_) self.footer = Optional(footer()) } @_disfavoredOverload - init(title: String, body_: String, @ViewBuilder subtitle: () -> Subtitle, @ViewBuilder footer: () -> Footer) { + init(title: String, @ViewBuilder subtitle: () -> Subtitle, body_: String, @ViewBuilder footer: () -> Footer) { self.title = Text(title) - self.body_ = Text(body_) self.subtitle = Optional(subtitle()) + self.body_ = Text(body_) self.footer = Optional(footer()) } } extension Card where Title == Text, Body == Text, Footer == Never { - init(title: LocalizedStringResource, body_: LocalizedStringResource, @ViewBuilder subtitle: () -> Subtitle) { + init(title: LocalizedStringResource, @ViewBuilder subtitle: () -> Subtitle, body_: LocalizedStringResource) { self.title = Text(title) - self.body_ = Text(body_) self.subtitle = Optional(subtitle()) + self.body_ = Text(body_) self.footer = nil } @_disfavoredOverload - init(title: LocalizedStringResource, body_: String, @ViewBuilder subtitle: () -> Subtitle) { + init(title: LocalizedStringResource, @ViewBuilder subtitle: () -> Subtitle, body_: String) { self.title = Text(title) - self.body_ = Text(body_) self.subtitle = Optional(subtitle()) + self.body_ = Text(body_) self.footer = nil } @_disfavoredOverload - init(title: String, body_: LocalizedStringResource, @ViewBuilder subtitle: () -> Subtitle) { + init(title: String, @ViewBuilder subtitle: () -> Subtitle, body_: LocalizedStringResource) { self.title = Text(title) - self.body_ = Text(body_) self.subtitle = Optional(subtitle()) + self.body_ = Text(body_) self.footer = nil } @_disfavoredOverload - init(title: String, body_: String, @ViewBuilder subtitle: () -> Subtitle) { + init(title: String, @ViewBuilder subtitle: () -> Subtitle, body_: String) { self.title = Text(title) - self.body_ = Text(body_) self.subtitle = Optional(subtitle()) + self.body_ = Text(body_) self.footer = nil } } @@ -941,19 +941,19 @@ final class SlotTests: XCTestCase { } extension Banner where Label == Text { - init(isEnabled: Bool, badge: Int = 0, label: LocalizedStringResource, @ViewBuilder icon: () -> Icon) { + init(isEnabled: Bool, badge: Int = 0, @ViewBuilder icon: () -> Icon, label: LocalizedStringResource) { self.isEnabled = isEnabled self.badge = badge - self.label = Text(label) self.icon = Optional(icon()) + self.label = Text(label) } @_disfavoredOverload - init(isEnabled: Bool, badge: Int = 0, label: String, @ViewBuilder icon: () -> Icon) { + init(isEnabled: Bool, badge: Int = 0, @ViewBuilder icon: () -> Icon, label: String) { self.isEnabled = isEnabled self.badge = badge - self.label = Text(label) self.icon = Optional(icon()) + self.label = Text(label) } } @@ -1085,15 +1085,15 @@ final class SlotTests: XCTestCase { } extension Chip where Label == Text { - init(label: LocalizedStringResource, @ViewBuilder icon: () -> Icon) { - self.label = Text(label) + init(@ViewBuilder icon: () -> Icon, label: LocalizedStringResource) { self.icon = Optional(icon()) + self.label = Text(label) } @_disfavoredOverload - init(label: String, @ViewBuilder icon: () -> Icon) { - self.label = Text(label) + init(@ViewBuilder icon: () -> Icon, label: String) { self.icon = Optional(icon()) + self.label = Text(label) } } @@ -1149,7 +1149,7 @@ final class SlotTests: XCTestCase { // MARK: - Parameter ordering tests - func testClosurePropertyOrdering() { + func testClosurePropertyDeclarationOrder() { assertMacroExpansion( """ @Slots @@ -1172,15 +1172,91 @@ final class SlotTests: XCTestCase { } extension ActionButton where Label == Text { - init(label: LocalizedStringResource, action: @escaping () -> Void) { + init(action: @escaping () -> Void, label: LocalizedStringResource) { + self.action = action self.label = Text(label) + } + + @_disfavoredOverload + init(action: @escaping () -> Void, label: String) { self.action = action + self.label = Text(label) + } + } + """, + macros: testMacros + ) + } + + func testParameterDeclarationOrdering() { + // Verifies: without .trailingViewBuilders, parameters preserve declaration order + assertMacroExpansion( + """ + @Slots + struct Composed: View { + var style: Int + var onTap: () -> Void + @Slot(.text) var label: Label + var trailing: Trailing? + var body: some View { EmptyView() } + } + """, + expandedSource: """ + struct Composed: View { + var style: Int + var onTap: () -> Void + var label: Label + var trailing: Trailing? + var body: some View { EmptyView() } + + init(style: Int, onTap: @escaping () -> Void, @ViewBuilder label: () -> Label, @ViewBuilder trailing: () -> Trailing) { + self.style = style + self.onTap = onTap + self.label = label() + self.trailing = Optional(trailing()) + } + } + + extension Composed where Trailing == Never { + init(style: Int, onTap: @escaping () -> Void, @ViewBuilder label: () -> Label) { + self.style = style + self.onTap = onTap + self.label = label() + self.trailing = nil + } + } + + extension Composed where Label == Text { + init(style: Int, onTap: @escaping () -> Void, label: LocalizedStringResource, @ViewBuilder trailing: () -> Trailing) { + self.style = style + self.onTap = onTap + self.label = Text(label) + self.trailing = Optional(trailing()) } @_disfavoredOverload - init(label: String, action: @escaping () -> Void) { + init(style: Int, onTap: @escaping () -> Void, label: String, @ViewBuilder trailing: () -> Trailing) { + self.style = style + self.onTap = onTap self.label = Text(label) - self.action = action + self.trailing = Optional(trailing()) + } + } + + extension Composed where Label == Text, Trailing == Never { + init(style: Int, onTap: @escaping () -> Void, label: LocalizedStringResource) { + self.style = style + self.onTap = onTap + self.label = Text(label) + self.trailing = nil + } + + @_disfavoredOverload + init(style: Int, onTap: @escaping () -> Void, label: String) { + self.style = style + self.onTap = onTap + self.label = Text(label) + self.trailing = nil } } """, @@ -1189,10 +1265,10 @@ final class SlotTests: XCTestCase { } func testParameterTierOrdering() { - // Verifies: value params → closure params → @ViewBuilder params + // Verifies: with .trailingViewBuilders, value params → closure params → @ViewBuilder params assertMacroExpansion( """ - @Slots + @Slots(.trailingViewBuilders) struct Composed: View { var style: Int var onTap: () -> Void @@ -1432,9 +1508,9 @@ final class SlotTests: XCTestCase { } extension EventCard where When == DateResolver.Output { - init(when_: DateResolver.Input, @ViewBuilder title: () -> Title) { - self.when_ = DateResolver.resolve(when_) + init(@ViewBuilder title: () -> Title, when_: DateResolver.Input) { self.title = title() + self.when_ = DateResolver.resolve(when_) } }