diff --git a/Plugins/Shared.swift b/Plugins/Shared.swift index a224dac9..8aad676e 100644 --- a/Plugins/Shared.swift +++ b/Plugins/Shared.swift @@ -29,7 +29,7 @@ import PackagePlugin // As of Xcode 15.0, Xcode command plugins have no way to read the package manifest, therefore we must hardcode the version number. // It is okay for this number to be behind the most current release if the inputs and outputs to SafeDITool have not changed. // Unlike SPM plugins, Xcode plugins can not determine the current version number, so we must hardcode it. - "1.5.1" + "1.5.2" } var safeDIOrigin: URL { diff --git a/SafeDI.podspec b/SafeDI.podspec index 16e59b56..56705f57 100644 --- a/SafeDI.podspec +++ b/SafeDI.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'SafeDI' - s.version = '1.5.1' + s.version = '1.5.2' s.summary = 'Compile-time-safe dependency injection' s.homepage = 'https://github.com/dfed/SafeDI' s.license = 'MIT' diff --git a/Sources/SafeDICore/Generators/ScopeGenerator.swift b/Sources/SafeDICore/Generators/ScopeGenerator.swift index 74336ed0..0843d363 100644 --- a/Sources/SafeDICore/Generators/ScopeGenerator.swift +++ b/Sources/SafeDICore/Generators/ScopeGenerator.swift @@ -79,6 +79,35 @@ actor ScopeGenerator: CustomStringConvertible, Sendable { } } ) + onlyIfAvailableUnwrappedReceivedProperties = Set( + propertiesToGenerate.flatMap { [propertiesToDeclare, scopeData] propertyToGenerate in + propertyToGenerate.onlyIfAvailableUnwrappedReceivedProperties + .subtracting(propertiesToDeclare) + .subtracting(scopeData.forwardedProperties) + } + ) + .union( + instantiable + .dependencies + .compactMap { + switch $0.source { + case .instantiated, .forwarded: + nil + case let .received(onlyIfAvailable): + if onlyIfAvailable { + $0.property.asUnwrappedProperty + } else { + nil + } + case let .aliased(fulfillingProperty, _, onlyIfAvailable): + if onlyIfAvailable { + fulfillingProperty.asUnwrappedProperty + } else { + nil + } + } + } + ) } init( @@ -95,6 +124,11 @@ actor ScopeGenerator: CustomStringConvertible, Sendable { onlyIfAvailable: onlyIfAvailable ) receivedProperties = [fulfillingProperty] + onlyIfAvailableUnwrappedReceivedProperties = if onlyIfAvailable { + [fulfillingProperty.asUnwrappedProperty] + } else { + [] + } description = property.asSource propertiesToGenerate = [] propertiesToDeclare = [] @@ -370,6 +404,8 @@ actor ScopeGenerator: CustomStringConvertible, Sendable { private let scopeData: ScopeData /// Properties that we require in order to satisfy our (and our children’s) dependencies. private let receivedProperties: Set + /// Unwrapped versions of received properties from transitive `@Received(onlyIfAvailable: true)` dependencies. + private let onlyIfAvailableUnwrappedReceivedProperties: Set /// Received properties that are optional and not created by a parent. private let unavailableOptionalProperties: Set /// Properties that will be generated as `let` constants. @@ -378,32 +414,6 @@ actor ScopeGenerator: CustomStringConvertible, Sendable { private let propertiesToDeclare: Set private let property: Property? - nonisolated private var onlyIfAvailableUnwrappedReceivedProperties: Set { - switch scopeData { - case let .property(instantiable, _, _, _, _): - .init(instantiable.dependencies.compactMap { - switch $0.source { - case .instantiated, .forwarded: - nil - case let .received(onlyIfAvailable): - if onlyIfAvailable { - $0.property.asUnwrappedProperty - } else { - nil - } - case let .aliased(fulfillingProperty, _, onlyIfAvailable): - if onlyIfAvailable { - fulfillingProperty.asUnwrappedProperty - } else { - nil - } - } - }) - case .root, .alias: - [] - } - } - private var unavailablePropertiesToGenerateCodeTask = [Set: Task]() private var orderedPropertiesToGenerate: [ScopeGenerator] { @@ -420,19 +430,21 @@ actor ScopeGenerator: CustomStringConvertible, Sendable { else { return } + // Mark as fulfilled before recursing to prevent cycles. + propertyToUnfulfilledScopeMap[property] = nil let scopeDependencies = propertyToUnfulfilledScopeMap .keys - .intersection(scope.receivedProperties) - .union(scope.onlyIfAvailableUnwrappedReceivedProperties) + .intersection( + scope.receivedProperties + .union(scope.onlyIfAvailableUnwrappedReceivedProperties) + ) .compactMap { propertyToUnfulfilledScopeMap[$0] } // Fulfill the scopes we depend upon. for dependentScope in scopeDependencies { fulfill(dependentScope) } - // We can now be marked as fulfilled! orderedPropertiesToGenerate.append(scope) - propertyToUnfulfilledScopeMap[property] = nil } for scope in propertiesToGenerate { diff --git a/Sources/SafeDITool/SafeDITool.swift b/Sources/SafeDITool/SafeDITool.swift index 2be304aa..d3138dbb 100644 --- a/Sources/SafeDITool/SafeDITool.swift +++ b/Sources/SafeDITool/SafeDITool.swift @@ -50,7 +50,7 @@ struct SafeDITool: AsyncParsableCommand, Sendable { // MARK: Internal static var currentVersion: String { - "1.5.1" + "1.5.2" } func run() async throws { diff --git a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift index 51024cdb..0c75a6ff 100644 --- a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift +++ b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift @@ -5933,6 +5933,77 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { ) } + @Test + mutating func run_writesConvenienceExtensionOnRootOfTree_whenInstantiatorClosureTransitivelyCapturesVariableDeclaredLaterAlphabetically() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public final class Root { + public init(childVCBuilder: Instantiator, service: Service) { + fatalError("SafeDI doesn't inspect the initializer body") + } + + @Instantiated let childVCBuilder: Instantiator + @Instantiated let service: Service + } + """, + """ + @Instantiable + public final class ChildVC { + public init(grandchildVCBuilder: Instantiator) { + fatalError("SafeDI doesn't inspect the initializer body") + } + + @Instantiated let grandchildVCBuilder: Instantiator + } + """, + """ + @Instantiable + public final class GrandchildVC { + public init(service: Service?) { + fatalError("SafeDI doesn't inspect the initializer body") + } + + @Received(onlyIfAvailable: true) let service: Service? + } + """, + """ + @Instantiable + public final class Service { + public init() { + fatalError("SafeDI doesn't inspect the initializer body") + } + } + """, + ], + buildDependencyTreeOutput: true, + filesToDelete: &filesToDelete + ) + + #expect(try #require(output.dependencyTree) == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + extension Root { + public convenience init() { + let service = Service() + func __safeDI_childVCBuilder() -> ChildVC { + func __safeDI_grandchildVCBuilder() -> GrandchildVC { + GrandchildVC(service: service) + } + let grandchildVCBuilder = Instantiator(__safeDI_grandchildVCBuilder) + return ChildVC(grandchildVCBuilder: grandchildVCBuilder) + } + let childVCBuilder = Instantiator(__safeDI_childVCBuilder) + self.init(childVCBuilder: childVCBuilder, service: service) + } + } + """ + ) + } + // MARK: Private private var filesToDelete = [URL]()