diff --git a/Sources/SourceGraph/Mutators/SwiftUIRetainer.swift b/Sources/SourceGraph/Mutators/SwiftUIRetainer.swift index 17e1134c2..a3a6afeb3 100644 --- a/Sources/SourceGraph/Mutators/SwiftUIRetainer.swift +++ b/Sources/SourceGraph/Mutators/SwiftUIRetainer.swift @@ -15,6 +15,7 @@ final class SwiftUIRetainer: SourceGraphMutator { func mutate() { retainSpecialProtocolConformances() + retainPreviewMacros() retainApplicationDelegateAdaptors() } @@ -38,6 +39,35 @@ final class SwiftUIRetainer: SourceGraphMutator { .forEach { graph.markRetained($0) } } + private func retainPreviewMacros() { + // #Preview macros are expanded by the compiler before indexing, creating + // wrapper structs we detect by their mangled names and characteristic patterns + let previewDecls = graph.allDeclarations.filter { self.isPreviewMacro($0) } + + if configuration.retainSwiftUIPreviews { + // With flag: retain preview macros + // Their references will be processed normally by UsedDeclarationMarker + previewDecls.forEach { graph.markRetained($0) } + } else { + // Without flag: mark preview machinery AND child declarations as ignored + // This includes the makePreview() function and any other generated code + // UsedDeclarationMarker will skip processing their references + for preview in previewDecls { + graph.markIgnored(preview) + preview.descendentDeclarations.forEach { graph.markIgnored($0) } + } + } + } + + private func isPreviewMacro(_ decl: Declaration) -> Bool { + // Match compiler-generated preview wrapper structs with mangled names + // starting with "$s" and containing "PreviewRegistry". We only detect the parent + // struct - children (like makePreview()) are handled via descendentDeclarations. + guard let name = decl.name else { return false } + + return name.hasPrefix("$s") && name.contains("PreviewRegistry") + } + private func retainApplicationDelegateAdaptors() { graph .mainAttributedDeclarations diff --git a/Sources/SourceGraph/Mutators/UsedDeclarationMarker.swift b/Sources/SourceGraph/Mutators/UsedDeclarationMarker.swift index 194cfac9d..a67241907 100644 --- a/Sources/SourceGraph/Mutators/UsedDeclarationMarker.swift +++ b/Sources/SourceGraph/Mutators/UsedDeclarationMarker.swift @@ -13,14 +13,45 @@ final class UsedDeclarationMarker: SourceGraphMutator { removeErroneousProtocolReferences() markUsed(graph.retainedDeclarations) - graph.rootReferences.forEach { markUsed(declarationsReferenced(by: $0)) } - + // Pre-compute preview-only declarations for efficient lookup. + // A declaration is preview-only if ALL its non-root references come from + // ignored declarations (like #Preview macro machinery). + // Example: MyPreviewOnlyView is ONLY referenced by makePreview() (which is ignored). + let previewOnlyDeclarations = computePreviewOnlyDeclarations() + + // Process root references (protocol conformances, etc.) but skip those targeting + // preview-only code. Even though a root reference exists (View conformance), we skip + // it so MyPreviewOnlyView remains unused and gets reported. + for rootRef in graph.rootReferences { + let targetDecls = declarationsReferenced(by: rootRef) + let hasActualUsage = targetDecls.contains { !previewOnlyDeclarations.contains($0) } + if hasActualUsage { + markUsed(targetDecls) + } + } ignoreUnusedDescendents(in: graph.rootDeclarations, unusedDeclarations: graph.unusedDeclarations) } // MARK: - Private + private func computePreviewOnlyDeclarations() -> Set { + let previewOnly = graph.allDeclarations.filter { decl in + let refs = graph.references(to: decl) + guard !refs.isEmpty else { return false } + + let nonRootRefs = refs.filter { $0.parent != nil } + + // Declaration is preview-only if all non-root references come from ignored declarations + return !nonRootRefs.isEmpty && nonRootRefs.allSatisfy { ref in + guard let parent = ref.parent else { return false } + + return graph.ignoredDeclarations.contains(parent) + } + } + return Set(previewOnly) + } + // Removes references from protocol member decls to conforming decls that have a dereferenced ancestor. private func removeErroneousProtocolReferences() { for protocolDecl in graph.declarations(ofKind: .protocol) { @@ -46,12 +77,21 @@ final class UsedDeclarationMarker: SourceGraphMutator { graph.markUsed(declaration) - for ref in declaration.references { - markUsed(declarationsReferenced(by: ref)) - } + // Skip processing references FROM ignored declarations (like #Preview machinery). + // This prevents preview code from marking the code it references as "used". + // Example: makePreview() is ignored. When we mark it as used (above), we then + // check if it's ignored. Since it is, we skip processing its references to + // MyPreviewOnlyView, so MyPreviewOnlyView stays unused. + let shouldProcessReferences = !graph.ignoredDeclarations.contains(declaration) - for ref in declaration.related { - markUsed(declarationsReferenced(by: ref)) + if shouldProcessReferences { + for ref in declaration.references { + markUsed(declarationsReferenced(by: ref)) + } + + for ref in declaration.related { + markUsed(declarationsReferenced(by: ref)) + } } } } diff --git a/Tests/XcodeTests/SwiftUIProject/SwiftUIProject/ContentView.swift b/Tests/XcodeTests/SwiftUIProject/SwiftUIProject/ContentView.swift index 325989105..21b521719 100644 --- a/Tests/XcodeTests/SwiftUIProject/SwiftUIProject/ContentView.swift +++ b/Tests/XcodeTests/SwiftUIProject/SwiftUIProject/ContentView.swift @@ -17,3 +17,24 @@ struct LibraryViewContent: LibraryContentProvider { LibraryItem(ContentView()) } } + +// View referenced only from #Preview (mirrors ContentView referenced from PreviewProvider) +struct DetailView: View { + var body: some View { + Text("Detail View") + } +} + +// Nested type referenced only from #Preview - tests nested type handling +struct PreviewHelpers { + struct NestedHelper { + static func makeText() -> String { + "Nested Helper Text" + } + } +} + +#Preview { + DetailView() + Text(PreviewHelpers.NestedHelper.makeText()) +} diff --git a/Tests/XcodeTests/SwiftUIProjectRetainPreviewsTest.swift b/Tests/XcodeTests/SwiftUIProjectRetainPreviewsTest.swift new file mode 100644 index 000000000..8aff8c941 --- /dev/null +++ b/Tests/XcodeTests/SwiftUIProjectRetainPreviewsTest.swift @@ -0,0 +1,32 @@ +import Configuration +@testable import TestShared + +final class SwiftUIProjectRetainPreviewsTest: XcodeSourceGraphTestCase { + override static func setUp() { + super.setUp() + + let configuration = Configuration() + configuration.schemes = ["SwiftUIProject"] + configuration.retainSwiftUIPreviews = true + + build(projectPath: SwiftUIProjectPath, configuration: configuration) + index(configuration: configuration) + } + + func testRetainsPreviewProvider() { + // With flag enabled, PreviewProvider structs should be retained + assertReferenced(.struct("ContentView_Previews")) + } + + func testRetainsPreviewMacroView() { + // With flag enabled, views referenced from #Preview should be retained + assertReferenced(.struct("DetailView")) + } + + func testRetainsNestedTypeFromPreviewMacro() { + // With flag enabled, nested types referenced from #Preview should be retained + assertReferenced(.struct("PreviewHelpers")) { + self.assertReferenced(.struct("NestedHelper")) + } + } +} diff --git a/Tests/XcodeTests/SwiftUIProjectTest.swift b/Tests/XcodeTests/SwiftUIProjectTest.swift index 5b32bff75..dceb12d44 100644 --- a/Tests/XcodeTests/SwiftUIProjectTest.swift +++ b/Tests/XcodeTests/SwiftUIProjectTest.swift @@ -33,4 +33,15 @@ final class SwiftUIProjectTest: XcodeSourceGraphTestCase { func testRetainsUIApplicationDelegateAdaptorReferencedType() { assertReferenced(.class("AppDelegate")) } + + func testDoesNotRetainPreviewMacro() { + // Mirrors testDoesNotRetainPreviewProvider + // DetailView is only referenced from #Preview, so should not be retained + assertNotReferenced(.struct("DetailView")) + } + + func testDoesNotRetainNestedTypeFromPreviewMacro() { + // Tests nested type references from #Preview + assertNotReferenced(.struct("PreviewHelpers")) + } }