diff --git a/Sources/SourceGraph/Mutators/DefaultConstructorReferenceBuilder.swift b/Sources/SourceGraph/Mutators/DefaultConstructorReferenceBuilder.swift index 743c2bbc4..6f0d5814a 100644 --- a/Sources/SourceGraph/Mutators/DefaultConstructorReferenceBuilder.swift +++ b/Sources/SourceGraph/Mutators/DefaultConstructorReferenceBuilder.swift @@ -11,6 +11,7 @@ final class DefaultConstructorReferenceBuilder: SourceGraphMutator { func mutate() { referenceDefaultConstructors() + referenceSoleClassInitializers() referenceDestructors() } @@ -24,19 +25,61 @@ final class DefaultConstructorReferenceBuilder: SourceGraphMutator { } for constructor in defaultConstructors { - if let parent = constructor.parent { - for usr in constructor.usrs { - let reference = Reference( - name: constructor.name, - kind: .normal, - declarationKind: .functionConstructor, - usr: usr, - location: parent.location - ) - reference.parent = parent - graph.add(reference, from: parent) - } + reference(constructor) + } + } + + /// Retains a class's sole designated initializer when the class has stored state to initialize. + /// + /// A struct synthesizes a memberwise initializer, so an explicit struct init that's never called + /// is genuinely dead code. A class has no such synthesis: when a class declares a stored property + /// without a default value, the explicit initializer is the only way to construct an instance, and + /// removing it would make the class unconstructable. This narrow case retains that initializer. + /// + /// The retention is deliberately limited to avoid masking dead code (see issue #1058): + /// it fires only when the class has exactly one non-implicit initializer and at least one stored + /// instance property. If the class has multiple explicit initializers there are alternatives, so + /// none are retained here. If the class itself is unused it folds away entirely along with its + /// initializer, so this retention cannot keep a dead class alive. + private func referenceSoleClassInitializers() { + for classDecl in graph.declarations(ofKind: .class) { + let explicitInits = classDecl.declarations.filter { + $0.kind == .functionConstructor && !$0.isImplicit } + + guard explicitInits.count == 1, let soleInit = explicitInits.first else { continue } + + let hasStoredProperty = classDecl.declarations.contains { decl in + decl.kind == .varInstance && !isComputedProperty(decl) + } + + guard hasStoredProperty else { continue } + + reference(soleInit) + } + } + + /// A computed property is identified by a getter accessor with no corresponding setter accessor. + /// A stored property either declares no accessors or declares a synthesized get/set pair. + private func isComputedProperty(_ property: Declaration) -> Bool { + let hasGetter = property.declarations.contains { $0.kind == .functionAccessorGetter } + let hasSetter = property.declarations.contains { $0.kind == .functionAccessorSetter } + return hasGetter && !hasSetter + } + + private func reference(_ constructor: Declaration) { + guard let parent = constructor.parent else { return } + + for usr in constructor.usrs { + let reference = Reference( + name: constructor.name, + kind: .normal, + declarationKind: .functionConstructor, + usr: usr, + location: parent.location + ) + reference.parent = parent + graph.add(reference, from: parent) } } diff --git a/Tests/Fixtures/Sources/RetentionFixtures/testDoesNotRetainClassInitializersWhenMultipleDeclared.swift b/Tests/Fixtures/Sources/RetentionFixtures/testDoesNotRetainClassInitializersWhenMultipleDeclared.swift new file mode 100644 index 000000000..54ca72394 --- /dev/null +++ b/Tests/Fixtures/Sources/RetentionFixtures/testDoesNotRetainClassInitializersWhenMultipleDeclared.swift @@ -0,0 +1,16 @@ +import Foundation + +// A class with stored state but TWO explicit initializers. Neither has external callers. +// The narrow sole-init retention must NOT fire here: the user has alternatives, so both +// initializers should remain reportable as unused. +public class FixtureClass401 { + private var value: Int + + init(value: Int) { + self.value = value + } + + init(other: Int) { + value = other + } +} diff --git a/Tests/Fixtures/Sources/RetentionFixtures/testDoesNotRetainSoleStructInitializer.swift b/Tests/Fixtures/Sources/RetentionFixtures/testDoesNotRetainSoleStructInitializer.swift new file mode 100644 index 000000000..37338bc5c --- /dev/null +++ b/Tests/Fixtures/Sources/RetentionFixtures/testDoesNotRetainSoleStructInitializer.swift @@ -0,0 +1,12 @@ +import Foundation + +// A struct with a sole explicit initializer that has no external callers. A struct synthesizes +// a memberwise initializer, so an unused explicit struct init is genuinely dead code. The +// class-specific retention must NOT extend to structs: this init should remain reportable as unused. +public struct FixtureStruct225 { + private var value: Int + + init(value: Int) { + self.value = value + } +} diff --git a/Tests/Fixtures/Sources/RetentionFixtures/testRetainsSoleRequiredClassInitializer.swift b/Tests/Fixtures/Sources/RetentionFixtures/testRetainsSoleRequiredClassInitializer.swift new file mode 100644 index 000000000..0aea17131 --- /dev/null +++ b/Tests/Fixtures/Sources/RetentionFixtures/testRetainsSoleRequiredClassInitializer.swift @@ -0,0 +1,16 @@ +import Foundation + +// A class with a stored property that has no default value, and a single explicit +// initializer that assigns it. The class itself is retained (referenced as the type +// of a retained property below), but its sole init has no external callers. +public class FixtureClass400 { + private var value: Int + + init(value: Int) { + self.value = value + } +} + +public class FixtureClass400Retainer { + public var page: FixtureClass400? +} diff --git a/Tests/Fixtures/Sources/RetentionFixtures/testRetainsUsedSoleClassInitializer.swift b/Tests/Fixtures/Sources/RetentionFixtures/testRetainsUsedSoleClassInitializer.swift new file mode 100644 index 000000000..f8d6d3d64 --- /dev/null +++ b/Tests/Fixtures/Sources/RetentionFixtures/testRetainsUsedSoleClassInitializer.swift @@ -0,0 +1,18 @@ +import Foundation + +// A class with stored state and a sole explicit initializer that IS called. This is a sanity +// check: the initializer is referenced by its caller regardless of the sole-init retention, and +// remains retained. +public class FixtureClass402 { + private var value: Int + + init(value: Int) { + self.value = value + } +} + +public class FixtureClass402Retainer { + public func build() -> FixtureClass402 { + FixtureClass402(value: 1) + } +} diff --git a/Tests/PeripheryTests/RetentionTest.swift b/Tests/PeripheryTests/RetentionTest.swift index 599bb83ec..577e98840 100644 --- a/Tests/PeripheryTests/RetentionTest.swift +++ b/Tests/PeripheryTests/RetentionTest.swift @@ -1095,6 +1095,39 @@ final class RetentionTest: FixtureSourceGraphTestCase { } } + func testRetainsSoleRequiredClassInitializer() { + analyze(retainPublic: true) { + assertReferenced(.class("FixtureClass400")) { + self.assertReferenced(.functionConstructor("init(value:)")) + } + } + } + + func testDoesNotRetainClassInitializersWhenMultipleDeclared() { + analyze(retainPublic: true) { + assertReferenced(.class("FixtureClass401")) { + self.assertNotReferenced(.functionConstructor("init(value:)")) + self.assertNotReferenced(.functionConstructor("init(other:)")) + } + } + } + + func testDoesNotRetainSoleStructInitializer() { + analyze(retainPublic: true) { + assertReferenced(.struct("FixtureStruct225")) { + self.assertNotReferenced(.functionConstructor("init(value:)")) + } + } + } + + func testRetainsUsedSoleClassInitializer() { + analyze(retainPublic: true) { + assertReferenced(.class("FixtureClass402")) { + self.assertReferenced(.functionConstructor("init(value:)")) + } + } + } + // https://github.com/apple/swift/issues/56541 func testStaticMemberUsedAsSubscriptKey() { analyze(retainPublic: true) {