Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
30 changes: 30 additions & 0 deletions Sources/SourceGraph/Mutators/SwiftUIRetainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ final class SwiftUIRetainer: SourceGraphMutator {

func mutate() {
retainSpecialProtocolConformances()
retainPreviewMacros()
retainApplicationDelegateAdaptors()
}

Expand All @@ -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
Expand Down
54 changes: 47 additions & 7 deletions Sources/SourceGraph/Mutators/UsedDeclarationMarker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Declaration> {
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) {
Expand All @@ -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))
}
}
}
}
Expand Down
21 changes: 21 additions & 0 deletions Tests/XcodeTests/SwiftUIProject/SwiftUIProject/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
32 changes: 32 additions & 0 deletions Tests/XcodeTests/SwiftUIProjectRetainPreviewsTest.swift
Original file line number Diff line number Diff line change
@@ -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"))
}
}
}
11 changes: 11 additions & 0 deletions Tests/XcodeTests/SwiftUIProjectTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
}