diff --git a/Sources/SafeDICore/Generators/DependencyTreeGenerator.swift b/Sources/SafeDICore/Generators/DependencyTreeGenerator.swift index 432c60d1..b4bc5863 100644 --- a/Sources/SafeDICore/Generators/DependencyTreeGenerator.swift +++ b/Sources/SafeDICore/Generators/DependencyTreeGenerator.swift @@ -90,6 +90,7 @@ public final class DependencyTreeGenerator { case instantiableHasForwardedProperty(property: Property, instantiableWithForwardedProperty: Instantiable, parent: Instantiable) case constantDependencyCycleDetected([TypeDescription]) case receivedInstantiatorDependencyCycleDetected(property: Property, directParent: TypeDescription, cycle: [TypeDescription]) + case receivedConstantCycleDetected(instantiated: Property, receivedPropertyChain: [Property]) var description: String { switch self { @@ -124,6 +125,14 @@ public final class DependencyTreeGenerator { .reversed() .joined(separator: " -> ")) """ + case let .receivedConstantCycleDetected(instantiated, receivedPropertyChain): + """ + Dependency received in same chain it is instantiated! + \("@\(Dependency.Source.instantiatedRawValue) \(instantiated.asSource) -> " + + receivedPropertyChain + .map { "@\(Dependency.Source.receivedRawValue) \($0.asSource)" } + .joined(separator: " -> ")) + """ } } @@ -151,7 +160,7 @@ public final class DependencyTreeGenerator { try validateReachableTypeDescriptions() let typeDescriptionToScopeMap = try createTypeDescriptionToScopeMapping() - try validateReceivedProperties(typeDescriptionToScopeMap: typeDescriptionToScopeMap) + try validatePropertiesAreFulfillable(typeDescriptionToScopeMap: typeDescriptionToScopeMap) return try rootInstantiables .sorted() .compactMap { @@ -314,9 +323,9 @@ public final class DependencyTreeGenerator { return typeDescriptionToScopeMap } - private func validateReceivedProperties(typeDescriptionToScopeMap: [TypeDescription: Scope]) throws { + private func validatePropertiesAreFulfillable(typeDescriptionToScopeMap: [TypeDescription: Scope]) throws { var unfulfillableProperties = Set() - func validateReceivedProperties( + func validatePropertiesAreFulfillable( on scope: Scope, receivableProperties: Set, property: Property?, @@ -341,6 +350,38 @@ public final class DependencyTreeGenerator { } .map(\.property) ) + if let property { + func validateNoCycleInReceivedProperties( + scope: Scope, + receivedPropertyStack: OrderedSet + ) throws { + for childProperty in scope.receivedProperties { + guard childProperty != property else { + throw DependencyTreeGeneratorError.receivedConstantCycleDetected( + instantiated: property, + receivedPropertyChain: receivedPropertyStack + [childProperty] + ) + } + guard !receivedPropertyStack.contains(childProperty) else { + // We've found a cycle, but it's not our cycle. Bail and let a future loop find this. + return + } + if let receivedPropertyScope = typeDescriptionToScopeMap[childProperty.typeDescription] { + var childPropertyStack = receivedPropertyStack + childPropertyStack.append(childProperty) + try validateNoCycleInReceivedProperties( + scope: receivedPropertyScope, + receivedPropertyStack: childPropertyStack + ) + } + } + } + try validateNoCycleInReceivedProperties( + scope: scope, + receivedPropertyStack: [] + ) + } + for receivedProperty in scope.receivedProperties { let parentContainsProperty = receivableProperties.contains(receivedProperty) let propertyIsCreatedAtThisScope = createdProperties.contains(receivedProperty) @@ -389,16 +430,17 @@ public final class DependencyTreeGenerator { for childPropertyToGenerate in scope.propertiesToGenerate { switch childPropertyToGenerate { - case let .instantiated(property, childScope, _): - guard !childPropertyStack.contains(property) else { + case let .instantiated(childProperty, childScope, _): + guard !childPropertyStack.contains(childProperty) else { // There is a cycle in our scope tree. Do not re-enter it. continue } - try validateReceivedProperties( + try validatePropertiesAreFulfillable( on: childScope, receivableProperties: receivableProperties - .union(scope.properties), - property: property, + .union(scope.properties) + .removing(childProperty), + property: childProperty, propertyStack: childPropertyStack, root: root ) @@ -409,7 +451,7 @@ public final class DependencyTreeGenerator { } for rootScope in rootInstantiables.compactMap({ typeDescriptionToScopeMap[$0] }) { - try validateReceivedProperties( + try validatePropertiesAreFulfillable( on: rootScope, receivableProperties: Set(rootScope.properties), property: nil, @@ -468,3 +510,13 @@ extension Collection { }) == nil } } + +// MARK: - Set + +extension Set { + fileprivate func removing(_ element: Element) -> Self { + var setWithoutElement = self + setWithoutElement.remove(element) + return setWithoutElement + } +} diff --git a/Sources/SafeDICore/Generators/ScopeGenerator.swift b/Sources/SafeDICore/Generators/ScopeGenerator.swift index 12c1612d..2e364c57 100644 --- a/Sources/SafeDICore/Generators/ScopeGenerator.swift +++ b/Sources/SafeDICore/Generators/ScopeGenerator.swift @@ -284,7 +284,7 @@ actor ScopeGenerator: CustomStringConvertible, Sendable { } func generateDOT() async throws -> String { - let orderedPropertiesToGenerate = try orderedPropertiesToGenerate + let orderedPropertiesToGenerate = orderedPropertiesToGenerate let instantiatedProperties = orderedPropertiesToGenerate.map(\.scopeData.asDOTNode) var childDOTs = [String]() for orderedPropertyToGenerate in orderedPropertiesToGenerate { @@ -351,58 +351,43 @@ actor ScopeGenerator: CustomStringConvertible, Sendable { private var generateCodeTask: Task? private var orderedPropertiesToGenerate: [ScopeGenerator] { - get throws { - var orderedPropertiesToGenerate = [ScopeGenerator]() - var propertyToUnfulfilledScopeMap = propertiesToGenerate - .reduce(into: OrderedDictionary()) { partialResult, scope in - if let property = scope.property { - partialResult[property] = scope - } - } - func fulfill(_ scope: ScopeGenerator, stack: OrderedSet = []) throws { - guard - let property = scope.property, - propertyToUnfulfilledScopeMap[property] != nil - else { - return - } - guard !stack.contains(property) else { - if property.propertyType.isConstant { - throw GenerationError.dependencyCycleDetected( - stack.drop(while: { $0 != property }) + [property], - scope: self - ) - } else { - return - } + var orderedPropertiesToGenerate = [ScopeGenerator]() + var propertyToUnfulfilledScopeMap = propertiesToGenerate + .reduce(into: OrderedDictionary()) { partialResult, scope in + if let property = scope.property { + partialResult[property] = scope } - - let scopeDependencies = propertyToUnfulfilledScopeMap - .keys - .intersection(scope.requiredReceivedProperties) - .compactMap { propertyToUnfulfilledScopeMap[$0] } - // Fulfill the scopes we depend upon. - for dependentScope in scopeDependencies { - var stack = stack - stack.append(property) - try fulfill(dependentScope, stack: stack) - } - // We can now be marked as fulfilled! - orderedPropertiesToGenerate.append(scope) - propertyToUnfulfilledScopeMap[property] = nil } - - for scope in propertiesToGenerate { - try fulfill(scope) + func fulfill(_ scope: ScopeGenerator) { + guard + let property = scope.property, + propertyToUnfulfilledScopeMap[property] != nil + else { + return } + let scopeDependencies = propertyToUnfulfilledScopeMap + .keys + .intersection(scope.requiredReceivedProperties) + .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 + } - return orderedPropertiesToGenerate + for scope in propertiesToGenerate { + fulfill(scope) } + + return orderedPropertiesToGenerate } private func generateProperties(leadingMemberWhitespace: String) async throws -> [String] { var generatedProperties = [String]() - for childGenerator in try orderedPropertiesToGenerate { + for childGenerator in orderedPropertiesToGenerate { try await generatedProperties.append( childGenerator .generateCode(leadingWhitespace: leadingMemberWhitespace) @@ -421,17 +406,11 @@ actor ScopeGenerator: CustomStringConvertible, Sendable { private enum GenerationError: Error, CustomStringConvertible { case erasedInstantiatorGenericDoesNotMatch(property: Property, instantiable: Instantiable) - case dependencyCycleDetected([Property], scope: ScopeGenerator) var description: String { switch self { case let .erasedInstantiatorGenericDoesNotMatch(property, instantiable): "Property `\(property.asSource)` on \(instantiable.concreteInstantiable.asSource) incorrectly configured. Property should instead be of type `\(Dependency.erasedInstantiatorType)<\(instantiable.concreteInstantiable.asSource).ForwardedProperties, \(property.typeDescription.asInstantiatedType.asSource)>`." - case let .dependencyCycleDetected(properties, scope): - """ - Dependency cycle detected on \(scope)! - \(properties.map(\.asSource).joined(separator: " -> ")) - """ } } } diff --git a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift index db375621..52742892 100644 --- a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift +++ b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift @@ -290,8 +290,8 @@ final class SafeDIToolCodeGenerationErrorTests: XCTestCase { func test_run_onCodeWithReceivedPropertyThatRefersToCurrentInstantiable_throwsError() async throws { await assertThrowsError( """ - Dependency cycle detected! - AuthService -> AuthService + Dependency received in same chain it is instantiated! + @Instantiated authService: AuthService -> @Received authService: AuthService """ ) { try await executeSafeDIToolTest( @@ -457,8 +457,8 @@ final class SafeDIToolCodeGenerationErrorTests: XCTestCase { func test_run_onCodeWhereAliasedReceivedPropertyRefersToCurrentInstantiable_throwsError() async throws { await assertThrowsError( """ - Dependency cycle detected! - AuthService -> AuthService + Dependency received in same chain it is instantiated! + @Instantiated authService: AuthService -> @Received authService: AuthService """ ) { try await executeSafeDIToolTest( @@ -900,8 +900,8 @@ final class SafeDIToolCodeGenerationErrorTests: XCTestCase { func test_run_onCodeWithCircularReceivedDependencies_throwsError() async { await assertThrowsError( """ - Dependency cycle detected on Root! - a: A -> b: B -> c: C -> a: A + Dependency received in same chain it is instantiated! + @Instantiated a: A -> @Received b: B -> @Received c: C -> @Received a: A """ ) { try await executeSafeDIToolTest( @@ -949,8 +949,59 @@ final class SafeDIToolCodeGenerationErrorTests: XCTestCase { func test_run_onCodeWithCircularReceivedRenamedDependencies_throwsError() async { await assertThrowsError( """ - Dependency cycle detected on A! - b: B -> c: C -> renamedB: B -> b: B + Dependency received in same chain it is instantiated! + @Instantiated a: A -> @Received renamedB: B -> @Received c: C -> @Received a: A + """ + ) { + try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Root { + @Instantiated + private let a: A + @Instantiated + private let b: B + @Received(fulfilledByDependencyNamed: "b", ofType: B.self) + private let renamedB: B + @Instantiated + private let c: C + } + """, + """ + @Instantiable + public struct A { + @Received + private let renamedB: B + } + """, + """ + @Instantiable + public struct B { + @Received + private let c: C + } + """, + """ + @Instantiable + public struct C { + @Received + private let a: A + } + """, + ], + buildDependencyTreeOutput: true, + filesToDelete: &filesToDelete + ) + } + } + + @MainActor + func test_run_onCodeWithMultipleCircularReceivedRenamedDependencies_throwsError() async { + await assertThrowsError( + """ + Dependency received in same chain it is instantiated! + @Instantiated c: C -> @Received renamedB: B -> @Received c: C """ ) { try await executeSafeDIToolTest(