Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Explicitly declare root instantiables #126

Merged
merged 6 commits into from
Dec 29, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import Subproject
import SwiftUI

// @Instantiable macro marks this type as capable of being instantiated by SafeDI.
dfed marked this conversation as resolved.
Show resolved Hide resolved
@Instantiable
@Instantiable(isRoot: true)
@MainActor
@main
public struct NotesApp: Instantiable, App {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import Foundation
import SafeDI
import SharedModule

@Instantiable @MainActor
@Instantiable(isRoot: true) @MainActor
public final class Root: Instantiable {
public init(childA: ChildA, childB: ChildB, childC: ChildC, shared: SharedThing, userDefaults: UserDefaults) {
self.childA = childA
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import SafeDI
import SwiftUI

// @Instantiable macro marks this type as capable of being instantiated by SafeDI.
dfed marked this conversation as resolved.
Show resolved Hide resolved
@Instantiable
@Instantiable(isRoot: true)
@MainActor
@main
public struct NotesApp: Instantiable, App {
Expand Down
10 changes: 2 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ When you want to instantiate a dependency after `init(…)`, you need to declare
The [`Instantiator`](Sources/SafeDI/DelayedInstantiation/Instantiator.swift) type is how SafeDI enables deferred instantiation of an `@Instantiable` type. `Instantiator` has a single generic that matches the type of the to-be-instantiated instance. Creating an `Instantiator` property is as simple as creating any other property in the SafeDI ecosystem:

```swift
@Instantiable
@Instantiable(isRoot: true)
public struct MyApp: App, Instantiable {
public init(contentViewInstantiator: Instantiator<ContentView>) {
self.contentViewInstantiator = contentViewInstantiator
Expand Down Expand Up @@ -353,13 +353,7 @@ public struct ParentView: View, Instantiable {

### Creating the root of your dependency tree

SafeDI automatically finds the root(s) of your dependency tree, and creates an extension on each root that contains a `public init()` function that instantiates the dependency tree.

An `@Instantiable` type qualifies as the root of a dependency tree if and only if:

1. The type‘s SafeDI-injected properties are all `@Instantiated` or `@Received(fulfilledByDependencyNamed:ofType:)`
2. The type‘s `@Received(fulfilledByDependencyNamed:ofType:)` properties can be fulfilled by `@Instantiated` or `@Received(fulfilledByDependencyNamed:ofType:)` properties declared on this type
3. The type is not instantiated by another `@Instantiable` type
Any type decorated with `@Instantiable(isRoot: true)` is a root of a SafeDI dependency tree. SafeDI creates a no-parameter `public init()` initializer that instantiates the dependency tree in an extension on each root type.

### Comparing SafeDI and Manual Injection: Key Differences

Expand Down
2 changes: 2 additions & 0 deletions Sources/SafeDI/PropertyDecoration/Instantiable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,12 @@
/// }
///
/// - Parameters:
/// - isRoot: Whether the decorated type represents a root of a dependency tree.
/// - additionalTypes: The types (in addition to the type decorated with this macro) of properties that can be decorated with `@Instantiated` and yield a result of this type. The types provided *must* be either superclasses of this type or protocols to which this type conforms.
/// - conformsElsewhere: Whether the decorated type already conforms to the `Instantiable` protocol elsewhere. If set to `true`, the macro does not enforce that this declaration conforms to `Instantiable`.
@attached(member, names: arbitrary)
public macro Instantiable(
isRoot: Bool = false,
fulfillingAdditionalTypes additionalTypes: [Any.Type] = [],
conformsElsewhere: Bool = false
) = #externalMacro(module: "SafeDIMacros", type: "InstantiableMacro")
Expand Down
18 changes: 16 additions & 2 deletions Sources/SafeDICore/Extensions/AttributeSyntaxExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,25 @@
import SwiftSyntax

extension AttributeSyntax {
public var fulfillingAdditionalTypes: ExprSyntax? {
public var isRoot: ExprSyntax? {
guard let arguments,
let labeledExpressionList = LabeledExprListSyntax(arguments),
let firstLabeledExpression = labeledExpressionList.first,
firstLabeledExpression.label?.text == "fulfillingAdditionalTypes"
firstLabeledExpression.label?.text == "isRoot"
else {
return nil
}

return firstLabeledExpression.expression
}

public var fulfillingAdditionalTypes: ExprSyntax? {
guard let arguments,
let labeledExpressionList = LabeledExprListSyntax(arguments),
let firstLabeledExpression = labeledExpressionList.first(where: {
// In `@Instantiatable`, the `fulfillingAdditionalTypes` parameter is the second parameter, though the first parameter has a default.
$0.label?.text == "fulfillingAdditionalTypes"
})
else {
return nil
}
Expand Down
58 changes: 12 additions & 46 deletions Sources/SafeDICore/Generators/DependencyTreeGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -199,12 +199,11 @@ public actor DependencyTreeGenerator {
.joined(separator: "\n")
}

/// A collection of `@Instantiable`-decorated types that do not explicitly receive dependencies.
/// - Note: These are not necessarily roots in the build graph, since these types may be instantiated by another `@Instantiable`.
private lazy var possibleRootInstantiables: Set<TypeDescription> = Set(
/// A collection of `@Instantiable`-decorated types that are at the roots of their respective dependency trees.
private lazy var rootInstantiables: Set<TypeDescription> = Set(
typeDescriptionToFulfillingInstantiableMap
.values
.filter(\.dependencies.couldRepresentRoot)
.filter(\.isRoot)
.map(\.concreteInstantiable)
)

Expand Down Expand Up @@ -233,39 +232,24 @@ public actor DependencyTreeGenerator {
}
}

for reachableTypeDescription in possibleRootInstantiables {
for reachableTypeDescription in rootInstantiables {
recordReachableTypeDescription(reachableTypeDescription)
}

return reachableTypeDescriptions
}()

/// A collection of `@Instantiable`-decorated types that are at the roots of their respective dependency trees.
private lazy var rootInstantiables: Set<TypeDescription> = possibleRootInstantiables
// Remove all `@Instantiable`-decorated types that are instantiated by another
// `@Instantiable`-decorated type.
.subtracting(Set(
reachableTypeDescriptions
.compactMap { typeDescriptionToFulfillingInstantiableMap[$0] }
.flatMap(\.dependencies)
.filter(\.isInstantiated)
.map(\.asInstantiatedType)
.compactMap { typeDescriptionToFulfillingInstantiableMap[$0]?.concreteInstantiable }
))

private func createTypeDescriptionToScopeMapping() throws -> [TypeDescription: Scope] {
// Create the mapping.
let typeDescriptionToScopeMap: [TypeDescription: Scope] = reachableTypeDescriptions
.reduce(into: [TypeDescription: Scope]()) { partialResult, typeDescription in
guard let instantiable = typeDescriptionToFulfillingInstantiableMap[typeDescription],
partialResult[instantiable.concreteInstantiable] == nil
else {
// We've already created a scope for this `instantiable`. Skip.
return
}
let scope = Scope(instantiable: instantiable)
for instantiableType in instantiable.instantiableTypes {
partialResult[instantiableType] = scope
if let instantiable = typeDescriptionToFulfillingInstantiableMap[typeDescription],
partialResult[instantiable.concreteInstantiable] == nil
{
let scope = Scope(instantiable: instantiable)
for instantiableType in instantiable.instantiableTypes {
partialResult[instantiableType] = scope
}
}
}

Expand Down Expand Up @@ -372,10 +356,7 @@ public actor DependencyTreeGenerator {
let parentContainsProperty = receivableProperties.contains(receivedProperty)
let propertyIsCreatedAtThisScope = createdProperties.contains(receivedProperty)
if !parentContainsProperty, !propertyIsCreatedAtThisScope {
if property == nil {
// This property's scope is not a real root instantiable! Remove it from the list.
rootInstantiables.remove(scope.instantiable.concreteInstantiable)
} else {
if property != nil {
// This property is in a dependency tree and is unfulfillable. Record the problem.
unfulfillableProperties.insert(.init(
property: receivedProperty,
Expand Down Expand Up @@ -486,21 +467,6 @@ extension Dependency {
}
}

// MARK: - Collection

extension Collection<Dependency> {
fileprivate var couldRepresentRoot: Bool {
first(where: {
switch $0.source {
case .instantiated, .aliased:
false
case .forwarded, .received:
true
}
}) == nil
}
}

// MARK: - Set

extension Set {
Expand Down
4 changes: 4 additions & 0 deletions Sources/SafeDICore/Models/Instantiable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,14 @@ public struct Instantiable: Codable, Hashable, Sendable {

public init(
instantiableType: TypeDescription,
isRoot: Bool,
initializer: Initializer?,
additionalInstantiables: [TypeDescription]?,
dependencies: [Dependency],
declarationType: DeclarationType
) {
instantiableTypes = [instantiableType] + (additionalInstantiables ?? [])
self.isRoot = isRoot
self.initializer = initializer
self.dependencies = dependencies
self.declarationType = declarationType
Expand All @@ -43,6 +45,8 @@ public struct Instantiable: Codable, Hashable, Sendable {
instantiableTypes[0]
}

/// Whether the instantiable type is a root of a dependency graph.
public let isRoot: Bool
/// A memberwise initializer for the concrete instantiable type.
/// If `nil`, the Instanitable type is incorrectly configured.
public let initializer: Initializer?
Expand Down
10 changes: 5 additions & 5 deletions Sources/SafeDICore/Models/Property.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ public struct Property: Codable, Hashable, Comparable, Sendable {
)
}

/// The property represented as source code.
public var asSource: String {
"\(label): \(typeDescription.asSource)"
}

// MARK: Hashable

public static func < (lhs: Property, rhs: Property) -> Bool {
Expand All @@ -55,11 +60,6 @@ public struct Property: Codable, Hashable, Comparable, Sendable {

// MARK: Internal

/// The property represented as source code.
var asSource: String {
"\(label): \(typeDescription.asSource)"
}

var asFunctionParamter: FunctionParameterSyntax {
switch typeDescription {
case .closure:
Expand Down
34 changes: 26 additions & 8 deletions Sources/SafeDICore/Visitors/InstantiableVisitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ public final class InstantiableVisitor: SyntaxVisitor {
if let instantiableType = node.signature.returnClause?.type.typeDescription {
extensionInstantiables.append(.init(
instantiableType: instantiableType,
isRoot: false,
initializer: initializer,
additionalInstantiables: additionalInstantiables,
dependencies: initializer.arguments.map {
Expand Down Expand Up @@ -280,6 +281,7 @@ public final class InstantiableVisitor: SyntaxVisitor {

// MARK: Public

public private(set) var isRoot = false
public private(set) var dependencies = [Dependency]()
public private(set) var initializers = [Initializer]()
public private(set) var instantiableType: TypeDescription?
Expand Down Expand Up @@ -324,6 +326,7 @@ public final class InstantiableVisitor: SyntaxVisitor {
[
Instantiable(
instantiableType: instantiableType,
isRoot: isRoot,
initializer: initializers.first(where: { $0.isValid(forFulfilling: dependencies) }),
additionalInstantiables: additionalInstantiables,
dependencies: dependencies,
Expand Down Expand Up @@ -378,16 +381,31 @@ public final class InstantiableVisitor: SyntaxVisitor {
}

private func processAttributes(_: AttributeListSyntax, on macro: AttributeSyntax) {
guard let fulfillingAdditionalTypesExpression = macro.fulfillingAdditionalTypes,
let fulfillingAdditionalTypesArray = ArrayExprSyntax(fulfillingAdditionalTypesExpression)
else {
// Nothing to do here.
return
func processIsRoot() {
guard let isRootExpression = macro.isRoot,
let boolExpression = BooleanLiteralExprSyntax(isRootExpression)
else {
// Nothing to do here.
return
}

isRoot = boolExpression.literal.tokenKind == .keyword(.true)
}
func processFulfillingAdditionalTypesParameter() {
guard let fulfillingAdditionalTypesExpression = macro.fulfillingAdditionalTypes,
let fulfillingAdditionalTypesArray = ArrayExprSyntax(fulfillingAdditionalTypesExpression)
else {
// Nothing to do here.
return
}

additionalInstantiables = fulfillingAdditionalTypesArray
.elements
.map(\.expression.typeDescription.asInstantiatedType)
}

additionalInstantiables = fulfillingAdditionalTypesArray
.elements
.map(\.expression.typeDescription.asInstantiatedType)
processIsRoot()
processFulfillingAdditionalTypesParameter()
}

private func processModifiers(_: DeclModifierListSyntax, on node: some ConcreteDeclSyntaxProtocol) {
Expand Down
37 changes: 37 additions & 0 deletions Sources/SafeDIMacros/Macros/InstantiableMacro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,26 @@ public struct InstantiableMacro: MemberMacro {
context.diagnose(diagnostic)
}

if visitor.isRoot, let instantiableType = visitor.instantiableType {
let inheritedDependencies = visitor.dependencies.filter {
switch $0.source {
case let .aliased(fulfillingProperty, _):
// Aliased properties must not be inherited from elsewhere.
!visitor.dependencies.contains { $0.property == fulfillingProperty }
case .instantiated:
false
case .forwarded, .received:
true
}
}
guard inheritedDependencies.isEmpty else {
throw InstantiableError.cannotBeRoot(
instantiableType,
violatingDependencies: inheritedDependencies
)
}
}

let forwardedProperties = visitor
.dependencies
.filter { $0.source == .forwarded }
Expand Down Expand Up @@ -235,6 +255,15 @@ public struct InstantiableMacro: MemberMacro {
context.diagnose(diagnostic)
}

if visitor.isRoot, let instantiableType = visitor.instantiableType {
guard visitor.instantiables.flatMap(\.dependencies).isEmpty else {
throw InstantiableError.cannotBeRoot(
instantiableType,
violatingDependencies: visitor.instantiables.flatMap(\.dependencies)
)
}
}

let instantiables = visitor.instantiables
if instantiables.count > 1 {
var concreteInstantiables = Set<TypeDescription>()
Expand Down Expand Up @@ -371,6 +400,7 @@ public struct InstantiableMacro: MemberMacro {
case fulfillingAdditionalTypesContainsOptional
case fulfillingAdditionalTypesArgumentInvalid
case tooManyInstantiateMethods(TypeDescription)
case cannotBeRoot(TypeDescription, violatingDependencies: [Dependency])

var description: String {
switch self {
Expand All @@ -382,6 +412,13 @@ public struct InstantiableMacro: MemberMacro {
"The argument `fulfillingAdditionalTypes` must be an inlined array"
case let .tooManyInstantiateMethods(type):
"@\(InstantiableVisitor.macroName)-decorated extension must have a single `\(InstantiableVisitor.instantiateMethodName)(…)` method that returns `\(type.asSource)`"
case let .cannotBeRoot(declaredRootType, violatingDependencies):
"""
Types decorated with `@\(InstantiableVisitor.macroName)(isRoot: true)` must only have dependencies that are all `@\(Dependency.Source.instantiatedRawValue)` or `@\(Dependency.Source.receivedRawValue)(fulfilledByDependencyNamed:ofType:)`, where the latter properties can be fulfilled by `@\(Dependency.Source.instantiatedRawValue)` or `@\(Dependency.Source.receivedRawValue)(fulfilledByDependencyNamed:ofType:)` properties declared on this type.

The following dependencies were found on \(declaredRootType.asSource) that violated this contract:
\(violatingDependencies.map(\.property.asSource).joined(separator: "\n"))
"""
}
}
}
Expand Down
1 change: 1 addition & 0 deletions Sources/SafeDITool/SafeDITool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ struct SafeDITool: AsyncParsableCommand, Sendable {
}
return Instantiable(
instantiableType: unnormalizedInstantiable.concreteInstantiable,
isRoot: unnormalizedInstantiable.isRoot,
initializer: normalizedInitializer,
additionalInstantiables: normalizedAdditionalInstantiables,
dependencies: normalizedDependencies,
Expand Down
Loading
Loading