Skip to content

Commit

Permalink
Enable forwarding multiple properties
Browse files Browse the repository at this point in the history
  • Loading branch information
dfed committed Jan 12, 2024
1 parent 4c83c05 commit f97ee6b
Show file tree
Hide file tree
Showing 11 changed files with 603 additions and 127 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,9 @@ public struct ParentView: View {

Property declarations within `@Instantiable` types decorated with [`@Forwarded`](Sources/SafeDI/PropertyDecoration/Forwarded.swift) represent dependencies that come from the runtime, e.g. user input or backend-delivered content. Like an `@Instantiated`-decorated property, a `@Forwarded`-decorated property is available to be `@Received` by objects instantiated further down the dependency tree.

A `@Forwarded` property is forwarded into the SafeDI dependency tree by a [`ForwardingInstantiator`](#forwardinginstantiator)’s `instantiate(_ argument: ArgumentToForward) -> InstantiableType` function that creates an instance of the property’s enclosing type. `@Instantiable` types with a `@Forwarded`-decorated property can _only_ be instantiated utilizing a `ForwardingInstantiator`.
A `@Forwarded` property is forwarded into the SafeDI dependency tree by a [`ForwardingInstantiator`](#forwardinginstantiator)’s `instantiate(_ arguments: ArgumentsToForward) -> InstantiableType` function that creates an instance of the property’s enclosing type. `@Instantiable` types with a `@Forwarded`-decorated property can _only_ be instantiated utilizing a `ForwardingInstantiator`.

A `ForwardingInstantiator`‘s single-argument `instantiate` function necessitates that an `@Instantiable` type may have at most one `@Forwarded`-decorated property. If you need to forward multiple properties, create a new container type that stores both properties and forward the container type. Forwarded property types do not need to be decorated with the `@Instantiable` macro.
Forwarded property types do not need to be decorated with the `@Instantiable` macro.

### @Received

Expand Down
10 changes: 5 additions & 5 deletions Sources/SafeDI/DelayedInstantiation/ForwardingInstantiator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,21 +27,21 @@
/// - SeeAlso: `Instantiator`
/// - Note: This class is the sole means for instantiating an `@Instantiable` type with a `@Forwarded`
/// property within the SafeDI framework.
public final class ForwardingInstantiator<ArgumentToForward, InstantiableType> {
public final class ForwardingInstantiator<ArgumentsToForward, InstantiableType> {
/// Initializes a new forwarding instantiator with the provided instantiation closure.
///
/// - Parameter instantiator: A closure that takes `ArgumentsToForward` and returns an instance of `InstantiableType`.
public init(_ instantiator: @escaping (ArgumentToForward) -> InstantiableType) {
public init(_ instantiator: @escaping (ArgumentsToForward) -> InstantiableType) {
self.instantiator = instantiator
}

/// Instantiates and returns a new instance of the `@Instantiable` type, using the provided arguments.
///
/// - Parameter arguments: Arguments required for instantiation.
/// - Returns: An `InstantiableType` instance.
public func instantiate(_ argument: ArgumentToForward) -> InstantiableType {
instantiator(argument)
public func instantiate(_ arguments: ArgumentsToForward) -> InstantiableType {
instantiator(arguments)
}

private let instantiator: (ArgumentToForward) -> InstantiableType
private let instantiator: (ArgumentsToForward) -> InstantiableType
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,23 +21,37 @@
import SwiftSyntax
import SwiftSyntaxBuilder

extension Array where Element == Dependency {

var removingDuplicateInitializerArguments: Self {
var alreadySeenInitializerArgument = Set<Property>()
return filter {
if alreadySeenInitializerArgument.contains($0.property) {
return false
} else {
alreadySeenInitializerArgument.insert($0.property)
return true
extension Collection where Element == Dependency {

var initializerFunctionParameters: [FunctionParameterSyntax] {
map(\.property)
.initializerFunctionParameters
}
}

extension Collection where Element == Property {

public var asTuple: TupleTypeSyntax {
let tupleElements = sorted()
.map(\.asTupleElement)
.transformUntilLast {
var node = $0
node.trailingComma = .commaToken(trailingTrivia: .space)
return node
}
var tuple = TupleTypeSyntax(elements: TupleTypeElementListSyntax())
for element in tupleElements {
tuple.elements.append(element)
}
return tuple
}

var asTupleTypeDescription: TypeDescription {
TypeSyntax(asTuple).typeDescription
}

var initializerFunctionParameters: [FunctionParameterSyntax] {
removingDuplicateInitializerArguments
.map { $0.property.asFunctionParamter }
map { $0.asFunctionParamter }
.transformUntilLast {
var node = $0
node.trailingComma = .commaToken(trailingTrivia: .space)
Expand All @@ -46,12 +60,12 @@ extension Array where Element == Dependency {
}
}

extension Array {
extension Collection {
fileprivate func transformUntilLast(_ transform: (Element) throws -> Element) rethrows -> [Element] {
var arrayToTransform = self
var arrayToTransform = Array(self)
guard let lastItem = arrayToTransform.popLast() else {
// Array is empty.
return self
return []
}
return try arrayToTransform.map { try transform($0) } + [lastItem]
}
Expand Down
66 changes: 40 additions & 26 deletions Sources/SafeDICore/Generators/ScopeGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,21 +33,21 @@ actor ScopeGenerator {
scopeData = .property(
instantiable: instantiable,
property: property,
forwardedProperty: instantiable
.dependencies
forwardedProperties: Set(
instantiable
.dependencies
// Instantiated properties will self-resolve.
.filter { $0.source == .forwarded }
.map(\.property)
// Our @Instantiable macro enforces that we have at most one forwarded property.
.first
.filter { $0.source == .forwarded }
.map(\.property)
)
)
} else {
scopeData = .root(instantiable: instantiable)
}
self.property = property
self.receivedProperties = receivedProperties
self.propertiesToGenerate = propertiesToGenerate
forwardedProperty = scopeData.forwardedProperty
forwardedProperties = scopeData.forwardedProperties
propertiesMadeAvailableByChildren = Set(
instantiable
.dependencies
Expand Down Expand Up @@ -78,7 +78,7 @@ actor ScopeGenerator {
scopeData = .alias(property: property, fulfillingProperty: fulfillingProperty)
requiredReceivedProperties = [fulfillingProperty]
propertiesToGenerate = []
forwardedProperty = nil
forwardedProperties = []
propertiesMadeAvailableByChildren = []
self.receivedProperties = receivedProperties
self.property = property
Expand Down Expand Up @@ -111,7 +111,7 @@ actor ScopeGenerator {
case let .property(
instantiable,
property,
forwardedProperty
forwardedProperties
):
let argumentList = try instantiable.generateArgumentList()
let concreteTypeName = instantiable.concreteInstantiableType.asSource
Expand All @@ -127,17 +127,31 @@ actor ScopeGenerator {
let propertyDeclaration: String
let leadingConcreteTypeName: String
let closureArguments: String
if let forwardedProperty {
guard property.generics.first == forwardedProperty.typeDescription else {
if forwardedProperties.isEmpty {
closureArguments = ""
} else {
if let firstForwardedProperty = forwardedProperties.first,
let forwardedArgument = property.generics.first,
!(
// The forwarded argument is the same type as our only `@Forwarded` property.
(forwardedProperties.count == 1 && forwardedArgument == firstForwardedProperty.typeDescription)
// The forwarded argument is the same as `InstantiableTypeName.ForwardedArguments`.
|| forwardedArgument == .nested(name: "ForwardedArguments", parentType: instantiable.concreteInstantiableType)
// The forwarded argument is the same as the tuple we generated for `InstantiableTypeName.ForwardedArguments`.
|| forwardedArgument == forwardedProperties.asTupleTypeDescription
)
{
throw GenerationError.forwardingInstantiatorGenericDoesNotMatch(
property: property,
expectedType: forwardedProperty.typeDescription,
instantiable: instantiable
)
}
closureArguments = " \(forwardedProperty.label) in"
} else {
closureArguments = ""

let forwardedArgumentList = forwardedProperties
.sorted()
.map(\.label)
.joined(separator: ", ")
closureArguments = " \(forwardedArgumentList) in"
}
switch property.propertyType {
case .instantiator, .forwardingInstantiator:
Expand Down Expand Up @@ -193,19 +207,19 @@ actor ScopeGenerator {
case property(
instantiable: Instantiable,
property: Property,
forwardedProperty: Property?
forwardedProperties: Set<Property>
)
case alias(
property: Property,
fulfillingProperty: Property
)

var forwardedProperty: Property? {
var forwardedProperties: Set<Property> {
switch self {
case let .property(_, _, forwardedProperty):
return forwardedProperty
case let .property(_, _, forwardedProperties):
return forwardedProperties
case .root, .alias:
return nil
return []
}
}
}
Expand All @@ -216,7 +230,7 @@ actor ScopeGenerator {
private let receivedProperties: Set<Property>
private let propertiesToGenerate: [ScopeGenerator]
private let property: Property?
private let forwardedProperty: Property?
private let forwardedProperties: Set<Property>

private var resolvedProperties = Set<Property>()
private var generateCodeTask: Task<String, Error>?
Expand Down Expand Up @@ -264,25 +278,25 @@ actor ScopeGenerator {
.requiredReceivedProperties
.contains(where: {
!isPropertyResolved($0)
&& propertyToGenerate.forwardedProperty != $0
&& !propertyToGenerate.forwardedProperties.contains($0)
})
}

private func isPropertyResolved(_ property: Property) -> Bool {
resolvedProperties.contains(property)
|| receivedProperties.contains(property)
|| forwardedProperty == property
|| forwardedProperties.contains(property)
}

// MARK: GenerationError

private enum GenerationError: Error, CustomStringConvertible {
case forwardingInstantiatorGenericDoesNotMatch(property: Property, expectedType: TypeDescription, instantiable: Instantiable)
case forwardingInstantiatorGenericDoesNotMatch(property: Property, instantiable: Instantiable)

var description: String {
switch self {
case let .forwardingInstantiatorGenericDoesNotMatch(property, expectedType, instantiable):
"Property `\(property.asSource)` on \(instantiable.concreteInstantiableType.asSource) incorrectly configured. Property should instead be of type `\(Dependency.forwardingInstantiatorType)<\(expectedType.asSource), \(property.typeDescription.asInstantiatedType.asSource)>`. First generic argument must match type of @\(Dependency.Source.forwarded.rawValue) property."
case let .forwardingInstantiatorGenericDoesNotMatch(property, instantiable):
"Property `\(property.asSource)` on \(instantiable.concreteInstantiableType.asSource) incorrectly configured. Property should instead be of type `\(Dependency.forwardingInstantiatorType)<\(instantiable.concreteInstantiableType.asSource).ForwardedArguments, \(property.typeDescription.asInstantiatedType.asSource)>`."
}
}
}
Expand Down
5 changes: 2 additions & 3 deletions Sources/SafeDICore/Models/Initializer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,9 @@ public struct Initializer: Codable, Hashable {

let dependencyAndArgumentBinding = try createDependencyAndArgumentBinding(given: dependencies)

let dependenciesWithDuplicateInitializerArgumentsRemoved = dependencies.removingDuplicateInitializerArguments
let initializerFulfulledDependencies = Set(dependencyAndArgumentBinding.map(\.dependency))
let missingArguments = Set(dependenciesWithDuplicateInitializerArgumentsRemoved).subtracting(initializerFulfulledDependencies)
let missingArguments = Set(dependencies).subtracting(initializerFulfulledDependencies)

guard missingArguments.isEmpty else {
throw GenerationError.missingArguments(missingArguments.map(\.property.asSource))
}
Expand Down
9 changes: 9 additions & 0 deletions Sources/SafeDICore/Models/Property.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ public struct Property: Codable, Hashable, Comparable, Sendable {

// MARK: Internal

/// The property represented as source code.
var asSource: String {
"\(label): \(typeDescription.asSource)"
}
Expand All @@ -61,6 +62,14 @@ public struct Property: Codable, Hashable, Comparable, Sendable {
)
}

var asTupleElement: TupleTypeElementSyntax {
TupleTypeElementSyntax(
firstName: .identifier(label),
colon: .colonToken(),
type: IdentifierTypeSyntax(name: .identifier(typeDescription.asSource))
)
}

var propertyType: PropertyType {
switch typeDescription {
case let .simple(name, _):
Expand Down
45 changes: 34 additions & 11 deletions Sources/SafeDICore/Models/TypeDescription.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ public enum TypeDescription: Codable, Hashable, Comparable, Sendable {
indirect case array(element: TypeDescription)
/// A dictionary. e.g. [Int: String]
indirect case dictionary(key: TypeDescription, value: TypeDescription)
/// A tuple. e.g. (Int, String)
indirect case tuple([TypeDescription])
/// A tuple. e.g. (Int, string: String)
indirect case tuple([TupleElement])
/// A closure. e.g. (Int, Double) throws -> String
indirect case closure(arguments: [TypeDescription], isAsync: Bool, doesThrow: Bool, returnType: TypeDescription)
/// A type that can't be represented by the above cases.
Expand Down Expand Up @@ -117,7 +117,15 @@ public enum TypeDescription: Codable, Hashable, Comparable, Sendable {
case let .dictionary(key, value):
return "Dictionary<\(key.asSource), \(value.asSource)>"
case let .tuple(types):
return "(\(types.map { $0.asSource }.joined(separator: ", ")))"
return """
(\(types.map {
if let label = $0.label {
"\(label): \($0.typeDescription.asSource)"
} else {
$0.typeDescription.asSource
}
}.joined(separator: ", ")))
"""
case let .closure(arguments, isAsync, doesThrow, returnType):
return "(\(arguments.map { $0.asSource }.joined(separator: ", ")))\([isAsync ? " async" : "", doesThrow ? " throws" : ""].filter { !$0.isEmpty }.joined()) -> \(returnType.asSource)"
case let .unknown(text):
Expand All @@ -129,6 +137,11 @@ public enum TypeDescription: Codable, Hashable, Comparable, Sendable {
lhs.asSource < rhs.asSource
}

public struct TupleElement: Codable, Hashable, Sendable {
public let label: String?
public let typeDescription: TypeDescription
}

var isOptional: Bool {
switch self {
case .any,
Expand Down Expand Up @@ -179,9 +192,9 @@ public enum TypeDescription: Codable, Hashable, Comparable, Sendable {
// This is a type that is lazily instantiated.
// The first generic is the built type.
return builtType
} else if name == Dependency.forwardingInstantiatorType, let builtType = generics.last {
} else if name == Dependency.forwardingInstantiatorType, let builtType = generics.dropFirst().first {
// This is a type that is lazily instantiated with forwarded arguments.
// The last generic is the built type.
// The second generic is the built type.
return builtType
} else {
return self
Expand Down Expand Up @@ -264,8 +277,13 @@ extension TypeSyntax {
key: typeIdentifier.key.typeDescription,
value: typeIdentifier.value.typeDescription)

} else if let typeIdentifiers = TupleTypeSyntax(self) {
return .tuple(typeIdentifiers.elements.map { $0.type.typeDescription })
} else if let typeIdentifier = TupleTypeSyntax(self) {
return .tuple(typeIdentifier.elements.map {
TypeDescription.TupleElement(
label: $0.secondName?.text ?? $0.firstName?.text,
typeDescription: $0.type.typeDescription
)
})

} else if ClassRestrictionTypeSyntax(self) != nil {
// A class restriction is the same as requiring inheriting from AnyObject:
Expand Down Expand Up @@ -341,17 +359,22 @@ extension ExprSyntax {
return .unknown(text: trimmedDescription)
}
} else if let tupleExpr = TupleExprSyntax(self) {
let tupleTypes = tupleExpr.elements.map(\.expression.typeDescription)
if tupleTypes.count == 1 {
let tupleElements = tupleExpr.elements
if tupleElements.count == 1 {
// Single-element tuple types must be unwrapped.
// Certain types can not be in a Any.Type list without being wrapped
// in a tuple. We care only about the underlying types in this case.
// A @Instantiable that fulfills an addition type `(some Collection).self`
// should be unwrapped as `some Collection` to enable the @Instantiable
// to fulfill `some Collection`.
return tupleTypes[0]
return tupleElements.lazy.map(\.expression)[0].typeDescription
} else {
return .tuple(tupleTypes)
return .tuple(tupleElements.map {
TypeDescription.TupleElement(
label: $0.label?.text,
typeDescription: $0.expression.typeDescription
)
})
}
} else if let sequenceExpr = SequenceExprSyntax(self) {
if sequenceExpr.elements.contains(where: { BinaryOperatorExprSyntax($0) != nil }) {
Expand Down
Loading

0 comments on commit f97ee6b

Please sign in to comment.